Skip to content

Commit 49bc027

Browse files
committed
fix(docs): read spec examples as text so a type mismatch cannot fail generation
1 parent 35147d4 commit 49bc027

2 files changed

Lines changed: 38 additions & 10 deletions

File tree

.github/workflows/ci.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,12 @@ jobs:
2626
dotnet tool install --global Microsoft.OpenApi.Kiota --version 1.32.2
2727
echo "$HOME/.dotnet/tools" >> "$GITHUB_PATH"
2828
29+
# Hermetic: regenerates from the committed spec, so this verifies that the committed
30+
# generated code still reproduces from it, with no network dependency. Refreshing the
31+
# spec itself is the release workflow's job.
2932
- name: Codegen drift check
33+
env:
34+
ROXYAPI_SPEC_FILE: specs/openapi.json
3035
run: |
3136
dotnet run --project tools/RoxyDevTools -- generate
3237
git diff --exit-code -- specs/openapi.json src/Generated \

tools/RoxyDevTools/Program.cs

Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,7 @@ int Usage()
4141

4242
async Task<int> GenerateAsync()
4343
{
44-
Console.WriteLine($"Fetching OpenAPI spec from {SpecUrl}");
45-
using var http = new HttpClient();
46-
http.DefaultRequestHeaders.Add("Cache-Control", "no-cache");
47-
var raw = await FetchSpecAsync(http);
44+
var raw = await LoadSpecAsync();
4845

4946
var spec = JsonNode.Parse(raw)!.AsObject();
5047
PatchServerUrl(spec);
@@ -64,6 +61,22 @@ async Task<int> GenerateAsync()
6461
return SyncDocs();
6562
}
6663

64+
// Read the spec from disk when ROXYAPI_SPEC_FILE is set, fetch it otherwise. Reading from a file
65+
// keeps generation offline and byte-reproducible, which is what the CI drift check relies on.
66+
async Task<string> LoadSpecAsync()
67+
{
68+
var file = Environment.GetEnvironmentVariable("ROXYAPI_SPEC_FILE");
69+
if (!string.IsNullOrEmpty(file))
70+
{
71+
Console.WriteLine($"Reading OpenAPI spec from {file} (offline, ROXYAPI_SPEC_FILE)");
72+
return await File.ReadAllTextAsync(file);
73+
}
74+
Console.WriteLine($"Fetching OpenAPI spec from {SpecUrl}");
75+
using var http = new HttpClient();
76+
http.DefaultRequestHeaders.Add("Cache-Control", "no-cache");
77+
return await FetchSpecAsync(http);
78+
}
79+
6780
// Retry with exponential backoff: a transient upstream error (e.g. a CDN 520)
6881
// must not fail the daily release run.
6982
async Task<string> FetchSpecAsync(HttpClient http, int attempts = 5)
@@ -488,12 +501,12 @@ string RenderCall(JsonObject spec, string path, string verb, JsonObject op)
488501
}
489502

490503
var schema = Deref(spec, schemaIn);
491-
var type = schema["type"]?.GetValue<string>();
504+
var type = TypeOf(schema);
492505
var format = schema["format"]?.GetValue<string>();
493506

494507
if (type == "string" && format == "date")
495508
{
496-
var s = example?.GetValue<string>() ?? "1990-01-15";
509+
var s = ExampleText(example) ?? "1990-01-15";
497510
var p = s.Split('-');
498511
return p.Length == 3 && int.TryParse(p[0], out var y) && int.TryParse(p[1], out var m) && int.TryParse(p[2], out var d)
499512
? $"new Date({y}, {m}, {d})" : "new Date(1990, 1, 15)";
@@ -503,14 +516,14 @@ string RenderCall(JsonObject spec, string path, string verb, JsonObject op)
503516
// the Time(hour, minute, second) constructor (seconds default to 0 when absent).
504517
if (type == "string" && format == "time")
505518
{
506-
var s = example?.GetValue<string>() ?? "14:30:00";
519+
var s = ExampleText(example) ?? "14:30:00";
507520
var p = s.Split(':');
508521
var sec = p.Length > 2 && int.TryParse(p[2], out var se) ? se : 0;
509522
return p.Length >= 2 && int.TryParse(p[0], out var h) && int.TryParse(p[1], out var mi)
510523
? $"new Time({h}, {mi}, {sec})" : "new Time(14, 30, 0)";
511524
}
512525
if (type == "string" && format == "date-time")
513-
return $"DateTimeOffset.Parse({Quote(example?.GetValue<string>() ?? "2026-01-01T00:00:00Z")})";
526+
return $"DateTimeOffset.Parse({Quote(ExampleText(example) ?? "2026-01-01T00:00:00Z")})";
514527
if (type == "object" || schema["properties"] is JsonObject)
515528
return RenderObject(spec, schema, depth);
516529
if (type == "array" && schema["items"] is JsonObject items)
@@ -523,7 +536,7 @@ string RenderCall(JsonObject spec, string path, string verb, JsonObject op)
523536
return example is JsonValue num && num.TryGetValue<double>(out var d2) ? FormatNumber(d2) : (type == "integer" ? "0" : "0.0");
524537
if (type == "boolean")
525538
return example is JsonValue b && b.TryGetValue<bool>(out var bb) ? (bb ? "true" : "false") : "false";
526-
return Quote(example?.GetValue<string>() ?? "string");
539+
return Quote(ExampleText(example) ?? "string");
527540
}
528541

529542
// Render a path or query parameter value. Path indexers and query properties are typed
@@ -575,6 +588,16 @@ JsonObject Deref(JsonObject spec, JsonObject schema, int depth = 0)
575588
return param["schema"] is JsonObject s ? ExampleOf(spec, s) : null;
576589
}
577590

591+
// Read a spec `example` as text, whatever JSON type it actually carries. An example is not
592+
// guaranteed to match the `type` its own schema declares, and reading one through the declared type
593+
// would throw on a mismatch. A mismatched scalar renders as its literal text (5.5 -> "5.5", which
594+
// the wire accepts); a non-scalar yields null so the caller falls back to its own default.
595+
string? ExampleText(JsonNode? example) => example is JsonValue v ? v.ToString() : null;
596+
597+
// Same hazard as ExampleText for the `type` keyword itself: this spec is OpenAPI 3.1, where
598+
// `type` may legally be an array (`["string", "null"]`). Only a plain string names one type.
599+
string? TypeOf(JsonObject schema) => schema["type"] is JsonValue t ? t.ToString() : null;
600+
578601
// Enum-like = a direct enum, or an anyOf/oneOf whose branches are enums (e.g. houseSystem).
579602
// Kiota generates a dedicated enum type for these; we cannot name its members from an example.
580603
bool IsEnumLike(JsonObject spec, JsonObject schema)
@@ -588,7 +611,7 @@ bool IsEnumLike(JsonObject spec, JsonObject schema)
588611
bool IsNumberStringUnion(JsonObject spec, JsonObject schema)
589612
{
590613
if ((schema["anyOf"] ?? schema["oneOf"]) is not JsonArray arr) return false;
591-
var types = arr.Select(x => Deref(spec, (JsonObject)x!)["type"]?.GetValue<string>()).ToHashSet();
614+
var types = arr.Select(x => TypeOf(Deref(spec, (JsonObject)x!))).ToHashSet();
592615
return types.Contains("string") && (types.Contains("number") || types.Contains("integer"));
593616
}
594617

0 commit comments

Comments
 (0)