Skip to content

Commit 4787d9b

Browse files
mridangclaude
andcommitted
fix: resolve 29 cross-language parity issues across all 12 generators
Address all findings from cross-language audit: Ruby gsub fix, missing tests, DateTime format standardization, proxy validation, transport exception wrapping, multipart support, cookie escaping, binary response handling, and consistent oneOf/anyOf serialization. Add JUnit @tag annotations to all spec interfaces for per-language test filtering. Update README with full 12-language documentation. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent d47fabb commit 4787d9b

203 files changed

Lines changed: 3013 additions & 965 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 133 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,22 @@ Before code generation, the tool can preprocess and standardize your OpenAPI v3
2121

2222
**Opinionated Code Generators**
2323

24-
A set of custom generators that produce lean, modern clients for various languages. They are "opinionated" in that they make specific technology choices and generate only what is necessary.
25-
* **Java**: Generates a minimal client using Apache HttpClient for requests, Jackson for JSON serialization, and the modern `java.time` library for dates
26-
* **PHP**: Creates a Guzzle-based client that uses `camelCase` for variable and parameter naming and generates a `ModelInterface.php` for type-hinting.
27-
* **Python**: Produces a simple client built on `urllib3`. It generates the necessary `__init__.py` files to ensure the output is a well-formed Python package.
28-
* **Ruby**: Creates a modern client using Typhoeus for performance. It correctly generates namespaced modules and Zeitwerk-compatible, snake_cased filenames for seamless autoloading.
29-
* **Node.js / TypeScript**: A Fetch API-based client configured for modern JavaScript environments, supporting ES Modules with `.js` import extensions.
24+
A set of custom generators that produce lean, modern clients for 12 languages. They are "opinionated" in that they make specific technology choices and generate only what is necessary. All generators share a consistent architecture: a `BaseApi` for request construction, a `DefaultApiClient` for HTTP transport, an `ObjectSerializer` for JSON handling, a `ValueSerializer` for OpenAPI style/explode serialization, and `TransportOptions` for client configuration.
25+
26+
| Generator | Language | HTTP Client | Serialization |
27+
|-----------|----------|-------------|---------------|
28+
| `java-plus` | Java | `java.net.http.HttpClient` | Jackson |
29+
| `kotlin-plus` | Kotlin | OkHttp | kotlinx.serialization |
30+
| `csharp-plus` | C# | `System.Net.Http.HttpClient` | `System.Text.Json` |
31+
| `node-plus` | Node.js / TypeScript | Fetch API (undici) | Native JSON |
32+
| `python-plus` | Python | urllib3 | Native JSON |
33+
| `ruby-plus` | Ruby | Faraday | Native JSON |
34+
| `php-plus` | PHP | Symfony HttpClient | Native JSON |
35+
| `go-plus` | Go | `net/http` | `encoding/json` |
36+
| `rust-plus` | Rust | reqwest | serde |
37+
| `swift-plus` | Swift | URLSession | `JSONEncoder`/`JSONDecoder` |
38+
| `dart-plus` | Dart | `package:http` | `dart:convert` |
39+
| `elixir-plus` | Elixir | Req | Jason |
3040

3141
## Installation
3242

