From 1bace2ae0e15608015291c6ac1090279cd1aa430 Mon Sep 17 00:00:00 2001 From: "Per Christian B. Viken" Date: Sun, 14 Jun 2026 10:18:31 +0200 Subject: [PATCH 1/3] feat(lib): Pass `IsGet`/`IsPost`/`IsPut`/`IsPatch`/`IsDelete` to API templates --- CHANGELOG.md | 2 ++ TypeContractor/Templates/EndpointTemplateDto.cs | 5 +++++ TypeContractor/TypeScript/ApiClientWriter.cs | 5 +++++ 3 files changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fbded91..fbe6b9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Allow `TypeContractorIgnore` to be used on controller methods +- Include `IsGet`/`IsPost`/`IsPut`/`IsPatch`/`IsDelete` + in API endpoint template DTOs ## [0.20.0] - 2026-05-30 diff --git a/TypeContractor/Templates/EndpointTemplateDto.cs b/TypeContractor/Templates/EndpointTemplateDto.cs index b9dc57c..e810faf 100644 --- a/TypeContractor/Templates/EndpointTemplateDto.cs +++ b/TypeContractor/Templates/EndpointTemplateDto.cs @@ -6,6 +6,11 @@ public record EndpointTemplateDto( string ObsoleteReason, string HttpMethod, string HttpMethodUppercase, + bool IsGet, + bool IsPost, + bool IsPut, + bool IsPatch, + bool IsDelete, string ReturnType, string? UnwrappedReturnType, string? UnwrappedReturnSchema, diff --git a/TypeContractor/TypeScript/ApiClientWriter.cs b/TypeContractor/TypeScript/ApiClientWriter.cs index 1bcf85c..14a6cde 100644 --- a/TypeContractor/TypeScript/ApiClientWriter.cs +++ b/TypeContractor/TypeScript/ApiClientWriter.cs @@ -124,6 +124,11 @@ public string Write(ApiClient apiClient, IEnumerable allTypes, TypeS endpoint.Obsolete?.Reason ?? "", method.ToLower(CultureInfo.InvariantCulture), method, + endpoint.Method is EndpointMethod.GET, + endpoint.Method is EndpointMethod.POST, + endpoint.Method is EndpointMethod.PUT, + endpoint.Method is EndpointMethod.PATCH, + endpoint.Method is EndpointMethod.DELETE, returnType, endpoint.UnwrappedReturnType?.Name, unwrappedReturnSchema, From 20c7397632f64f49437e58acb1570b4829293c89 Mon Sep 17 00:00:00 2001 From: "Per Christian B. Viken" Date: Sun, 14 Jun 2026 10:20:12 +0200 Subject: [PATCH 2/3] fix(lib): `DELETE` endpoints should not have bodies RFC 9110[1] specifies "A client SHOULD NOT generate content in a DELETE request", so don't do it here either. We don't know what intermediaries might be along the request chain, so don't assume it works. The previous commit passes `IsDelete` to the template, allowing us to generate clients using the Aurelia template, since that library allows bodies in `DELETE` requests. [1]: https://www.rfc-editor.org/rfc/rfc9110.html#section-9.3.5-6 --- CHANGELOG.md | 4 ++ .../TypeScript/ApiClientWriterTests.cs | 57 +++++++++++++++++-- TypeContractor/Templates/aurelia.hbs | 2 +- TypeContractor/TypeScript/ApiClientWriter.cs | 2 +- 4 files changed, 59 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fbe6b9c..a0fa5f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Include `IsGet`/`IsPost`/`IsPut`/`IsPatch`/`IsDelete` in API endpoint template DTOs +### Fixed + +- Don't set `RequireBody` for API endpoints using `DELETE` + ## [0.20.0] - 2026-05-30 ### Added diff --git a/TypeContractor.Tests/TypeScript/ApiClientWriterTests.cs b/TypeContractor.Tests/TypeScript/ApiClientWriterTests.cs index 7663348..c2067d0 100644 --- a/TypeContractor.Tests/TypeScript/ApiClientWriterTests.cs +++ b/TypeContractor.Tests/TypeScript/ApiClientWriterTests.cs @@ -27,10 +27,7 @@ public ApiClientWriterTests() _converter = new TypeScriptConverter(_configuration, BuildMetadataLoadContext()); Sut = new ApiClientWriter(_configuration.OutputPath, "~"); - var embed = typeof(ApiClientWriter).Assembly.GetManifestResourceStream("TypeContractor.Templates.aurelia.hbs"); - using var sr = new StreamReader(embed!); - var template = sr.ReadToEnd(); - _templateFn = Handlebars.Compile(template); + _templateFn = CompileTemplate("TypeContractor.Templates.aurelia.hbs"); } [Fact] @@ -143,6 +140,50 @@ public void Writes_Post_With_Body() .And.Contain("return await response.parseJson(z.string());"); } + [Fact] + public void Writes_Delete_Without_Body() + { + // Arrange + var apiClient = new ApiClient("TestClient", "TestController", "test", null); + apiClient.AddEndpoint(new ApiClientEndpoint("byId", "{id}", EndpointMethod.DELETE, null, null, false, [new EndpointParameter("id", typeof(Guid), null, false, false, true, false, false, false, false, false)], null)); + + // Act + var result = Sut.Write(apiClient, [], _converter, true, _templateFn, Casing.Pascal); + + // Assert + var file = File.ReadAllText(result).Trim(); + file.Should() + .NotBeEmpty() + .And.NotContain("import { z } from 'zod';") // No schema to care about + .And.Contain("export class TestClient {") + .And.Contain("public async byId(id: string, cancellationToken: AbortSignal = null): Promise {") + .And.Contain("const url = new URL(`test/${id}`, window.location.origin);") + .And.Contain("const response = await this.http.delete(`${url.pathname}${url.search}`.slice(1), null, { signal: cancellationToken });") + .And.Contain("return response;"); + } + + [Fact] + public void Writes_Delete_Without_Body_Using_React_Template() + { + // Arrange + var apiClient = new ApiClient("TestClient", "TestController", "test", null); + apiClient.AddEndpoint(new ApiClientEndpoint("byId", "{id}", EndpointMethod.DELETE, null, null, false, [new EndpointParameter("id", typeof(Guid), null, false, false, true, false, false, false, false, false)], null)); + + // Act + var result = Sut.Write(apiClient, [], _converter, true, CompileTemplate("TypeContractor.Templates.react-axios.hbs"), Casing.Pascal); + + // Assert + var file = File.ReadAllText(result).Trim(); + file.Should() + .NotBeEmpty() + .And.Contain("import axios from 'axios';") + .And.NotContain("import { z } from 'zod';") // No schema to care about + .And.Contain("export const TestClient = {") + .And.Contain("byId: async (id: string, signal: AbortSignal | undefined = undefined) => {") + .And.Contain("const url = new URL(`test/${id}`, window.location.origin);") + .And.Contain("return await axios.delete(`${url.pathname}${url.search}`.slice(1), { signal });"); + } + [Fact] public void Writes_Get_With_Query() { @@ -424,4 +465,12 @@ public void Dispose() if (_outputDirectory.Exists) _outputDirectory.Delete(true); } + + private static HandlebarsTemplate CompileTemplate(string resourceName) + { + var embed = typeof(ApiClientWriter).Assembly.GetManifestResourceStream(resourceName); + using var sr = new StreamReader(embed!); + var template = sr.ReadToEnd(); + return Handlebars.Compile(template); + } } diff --git a/TypeContractor/Templates/aurelia.hbs b/TypeContractor/Templates/aurelia.hbs index d08599a..5353693 100644 --- a/TypeContractor/Templates/aurelia.hbs +++ b/TypeContractor/Templates/aurelia.hbs @@ -63,7 +63,7 @@ export class {{Name}} { {{/if}} {{/if}} {{/each}} - const response = await this.http.{{HttpMethod}}(`${url.pathname}${url.search}`.slice(1), {{#if RequiresBody}}{{#if BodyParameter}}json({{BodyParameter}}){{else}}null{{/if}}, {{/if}}{ signal: cancellationToken }); + const response = await this.http.{{HttpMethod}}(`${url.pathname}${url.search}`.slice(1), {{#if RequiresBody}}{{#if BodyParameter}}json({{BodyParameter}}){{else}}null{{/if}}, {{else}}{{#if IsDelete}}null, {{/if}}{{/if}}{ signal: cancellationToken }); {{#if ../BuildZodSchema}} {{#if UnwrappedReturnType}} return await response.parseJson<{{UnwrappedReturnType}}{{#if EnumerableReturnType}}[]{{/if}}>({{UnwrappedReturnSchema}}{{#if EnumerableReturnType}}.array(){{/if}}); diff --git a/TypeContractor/TypeScript/ApiClientWriter.cs b/TypeContractor/TypeScript/ApiClientWriter.cs index 14a6cde..9205e74 100644 --- a/TypeContractor/TypeScript/ApiClientWriter.cs +++ b/TypeContractor/TypeScript/ApiClientWriter.cs @@ -105,7 +105,7 @@ public string Write(ApiClient apiClient, IEnumerable allTypes, TypeS } } - var requiresBody = endpoint.Method is EndpointMethod.POST or EndpointMethod.PUT or EndpointMethod.PATCH or EndpointMethod.DELETE; + var requiresBody = endpoint.Method is EndpointMethod.POST or EndpointMethod.PUT or EndpointMethod.PATCH; var body = endpoint.Parameters.FirstOrDefault(p => p.FromBody || (!p.FromRoute && !p.FromQuery)); var returnUnparsedResponse = endpoint.UnwrappedReturnType is null && endpoint.ReturnType is null; From f1747799b7316f00dcd7d6b552eac64427ec96d3 Mon Sep 17 00:00:00 2001 From: "Per Christian B. Viken" Date: Sun, 14 Jun 2026 10:27:41 +0200 Subject: [PATCH 3/3] fix(lib): `await` all requests in the React template --- CHANGELOG.md | 1 + TypeContractor/Templates/react-axios.hbs | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a0fa5f1..9fd7e70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Don't set `RequireBody` for API endpoints using `DELETE` +- `await` all requests in the React template ## [0.20.0] - 2026-05-30 diff --git a/TypeContractor/Templates/react-axios.hbs b/TypeContractor/Templates/react-axios.hbs index dabf513..f88fb55 100644 --- a/TypeContractor/Templates/react-axios.hbs +++ b/TypeContractor/Templates/react-axios.hbs @@ -66,7 +66,7 @@ else {{/if}} {{/each}} {{#if ReturnUnparsedResponse}} - return axios.{{HttpMethod}}{{#if UnwrappedReturnType}}<{{UnwrappedReturnType}}{{#if EnumerableReturnType}}[]{{/if}}>{{/if}}(`${url.pathname}${url.search}`.slice(1), {{#if RequiresBody}}{{#if BodyParameter}}{{BodyParameter}}{{else}}null{{/if}}, {{/if}}{ signal }); + return await axios.{{HttpMethod}}{{#if UnwrappedReturnType}}<{{UnwrappedReturnType}}{{#if EnumerableReturnType}}[]{{/if}}>{{/if}}(`${url.pathname}${url.search}`.slice(1), {{#if RequiresBody}}{{#if BodyParameter}}{{BodyParameter}}{{else}}null{{/if}}, {{/if}}{ signal }); {{else}} const response = await axios.{{HttpMethod}}{{#if UnwrappedReturnType}}<{{UnwrappedReturnType}}{{#if EnumerableReturnType}}[]{{/if}}>{{/if}}(`${url.pathname}${url.search}`.slice(1), {{#if RequiresBody}}{{#if BodyParameter}}{{BodyParameter}}{{else}}null{{/if}}, {{/if}}{ signal }); return response.data;