Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ ZibStack is designed so you can adopt as little or as much as you want. Start at

## Packages

TypeGen's TanStack Query client supports reverse-proxy service prefixes via
`q.PreserveBaseUrlPath = true`; see the
[URL resolution guide](https://mistykuu.github.io/ZibStack.NET/packages/typegen/emitters/tanstack-query/#reverse-proxy-service-prefixes).

| Package | NuGet | Description |
|---|---|---|
| [**ZibStack.NET.Aop**](packages/ZibStack.NET.Aop/) | `dotnet add package ZibStack.NET.Aop` | AOP framework with C# interceptors. Built-in: `[Log]`, `[Trace]`, `[Retry]`, `[Cache]`, `[Metrics]`, `[Timeout]`, `[Authorize]`, `[Validate]`, `[Transaction]`. Custom aspects via `IAspectHandler`. Global `Apply<>()` rules. |
Expand Down
1 change: 1 addition & 0 deletions docs/src/content/docs/packages/typegen/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ public sealed class TypeGenConfig : ITypeGenConfigurator
q.OutputDir = "../client/src/api";
q.SingleFileName = "api.gen.ts";
q.BaseUrlExpression = "import.meta.env.VITE_API_URL";
// q.PreserveBaseUrlPath = true; // retain reverse-proxy prefixes
// q.FileLayout = QueryFileLayout.SplitByTag;
// q.ApiClientImportPath = "./http-client";
// q.ApiClientName = "request";
Expand Down
47 changes: 47 additions & 0 deletions docs/src/content/docs/packages/typegen/emitters/tanstack-query.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ public sealed class TypeGenConfig : ITypeGenConfigurator
q.OutputDir = "../client/src/api";
q.SingleFileName = "api.gen.ts";
q.BaseUrlExpression = "import.meta.env.VITE_API_URL";
// q.PreserveBaseUrlPath = true; // reverse-proxy service prefix
// q.FileLayout = QueryFileLayout.SplitByTag;
// q.ApiClientImportPath = "./http-client";
// q.ApiClientName = "request";
Expand All @@ -102,6 +103,52 @@ configured expression is missing, empty, or throws, it falls back to
non-browser execution. You can still set `BaseUrlExpression` to a literal origin
or import a custom client when you want stricter environment handling.

### Reverse-proxy service prefixes

Set `PreserveBaseUrlPath = true` when the configured base URL includes the
service's externally visible prefix:

```csharp
b.TanStackQuery(q =>
{
q.BaseUrlExpression = "import.meta.env.VITE_API_URL";
q.PreserveBaseUrlPath = true;
});
```

For example, set `VITE_API_URL=http://localhost:3100/aoi`. The built-in client
then resolves `/asdf` to `http://localhost:3100/aoi/asdf`. No custom client or
generated-file edits are needed.

The default is `false` to preserve existing consumers' standard URL resolution.
In that mode, `/asdf` resolves from the origin and a base's final pathname
segment without a trailing slash is treated as a file:

| Base URL | Route | Default (`false`) | Preserve path (`true`) |
|---|---|---|---|
| `https://host/aoi` | `/asdf` | `https://host/asdf` | `https://host/aoi/asdf` |
| `https://host/aoi` | `asdf` | `https://host/asdf` | `https://host/aoi/asdf` |
| `https://host/proxy/aoi/` | `/asdf` | `https://host/asdf` | `https://host/proxy/aoi/asdf` |
| `https://host` | `/asdf` | `https://host/asdf` | `https://host/asdf` |

With preservation enabled, the base pathname is treated as a directory, with
or without a trailing slash. A root (`/`) or empty route resolves to that
directory. Endpoint routes should contain the backend route only; including
the proxy prefix in both the base and route duplicates it.

Absolute URLs (`https://other/path`) and protocol-relative paths (`//other/path`)
retain standard URL semantics in both modes: they can select another host and
bypass the service prefix. Dot segments still follow URL normalization rules;
this setting is not a restriction on where requests may be sent.

Route encoding and query handling remain unchanged. Encoded route values stay
encoded, route query strings are retained, and `options.query` sets scalar
values or appends array values using `URLSearchParams`. Base query strings and
fragments follow standard URL rules (an empty route inherits the base query).
The existing base URL fallback still applies. `ApiClientImportPath` continues
to replace the built-in client entirely; this option does not alter imported
clients.

## Generated shape

```typescript
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ public void Configure(ITypeGenBuilder b)
// The default is import.meta.env.VITE_API_URL. The sample uses the
// current origin so api.gen.ts type-checks in non-Vite clients too.
q.BaseUrlExpression = "window.location.origin";
// When the configured base includes a reverse-proxy service prefix,
// resolve backend routes beneath it (opt-in for compatibility):
// q.PreserveBaseUrlPath = true;
});

b.OpenApi(oa =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,14 @@ public sealed class TanStackQuerySettings
/// </summary>
public string BaseUrlExpression { get; set; } = "import.meta.env.VITE_API_URL";

/// <summary>
/// Resolve endpoint routes beneath the base URL pathname, treating it as a
/// directory even without a trailing slash. Default <c>false</c> preserves
/// standard URL resolution. Absolute URLs and protocol-relative paths retain
/// standard URL semantics in either mode. Applies only to the default client.
/// </summary>
public bool PreserveBaseUrlPath { get; set; }

/// <summary>
/// Optional module specifier for a user-supplied API client. When set, the
/// emitter imports <see cref="ApiClientName"/> from this module and does not
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -606,7 +606,17 @@ private static void EmitDefaultClient(StringBuilder sb, TanStackQuerySettings qu
sb.AppendLine($" try {{ return {query.BaseUrlExpression}; }} catch {{ return undefined; }}");
sb.AppendLine(" })();");
sb.AppendLine(" const baseUrl = configuredBaseUrl || (typeof window !== 'undefined' ? window.location.origin : 'http://localhost');");
sb.AppendLine(" const url = new URL(path, baseUrl);");
if (query.PreserveBaseUrlPath)
{
sb.AppendLine(" const serviceBaseUrl = new URL(baseUrl);");
sb.AppendLine(" if (!serviceBaseUrl.pathname.endsWith('/')) serviceBaseUrl.pathname += '/';");
sb.AppendLine(" const isAbsoluteUrl = /^[a-z][a-z\\d+.-]*:/i.test(path) || path.startsWith('//');");
sb.AppendLine(" const url = new URL(isAbsoluteUrl ? path : path.replace(/^\\/+/, ''), serviceBaseUrl);");
}
else
{
sb.AppendLine(" const url = new URL(path, baseUrl);");
}
sb.AppendLine(" if (options.query) {");
sb.AppendLine(" for (const [key, value] of Object.entries(options.query)) {");
sb.AppendLine(" if (value === undefined || value === null) continue;");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ internal sealed class TanStackQuerySettings
public QueryFileLayout FileLayout { get; set; } = QueryFileLayout.SingleFile;
public string SingleFileName { get; set; } = "api.gen.ts";
public string BaseUrlExpression { get; set; } = "import.meta.env.VITE_API_URL";
public bool PreserveBaseUrlPath { get; set; }
public string? ApiClientImportPath { get; set; }
public string ApiClientName { get; set; } = "apiFetch";
public string? ModelsImportPath { get; set; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,7 @@ private static void AssignTanStackQuery(TanStackQuerySettings s, string prop, ob
case "FileLayout": if (val is int fl) s.FileLayout = (QueryFileLayout)fl; break;
case "SingleFileName": if (val is string sf) s.SingleFileName = sf; break;
case "BaseUrlExpression": if (val is string bu) s.BaseUrlExpression = bu; break;
case "PreserveBaseUrlPath": if (val is bool pb) s.PreserveBaseUrlPath = pb; break;
case "ApiClientImportPath": s.ApiClientImportPath = val as string; break;
case "ApiClientName": if (val is string ac) s.ApiClientName = ac; break;
case "ModelsImportPath": s.ModelsImportPath = val as string; break;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ public void Configure(ITypeGenBuilder b) {
q.FileLayout = QueryFileLayout.SplitByTag;
q.SingleFileName = "query.gen.ts";
q.BaseUrlExpression = "window.location.origin";
q.PreserveBaseUrlPath = true;
q.ApiClientImportPath = "./client";
q.ApiClientName = "request";
q.ModelsImportPath = "../models";
Expand All @@ -104,6 +105,7 @@ public void Configure(ITypeGenBuilder b) {
Assert.Equal(QueryFileLayout.SplitByTag, parsed.Settings.TanStackQuery.FileLayout);
Assert.Equal("query.gen.ts", parsed.Settings.TanStackQuery.SingleFileName);
Assert.Equal("window.location.origin", parsed.Settings.TanStackQuery.BaseUrlExpression);
Assert.True(parsed.Settings.TanStackQuery.PreserveBaseUrlPath);
Assert.Equal("./client", parsed.Settings.TanStackQuery.ApiClientImportPath);
Assert.Equal("request", parsed.Settings.TanStackQuery.ApiClientName);
Assert.Equal("../models", parsed.Settings.TanStackQuery.ModelsImportPath);
Expand Down Expand Up @@ -517,6 +519,7 @@ public sealed class TanStackQuerySettings {
public QueryFileLayout FileLayout { get; set; }
public string SingleFileName { get; set; } = "api.gen.ts";
public string BaseUrlExpression { get; set; } = "import.meta.env.VITE_API_URL";
public bool PreserveBaseUrlPath { get; set; }
public string? ApiClientImportPath { get; set; }
public string ApiClientName { get; set; } = "apiFetch";
public string? ModelsImportPath { get; set; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ public void SplitByTag_WithCustomClient_EmitsOneFilePerTagWithoutDefaultClient()
var settings = new GlobalSettings();
settings.TanStackQuery.FileLayout = QueryFileLayout.SplitByTag;
settings.TanStackQuery.ApiClientImportPath = "./http-client";
settings.TanStackQuery.PreserveBaseUrlPath = true;
settings.TanStackQuery.ApiClientName = "request";
settings.TanStackQuery.EmitHooks = false;
settings.TanStackQuery.EmitCacheHelpers = false;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Threading.Tasks;
using Xunit;
using ZibStack.NET.TypeGen.Generator;

namespace TypeGenTests;

public class TanStackQueryUrlTests
{
// Execute the complete emitted TypeScript client, including a generated
// endpoint, against a fetch stub. Node 22+ is installed by CI.
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task GeneratedClient_ResolvesActualRequestUrls(bool preserveBaseUrlPath)
{
var model = new SchemaModel();
model.Endpoints.Add(new EndpointInfo
{
Verb = "get", Pattern = "/items/{id}", OperationId = "getItem",
Parameters =
{
new EndpointParameter { Name = "id", Location = ParamLocation.Route, CSharpType = "string", Required = true },
},
});
var settings = new GlobalSettings();
settings.TanStackQuery.PreserveBaseUrlPath = preserveBaseUrlPath;
settings.TanStackQuery.BaseUrlExpression = "configuredBase()";
settings.TanStackQuery.EmitHooks = false;
settings.TanStackQuery.EmitQueryOptions = false;
settings.TanStackQuery.EmitMutationOptions = false;
settings.TanStackQuery.EmitCacheHelpers = false;
var client = Assert.Single(TanStackQueryEmitter.Emit(model, settings)).Content;
var script = "const preserve = " + (preserveBaseUrlPath ? "true" : "false") + ";\n" + client + "\n" + """
import assert from 'node:assert/strict';
let base: string | undefined = undefined;
let throwBase = false;
function configuredBase() {
if (throwBase) throw new Error('missing configuration');
return base;
}
let actual = '';
globalThis.fetch = async (url) => {
actual = String(url);
return new Response(null, { status: 204 });
};
let checks = 0;
async function check(path: string, expected: string, query?: Record<string, unknown>) {
await apiFetch(path, { query });
assert.equal(actual, expected, `base=${base}, path=${path}, preserve=${preserve}`);
checks++;
}
for (const prefix of ['', '/aoi', '/proxy/services/aoi']) {
for (const trailing of ['', '/']) {
base = 'https://example.test' + prefix + trailing;
const service = 'https://example.test' + prefix + '/';
for (const route of ['asdf', '/asdf', '', '/']) {
const expected = preserve
? service + route.replace(/^\/+/, '')
: new URL(route, base).toString();
await check(route, expected);
}
await check('https://other.test/remote?x=1', 'https://other.test/remote?x=1');
await check('//other.test/remote', 'https://other.test/remote');
const target = preserve ? service : 'https://example.test/';
await check('/asdf?keep=yes&replace=old', target + 'asdf?keep=yes&replace=new&tag=a&tag=b', {
replace: 'new', tag: ['a', 'b'], omitted: undefined, nil: null,
});
await getItem({ id: 'a/b ?#%ü' });
assert.equal(actual, target + 'items/a%2Fb%20%3F%23%25%C3%BC');
checks++;
}
}
// Base queries/fragments retain URL semantics: route queries win;
// an empty route inherits the base query but drops the fragment.
base = 'https://example.test/aoi?base=1#fragment';
await check('/asdf?route=2', preserve ? 'https://example.test/aoi/asdf?route=2' : 'https://example.test/asdf?route=2');
await check('', preserve ? 'https://example.test/aoi/?base=1' : 'https://example.test/aoi?base=1');
for (const fallback of [undefined, '']) {
base = fallback;
await check('/asdf', 'http://localhost/asdf');
(globalThis as any).window = { location: { origin: 'https://browser.test' } };
await check('/asdf', 'https://browser.test/asdf');
delete (globalThis as any).window;
}
throwBase = true;
await check('/asdf', 'http://localhost/asdf');
(globalThis as any).window = { location: { origin: 'https://browser.test' } };
await check('/asdf', 'https://browser.test/asdf');
delete (globalThis as any).window;
console.log(`Verified ${checks} request URLs`);
""";
var dir = Path.Combine(Path.GetTempPath(), "typegen-url-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
try
{
var path = Path.Combine(dir, "client.mts");
await File.WriteAllTextAsync(path, script);
var start = new ProcessStartInfo("node")
{
RedirectStandardOutput = true, RedirectStandardError = true,
UseShellExecute = false, CreateNoWindow = true,
};
start.ArgumentList.Add("--experimental-transform-types");
start.ArgumentList.Add(path);
using var process = Process.Start(start)!;
var stdout = process.StandardOutput.ReadToEndAsync();
var stderr = process.StandardError.ReadToEndAsync();
if (!process.WaitForExit(30_000))
{
process.Kill(entireProcessTree: true);
Assert.Fail("Generated-client URL test timed out.");
}
Assert.True(process.ExitCode == 0, $"{await stdout}\n{await stderr}\n{client}");
Assert.Contains("Verified 56 request URLs", await stdout);
}
finally { Directory.Delete(dir, recursive: true); }
}
}
Loading