@@ -233,6 +243,123 @@ docker run --rm \
233243
--config="/local/config.yml"
234244
```
235245
246+
### Generate a C# Client
247+
248+
```yaml
249+
packageName: PetstoreClient
250+
sourceFolder: src
251+
```
252+
253+
```shell
254+
docker run --rm \
255+
--volume="${PWD}:/local" \
256+
my-generator generate \
257+
--input-spec="/local/spec.json" \
258+
--generator-name="csharp-plus" \
259+
--output="/local/client" \
260+
--config="/local/config.yml"
261+
```
262+
263+
### Generate a Go Client
264+
265+
```yaml
266+
packageName: petstore
267+
```
268+
269+
```shell
270+
docker run --rm \
271+
--volume="${PWD}:/local" \
272+
my-generator generate \
273+
--input-spec="/local/spec.json" \
274+
--generator-name="go-plus" \
275+
--output="/local/client" \
276+
--config="/local/config.yml"
277+
```
278+
279+
### Generate a Kotlin Client
280+
281+
```yaml
282+
groupId: com.example
283+
invokerPackage: com.example.petstore
284+
apiPackage: com.example.petstore.api
285+
modelPackage: com.example.petstore.models
286+
```
287+
288+
```shell
289+
docker run --rm \
290+
--volume="${PWD}:/local" \
291+
my-generator generate \
292+
--input-spec="/local/spec.json" \
293+
--generator-name="kotlin-plus" \
294+
--output="/local/client" \
295+
--config="/local/config.yml"
296+
```
297+
298+
### Generate a Rust Client
299+
300+
```yaml
301+
packageName: petstore
302+
```
303+
304+
```shell
305+
docker run --rm \
306+
--volume="${PWD}:/local" \
307+
my-generator generate \
308+
--input-spec="/local/spec.json" \
309+
--generator-name="rust-plus" \
310+
--output="/local/client" \
311+
--config="/local/config.yml"
312+
```
313+
314+
### Generate a Swift Client
315+
316+
```yaml
317+
projectName: PetstoreClient
318+
```
319+
320+
```shell
321+
docker run --rm \
322+
--volume="${PWD}:/local" \
323+
my-generator generate \
324+
--input-spec="/local/spec.json" \
325+
--generator-name="swift-plus" \
326+
--output="/local/client" \
327+
--config="/local/config.yml"
328+
```
329+
330+
### Generate a Dart Client
331+
332+
```yaml
333+
pubName: petstore_client
334+
pubVersion: 1.0.0
335+
```
336+
337+
```shell
338+
docker run --rm \
339+
--volume="${PWD}:/local" \
340+
my-generator generate \
341+
--input-spec="/local/spec.json" \
342+
--generator-name="dart-plus" \
343+
--output="/local/client" \
344+
--config="/local/config.yml"
345+
```
346+
347+
### Generate an Elixir Client
348+
349+
```yaml
350+
packageName: petstore_client
351+
```
352+
353+
```shell
354+
docker run --rm \
355+
--volume="${PWD}:/local" \
356+
my-generator generate \
357+
--input-spec="/local/spec.json" \
358+
--generator-name="elixir-plus" \
359+
--output="/local/client" \
360+
--config="/local/config.yml"
361+
```
362+
236363
## Caveats
237364
238365
The following OAS 3.0 features are deliberately out of scope for this project:

src/main/resources/templates/csharp/base_api.mustache

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,8 @@ public abstract class BaseApi
136136
Dictionary<string, string> cookies = effectiveAuth.GetCookieParams();
137137
if (cookies.Count > 0)
138138
{
139-
string cookieStr = string.Join("; ", cookies.Select(c => c.Key + "=" + c.Value));
139+
string cookieStr = string.Join("; ", cookies.Select(c =>
140+
Uri.EscapeDataString(c.Key) + "=" + Uri.EscapeDataString(c.Value)));
140141
headers["Cookie"] =
141142
headers.TryGetValue("Cookie", out string? existing)
142143
&& !string.IsNullOrEmpty(existing)

src/main/resources/templates/csharp/default_api_client.mustache

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -193,10 +193,27 @@ public sealed class DefaultApiClient : IApiClient, IDisposable
193193
}
194194
}
195195

196-
using HttpResponseMessage response = await _httpClient
197-
.SendAsync(request)
198-
.ConfigureAwait(false);
199-
string responseBody = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
196+
HttpResponseMessage response;
197+
try
198+
{
199+
response = await _httpClient
200+
.SendAsync(request)
201+
.ConfigureAwait(false);
202+
}
203+
catch (HttpRequestException ex)
204+
{
205+
throw new ApiException(ex.Message, ex);
206+
}
207+
catch (TaskCanceledException ex) when (ex.InnerException is TimeoutException)
208+
{
209+
throw new ApiException("Request timed out", ex);
210+
}
211+
212+
byte[] responseBytes = await response.Content.ReadAsByteArrayAsync().ConfigureAwait(false);
213+
string responseBody = IsTextContentType(response.Content.Headers.ContentType?.MediaType)
214+
? Encoding.UTF8.GetString(responseBytes)
215+
: Convert.ToBase64String(responseBytes);
216+
200217
Dictionary<string, string> responseHeaders = [];
201218

202219
foreach (KeyValuePair<string, IEnumerable<string>> header in response.Headers)
@@ -283,6 +300,23 @@ public sealed class DefaultApiClient : IApiClient, IDisposable
283300
return "gzip, deflate, br";
284301
}
285302

303+
/// <summary>
304+
/// Determines whether the given media type represents text content
305+
/// that is safe to decode as a UTF-8 string.
306+
/// </summary>
307+
/// <param name="mediaType">The Content-Type media type, or null.</param>
308+
/// <returns><c>true</c> for text-like content types, <c>false</c> otherwise.</returns>
309+
private static bool IsTextContentType(string? mediaType)
310+
{
311+
return string.IsNullOrEmpty(mediaType)
312+
|| mediaType.StartsWith("text/", StringComparison.OrdinalIgnoreCase)
313+
|| mediaType.Equals("application/json", StringComparison.OrdinalIgnoreCase)
314+
|| mediaType.Equals("application/xml", StringComparison.OrdinalIgnoreCase)
315+
|| mediaType.Equals("application/javascript", StringComparison.OrdinalIgnoreCase)
316+
|| mediaType.EndsWith("+json", StringComparison.OrdinalIgnoreCase)
317+
|| mediaType.EndsWith("+xml", StringComparison.OrdinalIgnoreCase);
318+
}
319+
286320
/// <inheritdoc/>
287321
public void Dispose()
288322
{

src/main/resources/templates/csharp/object_serializer.mustache

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -150,20 +150,20 @@ public class ObjectSerializer
150150

151151
/// <summary>
152152
/// Resolve a oneOf schema by attempting deserialization against each candidate.
153-
/// Each candidate is a function that accepts a JSON string and returns a
154-
/// deserialized value, or throws on failure.
153+
/// Each candidate is a function that accepts a parsed <see cref="JsonElement"/>
154+
/// and returns a deserialized value, or throws on failure.
155155
/// Returns the first successful deserialization result.
156156
/// </summary>
157-
public static object? ResolveOneOf(string json, params Func<string, object?>[] candidates)
157+
public static object? ResolveOneOf(JsonElement json, params Func<JsonElement, object?>[] candidates)
158158
{
159159
ArgumentNullException.ThrowIfNull(candidates);
160160
161-
if (string.IsNullOrEmpty(json))
161+
if (json.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null)
162162
{
163163
return null;
164164
}
165165

166-
foreach (Func<string, object?> candidate in candidates)
166+
foreach (Func<JsonElement, object?> candidate in candidates)
167167
{
168168
try
169169
{
@@ -186,11 +186,11 @@ public class ObjectSerializer
186186

187187
/// <summary>
188188
/// Resolve an anyOf schema by attempting deserialization against each candidate.
189-
/// Each candidate is a function that accepts a JSON string and returns a
190-
/// deserialized value, or throws on failure.
189+
/// Each candidate is a function that accepts a parsed <see cref="JsonElement"/>
190+
/// and returns a deserialized value, or throws on failure.
191191
/// Returns the first successful deserialization result.
192192
/// </summary>
193-
public static object? ResolveAnyOf(string json, params Func<string, object?>[] candidates)
193+
public static object? ResolveAnyOf(JsonElement json, params Func<JsonElement, object?>[] candidates)
194194
{
195195
return ResolveOneOf(json, candidates);
196196
}

src/main/resources/templates/csharp/test/BaseApiTest.mustache

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,21 @@ public class BaseApiTest
4242
method, path, queryParams, headerParams, body,
4343
accepts, contentType, typeof(T), auth);
4444
}
45+
46+
public async Task<object?> CallAsyncWithNullReturnType(
47+
string method,
48+
string path,
49+
Dictionary<string, object?> queryParams,
50+
Dictionary<string, string> headerParams,
51+
object? body,
52+
string[] accepts,
53+
string contentType,
54+
IAuthenticator? auth = null)
55+
{
56+
return await InvokeApiAsync<object>(
57+
method, path, queryParams, headerParams, body,
58+
accepts, contentType, null, auth);
59+
}
4560
}
4661

4762
private sealed class CapturingApiClient : IApiClient
@@ -174,6 +189,17 @@ public class BaseApiTest
174189
Assert.Equal("success", result!["message"]?.GetValue<string>());
175190
}
176191

192+
[Fact]
193+
public async Task ReturnsNullWhenReturnTypeIsNull()
194+
{
195+
var result = await Api().CallAsyncWithNullReturnType(
196+
"GET", "/api/test",
197+
new Dictionary<string, object?>(),
198+
new Dictionary<string, string>(),
199+
null, ["application/json"], "application/json");
200+
Assert.Null(result);
201+
}
202+
177203
[Fact]
178204
public async Task ReturnsRawStringForNonJson()
179205
{

src/main/resources/templates/csharp/test/DefaultApiClientTest.mustache

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ public class DefaultApiClientTest
135135
136136
var client = new DefaultApiClient(transport);
137137
138-
await Assert.ThrowsAsync<TaskCanceledException>(() =>
138+
await Assert.ThrowsAsync<ApiException>(() =>
139139
client.SendRequestAsync(
140140
"GET",
141141
new Uri(_fixture.WireMockHttpUrl + "/api/slow"),

src/main/resources/templates/csharp/transport_options.mustache

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,17 @@ public sealed class TransportOptionsBuilder
182182
{
183183
if (proxy != null)
184184
{
185-
_ = new Uri(proxy);
185+
Uri uri = new(proxy);
186+
if (uri.Scheme is not "http" and not "https")
187+
{
188+
throw new UriFormatException(
189+
$"Invalid proxy URL (must use http or https scheme): {proxy}");
190+
}
191+
if (string.IsNullOrEmpty(uri.Host))
192+
{
193+
throw new UriFormatException(
194+
$"Invalid proxy URL (missing host): {proxy}");
195+
}
186196
}
187197
_proxy = proxy;
188198
return this;

src/main/resources/templates/dart/api_client.mustache

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,14 @@ abstract class ApiClient {
1212
/// - [method]: HTTP method (GET, POST, PUT, DELETE, etc.)
1313
/// - [url]: fully qualified URL
1414
/// - [headers]: caller-provided headers
15-
/// - [body]: request body, or null
15+
/// - [body]: request body as [Uint8List], [Map<String, Object>] for
16+
/// multipart form data, or null
1617
///
1718
/// Returns an [ApiResponse] and throws on transport errors.
1819
Future<ApiResponse> sendRequest(
1920
String method,
2021
String url,
2122
Map<String, String> headers,
22-
Uint8List? body,
23+
Object? body,
2324
);
2425
}

src/main/resources/templates/dart/base_api.mustache

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ class BaseApi {
9292
final cookies = effectiveAuth.cookieParams();
9393
if (cookies.isNotEmpty) {
9494
final cookieParts = cookies.entries
95-
.map((e) => '${e.key}=${e.value}')
95+
.map((e) => '${Uri.encodeComponent(e.key)}=${Uri.encodeComponent(e.value)}')
9696
.toList();
9797
final cookieStr = cookieParts.join('; ');
9898
if (headers.containsKey('Cookie')) {

0 commit comments

Comments
 (0)