Skip to content

Commit 32271f4

Browse files
committed
feat(tsclient): ExportTypes option to emit importable export interfaces
Adds NpgsqlRest.TsClient ExportTypes option (default false). When true, request/response/composite interfaces are emitted as `export interface`. With CreateSeparateTypeFile=true the separate type file becomes an importable module {name}Types.ts (instead of ambient {name}Types.d.ts) and the client file imports the named types from it; inline mode exports them in the same file. No effect when SkipTypes=true. Default keeps existing output byte-for-byte unchanged. Wired through the client config (appsettings, defaults, template, schema) and covered by a separate-module generation test.
1 parent b143131 commit 32271f4

10 files changed

Lines changed: 194 additions & 6 deletions

File tree

NpgsqlRestClient/App.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -515,6 +515,7 @@ public List<IEndpointCreateHandler> CreateCodeGenHandlers(string connectionStrin
515515
BySchema = _config.GetConfigBool("BySchema", tsClientCfg, true),
516516
IncludeStatusCode = _config.GetConfigBool("IncludeStatusCode", tsClientCfg, true),
517517
CreateSeparateTypeFile = _config.GetConfigBool("CreateSeparateTypeFile", tsClientCfg, true),
518+
ExportTypes = _config.GetConfigBool("ExportTypes", tsClientCfg),
518519
ImportBaseUrlFrom = _config.GetConfigStr("ImportBaseUrlFrom", tsClientCfg),
519520
ImportParseQueryFrom = _config.GetConfigStr("ImportParseQueryFrom", tsClientCfg),
520521
IncludeParseUrlParam = _config.GetConfigBool("IncludeParseUrlParam", tsClientCfg),

NpgsqlRestClient/ConfigDefaults.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1009,6 +1009,7 @@ private static JsonObject GetClientCodeGenDefaults()
10091009
["BySchema"] = true,
10101010
["IncludeStatusCode"] = true,
10111011
["CreateSeparateTypeFile"] = true,
1012+
["ExportTypes"] = false,
10121013
["ImportBaseUrlFrom"] = null,
10131014
["ImportParseQueryFrom"] = null,
10141015
["IncludeParseUrlParam"] = false,

NpgsqlRestClient/ConfigSchemaGenerator.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -463,6 +463,7 @@ public static partial class ConfigSchemaGenerator
463463
["NpgsqlRest:ClientCodeGen:BySchema"] = "Create files by PostgreSQL schema. File name will use formatted FilePath where {0} is the schema name in pascal case.",
464464
["NpgsqlRest:ClientCodeGen:IncludeStatusCode"] = "Set to true to include status code in response: {status: response.status, response: model}",
465465
["NpgsqlRest:ClientCodeGen:CreateSeparateTypeFile"] = "Create separate file with global types {name}Types.d.ts",
466+
["NpgsqlRest:ClientCodeGen:ExportTypes"] = "Emit interfaces with the `export` keyword so they can be imported by other modules. When true and CreateSeparateTypeFile is true, the separate type file becomes an importable module ({name}Types.ts) instead of an ambient {name}Types.d.ts, and the client file imports the named types from it. No effect when SkipTypes is true.",
466467
["NpgsqlRest:ClientCodeGen:ImportBaseUrlFrom"] = "Module name to import \"baseUrl\" constant, instead of defining it in a module.",
467468
["NpgsqlRest:ClientCodeGen:ImportParseQueryFrom"] = "Module name to import \"parseQuery\" function, instead of defining it in a module.",
468469
["NpgsqlRest:ClientCodeGen:IncludeParseUrlParam"] = "Include optional parameter `parseUrl: (url: string) => string = url=>url` that will parse the constructed URL.",

NpgsqlRestClient/ConfigTemplate.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2679,6 +2679,10 @@ public static partial class ConfigSchemaGenerator
26792679
//
26802680
"CreateSeparateTypeFile": true,
26812681
//
2682+
// Emit interfaces with the `export` keyword so they can be imported by other modules. When true and CreateSeparateTypeFile is true, the separate type file becomes an importable module ({name}Types.ts) instead of an ambient {name}Types.d.ts, and the client file imports the named types from it. No effect when SkipTypes is true.
2683+
//
2684+
"ExportTypes": false,
2685+
//
26822686
// Module name to import "baseUrl" constant, instead of defining it in a module.
26832687
//
26842688
"ImportBaseUrlFrom": null,

NpgsqlRestClient/appsettings.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2670,6 +2670,10 @@
26702670
//
26712671
"CreateSeparateTypeFile": true,
26722672
//
2673+
// Emit interfaces with the `export` keyword so they can be imported by other modules. When true and CreateSeparateTypeFile is true, the separate type file becomes an importable module ({name}Types.ts) instead of an ambient {name}Types.d.ts, and the client file imports the named types from it. No effect when SkipTypes is true.
2674+
//
2675+
"ExportTypes": false,
2676+
//
26732677
// Module name to import "baseUrl" constant, instead of defining it in a module.
26742678
//
26752679
"ImportBaseUrlFrom": null,

NpgsqlRestTests/Setup/Program.cs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,11 @@ public class Program
3333
/// </summary>
3434
public static string TsClientJsOutputPath { get; } = Path.Combine(Path.GetTempPath(), "NpgsqlRestTests", "TsClientJs");
3535

36+
/// <summary>
37+
/// Output path for TsClient generated files with ExportTypes=true + separate type file (used by tests)
38+
/// </summary>
39+
public static string TsClientExportOutputPath { get; } = Path.Combine(Path.GetTempPath(), "NpgsqlRestTests", "TsClientExport");
40+
3641
/// <summary>
3742
/// Output path for HttpFiles generated files (used by tests)
3843
/// </summary>
@@ -230,6 +235,20 @@ public static void Main()
230235
IncludeStatusCode = false,
231236
SkipTypes = true
232237
}),
238+
// TsClient configuration for ExportTypes testing - exported interfaces in a separate importable module
239+
new TsClient(new TsClientOptions
240+
{
241+
FilePath = Path.Combine(TsClientExportOutputPath, "{0}.ts"),
242+
FileOverwrite = true,
243+
BySchema = true,
244+
IncludeHost = false,
245+
CreateSeparateTypeFile = true,
246+
ExportTypes = true,
247+
CommentHeader = CommentHeader.None,
248+
HeaderLines = [],
249+
SkipSchemas = ["public", "custom_param_schema", "my_schema", "custom_table_param_schema", ""],
250+
IncludeStatusCode = false
251+
}),
233252
// HttpFiles configuration for testing path parameters
234253
new HttpFile(new HttpFileOptions
235254
{
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
namespace NpgsqlRestTests
2+
{
3+
public static partial class Database
4+
{
5+
public static void TsClientExportTypesTests()
6+
{
7+
script.Append("""
8+
create function tsclient_test.search_products(_query text default null, _max_price numeric default null)
9+
returns table (id int, name text, price numeric)
10+
language sql
11+
as $$
12+
select 1, 'x'::text, 1.0::numeric;
13+
$$;
14+
comment on function tsclient_test.search_products(text, numeric) is '
15+
HTTP GET
16+
tsclient_module=search_products
17+
';
18+
""");
19+
}
20+
}
21+
}
22+
23+
namespace NpgsqlRestTests.TsClientTests
24+
{
25+
[Collection("TestFixture")]
26+
public class ExportTypesTests
27+
{
28+
// ExportTypes = true with CreateSeparateTypeFile = true: interfaces live in an importable
29+
// module {name}Types.ts (not an ambient .d.ts), emitted as `export interface`, and the
30+
// client file imports them by name.
31+
private const string ExpectedClient = """
32+
import type { ITsclientTestSearchProductsRequest, ITsclientTestSearchProductsResponse } from "./search_productsTypes";
33+
const baseUrl = "";
34+
const parseQuery = (query: Record<any, any>) => "?" + Object.keys(query ? query : {})
35+
.map(key => {
36+
const value = (query[key] != null ? query[key] : "") as string;
37+
if (Array.isArray(value)) {
38+
return value.map((s: string) => s ? `${key}=${encodeURIComponent(s)}` : `${key}=`).join("&");
39+
}
40+
return `${key}=${encodeURIComponent(value)}`;
41+
})
42+
.join("&");
43+
44+
/**
45+
*
46+
* @param request - Object containing request parameters.
47+
* @returns {ITsclientTestSearchProductsResponse[]}
48+
*
49+
* @see FUNCTION tsclient_test.search_products
50+
*/
51+
export async function tsclientTestSearchProducts(
52+
request: ITsclientTestSearchProductsRequest
53+
) : Promise<ITsclientTestSearchProductsResponse[]> {
54+
const response = await fetch(baseUrl + "/api/tsclient-test/search-products" + parseQuery(request), {
55+
method: "GET",
56+
headers: {
57+
"Content-Type": "application/json"
58+
},
59+
});
60+
return await response.json() as ITsclientTestSearchProductsResponse[];
61+
}
62+
63+
""";
64+
65+
private const string ExpectedTypes = """
66+
export interface ITsclientTestSearchProductsRequest {
67+
query?: string | null;
68+
maxPrice?: number | null;
69+
}
70+
71+
export interface ITsclientTestSearchProductsResponse {
72+
id: number | null;
73+
name: string | null;
74+
price: number | null;
75+
}
76+
77+
78+
""";
79+
80+
[Fact]
81+
public void Test_ExportTypes_ClientFile()
82+
{
83+
var filePath = Path.Combine(Setup.Program.TsClientExportOutputPath, "search_products.ts");
84+
File.Exists(filePath).Should().BeTrue($"Expected file at {filePath}");
85+
var content = File.ReadAllText(filePath);
86+
content.Should().Be(ExpectedClient);
87+
}
88+
89+
[Fact]
90+
public void Test_ExportTypes_TypeFile_IsImportableModule()
91+
{
92+
// Importable module: {name}Types.ts, not the ambient {name}Types.d.ts.
93+
var tsPath = Path.Combine(Setup.Program.TsClientExportOutputPath, "search_productsTypes.ts");
94+
var dtsPath = Path.Combine(Setup.Program.TsClientExportOutputPath, "search_productsTypes.d.ts");
95+
File.Exists(tsPath).Should().BeTrue($"Expected importable type module at {tsPath}");
96+
File.Exists(dtsPath).Should().BeFalse($"Ambient declaration file should not be produced at {dtsPath}");
97+
var content = File.ReadAllText(tsPath);
98+
content.Should().Be(ExpectedTypes);
99+
}
100+
}
101+
}

changelog/v3.17.0.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,15 @@ Authorization: Bearer {WEATHER_API_KEY}';
7070
- Resolved **once at startup**, matched **case-insensitively**, injected as the **raw** value. A **routine parameter of the same name takes precedence**.
7171
- **Security:** a value substituted into a *response* header is sent to the client — reserve secrets for outbound HTTP-type calls / custom parameters, and use response headers only for non-secret values (e.g. server/environment name).
7272

73+
### TsClient: `ExportTypes` — emit request/response interfaces with the `export` keyword
74+
75+
The TypeScript client generator (`NpgsqlRest.TsClient`) previously emitted its request/response (and composite) interfaces as plain `interface` declarations: module-private when inlined into the client file (`CreateSeparateTypeFile: false`), or ambient/global in the separate `{name}Types.d.ts` file (the default). Neither form could be imported by other modules. The new **`ExportTypes`** option (config `NpgsqlRest:ClientCodeGen:ExportTypes`, default `false`) emits them as `export interface` so they can be imported:
76+
77+
- **Inline** (`CreateSeparateTypeFile: false`) — interfaces are emitted as `export interface` in the same file as the functions.
78+
- **Separate file** (`CreateSeparateTypeFile: true`) — the type file becomes an importable module **`{name}Types.ts`** (`export interface …`) instead of an ambient **`{name}Types.d.ts`**, and the generated client file gets an `import type { … } from "./{name}Types";` referencing the named types.
79+
80+
Has no effect when `SkipTypes` is `true`. Defaulting to `false` keeps existing output byte-for-byte unchanged.
81+
7382
## Breaking Changes
7483

7584
### ⚠️ Safer configuration defaults: CORS credentials, passkey requirements, connection testing

plugins/NpgsqlRest.TsClient/TsClient.cs

Lines changed: 46 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,8 @@ private void Run(RoutineEndpoint[] endpoints, string? fileName)
117117
StringBuilder interfaces = new();
118118
StringBuilder compositeInterfaces = new();
119119
bool needsStatusTypes = false;
120+
// Plain `interface` (module-private inline / ambient in .d.ts) vs exported `export interface` (importable module).
121+
var interfaceDecl = options.ExportTypes ? "export interface" : "interface";
120122

121123
foreach (var import in options.CustomImports)
122124
{
@@ -298,7 +300,18 @@ options.ImportBaseUrlFrom is not null ?
298300
{
299301
if (!options.SkipTypes)
300302
{
301-
var typeFile = fileName.Replace(".ts", "Types.d.ts");
303+
// ExportTypes turns the separate file into an importable module (`{name}Types.ts` with `export interface`);
304+
// otherwise it stays an ambient global declaration file (`{name}Types.d.ts`) referenced without an import.
305+
var typeFile = fileName.Replace(".ts", options.ExportTypes ? "Types.ts" : "Types.d.ts");
306+
if (options.ExportTypes)
307+
{
308+
var typeNames = ExtractExportedTypeNames(interfaces.ToString());
309+
if (typeNames.Count > 0)
310+
{
311+
var moduleName = Path.GetFileNameWithoutExtension(typeFile);
312+
contentHeader.Insert(0, $"import type {{ {string.Join(", ", typeNames)} }} from \"./{moduleName}\";{Environment.NewLine}");
313+
}
314+
}
302315
AddHeader(interfaces);
303316
File.WriteAllText(typeFile, interfaces.ToString());
304317
Logger?.LogTrace("Created Typescript type file: {typeFile}", typeFile);
@@ -497,7 +510,7 @@ bool Handle(RoutineEndpoint endpoint)
497510
{
498511
modelsDict.Add(req.ToString(), requestName);
499512
}
500-
req.Insert(0, $"interface {requestName} {{{Environment.NewLine}");
513+
req.Insert(0, $"{interfaceDecl} {requestName} {{{Environment.NewLine}");
501514
req.AppendLine("}");
502515
req.AppendLine();
503516
interfaces.Append(req);
@@ -547,7 +560,7 @@ string GetReturnExp(string responseExp)
547560
responseName = $"I{pascal}Response";
548561
StringBuilder mcResp = new();
549562

550-
mcResp.AppendLine($"interface {responseName} {{");
563+
mcResp.AppendLine($"{interfaceDecl} {responseName} {{");
551564
foreach (var cmdInfo in routine.MultiCommandInfo)
552565
{
553566
if (cmdInfo.IsSkipped) continue;
@@ -600,7 +613,7 @@ string GetReturnExp(string responseExp)
600613
responseName = $"I{pascal}Response";
601614
StringBuilder resp = new();
602615

603-
resp.AppendLine($"interface {responseName} {{");
616+
resp.AppendLine($"{interfaceDecl} {responseName} {{");
604617
resp.AppendLine(" type: string;");
605618
resp.AppendLine(" fileName: string;");
606619
resp.AppendLine(" contentType: string;");
@@ -823,7 +836,7 @@ descriptor.CompositeFieldNames is not null &&
823836
{
824837
modelsDict.Add(resp.ToString(), responseName);
825838
}
826-
resp.Insert(0, $"interface {responseName} {{{Environment.NewLine}");
839+
resp.Insert(0, $"{interfaceDecl} {responseName} {{{Environment.NewLine}");
827840
resp.AppendLine("}");
828841
resp.AppendLine();
829842
interfaces.Append(resp);
@@ -1913,7 +1926,7 @@ private string GetOrCreateCompositeInterface(
19131926

19141927
// Build the interface
19151928
StringBuilder interfaceBuilder = new();
1916-
interfaceBuilder.AppendLine($"interface {interfaceName} {{");
1929+
interfaceBuilder.AppendLine($"{(options.ExportTypes ? "export interface" : "interface")} {interfaceName} {{");
19171930

19181931
for (var i = 0; i < fieldNames.Length; i++)
19191932
{
@@ -1960,4 +1973,31 @@ private string GetOrCreateCompositeInterface(
19601973

19611974
return interfaceName;
19621975
}
1976+
1977+
/// <summary>
1978+
/// Extracts the names of `export interface {name} {` declarations from generated type-file content,
1979+
/// so the client file can import them. Top-level declarations only — indented field lines and inline
1980+
/// object types never start with the keyword, so they are not matched.
1981+
/// </summary>
1982+
private static List<string> ExtractExportedTypeNames(string typeFileContent)
1983+
{
1984+
const string prefix = "export interface ";
1985+
var names = new List<string>();
1986+
foreach (var rawLine in typeFileContent.Split('\n'))
1987+
{
1988+
var line = rawLine.TrimStart();
1989+
if (!line.StartsWith(prefix, StringComparison.Ordinal))
1990+
{
1991+
continue;
1992+
}
1993+
var rest = line.Substring(prefix.Length);
1994+
var brace = rest.IndexOf('{');
1995+
var name = (brace >= 0 ? rest.Substring(0, brace) : rest).Trim();
1996+
if (name.Length > 0 && !names.Contains(name))
1997+
{
1998+
names.Add(name);
1999+
}
2000+
}
2001+
return names;
2002+
}
19632003
}

plugins/NpgsqlRest.TsClient/TsClientOptions.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,14 @@ public class TsClientOptions
5050
/// </summary>
5151
public bool CreateSeparateTypeFile { get; set; } = true;
5252

53+
/// <summary>
54+
/// Emit request/response (and composite) interfaces with the `export` keyword so they can be imported by other modules.
55+
/// When false (default), interfaces are emitted as plain `interface` declarations (module-private when inline, ambient/global in the separate `.d.ts` file).
56+
/// When true and CreateSeparateTypeFile is true, the separate type file becomes an importable module (`{name}Types.ts` with `export interface`) instead of an ambient `{name}Types.d.ts`, and the generated client file imports the named types from it.
57+
/// Has no effect when SkipTypes is true.
58+
/// </summary>
59+
public bool ExportTypes { get; set; } = false;
60+
5361
/// <summary>
5462
/// Lines to add to each header. {0} format placeholder is current timestamp
5563
/// </summary>

0 commit comments

Comments
 (0)