@@ -41,10 +41,7 @@ int Usage()
4141
4242async 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.
6982async 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.
580603bool IsEnumLike ( JsonObject spec , JsonObject schema )
@@ -588,7 +611,7 @@ bool IsEnumLike(JsonObject spec, JsonObject schema)
588611bool 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