From 64fbf5bdcb23a0b837e337aa73df7f5228f8805b Mon Sep 17 00:00:00 2001 From: RejectKid Date: Fri, 2 Oct 2026 12:40:40 -0400 Subject: [PATCH] [TypeGen]: preserve proxy prefixes in TanStack client URLs --- README.md | 4 + .../docs/packages/typegen/configuration.md | 1 + .../typegen/emitters/tanstack-query.md | 47 +++++++ .../sample/SampleApi/TypeGenConfig.cs | 3 + .../Settings.cs | 8 ++ .../Emitters/TanStackQueryEmitter.cs | 12 +- .../ZibStack.NET.TypeGen/Model/SchemaModel.cs | 1 + .../Parser/ConfiguratorParser.cs | 1 + .../ConfiguratorParserTests.cs | 3 + .../TanStackQueryEmitterTests.cs | 1 + .../TanStackQueryUrlTests.cs | 121 ++++++++++++++++++ 11 files changed, 201 insertions(+), 1 deletion(-) create mode 100644 packages/ZibStack.NET.TypeGen/tests/ZibStack.NET.TypeGen.Tests/TanStackQueryUrlTests.cs diff --git a/README.md b/README.md index f5d7c57..740782e 100644 --- a/README.md +++ b/README.md @@ -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. | diff --git a/docs/src/content/docs/packages/typegen/configuration.md b/docs/src/content/docs/packages/typegen/configuration.md index c71a81e..ce6f3c0 100644 --- a/docs/src/content/docs/packages/typegen/configuration.md +++ b/docs/src/content/docs/packages/typegen/configuration.md @@ -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"; diff --git a/docs/src/content/docs/packages/typegen/emitters/tanstack-query.md b/docs/src/content/docs/packages/typegen/emitters/tanstack-query.md index 25d8888..17ee3cb 100644 --- a/docs/src/content/docs/packages/typegen/emitters/tanstack-query.md +++ b/docs/src/content/docs/packages/typegen/emitters/tanstack-query.md @@ -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"; @@ -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 diff --git a/packages/ZibStack.NET.TypeGen/sample/SampleApi/TypeGenConfig.cs b/packages/ZibStack.NET.TypeGen/sample/SampleApi/TypeGenConfig.cs index 8530fa9..2aca609 100644 --- a/packages/ZibStack.NET.TypeGen/sample/SampleApi/TypeGenConfig.cs +++ b/packages/ZibStack.NET.TypeGen/sample/SampleApi/TypeGenConfig.cs @@ -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 => diff --git a/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen.Abstractions/Settings.cs b/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen.Abstractions/Settings.cs index 1694f8e..46bcead 100644 --- a/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen.Abstractions/Settings.cs +++ b/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen.Abstractions/Settings.cs @@ -338,6 +338,14 @@ public sealed class TanStackQuerySettings /// public string BaseUrlExpression { get; set; } = "import.meta.env.VITE_API_URL"; + /// + /// Resolve endpoint routes beneath the base URL pathname, treating it as a + /// directory even without a trailing slash. Default false preserves + /// standard URL resolution. Absolute URLs and protocol-relative paths retain + /// standard URL semantics in either mode. Applies only to the default client. + /// + public bool PreserveBaseUrlPath { get; set; } + /// /// Optional module specifier for a user-supplied API client. When set, the /// emitter imports from this module and does not diff --git a/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen/Emitters/TanStackQueryEmitter.cs b/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen/Emitters/TanStackQueryEmitter.cs index 4a824c4..8734c26 100644 --- a/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen/Emitters/TanStackQueryEmitter.cs +++ b/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen/Emitters/TanStackQueryEmitter.cs @@ -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;"); diff --git a/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen/Model/SchemaModel.cs b/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen/Model/SchemaModel.cs index cefff18..81ab87e 100644 --- a/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen/Model/SchemaModel.cs +++ b/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen/Model/SchemaModel.cs @@ -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; } diff --git a/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen/Parser/ConfiguratorParser.cs b/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen/Parser/ConfiguratorParser.cs index d79e961..ecf4416 100644 --- a/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen/Parser/ConfiguratorParser.cs +++ b/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen/Parser/ConfiguratorParser.cs @@ -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; diff --git a/packages/ZibStack.NET.TypeGen/tests/ZibStack.NET.TypeGen.Tests/ConfiguratorParserTests.cs b/packages/ZibStack.NET.TypeGen/tests/ZibStack.NET.TypeGen.Tests/ConfiguratorParserTests.cs index 07cfcd6..20a8dae 100644 --- a/packages/ZibStack.NET.TypeGen/tests/ZibStack.NET.TypeGen.Tests/ConfiguratorParserTests.cs +++ b/packages/ZibStack.NET.TypeGen/tests/ZibStack.NET.TypeGen.Tests/ConfiguratorParserTests.cs @@ -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"; @@ -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); @@ -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; } diff --git a/packages/ZibStack.NET.TypeGen/tests/ZibStack.NET.TypeGen.Tests/TanStackQueryEmitterTests.cs b/packages/ZibStack.NET.TypeGen/tests/ZibStack.NET.TypeGen.Tests/TanStackQueryEmitterTests.cs index c0279cf..6d840f6 100644 --- a/packages/ZibStack.NET.TypeGen/tests/ZibStack.NET.TypeGen.Tests/TanStackQueryEmitterTests.cs +++ b/packages/ZibStack.NET.TypeGen/tests/ZibStack.NET.TypeGen.Tests/TanStackQueryEmitterTests.cs @@ -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; diff --git a/packages/ZibStack.NET.TypeGen/tests/ZibStack.NET.TypeGen.Tests/TanStackQueryUrlTests.cs b/packages/ZibStack.NET.TypeGen/tests/ZibStack.NET.TypeGen.Tests/TanStackQueryUrlTests.cs new file mode 100644 index 0000000..53d60fe --- /dev/null +++ b/packages/ZibStack.NET.TypeGen/tests/ZibStack.NET.TypeGen.Tests/TanStackQueryUrlTests.cs @@ -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) { + 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); } + } +}