From f1536deccd0dbae27dfd539b61a9b637c961fb6a Mon Sep 17 00:00:00 2001 From: Filip Seman Date: Mon, 26 Oct 2026 18:21:12 +0100 Subject: [PATCH 01/18] fix(parser): join array params by their style, fill server variables What typescript-fetch got wrong traced back to the parser in places every generator shares: - an array parameter joins its items as its style says (csv, ssv, pipes, one per value for an exploded form) instead of always "multi"; - the base path takes the defaults of the first server's variables; - an enum's string literal escapes line breaks (U+2028 and U+2029 too), and go-fetch reads them back; - a description holding "/*/*" no longer ends its doc comment: the two replacements run one after the other, as upstream's do; - a union with an inline-object member is any, not its other members (string | object read as string); - a nested container in a response imports its innermost model, not "ArrayCell"; - a form field keeps its free-form flag, and an anyOf with inline members says so (x-any-of-inline). dart-fetch gains the same import and enum escape; go-fetch's output is unchanged. --- internal/generator/golang/fetch.go | 2 +- internal/generator/golang/fetch_test.go | 2 +- internal/parser/enum_test.go | 10 ++--- internal/parser/explode_test.go | 45 ++++++++++++++++++++ internal/parser/imports.go | 17 ++++---- internal/parser/imports_test.go | 6 +-- internal/parser/model.go | 12 ++++++ internal/parser/naming.go | 10 ++--- internal/parser/naming_test.go | 1 + internal/parser/operation.go | 1 + internal/parser/parameter.go | 19 +++++++-- internal/parser/parser.go | 14 +++++- internal/parser/parser_test.go | 29 +++++++++++++ internal/parser/property.go | 8 +++- templates/go-fetch/README.md | 9 ++-- testdata/specs/feature/enum-edge-values.yaml | 2 +- 16 files changed, 152 insertions(+), 35 deletions(-) create mode 100644 internal/parser/parser_test.go diff --git a/internal/generator/golang/fetch.go b/internal/generator/golang/fetch.go index d9dea6c..4beae31 100644 --- a/internal/generator/golang/fetch.go +++ b/internal/generator/golang/fetch.go @@ -683,7 +683,7 @@ func jsonTag(nameOpts string) string { // property an inline enum belongs to, nil for an enum model. func enumDecl(name, base string, allowable map[string]any, owner *codegen.CodegenProperty) map[string]any { vars, _ := allowable["enumVars"].([]map[string]any) - unescape := strings.NewReplacer(`\\`, `\`, `\'`, `'`) + unescape := strings.NewReplacer(`\\`, `\`, `\'`, `'`, `\n`, "\n", `\r`, "\r", `\u2028`, "\u2028", `\u2029`, "\u2029") values := make([]map[string]any, 0, len(vars)) for _, v := range vars { diff --git a/internal/generator/golang/fetch_test.go b/internal/generator/golang/fetch_test.go index 110078c..0ba6b55 100644 --- a/internal/generator/golang/fetch_test.go +++ b/internal/generator/golang/fetch_test.go @@ -94,7 +94,7 @@ func TestEnums(t *testing.T) { literals []string }{ {"Level", "int", []string{"-1", "1", "2"}}, - {"Severity", "string", []string{`"low"`, `"LOW"`, `"it's"`, `"a\\b"`}}, + {"Severity", "string", []string{`"low"`, `"LOW"`, `"it's"`, `"a\\b"`, `"two\nlines"`}}, {"State", "string", []string{`"placed"`, `"approved"`}}, {"Rating", "int", []string{"1", "5"}}, } diff --git a/internal/parser/enum_test.go b/internal/parser/enum_test.go index f2f2674..ee8f146 100644 --- a/internal/parser/enum_test.go +++ b/internal/parser/enum_test.go @@ -22,7 +22,7 @@ components: type: integer enum: [0, 99, -1] Untyped: - enum: [placed, 'a\b'] + enum: [placed, 'a\b', "two\nlines"] UntypedNumbers: enum: [1, 2] `) @@ -46,16 +46,16 @@ components: }{ {"Status", []string{"Active", "WeirdValue", "Active2"}, true}, {"Priority", []string{"_0", "_99", "Minus1"}, false}, - {"Untyped", []string{"Placed", "AB"}, true}, // no type: quoted, as upstream - {"UntypedNumbers", []string{"_1", "_2"}, false}, // ...unless all are numbers + {"Untyped", []string{"Placed", "AB", "TwoLines"}, true}, // no type: quoted, as upstream + {"UntypedNumbers", []string{"_1", "_2"}, false}, // ...unless all are numbers } { vars, _ := findModel(t, models, tt.model).AllowableValues["enumVars"].([]map[string]any) if len(vars) != len(tt.want) { t.Fatalf("%s enumVars = %v, want names %v", tt.model, vars, tt.want) } - if tt.model == "Untyped" && vars[1]["value"] != `a\\b` { - t.Errorf("Untyped value = %v, want the backslash escaped", vars[1]["value"]) + if tt.model == "Untyped" && (vars[1]["value"] != `a\\b` || vars[2]["value"] != `two\nlines`) { + t.Errorf("Untyped values = %v, %v, want the backslash and the line break escaped", vars[1]["value"], vars[2]["value"]) } for i, v := range vars { diff --git a/internal/parser/explode_test.go b/internal/parser/explode_test.go index fdc0860..0bf3986 100644 --- a/internal/parser/explode_test.go +++ b/internal/parser/explode_test.go @@ -55,3 +55,48 @@ paths: } } } + +// TestCollectionFormatByStyle checks how an array parameter's items join, as +// upstream's getCollectionFormat derives it from style and explode. +func TestCollectionFormatByStyle(t *testing.T) { + spec := []byte(` +openapi: 3.0.0 +info: {title: Styles, version: 1.0.0} +paths: + /q/{p}: + get: + operationId: getQ + parameters: + - {name: p, in: path, required: true, schema: {type: array, items: {type: string}}} + - {name: exploded, in: query, schema: {type: array, items: {type: string}}} + - {name: joined, in: query, explode: false, schema: {type: array, items: {type: string}}} + - {name: spaced, in: query, style: spaceDelimited, explode: false, schema: {type: array, items: {type: string}}} + - {name: piped, in: query, style: pipeDelimited, explode: false, schema: {type: array, items: {type: string}}} + - {name: X-Ids, in: header, explode: true, schema: {type: array, items: {type: string}}} + responses: + '204': + description: ok +`) + + p := NewParser() + + p.SkipValidation = true + if err := p.LoadFromData(spec); err != nil { + t.Fatalf("LoadFromData: %v", err) + } + + ops, err := p.GetOperations() + if err != nil { + t.Fatalf("GetOperations: %v", err) + } + + want := map[string]string{"p": "csv", "exploded": "multi", "joined": "csv", "spaced": "ssv", "piped": "pipes", "X-Ids": "csv"} + + for _, list := range ops { + for _, param := range list[0].AllParams { + if param.CollectionFormat != want[param.BaseName] || param.IsCollectionFormatMulti != (want[param.BaseName] == "multi") { + t.Errorf("%s: collectionFormat %q, want %q", param.BaseName, param.CollectionFormat, want[param.BaseName]) + } + } + } +} diff --git a/internal/parser/imports.go b/internal/parser/imports.go index 7e7a37e..a9d3e67 100644 --- a/internal/parser/imports.go +++ b/internal/parser/imports.go @@ -92,19 +92,18 @@ func (p *Parser) collectOperationImports(op *codegen.CodegenOperation) []string } } - // A map of arrays has the base type "Array"; the model to import is X. - returnBaseType := op.ReturnBaseType - if rp := op.ReturnProperty; rp != nil && rp.IsMap && rp.Items != nil && rp.Items.IsArray { - returnBaseType = rp.Items.BaseType - } - // From return type - also check that ReturnBaseType is not primitive. A // composite (union/intersection) return type imports its member models // instead of the joined string, which is not an importable identifier; // such returns carry ReturnTypeIsPrimitive so no FromJSON import is needed. - if op.ReturnType != "" && !op.ReturnTypeIsPrimitive && - !isPrimitiveType(op.ReturnType) && !isPrimitiveType(returnBaseType) && !isCompositeType(returnBaseType) { - imports[returnBaseType] = true + // A container's models are its items' at any depth: Array> + // and a map of arrays of Cell import Cell. + switch rp := op.ReturnProperty; { + case rp != nil && rp.IsContainer: + collectPropertyImports(rp.Items, "", imports) + case op.ReturnType != "" && !op.ReturnTypeIsPrimitive && + !isPrimitiveType(op.ReturnType) && !isPrimitiveType(op.ReturnBaseType) && !isCompositeType(op.ReturnBaseType): + imports[op.ReturnBaseType] = true } for _, m := range op.ReturnComposedModels { diff --git a/internal/parser/imports_test.go b/internal/parser/imports_test.go index c8a300b..241b432 100644 --- a/internal/parser/imports_test.go +++ b/internal/parser/imports_test.go @@ -64,8 +64,8 @@ components: } // TestMapOfArraysResponseImportsItemModel guards the return import of a map of arrays: -// { [key: string]: Array; } imports Pet, not a non-existent ArrayPet, and a map of -// arrays of primitives or of nested arrays imports nothing and needs no mapper. +// { [key: string]: Array; } imports Pet, not a non-existent ArrayPet, a map of +// arrays of primitives imports nothing, and one of nested arrays its innermost model. func TestMapOfArraysResponseImportsItemModel(t *testing.T) { spec := []byte(` openapi: 3.1.0 @@ -115,7 +115,7 @@ components: t.Fatalf("GetOperations: %v", err) } - want := map[string][]string{"getPets": {"Pet"}, "getTags": {}, "getGrid": {}} + want := map[string][]string{"getPets": {"Pet"}, "getTags": {}, "getGrid": {"Pet"}} for _, group := range ops { for _, op := range group { diff --git a/internal/parser/model.go b/internal/parser/model.go index caa23ab..9d9ac44 100644 --- a/internal/parser/model.go +++ b/internal/parser/model.go @@ -1,6 +1,7 @@ package parser import ( + "maps" "slices" "sort" @@ -164,6 +165,17 @@ func (p *Parser) schemaToModel(name string, schema *openapi3.Schema) *codegen.Co model.AnyOf = append(model.AnyOf, extractRefName(ref.Ref)) } } + + // AnyOf names the $ref members only; x-any-of-inline says there are + // others. A copy: the extensions are the document's own map. + if len(model.AnyOf) < len(schema.AnyOf) { + model.VendorExtensions = maps.Clone(model.VendorExtensions) + if model.VendorExtensions == nil { + model.VendorExtensions = map[string]any{} + } + + model.VendorExtensions["x-any-of-inline"] = true + } } if len(schema.AllOf) > 0 { diff --git a/internal/parser/naming.go b/internal/parser/naming.go index 3c7d48e..6cfff87 100644 --- a/internal/parser/naming.go +++ b/internal/parser/naming.go @@ -6,14 +6,14 @@ import ( "unicode" ) -// unsafeCommentChars maps comment delimiters in spec description text to +// escapeUnsafeChars maps comment delimiters in spec description text to // harmless variants so they cannot terminate a generated doc comment early // (e.g. a Kubernetes description containing "'*/scale'"), mirroring upstream -// AbstractTypeScriptClientCodegen.escapeUnsafeCharacters. -var unsafeCommentChars = strings.NewReplacer("*/", "*_/", "/*", "/_*") - +// AbstractTypeScriptClientCodegen.escapeUnsafeCharacters: one replacement +// after the other, as one pass would turn "/*/*" into "/_*/_*", which holds +// a "*/" again. func escapeUnsafeChars(s string) string { - return unsafeCommentChars.Replace(s) + return strings.ReplaceAll(strings.ReplaceAll(s, "*/", "*_/"), "/*", "/_*") } func toCamelCase(s string) string { diff --git a/internal/parser/naming_test.go b/internal/parser/naming_test.go index 0269583..895954b 100644 --- a/internal/parser/naming_test.go +++ b/internal/parser/naming_test.go @@ -113,6 +113,7 @@ func TestEscapeUnsafeChars(t *testing.T) { {"a */ b", "a *_/ b"}, {"a /* b", "a /_* b"}, {"*/ then /*", "*_/ then /_*"}, + {"release/*/*", "release/_*_/_*"}, {"", ""}, } for _, tt := range tests { diff --git a/internal/parser/operation.go b/internal/parser/operation.go index d8ec423..2252469 100644 --- a/internal/parser/operation.go +++ b/internal/parser/operation.go @@ -297,6 +297,7 @@ func (p *Parser) addFormParams(co *codegen.CodegenOperation, schema *openapi3.Sc IsContainer: prop.IsContainer, IsPrimitiveType: prop.IsPrimitiveType, IsModel: prop.IsModel, + IsFreeFormObject: prop.IsFreeFormObject, IsString: prop.IsString, IsNumber: prop.IsNumber, IsInteger: prop.IsInteger, diff --git a/internal/parser/parameter.go b/internal/parser/parameter.go index c68b347..597a113 100644 --- a/internal/parser/parameter.go +++ b/internal/parser/parameter.go @@ -88,10 +88,23 @@ func (p *Parser) parameterToCodegen(param *openapi3.Parameter) *codegen.CodegenP cp.IsNullable = prop.IsNullable cp.Ref = prop.Ref - // Collection format + // Collection format, as upstream's getCollectionFormat derives it: + // an exploded form a value each, else joined by the style's delimiter. if cp.IsArray { - cp.CollectionFormat = "multi" - cp.IsCollectionFormatMulti = true + cp.CollectionFormat = "csv" + + if sm, err := param.SerializationMethod(); err == nil { + switch { + case sm.Explode && sm.Style != openapi3.SerializationSimple: + cp.CollectionFormat = "multi" + case sm.Style == openapi3.SerializationSpaceDelimited: + cp.CollectionFormat = "ssv" + case sm.Style == openapi3.SerializationPipeDelimited: + cp.CollectionFormat = "pipes" + } + } + + cp.IsCollectionFormatMulti = cp.CollectionFormat == "multi" } } diff --git a/internal/parser/parser.go b/internal/parser/parser.go index 1e018d1..bf7b7fc 100644 --- a/internal/parser/parser.go +++ b/internal/parser/parser.go @@ -2,6 +2,8 @@ package parser import ( + "strings" + "github.com/getkin/kin-openapi/openapi3" ) @@ -74,11 +76,19 @@ func (p *Parser) GetInfo() map[string]string { return info } -// GetBasePath returns the base path from servers. +// GetBasePath returns the base path from servers: the first one's URL. func (p *Parser) GetBasePath() string { if p.Doc == nil || len(p.Doc.Servers) == 0 { return "" } - return p.Doc.Servers[0].URL + // A variable takes its default, as upstream's getServerURL fills it. + url := p.Doc.Servers[0].URL + for name, v := range p.Doc.Servers[0].Variables { + if v != nil { + url = strings.ReplaceAll(url, "{"+name+"}", v.Default) + } + } + + return url } diff --git a/internal/parser/parser_test.go b/internal/parser/parser_test.go new file mode 100644 index 0000000..f050211 --- /dev/null +++ b/internal/parser/parser_test.go @@ -0,0 +1,29 @@ +package parser + +import "testing" + +// TestBasePathFillsServerVariables checks that the base path is the first +// server's URL with each variable's default in place. +func TestBasePathFillsServerVariables(t *testing.T) { + spec := []byte(` +openapi: 3.0.0 +info: {title: Servers, version: 1.0.0} +servers: + - url: "{scheme}://api.example.com/{version}" + variables: + scheme: {default: https, enum: [https, http]} + version: {default: v2} +paths: {} +`) + + p := NewParser() + + p.SkipValidation = true + if err := p.LoadFromData(spec); err != nil { + t.Fatalf("LoadFromData: %v", err) + } + + if got, want := p.GetBasePath(), "https://api.example.com/v2"; got != want { + t.Errorf("GetBasePath() = %q, want %q", got, want) + } +} diff --git a/internal/parser/property.go b/internal/parser/property.go index 2a96096..94af3df 100644 --- a/internal/parser/property.go +++ b/internal/parser/property.go @@ -225,6 +225,12 @@ func (p *Parser) memberTypeNames(refs openapi3.SchemaRefs) ([]string, []string) } } + // A member typed any that says something (an inline object, not {}) + // makes the union any: without it, string | object reads as string. + if t == "any" && m.Value != nil && !m.Value.IsEmpty() { + return []string{"any"}, models + } + if t == "" || t == "any" || seen[t] { continue } @@ -567,7 +573,7 @@ func isObjectSchema(schema *openapi3.Schema) bool { func enumVars(values []any, isString bool) []map[string]any { vars := make([]map[string]any, 0, len(values)) seen := make(map[string]int) - quote := strings.NewReplacer(`\`, `\\`, "'", `\'`) // for a single-quoted string literal + quote := strings.NewReplacer(`\`, `\\`, "'", `\'`, "\n", `\n`, "\r", `\r`, "\u2028", `\u2028`, "\u2029", `\u2029`) // for a single-quoted string literal for _, v := range values { // null is no member: a nullable enum lists it, the property carries it. diff --git a/templates/go-fetch/README.md b/templates/go-fetch/README.md index b536dd9..16552ec 100644 --- a/templates/go-fetch/README.md +++ b/templates/go-fetch/README.md @@ -119,7 +119,8 @@ for e, err := range events { | other multipart field | its own part: a list of scalars a part per item (comma-joined with `explode: false`), an object as JSON | | urlencoded form field | sent as its `encoding` says: form and exploded by default, `deepObject` key by key (`a[b][0]=x`); a form with a file goes as multipart | -Not covered: server code, XML, server variables, validation, a discriminator -without `oneOf` (the parent stays a plain struct), `additionalProperties` -next to the properties of an inline object (it stays a map) or inherited -through `allOf` (the extra keys are dropped). +Not covered: server code, XML, server variables other than their defaults +(which `BasePath` holds), validation, a discriminator without `oneOf` (the +parent stays a plain struct), `additionalProperties` next to the properties +of an inline object (it stays a map) or inherited through `allOf` (the extra +keys are dropped). diff --git a/testdata/specs/feature/enum-edge-values.yaml b/testdata/specs/feature/enum-edge-values.yaml index b3eb1b3..ece25fc 100644 --- a/testdata/specs/feature/enum-edge-values.yaml +++ b/testdata/specs/feature/enum-edge-values.yaml @@ -34,7 +34,7 @@ components: # values that name alike get a count suffix; quotes and backslashes are escaped Severity: type: string - enum: [low, LOW, "it's", 'a\b'] + enum: [low, LOW, "it's", 'a\b', "two\nlines"] # no type: values are strings State: enum: [placed, approved] From 73ceffc9073434d9cf8a06611f165233d9de9cb2 Mon Sep 17 00:00:00 2001 From: Filip Seman Date: Mon, 26 Oct 2026 18:21:21 +0100 Subject: [PATCH 02/18] fix(typescript-fetch): convert values at any depth, by style and by member Fixes what the e2e tests and generated clients of the GitHub, Kubernetes and Stripe specs showed. Every client of the feature specs and of the three real specs now compiles with tsc --strict. Values: a recursive converter (convert.go) writes FromJSON and ToJSON: - lists of lists and maps of models or dates, a nested response or body; - a $ref to a union by its model; an inline union by the property every member pins (pydantic's Literal), else by the member it fits best (bestFit: required keys there, most keys declared); scalar unions by a date's text; an anyOf of models as a oneOf; - an optional nullable null stays null; the keys next to the properties (additionalProperties) go through, converted by their schema; a binary property of a JSON model is a string; keys every object inherits (constructor, __proto__) are read and written as own keys; - a discriminator without mapping maps the allOf children by schema name. Requests: path, header, query and cookie parameters go by style and explode (label, matrix, objects, delimiters); cookies and an API key in a cookie as a Cookie header; form fields by their encoding, a list a field or part per item, an object a JSON part; a form with a file as multipart. Responses: a 2xx without a body (a 204 beside a 200, a HEAD) is null; a 2xx of another body than the result's throws ResponseError; a string or any sent as text is the text; JSON Lines and server-sent events (3.2's itemSchema) are an AsyncIterable, of ServerSentEvent for events. Names: a model named like a runtime export (BaseAPI) is prefixed, an operation named like a BaseAPI member (request) suffixed, a model mapped twice imported once, an enum value with a line break escaped. A parsed vendor-extension map is the document's own, shared by every property of one $ref: the generator writes to a copy. --- internal/gen/render.go | 14 +- internal/gen/typescript.go | 47 +- internal/gen/typescript_test.go | 12 +- internal/generator/typescript/base.go | 4 + internal/generator/typescript/convert.go | 345 +++++++++++++++ internal/generator/typescript/convert_test.go | 147 +++++++ internal/generator/typescript/fetch.go | 415 +++++++++++++++++- templates/typescript-fetch/README.md | 48 +- templates/typescript-fetch/apis.mustache | 268 +++-------- .../apisAssignQueryParam.mustache | 14 +- .../typescript-fetch/modelGeneric.mustache | 144 +----- .../typescript-fetch/modelOneOf.mustache | 32 +- templates/typescript-fetch/runtime.mustache | 233 +++++++++- 13 files changed, 1311 insertions(+), 412 deletions(-) create mode 100644 internal/generator/typescript/convert.go create mode 100644 internal/generator/typescript/convert_test.go diff --git a/internal/gen/render.go b/internal/gen/render.go index 59dece6..020cf52 100644 --- a/internal/gen/render.go +++ b/internal/gen/render.go @@ -200,20 +200,8 @@ func (r *renderer) renderModels(baseData map[string]any, models []*generator.Cod data["dartImports"] = toDartImports(model.Imports, r.dartGen) data["oneOfImports"] = toDartImports(oneOfImportNames, r.dartGen) } - // Skip importing Blob helpers if this model IS Blob (to avoid conflicts - // with its own generated BlobFromJSON/BlobToJSON exports). - isNotBlobModel := model.Classname != "Blob" - data["isNotBlobModel"] = isNotBlobModel - // True when at least one var's FromJSON/ToJSON body will literally call - // the runtime's anyFromJSON/anyToJSON or BlobFromJSON/BlobToJSON helpers - // (datatype resolved to "any"/"Blob" outside the - // isFreeFormObject/isPrimitiveType/isArray/isMap fast paths that - // modelGeneric.mustache passes through directly). Computed here so the - // template can import those runtime helpers only when actually used, - // instead of unconditionally importing them into every model file. + if r.tsGen != nil { - data["usesAnyJSON"] = modelUsesRuntimeJSONHelper(model, "any") - data["usesBlobJSON"] = isNotBlobModel && modelUsesRuntimeJSONHelper(model, "Blob") data["usesDateHelpers"] = modelUsesDateHelpers(model) } // Add model-level properties at top level for template access diff --git a/internal/gen/typescript.go b/internal/gen/typescript.go index 8aacda1..be3d5d4 100644 --- a/internal/gen/typescript.go +++ b/internal/gen/typescript.go @@ -82,45 +82,20 @@ func isPrimitiveTypeTS(t string) bool { return primitives[t] } -// modelUsesRuntimeJSONHelper reports whether modelGeneric.mustache will -// literally emit a call to the runtime's {{datatype}}FromJSON/ToJSON helper -// named wantDatatype (e.g. "any" -> anyFromJSON/anyToJSON, "Blob" -> -// BlobFromJSON/BlobToJSON) for this model. That happens for a scalar var -// whose datatype resolved to wantDatatype outside of the -// isFreeFormObject/isPrimitiveType fast paths (which pass the value through -// directly instead), and for an array var whose item type resolved the same -// way (which maps the helper over the elements). Map vars never call it: -// modelGeneric.mustache only invokes a per-item converter for maps when the -// item is a model (mapValues({{items.datatype}}FromJSON)). -func modelUsesRuntimeJSONHelper(model *generator.CodegenModel, wantDatatype string) bool { - callsHelper := func(p *generator.CodegenProperty) bool { - return p != nil && p.Datatype == wantDatatype && !p.IsPrimitiveType && !p.IsFreeFormObject - } - +// modelUsesDateHelpers reports whether a model's FromJSON or ToJSON calls +// the runtime's date helpers, for a property or for the keys next to them, +// so a model without dates does not import them. +func modelUsesDateHelpers(model *generator.CodegenModel) bool { + exts := []map[string]any{model.VendorExtensions} for _, v := range model.Vars { - if v == nil { - continue - } - - if !v.IsArray && !v.IsMap && callsHelper(v) { - return true - } - - if v.IsArray && v.Items != nil && !v.Items.IsContainer && callsHelper(v.Items) { - return true - } + exts = append(exts, v.VendorExtensions) } - return false -} - -// modelUsesDateHelpers reports whether modelGeneric.mustache calls the -// runtime's parseDate/serializeDate helpers for any var, mirroring upstream's -// x-hasDateVars so a model without dates does not import them. -func modelUsesDateHelpers(model *generator.CodegenModel) bool { - for _, v := range model.Vars { - if v != nil && v.IsPrimitiveType && !v.IsArray && (v.IsDateType || v.IsDateTimeType) { - return true + for _, ext := range exts { + for _, k := range []string{"x-ts-from-json", "x-ts-to-json", "x-ts-rest-from", "x-ts-rest-to"} { + if e, _ := ext[k].(string); strings.Contains(e, "parseDate") || strings.Contains(e, "serializeDate") { + return true + } } } diff --git a/internal/gen/typescript_test.go b/internal/gen/typescript_test.go index ab2a05c..535ad20 100644 --- a/internal/gen/typescript_test.go +++ b/internal/gen/typescript_test.go @@ -65,12 +65,12 @@ components: api := string(data) for _, want := range []string{ - `runtime.mapValues(jsonValue, (items) => items.map(PetFromJSON))`, - `formParams.append("pets", new Blob([JSON.stringify(requestParameters["pets"].map(PetToJSON))], { type: "application/json", }))`, - `formParams.append("colors", requestParameters["colors"]!.join(runtime.COLLECTION_FORMATS["csv"]))`, - `formParams.append("ids", requestParameters["ids"]!.join(runtime.COLLECTION_FORMATS["csv"]))`, - `formParams.append("tags", requestParameters["tags"]!.join(runtime.COLLECTION_FORMATS["csv"]))`, - `formParams.append("files", element as any)`, + `runtime.mapValues(jsonValue, (v: any) => (v as Array).map(PetFromJSON))`, + `formParams.append("pets", new Blob([JSON.stringify((requestParameters["pets"]! as Array).map(PetToJSON))], { type: "application/json", }))`, + `runtime.formFields("colors", (requestParameters["colors"]! as Array).map(ColorToJSON), "form", true)`, + `runtime.formFields("ids", (requestParameters["ids"]! as Array).map(IdToJSON), "form", true)`, + `runtime.formFields("tags", requestParameters["tags"]!, "form", true)`, + `formParams.append("files", file)`, } { if !strings.Contains(api, want) { t.Errorf("defaultApi.ts lacks %s", want) diff --git a/internal/generator/typescript/base.go b/internal/generator/typescript/base.go index 9ea9556..11a2dd7 100644 --- a/internal/generator/typescript/base.go +++ b/internal/generator/typescript/base.go @@ -99,6 +99,10 @@ var tsReservedKeywords = map[string]bool{ "true": true, "try": true, "typeof": true, "var": true, "void": true, "volatile": true, "while": true, "with": true, "yield": true, + + // Every object has a constructor of type Function: an optional string + // property of that name takes no object literal. + "constructor": true, } // Primitives is the set of TypeScript primitive types. It mirrors upstream diff --git a/internal/generator/typescript/convert.go b/internal/generator/typescript/convert.go new file mode 100644 index 0000000..08ccced --- /dev/null +++ b/internal/generator/typescript/convert.go @@ -0,0 +1,345 @@ +package typescript + +import ( + "fmt" + "maps" + "regexp" + "slices" + "strconv" + "strings" + + "github.com/xseman/openapi-generator/internal/codegen" +) + +// converter writes the TypeScript expressions that turn a value from its +// JSON into the client's type (FromJSON) or back (ToJSON). It descends into +// arrays and maps at any depth, so a list of lists of models, a map of +// dates or a map of lists of models convert as a model or a date does. +type converter struct { + g *FetchGenerator + + // to converts into JSON; else from it. + to bool + + // rt prefixes the runtime's helpers: "" in a model file, which imports + // them, "runtime." in an API file. + rt string + + // used collects the models whose functions the expressions call. + used map[string]bool +} + +// expr returns the expression converting x, a value of p; x itself when +// JSON holds the value as the client does. +func (c *converter) expr(p *codegen.CodegenProperty, x string) string { + if p == nil || c.g.WithoutRuntimeChecks { + return x + } + + if p.IsArray || p.IsMap { + item := c.expr(p.Items, "v") + if item == "v" { + return x + } + + if p.Items.IsNullable && (p.Items.IsDateType || p.Items.IsDateTimeType) { + item = "v == null ? v : " + item + } + + fn := "(v: any) => " + item + if name, ok := strings.CutSuffix(item, "(v)"); ok && identifier.MatchString(name) { + fn = name + } + + if p.IsArray { + return "(" + x + " as Array).map(" + fn + ")" + } + + return c.rt + "mapValues(" + x + ", " + fn + ")" + } + + switch { + case p.IsDateType && c.to: + return c.rt + "serializeDate(" + x + ")" + case p.IsDateType: + return c.rt + "parseDate(" + x + ")" + case p.IsDateTimeType && c.to: + return c.rt + "serializeDateTime(" + x + ")" + case p.IsDateTimeType: + return c.rt + "parseDateTime(" + x + ")" + } + + model := c.g.modelOf(p) + if model == "" { + return c.union(p, x) + } + + c.used[model] = true + + return model + c.fn() + "(" + x + ")" +} + +// fn is the suffix of the models' functions the converter calls. +func (c *converter) fn() string { + if c.to { + return "ToJSON" + } + + return "FromJSON" +} + +var identifier = regexp.MustCompile(`^[\w$.]+$`) + +// union converts x, a value of an inline union: of models by the model its +// discriminator names, a property every member pins to a string of its own +// (pydantic's Literal), else by the model it fits best; +// of scalars, a date among them, by the date's text. A union with another +// member, an array say, stays as it is. +func (c *converter) union(p *codegen.CodegenProperty, x string) string { + if p.ComposedSchemas == nil { + return x + } + + members := slices.Concat(p.ComposedSchemas.OneOf, p.ComposedSchemas.AnyOf) + + dates := slices.DeleteFunc(slices.Clone(members), func(m *codegen.CodegenProperty) bool { return !m.IsDateType && !m.IsDateTimeType }) + if len(dates) == 1 { + pattern := `/^\d{4}-\d{2}-\d{2}$/` + if dates[0].IsDateTimeType { + pattern = `/^\d{4}-\d{2}-\d{2}T/` + } + + if c.to { + return "(" + x + " instanceof Date ? " + c.expr(dates[0], x) + " : " + x + ")" + } + + return "(typeof " + x + ` === "string" && ` + pattern + ".test(" + x + ") ? " + c.expr(dates[0], x) + " : " + x + ")" + } + + if d := c.discriminated(members, x); d != "" { + return d + } + + return c.bestFit(members, x) +} + +// discriminated converts x by a property every member of the union pins to +// a string of its own, or is "". +func (c *converter) discriminated(members []*codegen.CodegenProperty, x string) string { + var ( + key string + cases []string + ) + + used := map[string]bool{} + + for _, m := range members { + model := c.g.byName[c.g.modelOf(m)] + if model == nil { + return "" + } + + pin := pinned(model, key, c.to) + if pin == nil { + return "" + } + + key = pin.key + + value := strconv.Quote(fmt.Sprint(pin.value)) + + if slices.ContainsFunc(cases, func(s string) bool { return strings.HasPrefix(s, value+":") }) { + return "" + } + + used[model.Classname] = true + cases = append(cases, value+": "+model.Classname+c.fn()) + } + + if len(cases) == 0 { + return "" + } + + maps.Copy(c.used, used) + + return c.rt + "discriminate(" + x + ", " + strconv.Quote(key) + ", { " + strings.Join(cases, ", ") + " })" +} + +// bestFit converts x by the member model it fits best (the runtime's +// bestFit): one whose required keys it has, declaring the most of its keys +// and the fewest it does not. A scalar member (Stripe's id next to the +// object it expands to) takes a value that is no object. +func (c *converter) bestFit(members []*codegen.CodegenProperty, x string) string { + var models []*codegen.CodegenModel + + for _, m := range members { + model := c.g.byName[c.g.modelOf(m)] + + switch { + case model == nil && m.IsPrimitiveType && !m.IsContainer: + continue + case model == nil: + return x + } + + models = append(models, model) + } + + if len(models) == 0 { + return x + } + + return c.rt + "bestFit(" + x + ", " + c.fits(models) + ") ?? " + x +} + +// fits lists models for the runtime's bestFit: each one's required keys, +// its keys and its converter, in JSON (from) or in TypeScript (to). +func (c *converter) fits(models []*codegen.CodegenModel) string { + cases := make([]string, 0, len(models)) + + for _, m := range models { + var required, known []string + + // A union model declares the keys of its members, none required. + vars := m.AllVars + if len(vars) == 0 { + vars = m.Vars + } + + if m.HasOneOf { + vars = nil + + for _, name := range m.OneOfModels { + if member := c.g.byName[name]; member != nil && !member.HasOneOf { + for _, v := range member.Vars { + vars = append(vars, &codegen.CodegenProperty{Name: v.Name, BaseName: v.BaseName}) + } + } + } + } + + for _, v := range vars { + name := strconv.Quote(v.BaseName) + if c.to { + name = strconv.Quote(v.Name) + } + + known = append(known, name) + if v.Required { + required = append(required, name) + } + } + + c.used[m.Classname] = true + cases = append(cases, "[["+strings.Join(required, ", ")+"], ["+strings.Join(known, ", ")+"], "+m.Classname+c.fn()+"]") + } + + return "[" + strings.Join(cases, ", ") + "]" +} + +type pin struct { + key string + value any +} + +// pinned finds the property of m that holds one string value, named key +// in JSON (to false) or in TypeScript (to true) when key is not "". +func pinned(m *codegen.CodegenModel, key string, to bool) *pin { + for _, v := range m.Vars { + values, _ := v.AllowableValues["values"].([]any) + if !v.IsEnum || !v.Required || v.IsNullable || len(values) != 1 { + continue + } + + if _, ok := values[0].(string); !ok { + continue + } + + name := v.BaseName + if to { + name = v.Name + } + + if key == "" || key == name { + return &pin{name, values[0]} + } + } + + return nil +} + +// modelOf names the generated model whose FromJSON and ToJSON convert a +// value of p, or "". A $ref to a union is typed as its members joined, +// "Cat | Dog", but converts with the union model's functions. +func (g *FetchGenerator) modelOf(p *codegen.CodegenProperty) string { + if p.Ref != "" { + name := g.ToModelName(p.Ref[strings.LastIndex(p.Ref, "/")+1:]) + if m := g.byName[name]; m != nil && m.HasOneOf { + return name + } + } + + if p.IsModel && g.byName[p.Datatype] != nil { + return p.Datatype + } + + return "" +} + +// varFromJSON is the value of model property p in FromJSON: its JSON +// converted, absent or null as the property allows. +func (c *converter) varFromJSON(p *codegen.CodegenProperty) string { + x := "json[" + strconv.Quote(p.BaseName) + "]" + if objectMembers[p.BaseName] { + x = "(Object.prototype.hasOwnProperty.call(json, " + strconv.Quote(p.BaseName) + ") ? " + x + " : undefined)" + } + + v := c.expr(p, x) + + switch { + case !p.Required && p.IsNullable: + return x + " === undefined ? undefined : " + x + " === null ? null : " + v + case !p.Required: + return x + " == null ? undefined : " + v + case p.IsNullable: + return x + " == null ? null : " + v + case v != x && (p.IsContainer || p.IsDateType || p.IsDateTimeType): + // A required value the server left out stays as it came. + return x + " == null ? " + x + " : " + v + } + + return v +} + +// objectMembers are the keys every object inherits: JSON without one of +// them still reads it, so a property of that name reads its own key only. +var objectMembers = map[string]bool{ + "__proto__": true, "constructor": true, "hasOwnProperty": true, "isPrototypeOf": true, + "propertyIsEnumerable": true, "toLocaleString": true, "toString": true, "valueOf": true, + "__defineGetter__": true, "__defineSetter__": true, "__lookupGetter__": true, "__lookupSetter__": true, +} + +// varToJSON is the JSON of model property p in ToJSON. A null stays null +// where the property takes one; an optional property left out stays out. +func (c *converter) varToJSON(p *codegen.CodegenProperty) string { + x := "value[" + strconv.Quote(p.Name) + "]" + + v := c.expr(p, x) + if v == x || c.g.modelOf(p) != "" && !p.IsContainer { + return v + } + + if !p.Required && !p.IsNullable { + return x + " == null ? undefined : " + v + } + + return x + " == null ? " + x + " : " + v +} + +// asProperty is parameter p as a property, for the converter. +func asProperty(p *codegen.CodegenParameter) *codegen.CodegenProperty { + return &codegen.CodegenProperty{ + Datatype: p.DataType, IsModel: p.IsModel, IsArray: p.IsArray, IsMap: p.IsMap, IsContainer: p.IsContainer, + Items: p.Items, IsDateType: p.IsDateType, IsDateTimeType: p.IsDateTimeType, Ref: p.Ref, + } +} diff --git a/internal/generator/typescript/convert_test.go b/internal/generator/typescript/convert_test.go new file mode 100644 index 0000000..8c01580 --- /dev/null +++ b/internal/generator/typescript/convert_test.go @@ -0,0 +1,147 @@ +package typescript + +import ( + "testing" + + "github.com/xseman/openapi-generator/internal/codegen" +) + +// TestConvert pins the expressions that convert a value from its JSON and +// back: containers at any depth, dates, models, a $ref to a union model, an +// inline union told apart by a pinned property or by a date's text. +func TestConvert(t *testing.T) { + g := NewFetchGenerator() + + pinnedTo := func(name, value string) *codegen.CodegenModel { + return &codegen.CodegenModel{Classname: name, Vars: []*codegen.CodegenProperty{{ + Name: "kind", BaseName: "kind", IsEnum: true, Required: true, + AllowableValues: map[string]any{"values": []any{value}}, + }}} + } + + g.byName = map[string]*codegen.CodegenModel{ + "Cell": {Classname: "Cell"}, + "Pet": {Classname: "Pet", HasOneOf: true}, + "Delta": pinnedTo("Delta", "delta"), + "Done": pinnedTo("Done", "done"), + "Customer": {Classname: "Customer", Vars: []*codegen.CodegenProperty{ + {Name: "taxId", BaseName: "tax_id", Required: true}, {Name: "email", BaseName: "email"}, + }}, + } + + cell := &codegen.CodegenProperty{Datatype: "Cell", IsModel: true} + day := &codegen.CodegenProperty{IsDateType: true} + instant := &codegen.CodegenProperty{IsDateTimeType: true} + array := func(items *codegen.CodegenProperty) *codegen.CodegenProperty { + return &codegen.CodegenProperty{IsArray: true, IsContainer: true, Items: items} + } + union := func(members ...*codegen.CodegenProperty) *codegen.CodegenProperty { + return &codegen.CodegenProperty{ComposedSchemas: &codegen.CodegenComposedSchemas{OneOf: members}} + } + + tests := []struct { + name string + p *codegen.CodegenProperty + from, to string + }{ + {"scalar", &codegen.CodegenProperty{Datatype: "string", IsPrimitiveType: true}, "x", "x"}, + {"model", cell, "CellFromJSON(x)", "CellToJSON(x)"}, + { + "rows of models", array(array(cell)), + "(x as Array).map((v: any) => (v as Array).map(CellFromJSON))", + "(x as Array).map((v: any) => (v as Array).map(CellToJSON))", + }, + { + "map of instants", &codegen.CodegenProperty{IsMap: true, IsContainer: true, Items: instant}, + "mapValues(x, parseDateTime)", "mapValues(x, serializeDateTime)", + }, + { + "days, null among them", array(&codegen.CodegenProperty{IsDateType: true, IsNullable: true}), + "(x as Array).map((v: any) => v == null ? v : parseDate(v))", + "(x as Array).map((v: any) => v == null ? v : serializeDate(v))", + }, + {"$ref to a union", &codegen.CodegenProperty{Ref: "#/components/schemas/Pet", Datatype: "Cat | Dog"}, "PetFromJSON(x)", "PetToJSON(x)"}, + { + "union by a pinned property", + union(&codegen.CodegenProperty{Datatype: "Delta", IsModel: true}, &codegen.CodegenProperty{Datatype: "Done", IsModel: true}), + `discriminate(x, "kind", { "delta": DeltaFromJSON, "done": DoneFromJSON })`, + `discriminate(x, "kind", { "delta": DeltaToJSON, "done": DoneToJSON })`, + }, + { + "union of a day and a number", union(day, &codegen.CodegenProperty{Datatype: "number"}), + `(typeof x === "string" && /^\d{4}-\d{2}-\d{2}$/.test(x) ? parseDate(x) : x)`, + "(x instanceof Date ? serializeDate(x) : x)", + }, + { + "an id or the object it expands to", + union(&codegen.CodegenProperty{Datatype: "string", IsPrimitiveType: true}, &codegen.CodegenProperty{Datatype: "Customer", IsModel: true}), + `bestFit(x, [[["tax_id"], ["tax_id", "email"], CustomerFromJSON]]) ?? x`, + `bestFit(x, [[["taxId"], ["taxId", "email"], CustomerToJSON]]) ?? x`, + }, + {"union with a member of no known kind", union(cell, &codegen.CodegenProperty{Datatype: "Array", IsArray: true}), "x", "x"}, + } + + for _, tt := range tests { + from := (&converter{g: g, used: map[string]bool{}}).expr(tt.p, "x") + to := (&converter{g: g, to: true, used: map[string]bool{}}).expr(tt.p, "x") + + if from != tt.from || to != tt.to { + t.Errorf("%s:\nfrom %s\nwant %s\nto %s\nwant %s", tt.name, from, tt.from, to, tt.to) + } + } +} + +// TestModelProperties pins a property's value in FromJSON and ToJSON: +// absent and null as the property allows, keys every object inherits. +func TestModelProperties(t *testing.T) { + g := NewFetchGenerator() + g.byName = map[string]*codegen.CodegenModel{} + + tags := func(required, nullable bool) *codegen.CodegenProperty { + return &codegen.CodegenProperty{ + Name: "tags", BaseName: "tags", Required: required, IsNullable: nullable, + IsArray: true, IsContainer: true, Items: &codegen.CodegenProperty{IsDateType: true}, + } + } + + tests := []struct { + name string + p *codegen.CodegenProperty + from, to string + }{ + { + "optional", tags(false, false), + `json["tags"] == null ? undefined : (json["tags"] as Array).map(parseDate)`, + `value["tags"] == null ? undefined : (value["tags"] as Array).map(serializeDate)`, + }, + { + "optional and nullable: null stays", tags(false, true), + `json["tags"] === undefined ? undefined : json["tags"] === null ? null : (json["tags"] as Array).map(parseDate)`, + `value["tags"] == null ? value["tags"] : (value["tags"] as Array).map(serializeDate)`, + }, + { + "required", tags(true, false), + `json["tags"] == null ? json["tags"] : (json["tags"] as Array).map(parseDate)`, + `value["tags"] == null ? value["tags"] : (value["tags"] as Array).map(serializeDate)`, + }, + { + "inherited key", &codegen.CodegenProperty{Name: "_constructor", BaseName: "constructor", Datatype: "string"}, + `(Object.prototype.hasOwnProperty.call(json, "constructor") ? json["constructor"] : undefined) == null ? undefined : ` + + `(Object.prototype.hasOwnProperty.call(json, "constructor") ? json["constructor"] : undefined)`, + `value["_constructor"]`, + }, + } + + for _, tt := range tests { + from := (&converter{g: g, used: map[string]bool{}}).varFromJSON(tt.p) + to := (&converter{g: g, to: true, used: map[string]bool{}}).varToJSON(tt.p) + + if from != tt.from || to != tt.to { + t.Errorf("%s:\nfrom %s\nwant %s\nto %s\nwant %s", tt.name, from, tt.from, to, tt.to) + } + } + + if got := key("__proto__"); got != `["__proto__"]` { + t.Errorf(`key("__proto__") = %s, want a computed key`, got) + } +} diff --git a/internal/generator/typescript/fetch.go b/internal/generator/typescript/fetch.go index f3d4b77..2e4f602 100644 --- a/internal/generator/typescript/fetch.go +++ b/internal/generator/typescript/fetch.go @@ -1,7 +1,11 @@ package typescript import ( + "maps" + "reflect" + "regexp" "slices" + "strconv" "strings" "github.com/xseman/openapi-generator/internal/codegen" @@ -24,6 +28,10 @@ type FetchGenerator struct { FileNaming string WithInterfaces bool ValidationAttributes bool + + // byName are the generated models by class name: the ones with a + // FromJSON and a ToJSON a converted value may call. + byName map[string]*codegen.CodegenModel } // NewFetchGenerator creates a new TypeScript Fetch generator. @@ -154,24 +162,407 @@ func (g *FetchGenerator) EscapeReservedWord(name string) string { // PostProcessModels post-processes models for TypeScript-Fetch. func (g *FetchGenerator) PostProcessModels(models []*codegen.CodegenModel) []*codegen.CodegenModel { + g.byName = make(map[string]*codegen.CodegenModel, len(models)) + for _, cm := range models { + g.byName[cm.Classname] = cm + + // An anyOf of models converts as a oneOf does, by the member it fits + // best; without this it is an empty interface. One with an inline + // member stays so: its type would leave that member out. + if len(cm.AnyOf) > 0 && !cm.HasOneOf && !cm.HasVars && cm.VendorExtensions["x-any-of-inline"] == nil { + for _, name := range cm.AnyOf { + cm.OneOf = append(cm.OneOf, g.ToModelName(name)) + } + + cm.OneOfModels, cm.HasOneOf = slices.Sorted(slices.Values(cm.OneOf)), true + } + } + for _, cm := range models { g.processCodeGenModel(cm) + g.convertVars(cm, models) + g.mapDescendants(cm, models) + g.fitMembers(cm) } return models } +// convertVars gives each property of cm its FromJSON and ToJSON value +// (x-ts-from-json, x-ts-to-json) and imports the models they call. +func (g *FetchGenerator) convertVars(cm *codegen.CodegenModel, models []*codegen.CodegenModel) { + from := &converter{g: g, used: map[string]bool{}} + to := &converter{g: g, to: true, used: from.used} + + cm.VendorExtensions = own(cm.VendorExtensions) + + for _, v := range cm.Vars { + textBinary(v) + + v.VendorExtensions = own(v.VendorExtensions) + + v.VendorExtensions["x-ts-from-json"] = key(v.Name) + ": " + from.varFromJSON(v) + v.VendorExtensions["x-ts-to-json"] = key(v.BaseName) + ": " + to.varToJSON(v) + } + + // The keys next to the properties (additionalProperties) go through as + // they are, or converted as their schema says: the properties' own keys + // are left out, else a renamed one would be there twice, and so are those + // of a model that extends cm, whose ToJSON and FromJSON call cm's. + if cm.HasVars && (cm.AdditionalPropertiesType != "" || cm.IsAdditionalPropertiesTrue) { + if cm.AdditionalPropertiesType == "" { + cm.AdditionalPropertiesType = "any" + } + + var wire, names []string + + for _, m := range append(descendants(cm, models), cm) { + for _, v := range m.Vars { + if k := strconv.Quote(v.BaseName); !slices.Contains(wire, k) { + wire, names = append(wire, k), append(names, strconv.Quote(v.Name)) + } + } + } + + rest := func(c *converter, x string, keys []string) string { + return c.expr(&codegen.CodegenProperty{IsMap: true, Items: cm.AdditionalProperties}, "otherKeys("+x+", ["+strings.Join(keys, ", ")+"])") + } + + cm.VendorExtensions["x-ts-rest-from"] = rest(from, "json", wire) + cm.VendorExtensions["x-ts-rest-to"] = rest(to, "value", names) + } + + delete(from.used, cm.Classname) + cm.Imports = addImports(cm.Imports, from.used) +} + +// descendants are the models that extend cm through allOf, at any depth. +func descendants(cm *codegen.CodegenModel, models []*codegen.CodegenModel) []*codegen.CodegenModel { + var out []*codegen.CodegenModel + + for _, m := range models { + if m != cm && slices.Contains(m.AllOf, cm.SchemaName) { + out = append(append(out, m), descendants(m, models)...) + } + } + + return out +} + +// mapDescendants completes the discriminator of cm, a parent the others +// extend with allOf: a value without a mapping names its schema, as OpenAPI +// defines, so each child is mapped by its schema name. The mapped models +// are named as their classes, and x-ts-mapped imports each once: two values +// may map to one model. +func (g *FetchGenerator) mapDescendants(cm *codegen.CodegenModel, models []*codegen.CodegenModel) { + d := cm.Discriminator + if d == nil { + return + } + + for _, m := range models { + explicit := slices.ContainsFunc(d.MappedModels, func(mm *codegen.MappedModel) bool { return g.ToModelName(mm.ModelName) == m.Classname }) + if m != cm && !explicit && slices.Contains(m.AllOf, cm.SchemaName) { + d.MappedModels = append(d.MappedModels, &codegen.MappedModel{MappingName: m.SchemaName, ModelName: m.SchemaName}) + } + } + + var imports []string + + for _, mm := range d.MappedModels { + mm.ModelName = g.ToModelName(mm.ModelName) + if !slices.Contains(imports, mm.ModelName) { + imports = append(imports, mm.ModelName) + } + } + + cm.VendorExtensions = own(cm.VendorExtensions) + + mapped := make([]map[string]string, 0, len(imports)) + for _, name := range imports { + mapped = append(mapped, map[string]string{"modelName": name, "modelFilename": g.ToModelFilename(name)}) + } + + cm.VendorExtensions["x-ts-mapped"] = mapped +} + +// textBinary types a binary property of a JSON model, or one in its +// containers, as a string: JSON holds no bytes, a server writes its text +// (pydantic's bytes), and a Blob would go out as {}. +func textBinary(p *codegen.CodegenProperty) { + for c := p; c != nil; c = c.Items { + if (c.IsBinary || c.IsFile) && !c.IsContainer { + c.IsBinary, c.IsFile, c.IsString, c.IsPrimitiveType = false, false, true, true + } + + for _, t := range []*string{&c.DataType, &c.Datatype, &c.DatatypeWithEnum, &c.BaseType} { + *t = blobType.ReplaceAllString(*t, "string") + } + } +} + +var blobType = regexp.MustCompile(`\bBlob\b`) + +// own is a copy of the extensions ext to write to: the parser hands out the +// document's own map, which every property of one $ref shares. +func own(ext map[string]any) map[string]any { + if ext = maps.Clone(ext); ext == nil { + ext = map[string]any{} + } + + return ext +} + +// fitMembers gives a oneOf model without a discriminator the lists the +// runtime's bestFit picks its model member by (x-ts-fit-from, x-ts-fit-to): +// the first one whose required keys a value has would take a value of a +// later one with more keys, and drop them. +func (g *FetchGenerator) fitMembers(cm *codegen.CodegenModel) { + if cm.Discriminator != nil || len(cm.OneOfModels) == 0 { + return + } + + var members []*codegen.CodegenModel + + for _, name := range cm.OneOfModels { + if m := g.byName[name]; m != nil { + members = append(members, m) + } + } + + from := &converter{g: g, used: map[string]bool{}} + to := &converter{g: g, to: true, used: from.used} + + cm.VendorExtensions = own(cm.VendorExtensions) + cm.VendorExtensions["x-ts-fit-from"] = from.fits(members) + cm.VendorExtensions["x-ts-fit-to"] = to.fits(members) +} + +// key is name as the key of an object literal. "__proto__" would set the +// prototype, not a property: it is computed. +func key(name string) string { + if name == "__proto__" { + return `["__proto__"]` + } + + return strconv.Quote(name) +} + +// addImports adds the names in used to imports, once, sorted. +func addImports(imports []string, used map[string]bool) []string { + for name := range used { + if !slices.Contains(imports, name) { + imports = append(imports, name) + } + } + + slices.Sort(imports) + + return imports +} + // PostProcessOperations post-processes operations for TypeScript-Fetch. func (g *FetchGenerator) PostProcessOperations(operations []*codegen.CodegenOperation) []*codegen.CodegenOperation { for _, op := range operations { g.escapeOperationId(op) g.updateOperationParameterForEnum(op) g.addOperationObjectResponseInformation(op) + g.convertOperation(op) } return operations } +// convertOperation gives op the transformer of its JSON result +// (x-ts-result, absent when the JSON is the result) and its body the +// value sent (x-ts-body), and imports the models they call. +func (g *FetchGenerator) convertOperation(op *codegen.CodegenOperation) { + from := &converter{g: g, rt: "runtime.", used: map[string]bool{}} + to := &converter{g: g, to: true, rt: "runtime.", used: from.used} + + op.VendorExtensions = own(op.VendorExtensions) + + result := op.ReturnProperty + if result == nil && op.ReturnType != "" { + result = &codegen.CodegenProperty{Datatype: op.ReturnBaseType, IsModel: !op.ReturnTypeIsPrimitive} + } + + if v := from.expr(result, "jsonValue"); v != "jsonValue" { + op.VendorExtensions["x-ts-result"] = v + } + + g.stream(op, from) + + if b := op.BodyParam; b != nil { + x := `requestParameters["` + b.ParamName + `"]` + + v := to.expr(asProperty(b), x) + if v != x && (b.IsContainer || b.IsDateType || b.IsDateTimeType) { + v = x + " == null ? " + x + " : " + v + } + + b.VendorExtensions = own(b.VendorExtensions) + + b.VendorExtensions["x-ts-body"] = v + } + + for _, list := range [][]*codegen.CodegenParameter{op.QueryParams, op.HeaderParams, op.PathParams, op.CookieParams, op.FormParams} { + for _, param := range list { + g.paramValue(to, param) + } + } + + op.Imports = addImports(op.Imports, from.used) + + g.responseKinds(op) + + multipart := slices.ContainsFunc(op.Consumes, func(c map[string]string) bool { return strings.HasPrefix(c["mediaType"], "multipart/") }) + for _, param := range op.FormParams { + multipart = multipart || param.IsFile + } + + op.VendorExtensions["x-ts-multipart"] = multipart + op.VendorExtensions["x-ts-cookies"] = len(op.CookieParams) > 0 || + slices.ContainsFunc(op.AuthMethods, func(a *codegen.CodegenSecurity) bool { return a.IsKeyInCookie }) + + for _, param := range op.FormParams { + formField(param, multipart) + } +} + +// paramValue gives param the value its serialization starts from +// (x-ts-value: dates as their text, a model as its JSON) and its style with +// the default for its location filled in (x-ts-style). +func (g *FetchGenerator) paramValue(to *converter, param *codegen.CodegenParameter) { + param.VendorExtensions = own(param.VendorExtensions) + + param.VendorExtensions["x-ts-value"] = to.expr(asProperty(param), `requestParameters["`+param.ParamName+`"]!`) + + style := param.Style + if style == "" { + style = "form" + if param.IsPathParam || param.IsHeaderParam { + style = "simple" + } + } + + param.VendorExtensions["x-ts-style"] = style + + // A query object not in deepObject style: exploded form spreads its + // properties as parameters, else it is one of key,value pairs. + if param.IsQueryParam && !param.IsDeepObject && isObject(param) { + param.VendorExtensions["x-ts-spread"] = param.IsExplode + param.VendorExtensions["x-ts-styled"] = !param.IsExplode + } +} + +// isObject says whether param's value is an object. A $ref to an enum or a +// primitive is a model too, and its value no object. +func isObject(param *codegen.CodegenParameter) bool { + return param.IsModel && !param.IsEnumRef && !param.IsPrimitiveType || param.IsMap || param.IsFreeFormObject +} + +// formField says how form field param goes into the body: a file as it is +// (x-ts-file, a list of them x-ts-files), an object in a multipart body as +// a JSON part (x-ts-json), anything else as the fields its encoding writes +// (x-ts-fields, the arguments of the runtime's formFields after the name). +func formField(param *codegen.CodegenParameter, multipart bool) { + value, _ := param.VendorExtensions["x-ts-value"].(string) + style, _ := param.VendorExtensions["x-ts-style"].(string) + object := isObject(param) || param.IsArray && param.Items != nil && (param.Items.IsModel && !param.Items.IsPrimitiveType || param.Items.IsFreeFormObject) + + switch { + case param.IsFile && param.IsArray: + param.VendorExtensions["x-ts-files"] = true + case param.IsFile: + param.VendorExtensions["x-ts-file"] = true + case multipart && object: + param.VendorExtensions["x-ts-json"] = value + default: + param.VendorExtensions["x-ts-fields"] = value + ", " + strconv.Quote(style) + ", " + strconv.FormatBool(param.IsExplode) + } +} + +// stream makes the result of op, whose 2xx response streams its items +// (x-stream), an AsyncIterable of them: of JSON Lines (application/jsonl, +// application/x-ndjson) a value per line, of a text/event-stream a +// ServerSentEvent per event, its data the item, parsed from JSON unless the +// item is text. x-ts-stream is the kind, x-ts-stream-item the conversion of +// one item v. Another streamed media type stays without a result. +func (g *FetchGenerator) stream(op *codegen.CodegenOperation, from *converter) { + for _, r := range op.Responses { + item, _ := r.VendorExtensions["x-stream-item"].(*codegen.CodegenProperty) + mediaType, _ := r.VendorExtensions["x-stream"].(string) + mediaType, _, _ = strings.Cut(mediaType, ";") + + kind := map[string]string{"application/jsonl": "lines", "application/x-ndjson": "lines", "text/event-stream": "events"}[mediaType] + if !r.Is2xx || item == nil || kind == "" || op.ReturnType != "" { + continue + } + + textBinary(item) + + x, yields := "v", item.DataType + if kind == "events" { + yields = "runtime.ServerSentEvent<" + yields + ">" + + if !item.IsString { + x = "JSON.parse(v)" + } + } + + op.ReturnType = "AsyncIterable<" + yields + ">" + op.VendorExtensions["x-ts-stream"] = kind + op.VendorExtensions["x-ts-stream-item"] = from.expr(item, x) + + // The item's type names its models, converted or not. + for p := item; p != nil; p = p.Items { + for _, m := range p.ComposedModels { + from.used[g.ToModelName(m)] = true + } + + if g.byName[p.DataType] != nil { + from.used[p.DataType] = true + } + } + + return + } +} + +// responseKinds marks how op reads its result: x-ts-text when the result +// may come as text (a string, or any body the server does not send as +// JSON), x-ts-other-2xx the statuses whose body is of another type than the +// result, isResponseOptional when a 2xx comes without a body (a 204 beside +// a 200, the answer to a HEAD), which reads as null. +func (g *FetchGenerator) responseKinds(op *codegen.CodegenOperation) { + if op.ReturnType == "" || op.VendorExtensions["x-ts-stream"] != nil { + return + } + + op.VendorExtensions["x-ts-text"] = op.ReturnType == "string" || op.ReturnType == "any" + op.IsResponseOptional = op.HttpMethod == "HEAD" + + var other []string + + for _, r := range op.Responses { + if _, err := strconv.Atoi(r.Code); err != nil || !r.Is2xx { + continue + } + + switch { + case r.DataType == "": + op.IsResponseOptional = true + case op.ReturnType != "any" && r.DataType != op.ReturnType && (r.ReturnProperty == nil || !reflect.DeepEqual(r.ReturnProperty, op.ReturnProperty)): + other = append(other, r.Code) + } + } + + if len(other) > 0 { + op.VendorExtensions["x-ts-other-2xx"] = "[" + strings.Join(other, ", ") + "]" + } +} + // processCodeGenModel processes a model for TypeScript-Fetch specific transformations. func (g *FetchGenerator) processCodeGenModel(cm *codegen.CodegenModel) { // Process enum names @@ -224,6 +615,15 @@ func (g *FetchGenerator) escapeOperationId(op *codegen.CodegenOperation) { op.Nickname += "Operation" } + // A method of BaseAPI would be overridden: the generated methods call + // this.request, a method named request would call itself. + if baseAPIMembers[op.Nickname] { + op.OperationIdCamelCase += "Operation" + op.OperationIdLowerCase += "operation" + op.OperationIdSnakeCase += "_operation" + op.Nickname += "Operation" + } + // Check if operationId ends with "Raw" which would conflict with the internal method naming pattern // The template generates methodNameRaw() for internal methods, so an operationId like "fooRaw" // would conflict with the internal method of operation "foo" @@ -269,7 +669,17 @@ func (g *FetchGenerator) convertUsingFileNamingConvention(name string) string { } } -// addExtraReservedWords adds typescript-fetch specific reserved words. +// baseAPIMembers are the methods and fields of the runtime's BaseAPI, which +// an operation's method must not override. +var baseAPIMembers = map[string]bool{ + "request": true, "fetchApi": true, "configuration": true, "middleware": true, "clone": true, + "withMiddleware": true, "withPreMiddleware": true, "withPostMiddleware": true, "isJsonMime": true, + "createFetchParams": true, +} + +// addExtraReservedWords adds typescript-fetch specific reserved words: the +// runtime's exports and the DOM types an API file uses, which a model of +// the same name would shadow. IsReservedWord looks them up lower-cased. func (g *FetchGenerator) addExtraReservedWords() { extraWords := []string{ "BASE_PATH", "BaseAPI", "RequiredError", "COLLECTION_FORMATS", @@ -279,8 +689,9 @@ func (g *FetchGenerator) addExtraReservedWords() { "RequestContext", "ResponseContext", "Middleware", "ApiResponse", "ResponseTransformer", "JSONApiResponse", "VoidApiResponse", "BlobApiResponse", "TextApiResponse", "Index", + "RequestInit", "FormData", "URLSearchParams", } for _, word := range extraWords { - g.ReservedWords[word] = true + g.ReservedWords[strings.ToLower(word)] = true } } diff --git a/templates/typescript-fetch/README.md b/templates/typescript-fetch/README.md index 198e4be..22510e5 100644 --- a/templates/typescript-fetch/README.md +++ b/templates/typescript-fetch/README.md @@ -202,6 +202,48 @@ export function PetFromJSON(json: any): Pet { // Use JSON directly with type assertions ``` +## Values on the wire + +With runtime checks, `FromJSON` and `ToJSON` convert at every depth: a list +of lists of models, a map of dates, a map of lists of models. + +| Schema | TypeScript | +| ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| `date` / `date-time` | `Date`: a day at local midnight; an instant to the millisecond, sent in UTC (a time without an offset is read as local time) | +| optional and nullable | `T \| null`: `null` is sent and read as `null`, a property left out stays out | +| `additionalProperties` next to properties | an index signature; the other keys go through both ways, converted as their schema says | +| `binary` property of a JSON model | `string`: JSON holds no bytes, a server writes their text | +| `oneOf` / `anyOf` | the member the discriminator names (its mapping, else the schema's name), one every member pins a property for, else the member it fits best | + +| Request | Sent | +| --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | +| path, header, query, cookie | by `style` and `explode`: `simple`, `label`, `matrix`, `form`, `spaceDelimited`, `pipeDelimited`, `deepObject`; an object as its pairs | +| cookie, API key in a cookie | a `Cookie` header: Node.js, Deno and Bun send it, a browser drops it and sends its own cookies | +| urlencoded form field | by its `encoding`: a list a field per item (or joined by its delimiter with `explode: false`), `deepObject` key by key (`a[b][0]=x`) | +| multipart field | a list a part per item, an object as a JSON part (a file named `blob`: `FormData` gives a part a type only as a file); a form with a file goes as multipart | + +| Response | Result | +| ---------------------------------------------------- | --------------------------------------------------------------------------------------- | +| a 2xx without a body (a 204, a HEAD) | `null` | +| a 2xx of another body than the result's | a `ResponseError`, its body unread | +| a string or `any` the server sends as text | the text; a number or boolean sent as text is parsed | +| JSON Lines, `text/event-stream` (3.2's `itemSchema`) | `AsyncIterable`, of `ServerSentEvent` (`data`, `event`, `id`, `retry`) for events | + +```typescript +for await (const event of await api.chat({ prompt: { text: "hi" } })) { + console.log(event.event, event.data); +} +``` + +A stream stays open until the loop ends or breaks, or the request's +`signal` aborts it. + +A model named like a runtime export or a DOM type an API file uses +(`BaseAPI`, `RequestInit`, `FormData`) is prefixed with `Model`; an operation +named like a `BaseAPI` member (`request`) gets the suffix `Operation`; a +property named `constructor` is `_constructor`. A server URL's variables +take their defaults in `BASE_PATH`. + ## Import File Extension For ES modules compatibility, you can specify import file extensions: @@ -279,11 +321,13 @@ const api = new PetApi(config); ### Error Handling ```typescript +import { ResponseError } from './generated'; + try { const pet = await api.getPetById({ petId: 999 }); } catch (error) { - if (error instanceof Response) { - console.error('HTTP Error:', error.status, await error.text()); + if (error instanceof ResponseError) { + console.error('HTTP Error:', error.response.status, await error.response.text()); } else { console.error('Error:', error); } diff --git a/templates/typescript-fetch/apis.mustache b/templates/typescript-fetch/apis.mustache index fc8edd3..d82da1c 100644 --- a/templates/typescript-fetch/apis.mustache +++ b/templates/typescript-fetch/apis.mustache @@ -128,38 +128,31 @@ export class {{classname}} extends runtime.BaseAPI { {{#isArray}} if (requestParameters["{{paramName}}"] != null) { {{#isCollectionFormatMulti}} - queryParameters["{{baseName}}"] = requestParameters["{{paramName}}"]; + queryParameters["{{baseName}}"] = {{{vendorExtensions.x-ts-value}}}; {{/isCollectionFormatMulti}} {{^isCollectionFormatMulti}} - queryParameters["{{baseName}}"] = {{#uniqueItems}}Array.from({{/uniqueItems}}requestParameters["{{paramName}}"]{{#uniqueItems}}){{/uniqueItems}}!.join(runtime.COLLECTION_FORMATS["{{collectionFormat}}"]); + queryParameters["{{baseName}}"] = {{#uniqueItems}}Array.from({{/uniqueItems}}{{{vendorExtensions.x-ts-value}}}{{#uniqueItems}}){{/uniqueItems}}.join(runtime.COLLECTION_FORMATS["{{collectionFormat}}"]); {{/isCollectionFormatMulti}} } {{/isArray}} {{^isArray}} if (requestParameters["{{paramName}}"] != null) { -{{#isExplode}} -{{! form + explode: one parameter per entry. isMap (not isContainer) also covers free-form objects; deepObject is assigned whole so the runtime brackets it. }} -{{#isDeepObject}} -{{>apisAssignQueryParam}} -{{/isDeepObject}} -{{^isDeepObject}} -{{#isMap}} - for (let key of Object.keys(requestParameters["{{paramName}}"])) { - const value = (requestParameters["{{paramName}}"] as any)[key]; +{{#vendorExtensions.x-ts-spread}} + for (const [key, value] of Object.entries({{{vendorExtensions.x-ts-value}}})) { if (value != null) { Object.defineProperty(queryParameters, key, { value, enumerable: true, writable: true, configurable: true }); } } -{{/isMap}} -{{^isMap}} -{{>apisAssignQueryParam}} -{{/isMap}} -{{/isDeepObject}} -{{/isExplode}} -{{^isExplode}} +{{/vendorExtensions.x-ts-spread}} +{{#vendorExtensions.x-ts-styled}} + queryParameters["{{baseName}}"] = runtime.styleParam("{{baseName}}", {{{vendorExtensions.x-ts-value}}}, "form", false); +{{/vendorExtensions.x-ts-styled}} +{{^vendorExtensions.x-ts-spread}} +{{^vendorExtensions.x-ts-styled}} {{>apisAssignQueryParam}} -{{/isExplode}} +{{/vendorExtensions.x-ts-styled}} +{{/vendorExtensions.x-ts-spread}} } {{/isArray}} @@ -169,20 +162,22 @@ export class {{classname}} extends runtime.BaseAPI { headerParameters["Content-Type"] = "application/json"; {{/consumes}}{{#consumes}} headerParameters["Content-Type"] = "{{{mediaType}}}"; -{{/consumes}}{{/bodyParam}}{{#headerParams}}{{#isArray}} - if (requestParameters["{{paramName}}"] != null) { - headerParameters["{{baseName}}"] = {{#uniqueItems}}Array.from({{/uniqueItems}}requestParameters["{{paramName}}"]{{#uniqueItems}}){{/uniqueItems}}!.join(runtime.COLLECTION_FORMATS["{{collectionFormat}}"]); - } -{{/isArray}}{{^isArray}}{{#jsonHeaderUsesModelSerializer}} +{{/consumes}}{{/bodyParam}}{{#headerParams}}{{#jsonHeaderUsesModelSerializer}} if (requestParameters["{{paramName}}"] !== undefined) { headerParameters["{{baseName}}"] = JSON.stringify({{^withoutRuntimeChecks}}{{{dataType}}}ToJSON({{/withoutRuntimeChecks}}requestParameters["{{paramName}}"]{{^withoutRuntimeChecks}}){{/withoutRuntimeChecks}}); } {{/jsonHeaderUsesModelSerializer}}{{^jsonHeaderUsesModelSerializer}} if (requestParameters["{{paramName}}"] != null) { - headerParameters["{{baseName}}"] = String(requestParameters["{{paramName}}"]); + headerParameters["{{baseName}}"] = runtime.styleParam("{{baseName}}", {{{vendorExtensions.x-ts-value}}}, "{{vendorExtensions.x-ts-style}}", {{isExplode}}); } {{/jsonHeaderUsesModelSerializer}} -{{/isArray}}{{/headerParams}}{{#authMethods}}{{#isBasic}}{{#isBasicBasic}} +{{/headerParams}}{{#vendorExtensions.x-ts-cookies}} + const cookies: string[] = []; +{{/vendorExtensions.x-ts-cookies}}{{#cookieParams}} + if (requestParameters["{{paramName}}"] != null) { + cookies.push("{{baseName}}=" + runtime.styleParam("{{baseName}}", {{{vendorExtensions.x-ts-value}}}, "{{vendorExtensions.x-ts-style}}", {{isExplode}}, encodeURIComponent)); + } +{{/cookieParams}}{{#authMethods}}{{#isBasic}}{{#isBasicBasic}} if (this.configuration && (this.configuration.username !== undefined || this.configuration.password !== undefined)) { headerParameters["Authorization"] = "Basic " + btoa(this.configuration.username + ":" + this.configuration.password); } @@ -203,120 +198,48 @@ export class {{classname}} extends runtime.BaseAPI { if (this.configuration && this.configuration.apiKey) { queryParameters["{{keyParamName}}"] = await this.configuration.apiKey("{{keyParamName}}"); // {{name}} authentication } -{{/isKeyInQuery}}{{/isApiKey}}{{#isOAuth}} +{{/isKeyInQuery}}{{#isKeyInCookie}} + if (this.configuration && this.configuration.apiKey) { + cookies.push("{{keyParamName}}=" + encodeURIComponent(await this.configuration.apiKey("{{keyParamName}}"))); // {{name}} authentication + } +{{/isKeyInCookie}}{{/isApiKey}}{{#isOAuth}} if (this.configuration && this.configuration.accessToken) { // oauth required headerParameters["Authorization"] = await this.configuration.accessToken("{{name}}", [{{#scopes}}"{{{scope}}}"{{^-last}}, {{/-last}}{{/scopes}}]); } -{{/isOAuth}}{{/authMethods}}{{#hasFormParams}} - const consumes: runtime.Consume[] = [ -{{#consumes}} - { contentType: '{{{mediaType}}}' }, -{{/consumes}} - ]; - // @ts-ignore: canConsumeForm may be unused - const canConsumeForm = runtime.canConsumeForm(consumes); - - let formParams: { append(param: string, value: any): any }; - let useForm = false; -{{#formParams}} -{{#isFile}} - // use FormData to transmit files using content-type "multipart/form-data" - useForm = canConsumeForm; -{{/isFile}} -{{/formParams}} - if (useForm) { - formParams = new FormData(); - } else { - formParams = new URLSearchParams(); +{{/isOAuth}}{{/authMethods}}{{#vendorExtensions.x-ts-cookies}} + // Node, Deno and Bun send it; a browser drops a Cookie header and sends its own cookies. + if (cookies.length > 0) { + headerParameters["Cookie"] = cookies.join("; "); } +{{/vendorExtensions.x-ts-cookies}}{{#hasFormParams}} + const formParams = {{#vendorExtensions.x-ts-multipart}}new FormData(){{/vendorExtensions.x-ts-multipart}}{{^vendorExtensions.x-ts-multipart}}new URLSearchParams(){{/vendorExtensions.x-ts-multipart}}; {{#formParams}} -{{#isArray}} if (requestParameters["{{paramName}}"] != null) { -{{#isCollectionFormatMulti}} - requestParameters["{{paramName}}"].forEach((element) => { - formParams.append("{{baseName}}{{#useSquareBracketsInArrayNames}}[]{{/useSquareBracketsInArrayNames}}", element as any); - }) -{{/isCollectionFormatMulti}} -{{^isCollectionFormatMulti}} -{{#items.isModel}} -{{^items.isPrimitiveType}} - formParams.append("{{baseName}}", new Blob([JSON.stringify({{#uniqueItems}}Array.from({{/uniqueItems}}requestParameters["{{paramName}}"]{{#uniqueItems}}){{/uniqueItems}}{{^withoutRuntimeChecks}}.map({{items.dataType}}ToJSON){{/withoutRuntimeChecks}})], { type: "application/json", })); -{{/items.isPrimitiveType}} -{{#items.isPrimitiveType}} - formParams.append("{{baseName}}{{#useSquareBracketsInArrayNames}}[]{{/useSquareBracketsInArrayNames}}", {{#uniqueItems}}Array.from({{/uniqueItems}}requestParameters["{{paramName}}"]{{#uniqueItems}}){{/uniqueItems}}!.join(runtime.COLLECTION_FORMATS["{{collectionFormat}}"])); -{{/items.isPrimitiveType}} -{{/items.isModel}} -{{^items.isModel}} - formParams.append("{{baseName}}{{#useSquareBracketsInArrayNames}}[]{{/useSquareBracketsInArrayNames}}", {{#uniqueItems}}Array.from({{/uniqueItems}}requestParameters["{{paramName}}"]{{#uniqueItems}}){{/uniqueItems}}!.join(runtime.COLLECTION_FORMATS["{{collectionFormat}}"])); -{{/items.isModel}} -{{/isCollectionFormatMulti}} - } -{{/isArray}} -{{^isArray}} - if (requestParameters["{{paramName}}"] != null) { -{{#isDateTimeType}} - formParams.append("{{baseName}}", runtime.serializeDateTime(requestParameters["{{paramName}}"] as any)); -{{/isDateTimeType}} -{{^isDateTimeType}} -{{#isDateType}} - formParams.append("{{baseName}}", runtime.serializeDate(requestParameters["{{paramName}}"] as any)); -{{/isDateType}} -{{^isDateType}} -{{#isFile}} - formParams.append("{{baseName}}", requestParameters["{{paramName}}"] as any); -{{/isFile}} -{{^isFile}} -{{#isPrimitiveType}} - formParams.append("{{baseName}}", requestParameters["{{paramName}}"] as any); -{{/isPrimitiveType}} -{{^isPrimitiveType}} -{{#isEnumRef}} - formParams.append("{{baseName}}", requestParameters["{{paramName}}"] as any); -{{/isEnumRef}} -{{^isEnumRef}} -{{^withoutRuntimeChecks}} -{{^isContainer}} - formParams.append("{{baseName}}", new Blob([JSON.stringify({{{dataType}}}ToJSON(requestParameters["{{paramName}}"]))], { type: "application/json", })); -{{/isContainer}} -{{#isContainer}} - formParams.append("{{baseName}}", new Blob([JSON.stringify(requestParameters["{{paramName}}"])], { type: "application/json", })); -{{/isContainer}} -{{/withoutRuntimeChecks}}{{#withoutRuntimeChecks}} - formParams.append("{{baseName}}", new Blob([JSON.stringify(requestParameters["{{paramName}}"])], { type: "application/json", })); -{{/withoutRuntimeChecks}} -{{/isEnumRef}} -{{/isPrimitiveType}} -{{/isFile}} -{{/isDateType}} -{{/isDateTimeType}} +{{#vendorExtensions.x-ts-files}} + for (const file of requestParameters["{{paramName}}"]) { + formParams.append("{{baseName}}{{#useSquareBracketsInArrayNames}}[]{{/useSquareBracketsInArrayNames}}", file); + } +{{/vendorExtensions.x-ts-files}} +{{#vendorExtensions.x-ts-file}} + formParams.append("{{baseName}}", requestParameters["{{paramName}}"]); +{{/vendorExtensions.x-ts-file}} +{{#vendorExtensions.x-ts-json}} + formParams.append("{{baseName}}", new Blob([JSON.stringify({{{.}}})], { type: "application/json", })); +{{/vendorExtensions.x-ts-json}} +{{#vendorExtensions.x-ts-fields}} + for (const [key, value] of runtime.formFields("{{baseName}}", {{{.}}})) { + formParams.append(key, value); + } +{{/vendorExtensions.x-ts-fields}} } -{{/isArray}} {{/formParams}} {{/hasFormParams}} let urlPath = `{{{path}}}`; {{#pathParams}} -{{#isDateTimeType}} - if (requestParameters["{{paramName}}"] instanceof Date) { - urlPath = urlPath.replace(`{${"{{baseName}}"}}`, encodeURIComponent(runtime.serializeDateTime(requestParameters["{{paramName}}"]))); - } else { - urlPath = urlPath.replace(`{${"{{baseName}}"}}`, encodeURIComponent(String(requestParameters["{{paramName}}"]))); - } -{{/isDateTimeType}} -{{^isDateTimeType}} -{{#isDateType}} - if (requestParameters["{{paramName}}"] instanceof Date) { - urlPath = urlPath.replace(`{${"{{baseName}}"}}`, encodeURIComponent(runtime.serializeDate(requestParameters["{{paramName}}"]))); - } else { - urlPath = urlPath.replace(`{${"{{baseName}}"}}`, encodeURIComponent(String(requestParameters["{{paramName}}"]))); - } -{{/isDateType}} -{{^isDateType}} - urlPath = urlPath.replace(`{${"{{baseName}}"}}`, encodeURIComponent(String(requestParameters["{{paramName}}"]))); -{{/isDateType}} -{{/isDateTimeType}} + urlPath = urlPath.replace(`{${"{{baseName}}"}}`, runtime.styleParam("{{baseName}}", {{{vendorExtensions.x-ts-value}}}, "{{vendorExtensions.x-ts-style}}", {{isExplode}}, encodeURIComponent)); {{/pathParams}} return { @@ -325,30 +248,15 @@ export class {{classname}} extends runtime.BaseAPI { headers: headerParameters, query: queryParameters, {{#bodyParam}} -{{#isContainer}} -{{^withoutRuntimeChecks}} - body: requestParameters["{{paramName}}"]{{#isArray}}{{#items}}{{^isPrimitiveType}}!.map({{datatype}}ToJSON){{/isPrimitiveType}}{{/items}}{{/isArray}}, -{{/withoutRuntimeChecks}} -{{#withoutRuntimeChecks}} - body: requestParameters["{{paramName}}"], -{{/withoutRuntimeChecks}} -{{/isContainer}} -{{^isContainer}} -{{^isPrimitiveType}} {{^withoutRuntimeChecks}} - body: {{dataType}}ToJSON(requestParameters["{{paramName}}"]), + body: {{{vendorExtensions.x-ts-body}}}{{^isContainer}}{{#isPrimitiveType}} as any{{/isPrimitiveType}}{{/isContainer}}, {{/withoutRuntimeChecks}} {{#withoutRuntimeChecks}} - body: requestParameters["{{paramName}}"], + body: requestParameters["{{paramName}}"]{{^isContainer}}{{#isPrimitiveType}} as any{{/isPrimitiveType}}{{/isContainer}}, {{/withoutRuntimeChecks}} -{{/isPrimitiveType}} -{{#isPrimitiveType}} - body: requestParameters["{{paramName}}"] as any, -{{/isPrimitiveType}} -{{/isContainer}} {{/bodyParam}} {{#hasFormParams}} - body: formParams, + body: formParams, {{/hasFormParams}} }; } @@ -365,41 +273,39 @@ export class {{classname}} extends runtime.BaseAPI { async {{nickname}}Raw({{#hasAllParams}}requestParameters: {{#prefixParameterInterfaces}}{{classname}}{{/prefixParameterInterfaces}}{{operationIdCamelCase}}Request, {{/hasAllParams}}initOverrides?: RequestInit | runtime.InitOverrideFunction): PromiseapisReturnType}}{{^returnType}}void{{/returnType}}>> { const requestOptions = await this.{{nickname}}RequestOpts({{#hasAllParams}}requestParameters{{/hasAllParams}}); const response = await this.request(requestOptions, initOverrides); +{{#vendorExtensions.x-ts-other-2xx}} + // A 2xx of another body than the result's is an error, as its body is. + if ({{{.}}}.includes(response.status)) { + throw new runtime.ResponseError(response, `HTTP ${response.status}: another body than the result's`); + } +{{/vendorExtensions.x-ts-other-2xx}} {{#returnType}} +{{#vendorExtensions.x-ts-stream}} + return new runtime.StreamApiResponse(response, "{{.}}", (v: any) => {{{vendorExtensions.x-ts-stream-item}}}); +{{/vendorExtensions.x-ts-stream}} +{{^vendorExtensions.x-ts-stream}} {{#isResponseFile}} return new runtime.BlobApiResponse(response); {{/isResponseFile}} {{^isResponseFile}} -{{#returnTypeIsPrimitive}} -{{#isMap}} - return new runtime.JSONApiResponse<{{{returnType}}}>(response); -{{/isMap}} -{{#isArray}} - return new runtime.JSONApiResponse<{{{returnType}}}>(response); -{{/isArray}} -{{#returnSimpleType}} +{{#vendorExtensions.x-ts-result}} + return new runtime.JSONApiResponse(response{{^withoutRuntimeChecks}}, (jsonValue) => {{{.}}}{{/withoutRuntimeChecks}}); +{{/vendorExtensions.x-ts-result}} +{{^vendorExtensions.x-ts-result}} +{{#vendorExtensions.x-ts-text}} if (this.isJsonMime(response.headers.get('content-type'))) { return new runtime.JSONApiResponse<{{{returnType}}}>(response); } else { return new runtime.TextApiResponse(response) as any; } -{{/returnSimpleType}} -{{/returnTypeIsPrimitive}} -{{^returnTypeIsPrimitive}} -{{#isArray}} - return new runtime.JSONApiResponse(response{{^withoutRuntimeChecks}}, (jsonValue) => {{#uniqueItems}}new Set({{/uniqueItems}}jsonValue.map({{returnBaseType}}FromJSON){{/withoutRuntimeChecks}}){{#uniqueItems}}){{/uniqueItems}}; -{{/isArray}} -{{^isArray}} -{{#isMap}} - return new runtime.JSONApiResponse(response{{^withoutRuntimeChecks}}, (jsonValue) => runtime.mapValues(jsonValue, {{#returnProperty.items.isArray}}(items) => items.map({{returnProperty.items.baseType}}FromJSON){{/returnProperty.items.isArray}}{{^returnProperty.items.isArray}}{{returnBaseType}}FromJSON{{/returnProperty.items.isArray}}){{/withoutRuntimeChecks}}); -{{/isMap}} -{{^isMap}} - return new runtime.JSONApiResponse(response{{^withoutRuntimeChecks}}, (jsonValue) => {{returnBaseType}}FromJSON(jsonValue){{/withoutRuntimeChecks}}); -{{/isMap}} -{{/isArray}} -{{/returnTypeIsPrimitive}} +{{/vendorExtensions.x-ts-text}} +{{^vendorExtensions.x-ts-text}} + return new runtime.JSONApiResponse<{{{returnType}}}>(response); +{{/vendorExtensions.x-ts-text}} +{{/vendorExtensions.x-ts-result}} {{/isResponseFile}} +{{/vendorExtensions.x-ts-stream}} {{/returnType}} {{^returnType}} return new runtime.VoidApiResponse(response); @@ -416,21 +322,7 @@ export class {{classname}} extends runtime.BaseAPI { {{#returnType}} const response = await this.{{nickname}}Raw({{#hasAllParams}}{ {{#allParams}}{{paramName}}: {{paramName}}{{^-last}}, {{/-last}}{{/allParams}} }, {{/hasAllParams}}initOverrides); - {{#isResponseOptional}} - switch (response.raw.status) { - {{#responses}} - {{#is2xx}} - case {{code}}: - return {{#dataType}}await response.value(){{/dataType}}{{^dataType}}null{{/dataType}}; - {{/is2xx}} - {{/responses}} - default: - return await response.value(); - } - {{/isResponseOptional}} - {{^isResponseOptional}} return await response.value(); - {{/isResponseOptional}} {{/returnType}} {{^returnType}} await this.{{nickname}}Raw({{#hasAllParams}}{ {{#allParams}}{{paramName}}: {{paramName}}{{^-last}}, {{/-last}}{{/allParams}} }, {{/hasAllParams}}initOverrides); @@ -442,21 +334,7 @@ export class {{classname}} extends runtime.BaseAPI { {{#returnType}} const response = await this.{{nickname}}Raw({{#hasAllParams}}requestParameters, {{/hasAllParams}}initOverrides); - {{#isResponseOptional}} - switch (response.raw.status) { - {{#responses}} - {{#is2xx}} - case {{code}}: - return {{#dataType}}await response.value(){{/dataType}}{{^dataType}}null{{/dataType}}; - {{/is2xx}} - {{/responses}} - default: - return await response.value(); - } - {{/isResponseOptional}} - {{^isResponseOptional}} return await response.value(); - {{/isResponseOptional}} {{/returnType}} {{^returnType}} await this.{{nickname}}Raw({{#hasAllParams}}requestParameters, {{/hasAllParams}}initOverrides); diff --git a/templates/typescript-fetch/apisAssignQueryParam.mustache b/templates/typescript-fetch/apisAssignQueryParam.mustache index 4dc6555..f63b2db 100644 --- a/templates/typescript-fetch/apisAssignQueryParam.mustache +++ b/templates/typescript-fetch/apisAssignQueryParam.mustache @@ -1,12 +1,2 @@ -{{! Assign query parameters based on their type }} -{{#isDateTimeType}} - queryParameters["{{baseName}}"] = runtime.serializeDateTime(requestParameters["{{paramName}}"] as any); -{{/isDateTimeType}} -{{^isDateTimeType}} -{{#isDateType}} - queryParameters["{{baseName}}"] = runtime.serializeDate(requestParameters["{{paramName}}"] as any); -{{/isDateType}} -{{^isDateType}} - queryParameters["{{baseName}}"] = requestParameters["{{paramName}}"]; -{{/isDateType}} -{{/isDateTimeType}} \ No newline at end of file +{{! a date as its text, a model as its JSON }} + queryParameters["{{baseName}}"] = {{{vendorExtensions.x-ts-value}}}; diff --git a/templates/typescript-fetch/modelGeneric.mustache b/templates/typescript-fetch/modelGeneric.mustache index 6445e0a..2456844 100644 --- a/templates/typescript-fetch/modelGeneric.mustache +++ b/templates/typescript-fetch/modelGeneric.mustache @@ -1,14 +1,9 @@ {{^withoutRuntimeChecks}} import { + bestFit, + discriminate, mapValues, -{{#usesAnyJSON}} - anyFromJSON, - anyToJSON, -{{/usesAnyJSON}} -{{#usesBlobJSON}} - BlobFromJSON, - BlobToJSON, -{{/usesBlobJSON}} + otherKeys, {{#usesDateHelpers}} parseDate, parseDateTime, @@ -29,10 +24,10 @@ import { {{/tsImports}} {{/hasImports}} -{{#discriminator}} -{{#discriminator.mappedModels}} +{{#vendorExtensions.x-ts-mapped}} import { type {{modelName}}, {{modelName}}FromJSONTyped, {{modelName}}ToJSON, {{modelName}}ToJSONTyped } from './{{modelFilename}}{{importFileExtension}}'; -{{/discriminator.mappedModels}} +{{/vendorExtensions.x-ts-mapped}} +{{#discriminator}} {{/discriminator}} {{/withoutRuntimeChecks}} @@ -113,69 +108,11 @@ export function {{classname}}FromJSONTyped(json: any, ignoreDiscriminator: boole {{#parent}} ...{{#parentIsOneOf}}({{/parentIsOneOf}}{{{.}}}FromJSONTyped(json, true){{#parentIsOneOf}} as any){{/parentIsOneOf}}, {{/parent}} -{{#additionalPropertiesType}} - ...json, -{{/additionalPropertiesType}} +{{#vendorExtensions.x-ts-rest-from}} + ...{{{.}}}, +{{/vendorExtensions.x-ts-rest-from}} {{#vars}} - {{#isPrimitiveType}} - {{#isArray}} - {{#uniqueItems}} - "{{name}}": {{^required}}{{#isNullable}}json["{{baseName}}"] === undefined ? undefined : json["{{baseName}}"] === null ? null : {{/isNullable}}{{^isNullable}}json["{{baseName}}"] == null ? undefined : {{/isNullable}}{{/required}}{{#required}}{{#isNullable}}json["{{baseName}}"] == null ? null : {{/isNullable}}{{/required}}json["{{baseName}}"], - {{/uniqueItems}} - {{^uniqueItems}} - "{{name}}": {{^required}}{{#isNullable}}json["{{baseName}}"] === undefined ? undefined : json["{{baseName}}"] === null ? null : {{/isNullable}}{{^isNullable}}json["{{baseName}}"] == null ? undefined : {{/isNullable}}{{/required}}{{#required}}{{#isNullable}}json["{{baseName}}"] == null ? null : {{/isNullable}}{{/required}}json["{{baseName}}"], - {{/uniqueItems}} - {{/isArray}} - {{^isArray}} - {{#isDateType}} - "{{name}}": {{^required}}{{#isNullable}}json["{{baseName}}"] === undefined ? undefined : json["{{baseName}}"] === null ? null : {{/isNullable}}{{^isNullable}}json["{{baseName}}"] == null ? undefined : {{/isNullable}}{{/required}}({{#required}}{{#isNullable}}json["{{baseName}}"] == null ? null : {{/isNullable}}{{^isNullable}}json["{{baseName}}"] == null ? json["{{baseName}}"] : {{/isNullable}}{{/required}}parseDate(json["{{baseName}}"])), - {{/isDateType}} - {{#isDateTimeType}} - "{{name}}": {{^required}}{{#isNullable}}json["{{baseName}}"] === undefined ? undefined : json["{{baseName}}"] === null ? null : {{/isNullable}}{{^isNullable}}json["{{baseName}}"] == null ? undefined : {{/isNullable}}{{/required}}({{#required}}{{#isNullable}}json["{{baseName}}"] == null ? null : {{/isNullable}}{{^isNullable}}json["{{baseName}}"] == null ? json["{{baseName}}"] : {{/isNullable}}{{/required}}parseDateTime(json["{{baseName}}"])), - {{/isDateTimeType}} - {{^isDateType}} - {{^isDateTimeType}} - "{{name}}": {{^required}}{{#isNullable}}json["{{baseName}}"] === undefined ? undefined : json["{{baseName}}"] === null ? null : {{/isNullable}}{{^isNullable}}json["{{baseName}}"] == null ? undefined : {{/isNullable}}{{/required}}json["{{baseName}}"], - {{/isDateTimeType}} - {{/isDateType}} - {{/isArray}} - {{/isPrimitiveType}} - {{^isPrimitiveType}} - {{#isArray}} - {{#uniqueItems}} - "{{name}}": {{^required}}{{#isNullable}}json["{{baseName}}"] === undefined ? undefined : json["{{baseName}}"] === null ? null : {{/isNullable}}{{^isNullable}}json["{{baseName}}"] == null ? undefined : {{/isNullable}}{{/required}}{{#required}}{{#isNullable}}json["{{baseName}}"] == null ? null : {{/isNullable}}{{/required}}{{#items.isPrimitiveType}}(json["{{baseName}}"] as Array){{/items.isPrimitiveType}}{{^items.isPrimitiveType}}{{#items.isContainer}}(json["{{baseName}}"] as Array){{/items.isContainer}}{{^items.isContainer}}(json["{{baseName}}"] as Array).map({{#items}}{{{datatype}}}{{/items}}FromJSON){{/items.isContainer}}{{/items.isPrimitiveType}}{{^items}}json["{{baseName}}"]{{/items}}, - {{/uniqueItems}} - {{^uniqueItems}} - {{#items.isPrimitiveType}} - "{{name}}": {{^required}}{{#isNullable}}json["{{baseName}}"] === undefined ? undefined : json["{{baseName}}"] === null ? null : {{/isNullable}}{{^isNullable}}json["{{baseName}}"] == null ? undefined : {{/isNullable}}{{/required}}{{#required}}{{#isNullable}}json["{{baseName}}"] == null ? null : {{/isNullable}}{{/required}}(json["{{baseName}}"] as Array), - {{/items.isPrimitiveType}} - {{^items.isPrimitiveType}} - {{#items.isContainer}} - "{{name}}": {{^required}}{{#isNullable}}json["{{baseName}}"] === undefined ? undefined : json["{{baseName}}"] === null ? null : {{/isNullable}}{{^isNullable}}json["{{baseName}}"] == null ? undefined : {{/isNullable}}{{/required}}{{#required}}{{#isNullable}}json["{{baseName}}"] == null ? null : {{/isNullable}}{{/required}}(json["{{baseName}}"] as Array), - {{/items.isContainer}} - {{^items.isContainer}} - "{{name}}": {{^required}}{{#isNullable}}json["{{baseName}}"] === undefined ? undefined : json["{{baseName}}"] === null ? null : {{/isNullable}}{{^isNullable}}json["{{baseName}}"] == null ? undefined : {{/isNullable}}{{/required}}{{#required}}{{#isNullable}}json["{{baseName}}"] == null ? null : {{/isNullable}}{{/required}}(json["{{baseName}}"] as Array).map({{#items}}{{{datatype}}}{{/items}}FromJSON), - {{/items.isContainer}} - {{/items.isPrimitiveType}} - {{^items}} - "{{name}}": {{^required}}{{#isNullable}}json["{{baseName}}"] === undefined ? undefined : json["{{baseName}}"] === null ? null : {{/isNullable}}{{^isNullable}}json["{{baseName}}"] == null ? undefined : {{/isNullable}}{{/required}}json["{{baseName}}"], - {{/items}} - {{/uniqueItems}} - {{/isArray}} - {{#isMap}} - "{{name}}": {{^required}}{{#isNullable}}json["{{baseName}}"] === undefined ? undefined : json["{{baseName}}"] === null ? null : {{/isNullable}}{{^isNullable}}json["{{baseName}}"] == null ? undefined : {{/isNullable}}{{/required}}({{#required}}{{#isNullable}}json["{{baseName}}"] == null ? null : {{/isNullable}}{{/required}}{{#items.isModel}}mapValues(json["{{baseName}}"], {{{items.datatype}}}FromJSON){{/items.isModel}}{{^items.isModel}}json["{{baseName}}"]{{/items.isModel}}), - {{/isMap}} - {{^isArray}} - {{^isMap}} - {{^isFreeFormObject}} - "{{name}}": {{^required}}{{#isNullable}}json["{{baseName}}"] === undefined ? undefined : json["{{baseName}}"] === null ? null : {{/isNullable}}{{^isNullable}}json["{{baseName}}"] == null ? undefined : {{/isNullable}}{{/required}}{{{datatype}}}FromJSON(json["{{baseName}}"]), - {{/isFreeFormObject}} - {{#isFreeFormObject}} - "{{name}}": {{^required}}{{#isNullable}}json["{{baseName}}"] === undefined ? undefined : json["{{baseName}}"] === null ? null : {{/isNullable}}{{^isNullable}}json["{{baseName}}"] == null ? undefined : {{/isNullable}}{{/required}}json["{{baseName}}"], - {{/isFreeFormObject}} - {{/isMap}} - {{/isArray}} - {{/isPrimitiveType}} + {{{vendorExtensions.x-ts-from-json}}}, {{/vars}} }; {{/hasVars}} @@ -209,65 +146,12 @@ export function {{classname}}ToJSONTyped(value?: {{#hasReadOnly}}Omit<{{classnam {{#parent}} ...{{#parentIsOneOf}}({{/parentIsOneOf}}{{{.}}}ToJSONTyped(value, true){{#parentIsOneOf}} as any){{/parentIsOneOf}}, {{/parent}} -{{#additionalPropertiesType}} - ...value, -{{/additionalPropertiesType}} +{{#vendorExtensions.x-ts-rest-to}} + ...{{{.}}}, +{{/vendorExtensions.x-ts-rest-to}} {{#vars}} {{^isReadOnly}} - {{#isPrimitiveType}} - {{#isDateType}} - "{{baseName}}": value["{{name}}"] == null ? value["{{name}}"] : serializeDate(value["{{name}}"]), - {{/isDateType}} - {{#isDateTimeType}} - "{{baseName}}": value["{{name}}"] == null ? value["{{name}}"] : serializeDateTime(value["{{name}}"]), - {{/isDateTimeType}} - {{#isArray}} - "{{baseName}}": {{#uniqueItems}}value["{{name}}"]{{/uniqueItems}}{{^uniqueItems}}value["{{name}}"]{{/uniqueItems}}, - {{/isArray}} - {{^isDateType}} - {{^isDateTimeType}} - {{^isArray}} - "{{baseName}}": value["{{name}}"], - {{/isArray}} - {{/isDateTimeType}} - {{/isDateType}} - {{/isPrimitiveType}} - {{^isPrimitiveType}} - {{#isArray}} - {{#uniqueItems}} - "{{baseName}}": {{^required}}value["{{name}}"] == null ? undefined : {{/required}}{{#required}}{{#isNullable}}value["{{name}}"] == null ? null : {{/isNullable}}{{/required}}{{#items.isPrimitiveType}}(value["{{name}}"] as Array){{/items.isPrimitiveType}}{{^items.isPrimitiveType}}{{#items.isContainer}}(value["{{name}}"] as Array){{/items.isContainer}}{{^items.isContainer}}(value["{{name}}"] as Array).map({{#items}}{{{datatype}}}{{/items}}ToJSON){{/items.isContainer}}{{/items.isPrimitiveType}}{{^items}}value["{{name}}"]{{/items}}, - {{/uniqueItems}} - {{^uniqueItems}} - {{#items.isPrimitiveType}} - "{{baseName}}": {{^required}}value["{{name}}"] == null ? undefined : {{/required}}(value["{{name}}"] as Array), - {{/items.isPrimitiveType}} - {{^items.isPrimitiveType}} - {{#items.isContainer}} - "{{baseName}}": {{^required}}value["{{name}}"] == null ? undefined : {{/required}}({{#required}}{{#isNullable}}value["{{name}}"] == null ? null : {{/isNullable}}{{/required}}(value["{{name}}"] as Array)), - {{/items.isContainer}} - {{^items.isContainer}} - "{{baseName}}": {{^required}}value["{{name}}"] == null ? undefined : {{/required}}({{#required}}{{#isNullable}}value["{{name}}"] == null ? null : {{/isNullable}}{{/required}}(value["{{name}}"] as Array).map({{#items}}{{{datatype}}}{{/items}}ToJSON)), - {{/items.isContainer}} - {{/items.isPrimitiveType}} - {{^items}} - "{{baseName}}": value["{{name}}"], - {{/items}} - {{/uniqueItems}} - {{/isArray}} - {{#isMap}} - "{{baseName}}": {{^required}}value["{{name}}"] == null ? undefined : {{/required}}({{#required}}{{#isNullable}}value["{{name}}"] == null ? null : {{/isNullable}}{{/required}}{{#items.isModel}}mapValues(value["{{name}}"], {{{items.datatype}}}ToJSON){{/items.isModel}}{{^items.isModel}}value["{{name}}"]{{/items.isModel}}), - {{/isMap}} - {{^isArray}} - {{^isMap}} - {{^isFreeFormObject}} - "{{baseName}}": {{{datatype}}}ToJSON(value["{{name}}"]), - {{/isFreeFormObject}} - {{#isFreeFormObject}} - "{{baseName}}": value["{{name}}"], - {{/isFreeFormObject}} - {{/isMap}} - {{/isArray}} - {{/isPrimitiveType}} + {{{vendorExtensions.x-ts-to-json}}}, {{/isReadOnly}} {{/vars}} }; diff --git a/templates/typescript-fetch/modelOneOf.mustache b/templates/typescript-fetch/modelOneOf.mustache index 4957037..2a3e9dc 100644 --- a/templates/typescript-fetch/modelOneOf.mustache +++ b/templates/typescript-fetch/modelOneOf.mustache @@ -1,5 +1,5 @@ {{^withoutRuntimeChecks}} -import { parseDate, parseDateTime, serializeDate, serializeDateTime } from '../runtime{{importFileExtension}}'; +import { bestFit, parseDate, parseDateTime, serializeDate, serializeDateTime } from '../runtime{{importFileExtension}}'; {{/withoutRuntimeChecks}} {{#hasImports}} {{#oneOfImports}} @@ -40,11 +40,12 @@ export function {{classname}}FromJSONTyped(json: any, ignoreDiscriminator: boole return json; } {{/hasOneOfModels}} - {{#oneOfModels}} - if (instanceOf{{{.}}}(json)) { - return {{{.}}}FromJSONTyped(json, true); + {{#vendorExtensions.x-ts-fit-from}} + const fit = bestFit(json, {{{.}}}); + if (fit !== undefined) { + return fit; } - {{/oneOfModels}} + {{/vendorExtensions.x-ts-fit-from}} {{#hasOneOfArrays}} if (Array.isArray(json)) { if (json.every((item: any) => typeof item === 'object')) { @@ -61,14 +62,14 @@ export function {{classname}}FromJSONTyped(json: any, ignoreDiscriminator: boole {{#items}} {{#isDateType}} if (Array.isArray(json)) { - if (json.every((item: any) => !(isNaN(parseDate(item).getTime())))) { + if (json.every((item: any) => typeof item === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(item))) { return json.map((value: any) => parseDate(value)); } } {{/isDateType}} {{#isDateTimeType}} if (Array.isArray(json)) { - if (json.every((item: any) => !(isNaN(parseDateTime(item).getTime())))) { + if (json.every((item: any) => typeof item === 'string' && /^\d{4}-\d{2}-\d{2}T/.test(item))) { return json.map((value: any) => parseDateTime(value)); } } @@ -107,13 +108,13 @@ export function {{classname}}FromJSONTyped(json: any, ignoreDiscriminator: boole } {{/isMap}} {{#isDateType}} - if (!(isNaN(parseDate(json).getTime()))) { + if (typeof json === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(json)) { return {{^required}}json == null ? undefined : {{/required}}({{#required}}{{#isNullable}}json == null ? null : {{/isNullable}}{{/required}}parseDate(json)); } {{/isDateType}} {{^isDateType}} {{#isDateTimeType}} - if (!(isNaN(parseDateTime(json).getTime()))) { + if (typeof json === 'string' && /^\d{4}-\d{2}-\d{2}T/.test(json)) { return {{^required}}json == null ? undefined : {{/required}}({{#required}}{{#isNullable}}json == null ? null : {{/isNullable}}{{/required}}parseDateTime(json)); } {{/isDateTimeType}} @@ -144,7 +145,7 @@ export function {{classname}}FromJSONTyped(json: any, ignoreDiscriminator: boole return json; } {{/hasOneOfArrays}} - return {} as any; + return json; {{/discriminator}} } @@ -172,11 +173,12 @@ export function {{classname}}ToJSONTyped(value?: {{classname}} | null, ignoreDis return value; } {{/hasOneOfModels}} - {{#oneOfModels}} - if (instanceOf{{{.}}}(value)) { - return {{{.}}}ToJSON(value as {{{.}}}); + {{#vendorExtensions.x-ts-fit-to}} + const fit = bestFit(value, {{{.}}}); + if (fit !== undefined) { + return fit; } - {{/oneOfModels}} + {{/vendorExtensions.x-ts-fit-to}} {{#hasOneOfArrays}} if (Array.isArray(value)) { if (value.every((item: any) => typeof item === 'object')) { @@ -274,6 +276,6 @@ export function {{classname}}ToJSONTyped(value?: {{classname}} | null, ignoreDis return value; } {{/hasOneOfArrays}} - return {}; + return value; {{/discriminator}} } diff --git a/templates/typescript-fetch/runtime.mustache b/templates/typescript-fetch/runtime.mustache index ad674b9..2d438ad 100644 --- a/templates/typescript-fetch/runtime.mustache +++ b/templates/typescript-fetch/runtime.mustache @@ -396,6 +396,63 @@ function querystringSingleKey(key: string, value: string | number | null | undef return `${encodeURIComponent(fullKey)}=${encodeURIComponent(String(value))}`; } +/** + * A path, header or cookie parameter as its style writes it: simple (a,b; an object k,v or, exploded, + * k=v), label (.a,b or .a.b), matrix (;n=a,b or ;n=a;n=b) and form (a,b, a cookie's). encode escapes + * each name and value, as a path's must be. + */ +export function styleParam(name: string, value: any, style: string, explode: boolean, encode: (text: string) => string = (text) => text): string { + const object = value !== null && typeof value === "object" && !Array.isArray(value); + const items: string[] = Array.isArray(value) + ? value.map((v) => encode(String(v))) + : object + ? Object.entries(value).map(([k, v]) => encode(k) + (explode ? "=" : ",") + encode(String(v))) + : [encode(String(value))]; + switch (style) { + case "label": + return "." + items.join(explode ? "." : ","); + case "matrix": + if (explode && object) { + return items.map((item) => ";" + item).join(""); + } + if (explode && Array.isArray(value)) { + return items.map((item) => `;${encode(name)}=${item}`).join(""); + } + return `;${encode(name)}=` + items.join(","); + default: + return items.join(","); + } +} + +/** + * The fields of a form value as its encoding writes them: deepObject as name[key] and name[key][0], + * as deep as the value goes; an array a field per item (exploded form) or one joined by its style's + * delimiter; an object a field per property (exploded form) or one of key,value pairs. + */ +export function formFields(name: string, value: any, style: string, explode: boolean): Array<[string, string]> { + if (style === "deepObject") { + return deepObjectFields(name, value); + } + if (value === null || typeof value !== "object") { + return [[name, String(value)]]; + } + if (explode) { + return Array.isArray(value) + ? value.map((v): [string, string] => [name, String(v)]) + : Object.entries(value).map(([k, v]): [string, string] => [k, String(v)]); + } + const delimiter = style === "spaceDelimited" ? " " : style === "pipeDelimited" ? "|" : ","; + return [[name, (Array.isArray(value) ? value : Object.entries(value).flat()).join(delimiter)]]; +} + +function deepObjectFields(name: string, value: any): Array<[string, string]> { + if (value === null || typeof value !== "object") { + return [[name, String(value)]]; + } + const entries: Array<[string, any]> = Array.isArray(value) ? value.map((v, i) => [String(i), v]) : Object.entries(value); + return entries.flatMap(([k, v]) => (v === undefined ? [] : deepObjectFields(`${name}[${k}]`, v))); +} + export function exists(json: any, key: string): boolean { const value = json[key]; return value !== null && value !== undefined; @@ -454,6 +511,51 @@ export function parseDateTime(value: any): Date { {{/withoutRuntimeChecks}} {{^withoutRuntimeChecks}} +/** + * Converts a member of a union by the value of its discriminator property, with the function the + * value names; a value no function is named for, or null, stays as it is. + */ +export function discriminate(value: any, property: string, by: { [value: string]: (value: any) => any }): any { + const fn = value == null ? undefined : by[value[property]]; + return fn ? fn(value) : value; +} + +/** + * The keys of a value other than the given ones, with their values: what a model holds next to + * its properties. + */ +export function otherKeys(value: any, keys: string[]): { [key: string]: any } { + const result: { [key: string]: any } = {}; + for (const key of Object.keys(value)) { + if (!keys.includes(key)) { + result[key] = value[key]; + } + } + return result; +} + +/** + * A member of a union converted by the model it fits best: of the models whose required keys the + * value has, the one that declares the most of its keys and the fewest it does not. undefined when + * the value is no object (an id next to the object it expands to) or fits none. + */ +export function bestFit(value: any, models: Array<[string[], string[], (value: any) => any]>): any { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + return undefined; + } + const keys = Object.keys(value); + let best: ((value: any) => any) | undefined; + let score = -Infinity; + for (const [required, known, fn] of models) { + const fit = keys.reduce((n, key) => n + (known.includes(key) ? 1 : -1), 0); + if (required.every((key) => key in value) && fit > score) { + best = fn; + score = fit; + } + } + return best?.(value); +} + export function mapValues(data: any, fn: (item: any) => any): { [key: string]: any } { const result: { [key: string]: any } = {}; for (const key of Object.keys(data)) { @@ -525,8 +627,13 @@ export interface ResponseTransformer { export class JSONApiResponse { constructor(public raw: Response, private transformer: ResponseTransformer = (jsonValue: any) => jsonValue) {} + /** + * The body parsed and converted; null when there is none, as a 204 next to a 200 with a body + * or the answer to a HEAD has. + */ async value(): Promise { - return this.transformer(await this.raw.json()); + const text = await this.raw.text(); + return text === "" ? (null as T) : this.transformer(JSON.parse(text)); } } @@ -613,6 +720,130 @@ function basename(name: string | undefined): string | undefined { return !base || base === '.' || base === '..' ? undefined : base; } +/** + * A server-sent event: its data as the item type (parsed from JSON unless the item is text), its + * event name, id and retry when the server sent them. An id stays the id of the events after it, + * as the specification has it. + */ +export interface ServerSentEvent { + data: T; + event?: string; + id?: string; + retry?: number; +} + +/** + * A streamed body, read as it arrives: the JSON value of each line of JSON Lines ("lines"), or each + * server-sent event ("events"), converted by the transformer. The response stays open until the loop + * over the value ends or breaks, or the request's signal aborts it. + */ +export class StreamApiResponse { + constructor(public raw: Response, private kind: "lines" | "events", private transformer: (value: any) => any) {} + + async value(): Promise> { + return (this.kind === "lines" ? jsonLines(this.raw, this.transformer) : serverSentEvents(this.raw, this.transformer)) as AsyncIterable; + } +} + +/** The lines of a body as they arrive, without their line endings; the last one also without one. */ +async function* bodyLines(raw: Response): AsyncGenerator { + if (raw.body == null) { + return; + } + const reader = raw.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + try { + for (;;) { + const { done, value } = await reader.read(); + buffer += done ? decoder.decode() : decoder.decode(value, { stream: true }); + let end: number; + while ((end = buffer.search(/\r\n|\r|\n/)) >= 0) { + const newline = buffer.startsWith("\r\n", end) ? 2 : 1; + yield buffer.slice(0, end); + buffer = buffer.slice(end + newline); + } + if (done) { + break; + } + } + if (buffer !== "") { + yield buffer; + } + } finally { + // A loop that breaks early closes the response. + await reader.cancel().catch(() => undefined); + } +} + +async function* jsonLines(raw: Response, transformer: (value: any) => any): AsyncGenerator { + for await (const line of bodyLines(raw)) { + if (line.trim() !== "") { + yield transformer(JSON.parse(line)); + } + } +} + +/** + * The events of a text/event-stream (the HTML living standard's parser): data lines joined by a + * newline, a byte order mark and comments skipped, an event the stream ends in the middle of dropped. + */ +async function* serverSentEvents(raw: Response, transformer: (value: any) => any): AsyncGenerator> { + let data: string[] = []; + let event: string | undefined; + let id: string | undefined; + let retry: number | undefined; + let first = true; + for await (let line of bodyLines(raw)) { + if (first && line.startsWith("\uFEFF")) { + line = line.slice(1); + } + first = false; + if (line === "") { + if (data.length > 0) { + const e: ServerSentEvent = { data: transformer(data.join("\n")) }; + if (event !== undefined) { + e.event = event; + } + if (id !== undefined) { + e.id = id; + } + if (retry !== undefined) { + e.retry = retry; + } + yield e; + } + data = []; + event = undefined; + continue; + } + if (line.startsWith(":")) { + continue; + } + const colon = line.indexOf(":"); + const field = colon < 0 ? line : line.slice(0, colon); + const value = colon < 0 ? "" : line.slice(colon + 1).replace(/^ /, ""); + switch (field) { + case "data": + data.push(value); + break; + case "event": + event = value; + break; + case "id": + if (!value.includes("\0")) { + id = value; + } + break; + case "retry": + if (/^\d+$/.test(value)) { + retry = Number(value); + } + break; + } + } +} + export class TextApiResponse { constructor(public raw: Response) {} From ebb871e308d7ef4b978a91ed0047f7db929392a5 Mon Sep 17 00:00:00 2001 From: Filip Seman Date: Mon, 26 Oct 2026 18:21:28 +0100 Subject: [PATCH 03/18] test(e2e): hold typescript-fetch to strict tsc and exact generated values typescript-fetch now passes every scenario it can and every generated value case, as go-fetch does; only dart-fetch stays lenient. The harness hid gaps: tsc compiled without strict and ignored errors, and a FromJSON and a ToJSON that both leave a value alone pass a round trip. - tsc runs strict; a client with a type error fails its own suite, and a client whose JavaScript does not parse no longer stops the run; - the runner checks every argument and result against the client's types: TypeScript property names in a model, a Date where the interface says; it puts back the read-only properties ToJSON leaves out; - typescript-fetch compares date-times as instants to the millisecond, a Content-Type by its media type; its error model is the body only where the spec declares one for the status (`bodies` in the runner input); - streams are read as the list of their items. New and wider feature specs cover what a critic found the scenarios missed: nested containers of models and dates, a discriminator without mapping, oneOf members told apart by value, runtime name clashes, date headers, an integer sent as text, __proto__ and constructor properties. The skips typescript-fetch passes now are gone; seven stay: integers past 2^53, naive date-times, the FastAPI list header and a multipart JSON part (FormData gives a part a type only as a file). --- internal/e2e/e2e_test.go | 42 ++- internal/e2e/gofetch_test.go | 1 + internal/e2e/match_test.go | 56 +++- internal/e2e/scenario_test.go | 24 ++ internal/e2e/typescript_test.go | 44 ++- internal/e2e/values_test.go | 5 - testdata/e2e/README.md | 38 ++- testdata/e2e/runners/ts/runner.ts | 301 +++++++++++++++++- .../e2e/scenarios/additional-properties.yaml | 2 +- .../discriminator-allof-no-mapping.yaml | 14 + testdata/e2e/scenarios/fastapi.yaml | 28 +- .../e2e/scenarios/nullable-openapi31.yaml | 2 - .../e2e/scenarios/parameters-locations.yaml | 1 - .../scenarios/parameters-object-styles.yaml | 4 +- .../e2e/scenarios/parameters-path-style.yaml | 4 +- .../scenarios/parameters-scalar-formats.yaml | 10 +- testdata/e2e/scenarios/parameters.yaml | 2 +- .../e2e/scenarios/request-body-forms.yaml | 1 - .../e2e/scenarios/request-body-login.yaml | 2 +- .../scenarios/request-body-other-media.yaml | 2 +- .../e2e/scenarios/responses-mixed-2xx.yaml | 5 +- testdata/e2e/scenarios/responses.yaml | 2 +- testdata/e2e/scenarios/security.yaml | 2 +- testdata/specs/feature/arrays-of-arrays.yaml | 19 ++ .../feature/composition-oneof-scalars.yaml | 81 +++++ testdata/specs/feature/dates.yaml | 44 +++ .../discriminator-allof-no-mapping.yaml | 58 ++++ testdata/specs/feature/maps-of-models.yaml | 27 ++ .../specs/feature/naming-runtime-clash.yaml | 45 +++ .../feature/naming-sanitized-properties.yaml | 4 + .../feature/parameters-object-styles.yaml | 7 +- .../feature/parameters-scalar-formats.yaml | 4 +- .../specs/feature/responses-mixed-2xx.yaml | 10 + 33 files changed, 775 insertions(+), 116 deletions(-) create mode 100644 testdata/e2e/scenarios/discriminator-allof-no-mapping.yaml create mode 100644 testdata/specs/feature/composition-oneof-scalars.yaml create mode 100644 testdata/specs/feature/discriminator-allof-no-mapping.yaml create mode 100644 testdata/specs/feature/naming-runtime-clash.yaml diff --git a/internal/e2e/e2e_test.go b/internal/e2e/e2e_test.go index c257871..039acd7 100644 --- a/internal/e2e/e2e_test.go +++ b/internal/e2e/e2e_test.go @@ -24,12 +24,20 @@ import ( type runner struct { generator string run func(t *testing.T, suites []suite) map[string]map[int]outcome + + // lenient counts its failing generated cases and skips them, until a + // later change fixes its known gaps. + lenient bool + + // millis compares its date-times as instants to the millisecond: they + // are JavaScript Dates. + millis bool } var runners = []runner{ - {"go-fetch", runGoFetch}, - {"typescript-fetch", runTypeScript}, - {"dart-fetch", runDart}, + {generator: "go-fetch", run: runGoFetch}, + {generator: "typescript-fetch", run: runTypeScript, millis: true}, + {generator: "dart-fetch", run: runDart, lenient: true}, } // forEachRunner runs fn per generator; E2E_GENERATOR= picks one. @@ -91,7 +99,7 @@ func TestMock(t *testing.T) { for _, s := range suites { t.Run(s.Name, func(t *testing.T) { - tally[s.Name] = checkCases(t, r.generator, byName[s.Name], outcomes[s.Name], mocks[s.Name]) + tally[s.Name] = checkCases(t, r, byName[s.Name], outcomes[s.Name], mocks[s.Name]) }) } @@ -160,7 +168,7 @@ func TestFastAPI(t *testing.T) { var c *counts - t.Run(s.Name, func(t *testing.T) { c = checkCases(t, r.generator, s, outcomes[s.Name], nil) }) + t.Run(s.Name, func(t *testing.T) { c = checkCases(t, r, s, outcomes[s.Name], nil) }) report(t, r.generator, map[string]*counts{s.Name: c}) } }) @@ -174,7 +182,7 @@ func toSuite(t *testing.T, s *scenario, specPath, base string, isMock bool) suit t.Fatal(err) } - params := opParams(doc) + params, bodies := opParams(doc), opBodies(doc) out := suite{Name: s.Name, Base: base, Mock: isMock, Auth: s.Auth, Spec: specPath, Schemes: map[string]string{}} @@ -195,7 +203,7 @@ func toSuite(t *testing.T, s *scenario, specPath, base string, isMock bool) suit t.Fatalf("%s %s: request and response are for the mock; a backend answers itself", s.Name, k.Op) } - out.Cases = append(out.Cases, suiteCase{Op: k.Op, Params: params[k.Op], Args: k.Args, Auth: k.Auth, Save: k.Save}) + out.Cases = append(out.Cases, suiteCase{Op: k.Op, Params: params[k.Op], Bodies: bodies[k.Op], Args: k.Args, Auth: k.Auth, Save: k.Save}) } return out @@ -204,7 +212,7 @@ func toSuite(t *testing.T, s *scenario, specPath, base string, isMock bool) suit // checkCases compares each case's outcome, and with a mock the request it // received, with the scenario. A case skipped for this generator must still // fail: one that passes has its skip left over. -func checkCases(t *testing.T, generator string, s *scenario, outcomes map[int]outcome, m *mock) *counts { +func checkCases(t *testing.T, r runner, s *scenario, outcomes map[int]outcome, m *mock) *counts { t.Helper() c := &counts{} @@ -219,14 +227,14 @@ func checkCases(t *testing.T, generator string, s *scenario, outcomes map[int]ou var diffs []string if o, ok := outcomes[i]; ok { - diffs = caseDiffs(k, o, m, i) + diffs = caseDiffs(k, o, m, i, rules{exact: k.Exact, millis: r.millis}) } else { diffs = []string{"the runner reported nothing for this case"} } - reason, skipped := k.Skip[generator] + reason, skipped := k.Skip[r.generator] if !skipped { - reason, skipped = s.Skip[generator] + reason, skipped = s.Skip[r.generator] } switch { @@ -240,7 +248,7 @@ func checkCases(t *testing.T, generator string, s *scenario, outcomes map[int]ou t.Skipf("%s (%s)", reason, strings.Join(diffs, "; ")) - case len(diffs) > 0 && k.Exact && valueLenient[generator]: + case len(diffs) > 0 && k.Exact && r.lenient: c.fail++ t.Skip("lenient until its gaps are fixed:\n" + strings.Join(diffs, "\n")) @@ -259,7 +267,7 @@ func checkCases(t *testing.T, generator string, s *scenario, outcomes map[int]ou return c } -func caseDiffs(k kase, o outcome, m *mock, i int) []string { +func caseDiffs(k kase, o outcome, m *mock, i int, r rules) []string { var diffs []string // With a mock every case's request must be valid for the spec, and the @@ -283,10 +291,10 @@ func caseDiffs(k kase, o outcome, m *mock, i int) []string { sent["body"] = b } - diffs = append(diffs, match("request", decodeJSON(k.Request), sent, true)...) + diffs = append(diffs, match("request", decodeJSON(k.Request), sent, r)...) case k.Request != nil: - diffs = append(diffs, match("request", decodeJSON(k.Request), got, false)...) + diffs = append(diffs, match("request", decodeJSON(k.Request), got, r)...) } } @@ -305,12 +313,12 @@ func caseDiffs(k kase, o outcome, m *mock, i int) []string { got["model"] = decodeJSON(o.Error.Model) } - diffs = append(diffs, match("error", decodeJSON(k.Error), got, k.Exact)...) + diffs = append(diffs, match("error", decodeJSON(k.Error), got, r)...) case o.Error != nil: diffs = append(diffs, fmt.Sprintf("error: %d %s", o.Error.Status, o.Error.Body)) case k.Result != nil: - diffs = append(diffs, match("result", decodeJSON(k.Result), decodeJSON(o.Result), k.Exact)...) + diffs = append(diffs, match("result", decodeJSON(k.Result), decodeJSON(o.Result), r)...) } return diffs diff --git a/internal/e2e/gofetch_test.go b/internal/e2e/gofetch_test.go index e647148..ce5b9fe 100644 --- a/internal/e2e/gofetch_test.go +++ b/internal/e2e/gofetch_test.go @@ -34,6 +34,7 @@ type suite struct { type suiteCase struct { Op string `json:"op"` Params []string `json:"params"` + Bodies []string `json:"bodies,omitempty"` Args json.RawMessage `json:"args,omitempty"` Auth *auth `json:"auth,omitempty"` Save map[string]string `json:"save,omitempty"` diff --git a/internal/e2e/match_test.go b/internal/e2e/match_test.go index 6f09e11..f021419 100644 --- a/internal/e2e/match_test.go +++ b/internal/e2e/match_test.go @@ -5,19 +5,26 @@ import ( "fmt" "maps" "math/big" + "mime" "slices" "strings" "testing" + "time" ) +// rules are how strictly match compares. Exact, for the generated cases, +// allows no other keys and no string for a list. Millis is for a client +// whose date-times are JavaScript Dates: they keep the instant to the +// millisecond, not the offset or the digits past it. +type rules struct{ exact, millis bool } + // match compares a scenario's expectation with what arrived, the way the // scenarios read: an object lists the keys it checks (others may be there), // an array has exactly its items, "$any" is any value, "$absent" is a key // that must be missing. In a request, where a query, header or form value is // a list, a string stands for a list of just that string, and header names -// match in any case. Exact, for the generated cases, allows no other keys -// and no string for a list. It returns one line per difference. -func match(path string, want, got any, exact bool) []string { +// match in any case. It returns one line per difference. +func match(path string, want, got any, r rules) []string { switch w := want.(type) { case string: switch w { @@ -27,11 +34,16 @@ func match(path string, want, got any, exact bool) []string { return []string{path + ": want absent, got " + show(got)} } - if g, ok := got.([]any); ok && len(g) == 1 && !exact && strings.HasPrefix(path, "request.") { + if g, ok := got.([]any); ok && len(g) == 1 && !r.exact && strings.HasPrefix(path, "request.") { got = g[0] } - if g, ok := got.(string); !ok || g != w { + // A Content-Type's parameters (a charset fetch adds) do not count. + if g, ok := got.(string); ok && strings.EqualFold(path, "request.header.Content-Type") { + got, _, _ = mime.ParseMediaType(g) + } + + if g, ok := got.(string); !ok || g != w && (!r.millis || !sameMillis(w, g)) { return []string{path + ": want " + show(w) + ", got " + show(got)} } @@ -60,12 +72,12 @@ func match(path string, want, got any, exact bool) []string { case !present: diffs = append(diffs, path+"."+k+": missing, want "+show(w[k])) default: - diffs = append(diffs, match(path+"."+k, w[k], gv, exact)...) + diffs = append(diffs, match(path+"."+k, w[k], gv, r)...) } } for _, k := range slices.Sorted(maps.Keys(g)) { - if _, wanted := w[k]; exact && !wanted { + if _, wanted := w[k]; r.exact && !wanted { diffs = append(diffs, path+"."+k+": want absent, got "+show(g[k])) } } @@ -80,7 +92,7 @@ func match(path string, want, got any, exact bool) []string { var diffs []string for i := range w { - diffs = append(diffs, match(fmt.Sprintf("%s[%d]", path, i), w[i], g[i], exact)...) + diffs = append(diffs, match(fmt.Sprintf("%s[%d]", path, i), w[i], g[i], r)...) } return diffs @@ -121,6 +133,16 @@ func sameNumber(a, b json.Number) bool { return okA && okB && x.Cmp(y) == 0 } +// sameMillis says whether two RFC 3339 date-times are one instant to the +// millisecond: 03:04:05Z and 03:04:05.000Z, 23:59:59.123456+05:30 and +// 18:29:59.123Z. +func sameMillis(a, b string) bool { + x, errA := time.Parse(time.RFC3339Nano, a) + y, errB := time.Parse(time.RFC3339Nano, b) + + return errA == nil && errB == nil && x.Truncate(time.Millisecond).Equal(y.Truncate(time.Millisecond)) +} + func show(v any) string { b, err := json.Marshal(v) if err != nil { @@ -140,6 +162,7 @@ func TestMatch(t *testing.T) { {"missing key", "result", `{"a":1}`, `{"b":1}`, false, false}, {"key case matters in a result", "result", `{"id":"1"}`, `{"ID":"1"}`, false, false}, {"header name in any case", "request.header", `{"X-Request-ID":"r"}`, `{"X-Request-Id":["r"]}`, false, true}, + {"a Content-Type by its media type", "request.header", `{"Content-Type":"a/b"}`, `{"Content-Type":["a/b;charset=UTF-8"]}`, false, true}, {"string for a one-value list in a request", "request.query", `{"q":"a"}`, `{"q":["a"]}`, false, true}, {"but not in a result", "result", `"stored"`, `["stored"]`, false, false}, {"array exact length", "result", `[1]`, `[1,2]`, false, false}, @@ -160,12 +183,27 @@ func TestMatch(t *testing.T) { {"exact: absent is not null", "result", `{}`, `{"a":null}`, true, false}, {"exact: no string for a list", "request.body", `{"q":"a"}`, `{"q":["a"]}`, true, false}, {"exact: $any still any", "error", `{"status":500,"body":"$any"}`, `{"status":500,"body":"x"}`, true, true}, + {"date-time digits matter", "result", `"2024-01-02T03:04:05Z"`, `"2024-01-02T03:04:05.000Z"`, true, false}, } for _, tt := range tests { - diffs := match(tt.path, decodeJSON([]byte(tt.want)), decodeJSON([]byte(tt.got)), tt.exact) + diffs := match(tt.path, decodeJSON([]byte(tt.want)), decodeJSON([]byte(tt.got)), rules{exact: tt.exact}) if (len(diffs) == 0) != tt.ok { t.Errorf("%s: match(%s, %s, exact %t) = %v, want ok %t", tt.name, tt.want, tt.got, tt.exact, diffs, tt.ok) } } + + millis := rules{exact: true, millis: true} + for _, tt := range []struct { + want, got string + ok bool + }{ + {`"2024-02-29T23:59:59.123456+05:30"`, `"2024-02-29T18:29:59.123Z"`, true}, + {`"2024-01-02T03:04:05Z"`, `"2024-01-02T03:04:06.000Z"`, false}, + {`"2024-01-02T03:04:05"`, `"2024-01-02T02:04:05.000Z"`, false}, // naive: text + } { + if diffs := match("result", decodeJSON([]byte(tt.want)), decodeJSON([]byte(tt.got)), millis); (len(diffs) == 0) != tt.ok { + t.Errorf("millis: match(%s, %s) = %v, want ok %t", tt.want, tt.got, diffs, tt.ok) + } + } } diff --git a/internal/e2e/scenario_test.go b/internal/e2e/scenario_test.go index 0657098..87c3417 100644 --- a/internal/e2e/scenario_test.go +++ b/internal/e2e/scenario_test.go @@ -157,6 +157,30 @@ func opParams(doc *openapi3.T) map[string][]string { return out } +// opBodies lists the responses of each operation that have a body, by +// their key in the spec: "200", "404", "4XX", "default". +func opBodies(doc *openapi3.T) map[string][]string { + out := map[string][]string{} + + for _, item := range doc.Paths.Map() { + for _, op := range item.Operations() { + if op.Responses == nil { + continue + } + + for code, resp := range op.Responses.Map() { + if resp.Value != nil && len(resp.Value.Content) > 0 { + out[op.OperationID] = append(out[op.OperationID], code) + } + } + + slices.Sort(out[op.OperationID]) + } + } + + return out +} + // decodeJSON decodes JSON keeping numbers exact. func decodeJSON(b []byte) any { var v any diff --git a/internal/e2e/typescript_test.go b/internal/e2e/typescript_test.go index 6b0bdd5..0dbcc78 100644 --- a/internal/e2e/typescript_test.go +++ b/internal/e2e/typescript_test.go @@ -39,7 +39,9 @@ func runTypeScript(t *testing.T, suites []suite) map[string]map[int]outcome { var main strings.Builder - main.WriteString("import { run } from \"./runner\";\n") + // Each client loads when its suite runs: one whose JavaScript does not + // even parse fails its own cases, not the program. + main.WriteString("import { run } from \"./runner\";\n\ndeclare const require: any;\n") for i, s := range suites { pkg := "c" + strconv.Itoa(i) @@ -54,31 +56,53 @@ func runTypeScript(t *testing.T, suites []suite) map[string]map[int]outcome { if err != nil { t.Fatalf("generate %s: %v", s.Name, err) } - - fmt.Fprintf(&main, "import * as %s from \"./%s/index\";\n", pkg, pkg) } main.WriteString("\nrun({\n") for i, s := range suites { - fmt.Fprintf(&main, " %q: { module: c%d, dir: %q },\n", s.Name, i, filepath.Join(dir, "c"+strconv.Itoa(i))) + fmt.Fprintf(&main, " %q: { load: () => require(\"./c%d/index\"), dir: %q },\n", s.Name, i, filepath.Join(dir, "c"+strconv.Itoa(i))) } main.WriteString("});\n") write(t, filepath.Join(dir, "main.ts"), main.String()) write(t, filepath.Join(dir, "tsconfig.json"), `{"compilerOptions": {"target": "ES2022", "module": "commonjs", "lib": ["ES2022", "DOM"], - "outDir": "dist", "strict": false, "skipLibCheck": true, "noEmitOnError": false}, "files": ["main.ts"]}`) + "outDir": "dist", "strict": true, "skipLibCheck": true, "noEmitOnError": false}, "include": ["**/*.ts"]}`) - // Type errors in a client are its generator's; the JavaScript is - // emitted anyway and the cases show what they break. + // A client must compile as its users compile it, strict. The JavaScript + // is emitted anyway, so the others run; a client with a type error fails + // its cases with the first one. build := exec.Command(tsc, "-p", ".") build.Dir = dir - if out, err := build.CombinedOutput(); err != nil { - t.Logf("tsc: %v\n%s", err, out) + out, _ := build.CombinedOutput() + broken := map[string]string{} + + for _, line := range strings.Split(string(out), "\n") { + pkg, _, _ := strings.Cut(line, "/") + if _, seen := broken[pkg]; !strings.Contains(line, ": error TS") || seen { + continue + } + + if !strings.HasPrefix(pkg, "c") { + t.Fatalf("tsc: the runner does not compile:\n%s", out) + } + + broken[pkg] = line } writeInput(t, dir, suites) - return execRunner(t, exec.Command(node, filepath.Join(dir, "dist", "main.js"), filepath.Join(dir, "input.json"))) + outcomes := execRunner(t, exec.Command(node, filepath.Join(dir, "dist", "main.js"), filepath.Join(dir, "input.json"))) + + for i, s := range suites { + if msg, ok := broken["c"+strconv.Itoa(i)]; ok { + outcomes[s.Name] = map[int]outcome{} + for j := range s.Cases { + outcomes[s.Name][j] = outcome{Suite: s.Name, Case: j, Fail: "the client does not compile: " + msg} + } + } + } + + return outcomes } diff --git a/internal/e2e/values_test.go b/internal/e2e/values_test.go index e2dbc49..cdfebc5 100644 --- a/internal/e2e/values_test.go +++ b/internal/e2e/values_test.go @@ -20,11 +20,6 @@ import ( "go.yaml.in/yaml/v3" ) -// valueLenient are the generators whose failing generated cases are -// counted and skipped, not failed: they have known gaps a later change -// fixes. Without an entry a generator is strict, as go-fetch is. -var valueLenient = map[string]bool{"typescript-fetch": true, "dart-fetch": true} - // valueScenarios generates a scenario for each feature spec, the FastAPI // spec (served by the mock) and each spec in E2E_SPECS (comma-separated // paths), and logs the operations it leaves out. diff --git a/testdata/e2e/README.md b/testdata/e2e/README.md index 80a38e3..05f09f7 100644 --- a/testdata/e2e/README.md +++ b/testdata/e2e/README.md @@ -91,8 +91,9 @@ validates. **Portable values.** A case states what every generator should do. A value that a language cannot represent the same way belongs in a case of its own, which that generator's runner skips: an int64 above 2^53 in JavaScript, a -time with microseconds in a JavaScript `Date`, an error body a client does -not decode. +time without an offset in a JavaScript `Date`, an error body a client does +not decode. typescript-fetch compares date-times as instants to the +millisecond, the most a `Date` holds: `03:04:05Z` matches `03:04:05.000Z`. ## Generated values @@ -116,9 +117,9 @@ same cases. array in the path, a form body, a HEAD) is left out; the run logs which. - They compare **exactly**: no other keys, and null is not absent. -go-fetch must pass every case. A failing case of typescript-fetch or -dart-fetch counts as failed in the table but skips (`valueLenient`) until -their gaps are fixed. A Swagger +go-fetch and typescript-fetch must pass every case. A failing case of a +`lenient` runner (dart-fetch, `runners` in `e2e_test.go`) counts as failed +in the table but skips until its gaps are fixed. A Swagger 2.0 spec in `E2E_SPECS` is converted to OpenAPI 3 first, and of paths that differ only in their parameter names the first is kept. @@ -129,11 +130,11 @@ the client the way its users do. `internal/e2e` generates every suite's client, builds one program that holds them all and runs it, then compares what the program prints with the scenarios. -| Runner | Client | -| -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `runners/go` | go-fetch: finds methods, `Params` fields and results by reflection; standard library only | -| `runners/ts` | typescript-fetch, compiled to CommonJS with `tsc`: reads the `Request` interfaces and method signatures from the sources, converts with `FromJSON`/`ToJSON` | -| `runners/dart` | dart-fetch, on the VM with `dart:mirrors`: named parameters, `fromJson`/`toJson`; a client that does not compile fails only its own suite | +| Runner | Client | +| -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `runners/go` | go-fetch: finds methods, `Params` fields and results by reflection; standard library only | +| `runners/ts` | typescript-fetch, compiled to CommonJS with `tsc --strict`: reads the `Request` interfaces, the models and the method signatures from the sources, converts with `FromJSON`/`ToJSON`; a client with a type error fails its own suite | +| `runners/dart` | dart-fetch, on the VM with `dart:mirrors`: named parameters, `fromJson`/`toJson`; a client that does not compile fails only its own suite | Runners run with `TZ=Europe/Bratislava`, so a client that reads or writes local time shows it. @@ -141,16 +142,25 @@ local time shows it. typescript-fetch sends an OAuth2 token as the whole `Authorization` header, so its runner returns `Bearer ` for an OAuth2 scheme (`schemes` in the input). Its error has no decoded model: the runner reports the error body -parsed as JSON. A case one generator cannot pass has -`skip: { : why }`. A spec whose client does not compile at all -has the same `skip` at the top of its file. +parsed as JSON where the spec declares a body for the status (`bodies` in +the input). A converted round trip hides a `FromJSON` and a `ToJSON` that +both leave a value alone, so the runner also checks each argument and +result against the client's types: a model holds its TypeScript property +names, a `Date` stands where the interface says. The runner puts back +the read-only properties a model's `ToJSON` leaves out of a result. + +A case one generator cannot pass has `skip: { : why }`. A spec +whose client does not compile at all has the same `skip` at the top of its +file. A runner for another generator follows the same protocol: 1. **Input** is a JSON file given as the first argument: - `{ suites: [{ name, base, mock, auth, schemes, cases: [{ op, params, args, auth, save }] }] }`. + `{ suites: [{ name, base, mock, auth, schemes, cases: [{ op, params, bodies, args, auth, save }] }] }`. - `params` lists the names the operation's args may use (parameters and form fields); any other arg is `body`. + - `bodies` lists the operation's responses with a body, by their key in + the spec (`200`, `404`, `4XX`, `default`). - A case's `auth` fields replace those before it for the rest of the suite. 2. **Before each case** of a `mock` suite, the runner sends diff --git a/testdata/e2e/runners/ts/runner.ts b/testdata/e2e/runners/ts/runner.ts index 41d0b96..144d5b9 100644 --- a/testdata/e2e/runners/ts/runner.ts +++ b/testdata/e2e/runners/ts/runner.ts @@ -15,13 +15,15 @@ const path = require("path"); type Auth = { username?: string; password?: string; apiKey?: string; bearer?: string }; -type Case = { op: string; params: string[]; args?: Record; auth?: Auth; save?: Record }; +type Case = { op: string; params: string[]; bodies?: string[]; args?: Record; auth?: Auth; save?: Record }; type Suite = { name: string; base: string; mock: boolean; auth?: Auth; cases: Case[]; schemes?: Record }; -type Client = { module: any; dir: string }; +type Client = { load: () => any; dir: string; module?: any }; -type Method = { name: string; api: string; request?: string; result: string }; +// A Method's day says that its result's dates are days (format: date), +// which a Date holds as local midnight. +type Method = { name: string; api: string; request?: string; result: string; day: boolean }; const norm = (s: string): string => s.replace(/[^\p{L}\p{N}]/gu, "").toLowerCase(); @@ -51,7 +53,8 @@ function methods(dir: string): { methods: Method[]; requests: Record \{$/gm)) { if (!m[1].endsWith("Raw")) { - out.push({ name: m[1], api, request: m[2], result: m[3] }); + const raw = src.slice(src.indexOf(` async ${m[1]}Raw(`)).split("\n }\n")[0]; + out.push({ name: m[1], api, request: m[2], result: m[3], day: /runtime\.parseDate\b/.test(raw) }); } } } @@ -59,6 +62,195 @@ function methods(dir: string): { methods: Method[]; requests: Record; base?: string; open?: boolean; alias?: string; wire?: Record; readonly?: Set }; + +// models reads the models' interfaces and type aliases from the sources. +function models(dir: string): Record { + const out: Record = {}; + const d = path.join(dir, "models"); + + for (const file of fs.existsSync(d) ? fs.readdirSync(d) : []) { + const src: string = fs.readFileSync(path.join(d, file), "utf8"); + + // A FromJSON reads each property's JSON name: "name": ... json["wire"]. + const wire: Record = {}; + for (const f of src.matchAll(/^ "([^"]+)": .*?json\["([^"]+)"\]/gm)) { + wire[f[1]] = f[2]; + } + + for (const m of src.matchAll(/^export interface (\w+)(?: extends (\w+))? \{([\s\S]*?)\n\}/gm)) { + const fields: Record = {}; + const readonly = new Set(); + for (const f of m[3].matchAll(/^ (readonly )?("[^"]+"|[\w$]+)\??: (.+);$/gm)) { + const name = f[2].replace(/^"|"$/g, ""); + fields[name] = f[3]; + if (f[1]) { + readonly.add(name); + } + } + + // An interface without properties, as TypeScript's {}, takes any object. + const open = /^ \[key: string\]/m.test(m[3]) || (Object.keys(fields).length === 0 && !m[2]); + out[m[1]] = { fields, base: m[2], open, wire, readonly }; + } + + for (const m of src.matchAll(/^export type (\w+) = (.+);$/gm)) { + out[m[1]] ??= { alias: m[2] }; + } + } + + return out; +} + +// members splits a type at its top-level |. +function members(t: string): string[] { + const out: string[] = []; + let depth = 0; + let start = 0; + + for (let i = 0; i < t.length; i++) { + if ("<{(".includes(t[i])) { + depth++; + } else if (">})".includes(t[i])) { + depth--; + } else if (t[i] === "|" && depth === 0) { + out.push(t.slice(start, i).trim()); + start = i + 1; + } + } + + return [...out, t.slice(start).trim()]; +} + +const show = (v: any): string => (v instanceof Date ? "Date " + v.toISOString() : (JSON.stringify(v) ?? String(v)).slice(0, 80)); + +// shape says where v is not of the client's type t, or "": an object of a +// model with a key its interface lacks (a wire name FromJSON left), a +// string where a Date belongs. A FromJSON and a ToJSON that both leave a +// value alone pass a round trip; they do not pass this. Scalars count in +// results only: the runner hands arguments over as the scenario has them. +function shape(decls: Record, t: string, v: any, at: string, scalars: boolean): string { + if (v === null || v === undefined) { + return ""; + } + + const alts = members(t).filter((x) => x !== "null" && x !== "undefined"); + if (alts.length > 1) { + const errs = alts.map((x) => shape(decls, x, v, at, scalars)); + + return errs.every(Boolean) ? errs[0] : ""; + } + + t = alts[0] ?? "any"; + + let m: RegExpExecArray | null; + if ((m = /^(Array|Set)<(.+)>$/.exec(t))) { + if (!(m[1] === "Set" ? v instanceof Set : Array.isArray(v))) { + return `${at}: want ${t}, got ${show(v)}`; + } + + let i = 0; + for (const x of v) { + const e = shape(decls, m[2], x, `${at}[${i++}]`, scalars); + if (e) { + return e; + } + } + + return ""; + } + + if ((m = /^\{ \[key: string\]: (.+); \}$/.exec(t) ?? /^Record$/.exec(t))) { + if (typeof v !== "object" || Array.isArray(v)) { + return `${at}: want a map, got ${show(v)}`; + } + + for (const [k, x] of Object.entries(v)) { + const e = shape(decls, m[1], x, `${at}.${k}`, scalars); + if (e) { + return e; + } + } + + return ""; + } + + if (t === "Date") { + return v instanceof Date && !isNaN(v.getTime()) ? "" : `${at}: want a Date, got ${show(v)}`; + } + + if (t === "Blob") { + return v instanceof Blob ? "" : `${at}: want a Blob, got ${show(v)}`; + } + + if (["string", "number", "boolean"].includes(t)) { + return !scalars || typeof v === t ? "" : `${at}: want a ${t}, got ${show(v)}`; + } + + const d = decls[t]; + if (!d) { + return ""; + } + + if (d.alias !== undefined) { + return d.alias.startsWith("typeof ") ? "" : shape(decls, d.alias, v, at, scalars); + } + + // An interface without properties is TypeScript's {}: any value but null. + if (!d.base && Object.keys(d.fields ?? {}).length === 0) { + return ""; + } + + if (typeof v !== "object" || Array.isArray(v) || v instanceof Date || v instanceof Blob) { + return `${at}: want a ${t}, got ${show(v)}`; + } + + // A model that extends t (a child a discriminator picked) is a t too. + const own = fits(decls, t, v, at, scalars); + const isChild = (n: string): boolean => { + for (let c = decls[n]; c?.base; c = decls[c.base]) { + if (c.base === t) { + return true; + } + } + + return false; + }; + + return own && Object.keys(decls).some((n) => isChild(n) && !fits(decls, n, v, at, scalars)) ? "" : own; +} + +// fits says where v is not an object of model t, or "". +function fits(decls: Record, t: string, v: any, at: string, scalars: boolean): string { + const d = decls[t]; + const fields: Record = {}; + let open = false; + for (let c: Decl | undefined = d; c; c = c.base ? decls[c.base] : undefined) { + Object.assign(fields, { ...c.fields, ...fields }); + open ||= !!c.open; + } + + for (const [k, x] of Object.entries(v)) { + if (!(k in fields)) { + if (open) { + continue; + } + + return `${at}: ${k} is no property of ${t}`; + } + + const e = shape(decls, fields[k], x, `${at}.${k}`, scalars); + if (e) { + return e; + } + } + + return ""; +} + const nullable = (t: string): string => t.replace(/ \| null$/, "").replace(/ \| undefined$/, ""); // fromWire turns a wire value into what the client takes for type t. @@ -93,8 +285,9 @@ function fromWire(client: any, t: string, v: any): any { return typeof from === "function" ? from(v) : v; } -// toWire turns a client's result of type t back into wire JSON. -async function toWire(client: any, t: string, v: any): Promise { +// toWire turns a client's result of type t back into wire JSON; day says +// that a Date is a day. +async function toWire(client: any, decls: Record, t: string, v: any, day = false): Promise { t = nullable(t); if (v === null || v === undefined) { return v ?? null; @@ -106,25 +299,78 @@ async function toWire(client: any, t: string, v: any): Promise { let m: RegExpExecArray | null; if ((m = /^Array<(.+)>$/.exec(t)) && Array.isArray(v)) { - return Promise.all(v.map((x) => toWire(client, m![1], x))); + return Promise.all(v.map((x) => toWire(client, decls, m![1], x, day))); + } + + // A stream is the list of its items, an event { data, event, id, retry }. + if ((m = /^AsyncIterable<(.+)>$/.exec(t))) { + const items: any[] = []; + for await (const x of v) { + items.push(await toWire(client, decls, m[1], x, day)); + } + + return items; + } + + if ((m = /^runtime\.ServerSentEvent<(.+)>$/.exec(t))) { + return { ...v, data: await toWire(client, decls, m[1], v.data, day) }; } if ((m = /^\{ \[key: string\]: (.+); \}$/.exec(t)) && typeof v === "object") { const out: Record = {}; for (const [k, x] of Object.entries(v)) { - out[k] = await toWire(client, m[1], x); + out[k] = await toWire(client, decls, m[1], x, day); } return out; } if (v instanceof Date) { - return v.toISOString(); + return day ? client.serializeDate(v) : v.toISOString(); } const to = client[t + "ToJSON"]; + if (typeof to !== "function") { + return v; + } + + const out = to(v); + await restore(client, decls, t, v, out); + + return out; +} + +// restore puts back the read-only properties a model's ToJSON leaves out, +// as deep as the value goes: a result holds them, a request does not. +async function restore(client: any, decls: Record, t: string, v: any, out: any): Promise { + t = nullable(t); + if (v == null || out == null || typeof out !== "object") { + return; + } + + let m: RegExpExecArray | null; + if ((m = /^Array<(.+)>$/.exec(t)) || (m = /^\{ \[key: string\]: (.+); \}$/.exec(t))) { + for (const k of Object.keys(v)) { + await restore(client, decls, m[1], v[k as any], out[k as any]); + } + + return; + } + + for (let d: Decl | undefined = decls[t]; d; d = d.base ? decls[d.base] : undefined) { + for (const [f, type] of Object.entries(d.fields ?? {})) { + const w = d.wire?.[f]; + if (w === undefined || v[f] === undefined) { + continue; + } - return typeof to === "function" ? to(v) : v; + if (d.readonly?.has(f)) { + out[w] = await toWire(client, decls, type, v[f]); + } else { + await restore(client, decls, type, v[f], out[w]); + } + } + } } // expand replaces ${name} with a value a case before saved, escaped for a @@ -150,7 +396,7 @@ function save(result: any, paths: Record | undefined, vars: Reco } } -async function callCase(client: Client, info: ReturnType, suite: Suite, c: Case, auth: Auth, vars: Record) { +async function callCase(client: Client, info: ReturnType, decls: Record, suite: Suite, c: Case, auth: Auth, vars: Record) { const cfg = new client.module.Configuration({ basePath: suite.base, username: auth.username, @@ -202,6 +448,11 @@ async function callCase(client: Client, info: ReturnType, suite: used.add(name); params[field] = fromWire(client.module, type, args[name]); + + const bad = shape(decls, type, params[field], "arg " + name, false); + if (bad) { + return { fail: "FromJSON: " + bad }; + } } for (const name of Object.keys(args)) { @@ -212,16 +463,27 @@ async function callCase(client: Client, info: ReturnType, suite: try { const result = m.request ? await api[m.name](params) : await api[m.name](); - const wire = m.result === "void" ? null : await toWire(client.module, m.result, result); + + const bad = shape(decls, m.result, result, "result", true); + if (bad) { + return { fail: "the result is not of its type: " + bad }; + } + + const wire = m.result === "void" ? null : await toWire(client.module, decls, m.result, result, m.day); save(wire, c.save, vars); return { result: wire ?? null }; } catch (e: any) { if (e?.response && typeof e.response.status === "number") { const body = await e.response.text(); + const status = String(e.response.status); + // The client has no error model: the body parsed as JSON stands + // for one where the spec declares a body for the status. let model: any; try { - model = JSON.parse(body); + if (c.bodies?.some((k) => k === status || k === status[0] + "XX" || k === "default")) { + model = JSON.parse(body); + } } catch { model = undefined; } @@ -238,7 +500,18 @@ export async function run(clients: Record) { for (const suite of input.suites as Suite[]) { const client = clients[suite.name]; + try { + client.module = client.load(); + } catch (e: any) { + for (const i of suite.cases.keys()) { + process.stdout.write(JSON.stringify({ suite: suite.name, case: i, fail: "the client does not load: " + String(e) }) + "\n"); + } + + continue; + } + const info = methods(client.dir); + const decls = models(client.dir); const vars: Record = {}; let auth: Auth = { ...(suite.auth ?? {}) }; @@ -264,7 +537,7 @@ export async function run(clients: Record) { let out: any; try { - out = await callCase(client, info, suite, c, current, vars); + out = await callCase(client, info, decls, suite, c, current, vars); } catch (e: any) { out = { fail: "panic: " + String(e?.stack ?? e) }; } diff --git a/testdata/e2e/scenarios/additional-properties.yaml b/testdata/e2e/scenarios/additional-properties.yaml index b74ef23..1ef0b1c 100644 --- a/testdata/e2e/scenarios/additional-properties.yaml +++ b/testdata/e2e/scenarios/additional-properties.yaml @@ -6,4 +6,4 @@ cases: request: { body: { kind: click, at: "2024-01-02T03:04:05Z", x: 1, tags: [a] } } response: { json: { player: p, round1: 3, round2: 5 } } result: { player: p, round1: 3, round2: 5 } - skip: { dart-fetch: "drops the keys next to the declared properties (additionalProperties); a DateTime goes out with .000Z", typescript-fetch: "drops the keys next to the declared properties (additionalProperties); a Date goes out with .000Z" } + skip: { dart-fetch: "drops the keys next to the declared properties (additionalProperties); a DateTime goes out with .000Z" } diff --git a/testdata/e2e/scenarios/discriminator-allof-no-mapping.yaml b/testdata/e2e/scenarios/discriminator-allof-no-mapping.yaml new file mode 100644 index 0000000..33c6f75 --- /dev/null +++ b/testdata/e2e/scenarios/discriminator-allof-no-mapping.yaml @@ -0,0 +1,14 @@ +spec: ../../specs/feature/discriminator-allof-no-mapping.yaml +skip: + go-fetch: "a discriminator without oneOf: the parent stays a plain struct, the children's properties are dropped" + dart-fetch: "does not compile: lib/src/apis/default_api.dart:71: Expected an identifier." +cases: + - name: children by their schema names + op: listPets + response: { json: [{ petType: Cat, name: tom, born: "2024-01-02T03:04:05Z", nick-name: tommy }, { petType: Dog, pack-size: 3 }] } + result: [{ petType: Cat, name: tom, born: "2024-01-02T03:04:05Z", nick-name: tommy }, { petType: Dog, pack-size: 3 }] + - name: a child as the parent's body + op: addPet + args: { body: { petType: Cat, born: "2024-01-02T03:04:05Z", nick-name: tommy } } + request: { body: { petType: Cat, born: "2024-01-02T03:04:05Z", nick-name: tommy } } + response: { status: 204 } diff --git a/testdata/e2e/scenarios/fastapi.yaml b/testdata/e2e/scenarios/fastapi.yaml index 7227035..cfa9b44 100644 --- a/testdata/e2e/scenarios/fastapi.yaml +++ b/testdata/e2e/scenarios/fastapi.yaml @@ -11,7 +11,6 @@ cases: op: issueToken args: { username: alice, password: nope } error: { status: 400, model: null, body: '{"detail":"bad login"}' } - skip: { typescript-fetch: "no error models: the body is decoded as JSON whatever the status declares" } - name: password form with a scope op: issueToken args: { username: bob, password: secret, scope: "items:read" } @@ -68,7 +67,7 @@ cases: color: light green priority: 10 unit: lb - skip: { typescript-fetch: "binary in a JSON body: a Blob serializes as {}" } + skip: { typescript-fetch: "a JavaScript number loses integers past 2^53 (i64)" } - name: literals, string members and defaults op: echoLiterals args: { body: { retry: always, pin: x, order: asc } } @@ -94,7 +93,6 @@ cases: retries: 3 extras: null sent: [color, dims, due, lines, note, payment] - skip: { typescript-fetch: "null and absent are one to it for an optional nullable array: an explicit null decodes and encodes as absent" } - name: required nullables set, falsy values, explicit null over a default op: echoNulls args: @@ -109,7 +107,6 @@ cases: retries: null extras: null sent: [color, dims, due, extras, lines, note, payment, retries] - skip: { typescript-fetch: "null and absent are one to it for an optional nullable array: an explicit null decodes and encodes as absent" } - name: naive, aware and contained times round trip op: echoStamps args: @@ -136,7 +133,7 @@ cases: op: stampsQuery args: { days: ["2024-01-31", "2024-02-29"], since: "2024-03-01T10:00:00" } result: { naive: "2024-03-01T10:00:00", aware: "2024-03-01T10:00:00Z", clock: "13:45:30.250000", span: "-PT1.5S", days: ["2024-01-31", "2024-02-29"] } - skip: { typescript-fetch: "a date array in the query goes out as Date.toString() (Wed Jan 31 2024 ...); a naive date-time as UTC with Z" } + skip: { typescript-fetch: "a naive date-time is read as local time and sent in UTC with Z (422 timezone_naive)" } - name: containers, empty and null op: echoContainers args: @@ -162,7 +159,6 @@ cases: by_name: { box: { widthCm: 1.5, heightCm: 2, depthCm: null } } maybe_list: null maybe_map: { k: null, v: "1" } - skip: { typescript-fetch: "a tuple (prefixItems) renders an undefined FromJSON" } - name: Go keywords and odd property names op: echoKeywords args: @@ -172,7 +168,6 @@ cases: op: echoOpen args: { body: { kind: k, extra: 1, nested: { a: [true] } } } result: { kind: k, extra: 1, nested: { a: [true] } } - skip: { typescript-fetch: "drops the keys next to the declared properties (additionalProperties)" } - name: recursive tree op: echoTree args: { body: { name: root, children: [{ name: a, children: [{ name: a1 }] }], parent: { name: up } } } @@ -295,7 +290,6 @@ cases: ratio: 0.1 active: false old: null - skip: { typescript-fetch: "a Date comes back in UTC" } - name: query, optional ones left out op: queryParams args: { q: only } @@ -310,7 +304,6 @@ cases: tenant: t1 theme: dark result: { requestId: 0b5f0c6e-8e7b-4c8c-9d55-7f0f3c6f2a11, retry: 3, flags: [a], lang: "sk-SK,en;q=0.5", tenant: t1, theme: dark } - skip: { typescript-fetch: "no cookie parameters" } - name: list headers, one item each op: headerLists args: { x-tags: [a], x-ids: [7], x-colors: [dark-blue] } @@ -356,7 +349,6 @@ cases: id: $any createdAt: $any save: { item: id } - skip: { typescript-fetch: "runner: ToJSON leaves out a result's readOnly properties" } - name: create a second item, price as a number op: createItem args: { body: { sku: ABC-0002, name: Bag, price: 5 } } @@ -393,11 +385,9 @@ cases: op: patchItem args: { item_id: "${item}", if-match: 2, body: { name: null } } error: { status: 400, model: null, body: '{"detail":"name may not be null"}' } - skip: { typescript-fetch: "no error models: the body is decoded as JSON whatever the status declares" } - name: HEAD of an item that exists op: itemExists args: { item_id: "${item}" } - skip: { typescript-fetch: "fails on the empty body of a HEAD" } - name: HEAD of one that does not op: itemExists args: { item_id: 0b5f0c6e-8e7b-4c8c-9d55-7f0f3c6f2a11 } @@ -412,7 +402,6 @@ cases: labels: [x, y] meta: '{"k":1}' result: [{ name: docs/a b.txt, size: 5, labels: "x|y", meta: '{"k":1}' }, { name: docs/ΕΎ.bin, size: 2 }] - skip: { typescript-fetch: "a multipart array goes comma-joined in one part" } - name: duplicate file is a 409 op: uploadFiles args: { files: [{ $file: { name: a b.txt, text: again } }], folder: docs } @@ -425,22 +414,18 @@ cases: op: submitForm args: { day: "2024-01-31", when: "2024-03-01T10:00:00Z", price: "19.99", color: light green, tags: [a, "b,c"], colors: [red, dark-blue], days: ["2024-01-31", "2024-02-29"], qty: 0, agree: true } result: { day: "2024-01-31", when: "2024-03-01T10:00:00Z", price: "19.99", color: light green, tags: [a, "b,c"], colors: [red, dark-blue], days: ["2024-01-31", "2024-02-29"], qty: 0, agree: true } - skip: { typescript-fetch: "a union form field (Decimal: number or string) renders JSON.stringify(number | stringToJSON(...)), which does not compile" } - name: typed form, defaults left out, a number for a Decimal op: submitForm args: { day: "2024-01-31", when: "2024-03-01T10:00:00Z", price: 5, color: red } result: { price: "5", tags: [], colors: [], days: [], qty: null, agree: false } - skip: { typescript-fetch: "a union form field (Decimal: number or string) renders JSON.stringify(number | stringToJSON(...)), which does not compile" } - name: a form model with files, declared url-encoded op: uploadModel args: { folder: docs, files: [{ $file: { name: a.txt, text: abc } }, { $file: { name: b.txt, text: de } }], labels: [x, y] } result: { folder: docs, files: { a.txt: 3, b.txt: 2 }, labels: [x, y], cover: null } - skip: { typescript-fetch: &urlFile "a form declared url-encoded goes as URLSearchParams even with a file, a Blob as text" } - name: a form model's optional file given op: uploadModel args: { folder: docs, files: [{ $file: { name: a.txt, text: abc } }], cover: { $file: { name: c.png, text: C } } } result: { files: { a.txt: 3 }, cover: c.png } - skip: { typescript-fetch: *urlFile } - name: optional file left out op: uploadAvatar args: { image: { $file: { name: i.png, text: "12345" } } } @@ -480,7 +465,6 @@ cases: lines: [{ sku: ABC-0001, quantity: 500 }] payment: { method: card, last4: "4242", expires: "2030-01-01" } error: { status: 202, model: { title: Queued for review, status: 202 } } - skip: { typescript-fetch: "a 2xx with another body decodes as the result type" } - name: get the order op: getOrder args: { order_id: 1 } @@ -492,7 +476,6 @@ cases: - name: 202 without a body, embedded body field op: cancelOrder args: { order_id: 1, body: { reason: changed my mind } } - skip: { typescript-fetch: "fails on a 2xx without a body" } - name: a bound on a path parameter is a 422 op: getOrder args: { order_id: 0 } @@ -522,21 +505,17 @@ cases: op: streamLines args: { n: 2 } result: "{\"i\":0}\n{\"i\":1}\n" - skip: { typescript-fetch: "an any result is parsed as JSON whatever its media type" } - name: JSON Lines of a model op: streamTicks args: { n: 2 } result: [{ i: 0, label: t0 }, { i: 1, label: t1 }] - skip: &noItems { typescript-fetch: "a response with only an itemSchema (3.2 streaming) is void: the items are dropped" } - name: JSON Lines, none op: streamTicks args: { n: 0 } result: [] - skip: *noItems - name: JSON Lines of integers op: streamInts result: [0, 1, 2] - skip: *noItems - name: a bound on a stream's query is a 422 op: streamTicks args: { n: 11 } @@ -545,16 +524,13 @@ cases: op: streamEvents args: { n: 2 } result: [{ data: { i: 0, label: e0 } }, { data: { i: 1, label: e1 } }] - skip: *noItems - name: server-sent events over POST, a discriminated union per event op: chat args: { body: { prompt: hello world } } result: [{ data: { kind: delta, text: hello } }, { data: { kind: delta, text: world } }, { data: { kind: done, words: 2 } }] - skip: *noItems - name: server-sent events with names and ids, data as text op: streamRawEvents result: [{ event: tick, id: "1", data: '{"i": 1}' }, { event: end, id: "2", data: '"bye"' }] - skip: *noItems - name: float path parameter and an aliased query op: add args: { a: 2, b: 0.75, round: true } diff --git a/testdata/e2e/scenarios/nullable-openapi31.yaml b/testdata/e2e/scenarios/nullable-openapi31.yaml index 0ad15dc..57417cc 100644 --- a/testdata/e2e/scenarios/nullable-openapi31.yaml +++ b/testdata/e2e/scenarios/nullable-openapi31.yaml @@ -7,10 +7,8 @@ cases: request: { query: { q: mi, size: ["1", "3"] } } response: { json: [{ kind: pet, name: Mia, size: null, owner: null, born: "2020-05-17", nickname: null, tags: null, mood: happy }] } result: [{ kind: pet, name: Mia, size: null, owner: null, born: "2020-05-17", nickname: null, tags: null, mood: happy }] - skip: { typescript-fetch: "null and absent are one to it for an optional nullable array" } - name: contentMediaType file and multipart arrays op: uploadPhoto args: { id: "1", file: { $file: { name: p.png, base64: iVBORw== } }, caption: hi, tags: [a, b], labels: [x, y] } request: { body: { file: [{ filename: p.png, base64: iVBORw== }], caption: [hi], tags: [a, b], labels: ["x,y"] } } response: { status: 204 } - skip: { typescript-fetch: "a multipart array goes comma-joined in one part" } diff --git a/testdata/e2e/scenarios/parameters-locations.yaml b/testdata/e2e/scenarios/parameters-locations.yaml index 90c63b1..79b936f 100644 --- a/testdata/e2e/scenarios/parameters-locations.yaml +++ b/testdata/e2e/scenarios/parameters-locations.yaml @@ -11,7 +11,6 @@ cases: cookie: { sessionId: s-1 } response: { json: { id: "42" } } result: { id: "42" } - skip: { typescript-fetch: "no cookie parameters" } - name: optional ones left out op: getItem args: { orgId: o, itemId: 1 } diff --git a/testdata/e2e/scenarios/parameters-object-styles.yaml b/testdata/e2e/scenarios/parameters-object-styles.yaml index 2a693c4..dffa2f6 100644 --- a/testdata/e2e/scenarios/parameters-object-styles.yaml +++ b/testdata/e2e/scenarios/parameters-object-styles.yaml @@ -24,7 +24,7 @@ cases: header: { X-Color: "B,150,G,200,R,100", X-Color-Exploded: "B=150,G=200,R=100", X-Tags: "x,y" } cookie: { prefs: "p,q" } response: { status: 204 } - skip: { dart-fetch: "request: invalid for the spec: parameter \"a\" in path has an error: value Instance%20of%20'Color': a value must be a list of object's properties in format \"name,", typescript-fetch: "no object parameters in a path or header, no matrix or label style" } + skip: { dart-fetch: "request: invalid for the spec: parameter \"a\" in path has an error: value Instance%20of%20'Color': a value must be a list of object's properties in format \"name," } - name: optional ones left out op: paint args: @@ -40,4 +40,4 @@ cases: header: { X-Color: $absent, X-Tags: $absent } cookie: { prefs: $absent } response: { status: 204 } - skip: { dart-fetch: "request: invalid for the spec: parameter \"a\" in path has an error: value Instance%20of%20'Color': a value must be a list of object's properties in format \"name,", typescript-fetch: "no object parameters in a path or header, no matrix or label style" } + skip: { dart-fetch: "request: invalid for the spec: parameter \"a\" in path has an error: value Instance%20of%20'Color': a value must be a list of object's properties in format \"name," } diff --git a/testdata/e2e/scenarios/parameters-path-style.yaml b/testdata/e2e/scenarios/parameters-path-style.yaml index a1b3eca..561d038 100644 --- a/testdata/e2e/scenarios/parameters-path-style.yaml +++ b/testdata/e2e/scenarios/parameters-path-style.yaml @@ -1,5 +1,5 @@ spec: ../../specs/feature/parameters-path-style.yaml cases: - { op: getSimplePath, args: { id: [a, b] }, request: { path: "/simple/a,b" }, response: { status: 200 }, skip: { dart-fetch: "request.path: want \"/simple/a,b\", got \"/simple/[a, b]\"" } } - - { op: getMatrixPath, args: { id: [a, b] }, request: { path: "/matrix/;id=a,b" }, response: { status: 200 }, skip: { dart-fetch: "request: invalid for the spec: parameter \"id\" in path has an error: value %5Ba,%20b%5D: a value must be prefixed with \";id=\"", typescript-fetch: "no matrix path style" } } - - { op: getLabelPath, args: { id: [a, b] }, request: { path: "/label/.a.b" }, response: { status: 200 }, skip: { dart-fetch: "request: invalid for the spec: parameter \"id\" in path has an error: value %5Ba,%20b%5D: a value must be prefixed with \".\"", typescript-fetch: "no label path style" } } + - { op: getMatrixPath, args: { id: [a, b] }, request: { path: "/matrix/;id=a,b" }, response: { status: 200 }, skip: { dart-fetch: "request: invalid for the spec: parameter \"id\" in path has an error: value %5Ba,%20b%5D: a value must be prefixed with \";id=\"" } } + - { op: getLabelPath, args: { id: [a, b] }, request: { path: "/label/.a.b" }, response: { status: 200 }, skip: { dart-fetch: "request: invalid for the spec: parameter \"id\" in path has an error: value %5Ba,%20b%5D: a value must be prefixed with \".\"" } } diff --git a/testdata/e2e/scenarios/parameters-scalar-formats.yaml b/testdata/e2e/scenarios/parameters-scalar-formats.yaml index 0d56d27..c9a8f46 100644 --- a/testdata/e2e/scenarios/parameters-scalar-formats.yaml +++ b/testdata/e2e/scenarios/parameters-scalar-formats.yaml @@ -6,4 +6,12 @@ cases: request: query: { f32: "0.1", f64: "1000000000000000000000", n: "0.30000000000000004", i64: "4611686018427387905", ok: "false", day: "2024-01-02", at: "2024-02-29T23:59:58.0000005-01:30", raw: "+/8=" } response: { status: 204 } - skip: { dart-fetch: "request: invalid for the spec: parameter \"day\" in query has an error: string doesn't match the format \"date\": string doesn't match pattern \"^[0-9]{4}-(0[1-9]|10", typescript-fetch: "JavaScript numbers and Date: no int64, 1e21 as 1e+21, times in UTC with milliseconds" } + skip: { dart-fetch: "request: invalid for the spec: parameter \"day\" in query has an error: string doesn't match the format \"date\": string doesn't match pattern \"^[0-9]{4}-(0[1-9]|10", typescript-fetch: "JavaScript numbers: no int64, 1e21 as 1e+21" } + - name: what a JavaScript number holds, dates in headers + op: search + args: { f32: 0.1, n: 0.30000000000000004, ok: true, day: "2024-01-02", at: "2024-02-29T23:59:58.5+01:30", X-Day: "2024-12-31", X-At: "2024-01-02T03:04:05Z" } + request: + query: { f32: "0.1", n: "0.30000000000000004", ok: "true", day: "2024-01-02", at: "2024-02-29T23:59:58.5+01:30" } + header: { X-Day: "2024-12-31", X-At: "2024-01-02T03:04:05Z" } + response: { status: 204 } + skip: { dart-fetch: "request: invalid for the spec: parameter \"day\" in query has an error: string doesn't match the format \"date\"" } diff --git a/testdata/e2e/scenarios/parameters.yaml b/testdata/e2e/scenarios/parameters.yaml index 4984ecd..8a39f01 100644 --- a/testdata/e2e/scenarios/parameters.yaml +++ b/testdata/e2e/scenarios/parameters.yaml @@ -10,4 +10,4 @@ cases: query: { formExplodeTrue: [x, y], formExplodeFalse: "x,y", spaceDelimited: "1 2", pipeDelimited: "3|4" } response: { json: [ok] } result: [ok] - skip: { dart-fetch: "request.path: want \"/filter/a,b\", got \"/filter/[a, b]\"", typescript-fetch: "explode false, spaceDelimited and pipeDelimited go out as an exploded list" } + skip: { dart-fetch: "request.path: want \"/filter/a,b\", got \"/filter/[a, b]\"" } diff --git a/testdata/e2e/scenarios/request-body-forms.yaml b/testdata/e2e/scenarios/request-body-forms.yaml index 76b045b..a6fb708 100644 --- a/testdata/e2e/scenarios/request-body-forms.yaml +++ b/testdata/e2e/scenarios/request-body-forms.yaml @@ -23,4 +23,3 @@ cases: tags: "t1,t2" ids: ["1", "2"] response: { status: 204 } - skip: { typescript-fetch: "the form encoding (deepObject, explode) is ignored" } diff --git a/testdata/e2e/scenarios/request-body-login.yaml b/testdata/e2e/scenarios/request-body-login.yaml index 18d32d1..5a1d2b4 100644 --- a/testdata/e2e/scenarios/request-body-login.yaml +++ b/testdata/e2e/scenarios/request-body-login.yaml @@ -4,4 +4,4 @@ cases: args: { username: "a@b.c", password: "p&s=s w", rememberMe: true, tags: [x, y] } request: { body: { username: "a@b.c", password: "p&s=s w", rememberMe: "true", tags: [x, y] } } response: { status: 200 } - skip: { dart-fetch: "request.body.tags: want [\"x\",\"y\"], got [\"[x, y]\"]", typescript-fetch: "a form array goes comma-joined, not exploded" } + skip: { dart-fetch: "request.body.tags: want [\"x\",\"y\"], got [\"[x, y]\"]" } diff --git a/testdata/e2e/scenarios/request-body-other-media.yaml b/testdata/e2e/scenarios/request-body-other-media.yaml index 368b720..c49cd24 100644 --- a/testdata/e2e/scenarios/request-body-other-media.yaml +++ b/testdata/e2e/scenarios/request-body-other-media.yaml @@ -19,4 +19,4 @@ cases: args: { title: q3, meta: { author: ann, pages: 3 }, attachments: [{ $file: { name: a.txt, text: a } }] } request: { body: { title: [q3], meta: [{ json: { author: ann, pages: 3 } }], attachments: [{ filename: a.txt, text: a }] } } response: { status: 204 } - skip: { typescript-fetch: "an object multipart part goes as text, not application/json" } + skip: { typescript-fetch: "FormData gives a part a Content-Type only as a file: the JSON part goes as a file named blob" } diff --git a/testdata/e2e/scenarios/responses-mixed-2xx.yaml b/testdata/e2e/scenarios/responses-mixed-2xx.yaml index 8954a5f..1942ab4 100644 --- a/testdata/e2e/scenarios/responses-mixed-2xx.yaml +++ b/testdata/e2e/scenarios/responses-mixed-2xx.yaml @@ -6,8 +6,9 @@ cases: op: placeOrder response: { status: 202, json: { title: queued, status: 202 } } error: { status: 202, model: { title: queued, status: 202 } } - skip: { typescript-fetch: "a 2xx with another body decodes as the result type" } - - { name: ndjson as text, op: streamLines, response: { header: { Content-Type: application/x-ndjson }, text: "{\"i\":0}\n" }, result: "{\"i\":0}\n", skip: { typescript-fetch: "an any result is parsed as JSON whatever its media type" } } + - { name: ndjson as text, op: streamLines, response: { header: { Content-Type: application/x-ndjson }, text: "{\"i\":0}\n" }, result: "{\"i\":0}\n" } - { name: nullable result null, op: latestOrder, response: { json: null }, result: null } - { name: nullable text, op: greeting, response: { text: hello }, result: hello } + # offSpec: the validator reads a text body as a string, not as the integer it holds + - { name: an integer as text, op: count, response: { text: "42", offSpec: true }, result: 42 } - { name: any holds another 2xx, op: anything, response: { status: 201, json: { id: 1 } }, result: { id: 1 } } diff --git a/testdata/e2e/scenarios/responses.yaml b/testdata/e2e/scenarios/responses.yaml index 32bb404..226c28d 100644 --- a/testdata/e2e/scenarios/responses.yaml +++ b/testdata/e2e/scenarios/responses.yaml @@ -13,7 +13,7 @@ cases: error: { status: 500, model: { message: boom } } - { name: 204 no body, op: deleteTask, args: { id: "1" }, response: { status: 204 } } - { name: 201 with a body, op: createTask, args: { id: "1" }, response: { status: 201, json: { id: "1" } }, result: { id: "1" } } - - { name: 202 without a body, op: createTask, args: { id: "1" }, response: { status: 202 }, result: null, skip: { typescript-fetch: "fails on a 2xx without a body next to one with a body" } } + - { name: 202 without a body, op: createTask, args: { id: "1" }, response: { status: 202 }, result: null } - name: undeclared status keeps the text body op: deleteTask args: { id: "1" } diff --git a/testdata/e2e/scenarios/security.yaml b/testdata/e2e/scenarios/security.yaml index b4a176c..b8852d4 100644 --- a/testdata/e2e/scenarios/security.yaml +++ b/testdata/e2e/scenarios/security.yaml @@ -4,4 +4,4 @@ auth: { apiKey: k1 } cases: - { op: getWithHeaderKey, request: { header: { X-Api-Key: k1 } }, response: { status: 200 } } - { op: getWithQueryKey, request: { query: { api_key: k1 } }, response: { status: 200 } } - - { op: getWithCookieKey, request: { cookie: { session: k1 } }, response: { status: 200 }, skip: { typescript-fetch: "no API key in a cookie" } } + - { op: getWithCookieKey, request: { cookie: { session: k1 } }, response: { status: 200 } } diff --git a/testdata/specs/feature/arrays-of-arrays.yaml b/testdata/specs/feature/arrays-of-arrays.yaml index 1755ec3..abb3262 100644 --- a/testdata/specs/feature/arrays-of-arrays.yaml +++ b/testdata/specs/feature/arrays-of-arrays.yaml @@ -13,6 +13,20 @@ paths: application/json: schema: $ref: "#/components/schemas/Matrix" + /grid: + get: + operationId: getGrid + responses: + "200": + description: rows of cells + content: + application/json: + schema: + type: array + items: + type: array + items: + $ref: "#/components/schemas/Cell" components: schemas: Matrix: @@ -43,3 +57,8 @@ components: properties: value: type: number + cell-name: + type: string + seenAt: + type: string + format: date-time diff --git a/testdata/specs/feature/composition-oneof-scalars.yaml b/testdata/specs/feature/composition-oneof-scalars.yaml new file mode 100644 index 0000000..d631a07 --- /dev/null +++ b/testdata/specs/feature/composition-oneof-scalars.yaml @@ -0,0 +1,81 @@ +openapi: "3.0.0" +info: + title: OneOf Without Discriminator Feature + version: 1.0.0 + description: oneOf members a client tells apart by their values alone, scalars of one JSON type among them. +paths: + /holders: + post: + operationId: echoHolder + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/Holder" + responses: + "200": + description: ok + content: + application/json: + schema: + $ref: "#/components/schemas/Holder" +components: + schemas: + Holder: + type: object + properties: + dayOrCount: + $ref: "#/components/schemas/DayOrCount" + instantOrText: + $ref: "#/components/schemas/InstantOrText" + colorOrBox: + $ref: "#/components/schemas/ColorOrBox" + pet: + $ref: "#/components/schemas/Pet" + DayOrCount: + oneOf: + - type: string + format: date + - type: integer + InstantOrText: + oneOf: + - type: string + format: date-time + - type: string + pattern: "^[a-z ]+$" + example: "hello there" + ColorOrBox: + oneOf: + - $ref: "#/components/schemas/Color" + - $ref: "#/components/schemas/Box" + Color: + type: string + enum: [red, green] + Box: + type: object + required: [box-size] + properties: + box-size: + type: integer + # neither has a required property; the closed shapes tell them apart + Pet: + oneOf: + - $ref: "#/components/schemas/Cat" + - $ref: "#/components/schemas/Dog" + Cat: + type: object + additionalProperties: false + properties: + meows: + type: boolean + cat-name: + type: string + Dog: + type: object + additionalProperties: false + properties: + barks: + type: boolean + dog-name: + type: string diff --git a/testdata/specs/feature/dates.yaml b/testdata/specs/feature/dates.yaml index 3d58839..50d6a77 100644 --- a/testdata/specs/feature/dates.yaml +++ b/testdata/specs/feature/dates.yaml @@ -43,6 +43,45 @@ paths: application/json: schema: $ref: "#/components/schemas/Event" + /today: + get: + operationId: getToday + responses: + "200": + description: a day + content: + application/json: + schema: + type: string + format: date + /ticks: + get: + operationId: listTicks + responses: + "200": + description: instants + content: + application/json: + schema: + type: array + items: + type: string + format: date-time + /holidays: + put: + operationId: putHolidays + requestBody: + required: true + content: + application/json: + schema: + type: array + items: + type: string + format: date + responses: + "204": + description: stored components: schemas: Event: @@ -73,3 +112,8 @@ components: items: type: string format: date-time + seenBy: + type: object + additionalProperties: + type: string + format: date-time diff --git a/testdata/specs/feature/discriminator-allof-no-mapping.yaml b/testdata/specs/feature/discriminator-allof-no-mapping.yaml new file mode 100644 index 0000000..6119814 --- /dev/null +++ b/testdata/specs/feature/discriminator-allof-no-mapping.yaml @@ -0,0 +1,58 @@ +openapi: "3.0.0" +info: + title: Discriminator Without Mapping Feature + version: 1.0.0 + description: A discriminator on an allOf parent with no mapping; its value is the child's schema name. +paths: + /pets: + get: + operationId: listPets + responses: + "200": + description: ok + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Pet" + post: + operationId: addPet + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/Pet" + responses: + "204": + description: added +components: + schemas: + Pet: + type: object + required: [petType] + discriminator: + propertyName: petType + properties: + petType: + type: string + name: + type: string + Cat: + allOf: + - $ref: "#/components/schemas/Pet" + - type: object + properties: + born: + type: string + format: date-time + nick-name: + type: string + Dog: + allOf: + - $ref: "#/components/schemas/Pet" + - type: object + properties: + pack-size: + type: integer diff --git a/testdata/specs/feature/maps-of-models.yaml b/testdata/specs/feature/maps-of-models.yaml index 730f35d..a136b40 100644 --- a/testdata/specs/feature/maps-of-models.yaml +++ b/testdata/specs/feature/maps-of-models.yaml @@ -13,6 +13,19 @@ paths: application/json: schema: $ref: "#/components/schemas/Inventory" + put: + operationId: putStocks + requestBody: + required: true + content: + application/json: + schema: + type: object + additionalProperties: + $ref: "#/components/schemas/Stock" + responses: + "204": + description: stored components: schemas: Inventory: @@ -34,6 +47,18 @@ components: type: object additionalProperties: type: integer + byDayAndWarehouse: + type: object + additionalProperties: + type: object + additionalProperties: + $ref: "#/components/schemas/Stock" + snapshots: + type: array + items: + type: object + additionalProperties: + $ref: "#/components/schemas/Stock" Stock: type: object properties: @@ -41,3 +66,5 @@ components: type: string quantity: type: integer + unit-price: + type: number diff --git a/testdata/specs/feature/naming-runtime-clash.yaml b/testdata/specs/feature/naming-runtime-clash.yaml new file mode 100644 index 0000000..6866d06 --- /dev/null +++ b/testdata/specs/feature/naming-runtime-clash.yaml @@ -0,0 +1,45 @@ +openapi: "3.0.0" +info: + title: Runtime Name Clash Feature + version: 1.0.0 + description: Models and operations named like the client runtime's own types and methods. +paths: + /responses: + post: + operationId: request + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/Response" + responses: + "200": + description: ok + content: + application/json: + schema: + $ref: "#/components/schemas/Response" + /apis: + get: + operationId: fetchApi + responses: + "200": + description: ok + content: + application/json: + schema: + $ref: "#/components/schemas/BaseAPI" +components: + schemas: + Response: + type: object + required: [status-code] + properties: + status-code: + type: integer + BaseAPI: + type: object + properties: + base-path: + type: string diff --git a/testdata/specs/feature/naming-sanitized-properties.yaml b/testdata/specs/feature/naming-sanitized-properties.yaml index efa6d9a..00804ba 100644 --- a/testdata/specs/feature/naming-sanitized-properties.yaml +++ b/testdata/specs/feature/naming-sanitized-properties.yaml @@ -36,3 +36,7 @@ components: type: string trailingUnderscore_: type: string + __proto__: + type: string + constructor: + type: string diff --git a/testdata/specs/feature/parameters-object-styles.yaml b/testdata/specs/feature/parameters-object-styles.yaml index 9679d0a..0316ea7 100644 --- a/testdata/specs/feature/parameters-object-styles.yaml +++ b/testdata/specs/feature/parameters-object-styles.yaml @@ -25,10 +25,11 @@ paths: description: painted components: schemas: + # Keys in sorted order: a client may write them as declared or sorted. Color: type: object - required: [R, G, B] + required: [B, G, R] properties: - R: { type: integer } - G: { type: integer } B: { type: integer } + G: { type: integer } + R: { type: integer } diff --git a/testdata/specs/feature/parameters-scalar-formats.yaml b/testdata/specs/feature/parameters-scalar-formats.yaml index 425a12c..ca01166 100644 --- a/testdata/specs/feature/parameters-scalar-formats.yaml +++ b/testdata/specs/feature/parameters-scalar-formats.yaml @@ -2,7 +2,7 @@ openapi: "3.0.0" info: title: Scalar parameter formats version: 1.0.0 - description: Query parameters of each scalar type and format, as their text on the wire. + description: Query and header parameters of each scalar type and format, as their text on the wire. paths: /search: get: @@ -16,6 +16,8 @@ paths: - { name: day, in: query, schema: { type: string, format: date } } - { name: at, in: query, schema: { type: string, format: date-time } } - { name: raw, in: query, schema: { type: string, format: byte } } + - { name: X-Day, in: header, schema: { type: string, format: date } } + - { name: X-At, in: header, schema: { type: string, format: date-time } } responses: "204": description: ok diff --git a/testdata/specs/feature/responses-mixed-2xx.yaml b/testdata/specs/feature/responses-mixed-2xx.yaml index bee4170..1d6a54d 100644 --- a/testdata/specs/feature/responses-mixed-2xx.yaml +++ b/testdata/specs/feature/responses-mixed-2xx.yaml @@ -55,6 +55,16 @@ paths: schema: type: string nullable: true + /count: + get: + operationId: count + responses: + "200": + description: a number, as text + content: + text/plain: + schema: + type: integer /anything: get: operationId: anything From c8a61337c2fcb603e564bb923d8d1bb04d1bb18e Mon Sep 17 00:00:00 2001 From: Filip Seman Date: Mon, 26 Oct 2026 18:56:02 +0100 Subject: [PATCH 04/18] fix(typescript-fetch): type and convert schema aliases, allOf union members - A schema that only names an array, a map or a scalar (`Pets: {type: array, items: Pet}`, `Day: {format: date}`) is a type alias that converts as its value does, not an empty interface whose FromJSON and ToJSON leave a body or a result as it came. - A oneOf member that is an allOf of models is their intersection ("A & B"), converted by each and merged, not its first model: an object typed `type: object` over an allOf is typed so too, and a string model of format date or date-time says so (IsDate, IsDateTime). - A discriminator parent writes a value no mapping names as itself, not as it came. --- internal/gen/typescript.go | 2 +- internal/generator/typescript/convert.go | 86 ++++++++++++------- internal/generator/typescript/fetch.go | 64 +++++++++++++- internal/parser/imports.go | 12 ++- internal/parser/model.go | 14 ++- internal/parser/property.go | 42 +++++---- .../typescript-fetch/modelAlias.mustache | 53 ++++++++++++ .../typescript-fetch/modelGeneric.mustache | 2 - templates/typescript-fetch/models.mustache | 6 ++ 9 files changed, 215 insertions(+), 66 deletions(-) create mode 100644 templates/typescript-fetch/modelAlias.mustache diff --git a/internal/gen/typescript.go b/internal/gen/typescript.go index be3d5d4..c4c3d9d 100644 --- a/internal/gen/typescript.go +++ b/internal/gen/typescript.go @@ -92,7 +92,7 @@ func modelUsesDateHelpers(model *generator.CodegenModel) bool { } for _, ext := range exts { - for _, k := range []string{"x-ts-from-json", "x-ts-to-json", "x-ts-rest-from", "x-ts-rest-to"} { + for _, k := range []string{"x-ts-from-json", "x-ts-to-json", "x-ts-rest-from", "x-ts-rest-to", "x-ts-alias-from", "x-ts-alias-to"} { if e, _ := ext[k].(string); strings.Contains(e, "parseDate") || strings.Contains(e, "serializeDate") { return true } diff --git a/internal/generator/typescript/convert.go b/internal/generator/typescript/convert.go index 08ccced..0222e02 100644 --- a/internal/generator/typescript/convert.go +++ b/internal/generator/typescript/convert.go @@ -170,7 +170,7 @@ func (c *converter) discriminated(members []*codegen.CodegenProperty, x string) // and the fewest it does not. A scalar member (Stripe's id next to the // object it expands to) takes a value that is no object. func (c *converter) bestFit(members []*codegen.CodegenProperty, x string) string { - var models []*codegen.CodegenModel + var models [][]*codegen.CodegenModel for _, m := range members { model := c.g.byName[c.g.modelOf(m)] @@ -182,7 +182,7 @@ func (c *converter) bestFit(members []*codegen.CodegenProperty, x string) string return x } - models = append(models, model) + models = append(models, []*codegen.CodegenModel{model}) } if len(models) == 0 { @@ -192,49 +192,69 @@ func (c *converter) bestFit(members []*codegen.CodegenProperty, x string) string return c.rt + "bestFit(" + x + ", " + c.fits(models) + ") ?? " + x } -// fits lists models for the runtime's bestFit: each one's required keys, -// its keys and its converter, in JSON (from) or in TypeScript (to). -func (c *converter) fits(models []*codegen.CodegenModel) string { - cases := make([]string, 0, len(models)) +// fits lists members for the runtime's bestFit: each one's required keys, +// its keys and its converter, in JSON (from) or in TypeScript (to). A +// member is a model, or the intersection of several (an allOf), converted +// by each and merged. +func (c *converter) fits(members [][]*codegen.CodegenModel) string { + cases := make([]string, 0, len(members)) + + for _, parts := range members { + var required, known, fns []string + + for _, m := range parts { + for _, v := range c.keyVars(m) { + name := strconv.Quote(v.BaseName) + if c.to { + name = strconv.Quote(v.Name) + } + + if !slices.Contains(known, name) { + known = append(known, name) + } - for _, m := range models { - var required, known []string + if v.Required && !slices.Contains(required, name) { + required = append(required, name) + } + } - // A union model declares the keys of its members, none required. - vars := m.AllVars - if len(vars) == 0 { - vars = m.Vars + c.used[m.Classname] = true + fns = append(fns, m.Classname+c.fn()) } - if m.HasOneOf { - vars = nil + fn := fns[0] + if len(fns) > 1 { + fn = "(v: any) => ({ ..." + strings.Join(fns, "(v), ...") + "(v) })" + } - for _, name := range m.OneOfModels { - if member := c.g.byName[name]; member != nil && !member.HasOneOf { - for _, v := range member.Vars { - vars = append(vars, &codegen.CodegenProperty{Name: v.Name, BaseName: v.BaseName}) - } - } - } + cases = append(cases, "[["+strings.Join(required, ", ")+"], ["+strings.Join(known, ", ")+"], "+fn+"]") + } + + return "[" + strings.Join(cases, ", ") + "]" +} + +// keyVars are the properties whose keys tell a value of m: its own and +// inherited ones, or for a union model those of its members, none required. +func (c *converter) keyVars(m *codegen.CodegenModel) []*codegen.CodegenProperty { + if !m.HasOneOf { + if len(m.AllVars) > 0 { + return m.AllVars } - for _, v := range vars { - name := strconv.Quote(v.BaseName) - if c.to { - name = strconv.Quote(v.Name) - } + return m.Vars + } + + var vars []*codegen.CodegenProperty - known = append(known, name) - if v.Required { - required = append(required, name) + for _, name := range m.OneOfModels { + if member := c.g.byName[name]; member != nil && !member.HasOneOf { + for _, v := range member.Vars { + vars = append(vars, &codegen.CodegenProperty{Name: v.Name, BaseName: v.BaseName}) } } - - c.used[m.Classname] = true - cases = append(cases, "[["+strings.Join(required, ", ")+"], ["+strings.Join(known, ", ")+"], "+m.Classname+c.fn()+"]") } - return "[" + strings.Join(cases, ", ") + "]" + return vars } type pin struct { diff --git a/internal/generator/typescript/fetch.go b/internal/generator/typescript/fetch.go index 2e4f602..bf387fc 100644 --- a/internal/generator/typescript/fetch.go +++ b/internal/generator/typescript/fetch.go @@ -180,6 +180,7 @@ func (g *FetchGenerator) PostProcessModels(models []*codegen.CodegenModel) []*co for _, cm := range models { g.processCodeGenModel(cm) + g.alias(cm) g.convertVars(cm, models) g.mapDescendants(cm, models) g.fitMembers(cm) @@ -236,6 +237,41 @@ func (g *FetchGenerator) convertVars(cm *codegen.CodegenModel, models []*codegen cm.Imports = addImports(cm.Imports, from.used) } +// alias makes cm, a schema that only names an array, a map or a scalar +// (`Pets: {type: array, items: Pet}`, `Day: {format: date}`), a type alias +// that converts as its value does: x-ts-alias is the type, x-ts-alias-from +// and x-ts-alias-to the conversions. A property of that schema is typed as +// the value already; a body or a result is typed as the model. +func (g *FetchGenerator) alias(cm *codegen.CodegenModel) { + if cm.HasVars || cm.HasOneOf || cm.IsEnum || len(cm.AllOf) > 0 || len(cm.AnyOf) > 0 || cm.Discriminator != nil { + return + } + + var value *codegen.CodegenProperty + + switch { + case cm.IsArray && cm.Items != nil: + value = &codegen.CodegenProperty{IsArray: true, IsContainer: true, Items: cm.Items, DataType: "Array<" + cm.Items.DataType + ">"} + case cm.AdditionalProperties != nil: + value = &codegen.CodegenProperty{IsMap: true, IsContainer: true, Items: cm.AdditionalProperties, DataType: "{ [key: string]: " + cm.AdditionalProperties.DataType + "; }"} + case cm.IsPrimitiveType && cm.DataType != "" && cm.DataType != "any": + value = &codegen.CodegenProperty{DataType: cm.DataType, IsDateType: cm.IsDate && cm.DataType == "Date", IsDateTimeType: cm.IsDateTime && cm.DataType == "Date"} + default: + return + } + + from := &converter{g: g, used: map[string]bool{}} + to := &converter{g: g, to: true, used: from.used} + + cm.VendorExtensions = own(cm.VendorExtensions) + cm.VendorExtensions["x-ts-alias"] = value.DataType + cm.VendorExtensions["x-ts-alias-from"] = from.expr(value, "json") + cm.VendorExtensions["x-ts-alias-to"] = to.expr(value, "value") + + delete(from.used, cm.Classname) + cm.Imports = addImports(cm.Imports, from.used) +} + // descendants are the models that extend cm through allOf, at any depth. func descendants(cm *codegen.CodegenModel, models []*codegen.CodegenModel) []*codegen.CodegenModel { var out []*codegen.CodegenModel @@ -322,14 +358,34 @@ func (g *FetchGenerator) fitMembers(cm *codegen.CodegenModel) { return } - var members []*codegen.CodegenModel + // A member is a model, or the intersection of an allOf ("A & B"), whose + // models all import. + var members [][]*codegen.CodegenModel + + for _, member := range cm.OneOf { + var parts []*codegen.CodegenModel + + for name := range strings.SplitSeq(member, " & ") { + if m := g.byName[name]; m != nil { + parts = append(parts, m) + } + } + + if len(parts) == 0 || strings.Count(member, " & ")+1 != len(parts) { + continue + } - for _, name := range cm.OneOfModels { - if m := g.byName[name]; m != nil { - members = append(members, m) + members = append(members, parts) + + for _, m := range parts { + if !slices.Contains(cm.OneOfModels, m.Classname) { + cm.OneOfModels = append(cm.OneOfModels, m.Classname) + } } } + slices.Sort(cm.OneOfModels) + from := &converter{g: g, used: map[string]bool{}} to := &converter{g: g, to: true, used: from.used} diff --git a/internal/parser/imports.go b/internal/parser/imports.go index a9d3e67..1c5ba6c 100644 --- a/internal/parser/imports.go +++ b/internal/parser/imports.go @@ -2,6 +2,7 @@ package parser import ( "sort" + "strings" "github.com/xseman/openapi-generator/internal/codegen" ) @@ -43,10 +44,13 @@ func (p *Parser) collectImports(model *codegen.CodegenModel) []string { // AdditionalProperties, which is otherwise never visited above. collectPropertyImports(model.AdditionalProperties, model.Classname, imports) - for _, ref := range model.OneOf { - // Skip self-references to avoid circular imports - if ref != model.Classname && !isPrimitiveType(ref) { - imports[ref] = true + for _, member := range model.OneOf { + // Each model of an intersection; skip self-references to avoid + // circular imports + for ref := range strings.SplitSeq(member, " & ") { + if ref != model.Classname && !isPrimitiveType(ref) { + imports[ref] = true + } } } diff --git a/internal/parser/model.go b/internal/parser/model.go index 9d9ac44..afdd927 100644 --- a/internal/parser/model.go +++ b/internal/parser/model.go @@ -4,6 +4,7 @@ import ( "maps" "slices" "sort" + "strings" "github.com/getkin/kin-openapi/openapi3" "github.com/xseman/openapi-generator/internal/codegen" @@ -135,6 +136,7 @@ func (p *Parser) schemaToModel(name string, schema *openapi3.Schema) *codegen.Co case "string": model.IsString = true model.IsPrimitiveType = true + model.IsDate, model.IsDateTime = schema.Format == "date", schema.Format == "date-time" case "integer": model.IsInteger = true @@ -322,11 +324,13 @@ func (p *Parser) oneOfMembers(model *codegen.CodegenModel, schema *openapi3.Sche // per-member conversion branches ever run. prop := p.schemaToProperty("member", ref.Value, true) // Prefer the resolved property type: it carries type parameters - // (Array) where getTypeDeclaration returns a bare "Array". - // Composite declarations ("A & B") collapse to their first $ref so the - // alias members stay assignable from the narrowing conversions below. + // (Array) where getTypeDeclaration returns a bare "Array". An + // allOf member stays the intersection of its models ("A & B"), whose + // first $ref is the member model as before; a union collapses to it. typeName := prop.DataType - if typeName == "" || typeName == "any" || isCompositeType(typeName) { + intersection := strings.Contains(typeName, " & ") && !strings.Contains(typeName, " | ") + + if typeName == "" || typeName == "any" || isCompositeType(typeName) && !intersection { typeName = p.getTypeDeclaration(ref.Value) } @@ -341,6 +345,8 @@ func (p *Parser) oneOfMembers(model *codegen.CodegenModel, schema *openapi3.Sche // Primitive members (arrays of primitives included) get typed JSON // conversion branches instead of model imports. model.OneOfPrimitives = append(model.OneOfPrimitives, prop) + case intersection: + models[p.getTypeDeclaration(ref.Value)] = true case !isPrimitiveType(typeName) && !isCompositeType(typeName): // Model members, inline compositions that collapse to a single // referenced model included; free-form "any" is excluded by the guard. diff --git a/internal/parser/property.go b/internal/parser/property.go index 94af3df..a444df5 100644 --- a/internal/parser/property.go +++ b/internal/parser/property.go @@ -170,6 +170,27 @@ func (p *Parser) applyMemberType(prop *codegen.CodegenProperty, ref *openapi3.Sc return true } +// applyComposition types prop as the member, or the union or intersection +// of the members, its allOf, oneOf or anyOf names; false when it has none. +func (p *Parser) applyComposition(prop *codegen.CodegenProperty, schema *openapi3.Schema) bool { + switch { + case len(schema.AllOf) == 1: + return p.applyMemberType(prop, schema.AllOf[0]) + case len(schema.AllOf) > 1: + return p.applyCompositeType(prop, schema.AllOf, " & ") + case len(schema.OneOf) == 1: + return p.applyMemberType(prop, schema.OneOf[0]) + case len(schema.OneOf) > 1: + return p.applyCompositeType(prop, schema.OneOf, " | ") + case len(schema.AnyOf) == 1: + return p.applyMemberType(prop, schema.AnyOf[0]) + case len(schema.AnyOf) > 1: + return p.applyCompositeType(prop, schema.AnyOf, " | ") + } + + return false +} + // applyCompositeType builds a union ("|") or intersection ("&") type from the // composition members and registers the member models for import. No single // (de)serializer exists for a union/intersection, so the value is passed through. @@ -371,6 +392,8 @@ func (p *Parser) schemaToProperty(name string, schema *openapi3.Schema, required case "object": switch { + case len(schema.Properties) == 0 && len(schema.AllOf) > 1 && p.applyComposition(prop, schema): + // type: object over an allOf of models is their intersection. case len(schema.Properties) > 0: // For inline objects with properties, we don't generate a model // so treat them as "any" for simplicity @@ -486,24 +509,7 @@ func (p *Parser) schemaToProperty(name string, schema *openapi3.Schema, required default: // No explicit "type": resolve composition (allOf/oneOf/anyOf) to the // member type(s) that are available instead of collapsing to `any`. - resolved := false - - switch { - case len(schema.AllOf) == 1: - resolved = p.applyMemberType(prop, schema.AllOf[0]) - case len(schema.AllOf) > 1: - resolved = p.applyCompositeType(prop, schema.AllOf, " & ") - case len(schema.OneOf) == 1: - resolved = p.applyMemberType(prop, schema.OneOf[0]) - case len(schema.OneOf) > 1: - resolved = p.applyCompositeType(prop, schema.OneOf, " | ") - case len(schema.AnyOf) == 1: - resolved = p.applyMemberType(prop, schema.AnyOf[0]) - case len(schema.AnyOf) > 1: - resolved = p.applyCompositeType(prop, schema.AnyOf, " | ") - } - - if !resolved { + if resolved := p.applyComposition(prop, schema); !resolved { prop.DataType = p.getSchemaType(schemaType, schema.Format) if prop.DataType != "any" && prop.DataType != "" { prop.IsModel = true diff --git a/templates/typescript-fetch/modelAlias.mustache b/templates/typescript-fetch/modelAlias.mustache new file mode 100644 index 0000000..ec76a13 --- /dev/null +++ b/templates/typescript-fetch/modelAlias.mustache @@ -0,0 +1,53 @@ +{{! A schema that only names an array, a map or a scalar: a type alias that converts as its value does. }} +{{^withoutRuntimeChecks}} +import { + bestFit, + discriminate, + mapValues, +{{#usesDateHelpers}} + parseDate, + parseDateTime, + serializeDate, + serializeDateTime, +{{/usesDateHelpers}} +} from '../runtime{{importFileExtension}}'; +{{/withoutRuntimeChecks}} +{{#hasImports}} +{{#tsImports}} +import type { {{{classname}}} } from './{{filename}}{{importFileExtension}}'; +{{^withoutRuntimeChecks}} +import { + {{classname}}FromJSON, + {{classname}}ToJSON, +} from './{{filename}}{{importFileExtension}}'; +{{/withoutRuntimeChecks}} +{{/tsImports}} +{{/hasImports}} + +/**{{#unescapedDescription}} + * {{#lambda.indented_star_1}}{{{.}}}{{/lambda.indented_star_1}}{{/unescapedDescription}} + * @export + */ +export type {{classname}} = {{{vendorExtensions.x-ts-alias}}}; +{{^withoutRuntimeChecks}} + +export function instanceOf{{classname}}(value: any): value is {{classname}} { + return true; +} + +export function {{classname}}FromJSON(json: any): {{classname}} { + return {{classname}}FromJSONTyped(json, false); +} + +export function {{classname}}FromJSONTyped(json: any, ignoreDiscriminator: boolean): {{classname}} { + return json == null ? json : {{{vendorExtensions.x-ts-alias-from}}}; +} + +export function {{classname}}ToJSON(value?: {{classname}} | null): any { + return {{classname}}ToJSONTyped(value, false); +} + +export function {{classname}}ToJSONTyped(value?: {{classname}} | null, ignoreDiscriminator: boolean = false): any { + return value == null ? value : {{{vendorExtensions.x-ts-alias-to}}}; +} +{{/withoutRuntimeChecks}} diff --git a/templates/typescript-fetch/modelGeneric.mustache b/templates/typescript-fetch/modelGeneric.mustache index 2456844..5f67fd4 100644 --- a/templates/typescript-fetch/modelGeneric.mustache +++ b/templates/typescript-fetch/modelGeneric.mustache @@ -137,8 +137,6 @@ export function {{classname}}ToJSONTyped(value?: {{#hasReadOnly}}Omit<{{classnam case "{{mappingName}}": return {{modelName}}ToJSONTyped(value as {{modelName}}, ignoreDiscriminator); {{/discriminator.mappedModels}} - default: - return value; } } {{/discriminator}} diff --git a/templates/typescript-fetch/models.mustache b/templates/typescript-fetch/models.mustache index 06d5e26..ac86458 100644 --- a/templates/typescript-fetch/models.mustache +++ b/templates/typescript-fetch/models.mustache @@ -13,8 +13,14 @@ {{/hasOneOf}} {{^hasOneOf}} +{{#vendorExtensions.x-ts-alias}} +{{>modelAlias}} + +{{/vendorExtensions.x-ts-alias}} +{{^vendorExtensions.x-ts-alias}} {{>modelGeneric}} +{{/vendorExtensions.x-ts-alias}} {{/hasOneOf}} {{/isEnum}} {{/model}} From abfcfc0b3590ab9d92cfb56b7fc2c08f00de877f Mon Sep 17 00:00:00 2001 From: Filip Seman Date: Mon, 26 Oct 2026 18:56:02 +0100 Subject: [PATCH 05/18] test(e2e): cover schema aliases and an unmapped discriminator value A feature spec of an array, a map and a date alias as a body and a result, and the discriminator parent sent as itself. The TypeScript runner writes a result with its model's ToJSON before taking it for a plain Date. --- testdata/e2e/runners/ts/runner.ts | 19 +++---- .../discriminator-allof-no-mapping.yaml | 11 ++-- .../discriminator-allof-no-mapping.yaml | 3 ++ testdata/specs/feature/schema-aliases.yaml | 53 +++++++++++++++++++ 4 files changed, 74 insertions(+), 12 deletions(-) create mode 100644 testdata/specs/feature/schema-aliases.yaml diff --git a/testdata/e2e/runners/ts/runner.ts b/testdata/e2e/runners/ts/runner.ts index 144d5b9..da32326 100644 --- a/testdata/e2e/runners/ts/runner.ts +++ b/testdata/e2e/runners/ts/runner.ts @@ -325,19 +325,20 @@ async function toWire(client: any, decls: Record, t: string, v: an return out; } - if (v instanceof Date) { - return day ? client.serializeDate(v) : v.toISOString(); - } - + // A model's own ToJSON first: an alias of a date (Day) writes it as one. const to = client[t + "ToJSON"]; - if (typeof to !== "function") { - return v; + if (typeof to === "function") { + const out = to(v); + await restore(client, decls, t, v, out); + + return out; } - const out = to(v); - await restore(client, decls, t, v, out); + if (v instanceof Date) { + return day ? client.serializeDate(v) : v.toISOString(); + } - return out; + return v; } // restore puts back the read-only properties a model's ToJSON leaves out, diff --git a/testdata/e2e/scenarios/discriminator-allof-no-mapping.yaml b/testdata/e2e/scenarios/discriminator-allof-no-mapping.yaml index 33c6f75..90ea954 100644 --- a/testdata/e2e/scenarios/discriminator-allof-no-mapping.yaml +++ b/testdata/e2e/scenarios/discriminator-allof-no-mapping.yaml @@ -1,14 +1,19 @@ spec: ../../specs/feature/discriminator-allof-no-mapping.yaml -skip: - go-fetch: "a discriminator without oneOf: the parent stays a plain struct, the children's properties are dropped" - dart-fetch: "does not compile: lib/src/apis/default_api.dart:71: Expected an identifier." +skip: { dart-fetch: "does not compile: lib/src/apis/default_api.dart:71: Expected an identifier." } cases: - name: children by their schema names op: listPets response: { json: [{ petType: Cat, name: tom, born: "2024-01-02T03:04:05Z", nick-name: tommy }, { petType: Dog, pack-size: 3 }] } result: [{ petType: Cat, name: tom, born: "2024-01-02T03:04:05Z", nick-name: tommy }, { petType: Dog, pack-size: 3 }] + skip: { go-fetch: &plain "a discriminator without oneOf: the parent stays a plain struct, the children's properties are dropped" } - name: a child as the parent's body op: addPet args: { body: { petType: Cat, born: "2024-01-02T03:04:05Z", nick-name: tommy } } request: { body: { petType: Cat, born: "2024-01-02T03:04:05Z", nick-name: tommy } } response: { status: 204 } + skip: { go-fetch: *plain } + - name: the parent itself as the body + op: addPet + args: { body: { petType: Pet, name: rex, born_on: "2024-01-02" } } + request: { body: { petType: Pet, name: rex, born_on: "2024-01-02" } } + response: { status: 204 } diff --git a/testdata/specs/feature/discriminator-allof-no-mapping.yaml b/testdata/specs/feature/discriminator-allof-no-mapping.yaml index 6119814..78baba3 100644 --- a/testdata/specs/feature/discriminator-allof-no-mapping.yaml +++ b/testdata/specs/feature/discriminator-allof-no-mapping.yaml @@ -39,6 +39,9 @@ components: type: string name: type: string + born_on: + type: string + format: date Cat: allOf: - $ref: "#/components/schemas/Pet" diff --git a/testdata/specs/feature/schema-aliases.yaml b/testdata/specs/feature/schema-aliases.yaml new file mode 100644 index 0000000..70ca9a7 --- /dev/null +++ b/testdata/specs/feature/schema-aliases.yaml @@ -0,0 +1,53 @@ +openapi: "3.0.0" +info: + title: Schema Aliases Feature + version: 1.0.0 + description: Schemas that only name an array, a map or a scalar, as a body and as a result. +paths: + /pets: + post: + operationId: storePets + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/Pets" + responses: + "200": + description: the pets by name + content: + application/json: + schema: + $ref: "#/components/schemas/PetsByName" + /today: + get: + operationId: getToday + responses: + "200": + description: a day + content: + application/json: + schema: + $ref: "#/components/schemas/Day" +components: + schemas: + Day: + type: string + format: date + Pet: + type: object + required: [pet_name] + properties: + pet_name: + type: string + born_on: + $ref: "#/components/schemas/Day" + Pets: + type: array + items: + $ref: "#/components/schemas/Pet" + PetsByName: + type: object + additionalProperties: + $ref: "#/components/schemas/Pet" From cb81dace28074b8431315edeae9805288d305d98 Mon Sep 17 00:00:00 2001 From: Filip Seman Date: Mon, 26 Oct 2026 19:20:46 +0100 Subject: [PATCH 06/18] test(e2e): check days at local midnight and every query and header Two places a typescript-fetch bug passed the e2e: - A day (format: date) parsed as UTC midnight is the right day in Europe/Bratislava, where the runners run, and the day before west of UTC. The runner now requires a decoded day at local midnight, and makes a day argument with the client's parseDate, as its users do, not as UTC midnight. - A generated case checked the body only. Its query and headers must now hold the required parameters' values and none of the optional ones (headers an HTTP client sends itself aside), so an empty `?q=` fails. Both were proven with a broken template: 759 and 145 cases fail now. --- internal/e2e/e2e_test.go | 21 ++++++--- internal/e2e/values_test.go | 27 ++++++++++-- templates/typescript-fetch/README.md | 1 + testdata/e2e/README.md | 14 ++++-- testdata/e2e/runners/ts/runner.ts | 64 +++++++++++++++++++--------- 5 files changed, 95 insertions(+), 32 deletions(-) diff --git a/internal/e2e/e2e_test.go b/internal/e2e/e2e_test.go index 039acd7..1d76300 100644 --- a/internal/e2e/e2e_test.go +++ b/internal/e2e/e2e_test.go @@ -285,13 +285,24 @@ func caseDiffs(k kase, o outcome, m *mock, i int, r rules) []string { case got["responseInvalid"] != nil: diffs = append(diffs, fmt.Sprintf("response: the scenario's is invalid for the spec: %v", got["responseInvalid"])) case k.Request != nil && k.Exact: - // The body alone, absent when none was sent: null is not absent. - sent := map[string]any{} - if b, ok := got["body"]; ok { - sent["body"] = b + // The query and the headers as the scenarios read them (another + // key, an API key, may be there), the body exactly: absent when + // none was sent, and null is not absent. + want, _ := decodeJSON(k.Request).(map[string]any) + for _, where := range []string{"query", "header"} { + if w, ok := want[where]; ok { + diffs = append(diffs, match("request."+where, w, got[where], rules{millis: r.millis})...) + } } - diffs = append(diffs, match("request", decodeJSON(k.Request), sent, r)...) + if b, ok := want["body"]; ok { + sent := map[string]any{} + if gb, ok := got["body"]; ok { + sent["body"] = gb + } + + diffs = append(diffs, match("request", map[string]any{"body": b}, sent, r)...) + } case k.Request != nil: diffs = append(diffs, match("request", decodeJSON(k.Request), got, r)...) diff --git a/internal/e2e/values_test.go b/internal/e2e/values_test.go index cdfebc5..63a2666 100644 --- a/internal/e2e/values_test.go +++ b/internal/e2e/values_test.go @@ -153,6 +153,9 @@ func servable(t *testing.T, spec string) (string, *openapi3.T) { return out, doc } +// clientHeaders are the headers an HTTP client may send on its own. +var clientHeaders = map[string]bool{"Accept": true, "Accept-Language": true, "Accept-Encoding": true, "User-Agent": true} + // valueCases makes an operation's generated cases: each value of a JSON // response is the mock's answer and the result, or the error's model; each // value of a JSON request body is the body to send, answered by the first @@ -169,6 +172,10 @@ func valueCases(item *openapi3.PathItem, op *openapi3.Operation, is31 bool) ([]k query bool // a query is sent deepArray string // a deepObject array, which kin refuses in any query (Stripe's expand) dropped int // values their schema rejects: a generator gap to log + + // sent is what the query and the headers must hold: a required + // parameter its value, an optional one nothing. + sent = map[string]map[string]any{"query": {}, "header": {}} ) for _, p := range slices.Concat(item.Parameters, op.Parameters) { @@ -180,7 +187,15 @@ func valueCases(item *openapi3.PathItem, op *openapi3.Operation, is31 bool) ([]k deepArray = p.Value.Name } + where := sent[p.Value.In] + if !p.Value.Required { + // An HTTP client sends some headers itself: Node's fetch + // Accept and Accept-Language. + if where != nil && !clientHeaders[http.CanonicalHeaderKey(p.Value.Name)] { + where[p.Value.Name] = "$absent" + } + continue } @@ -191,6 +206,10 @@ func valueCases(item *openapi3.PathItem, op *openapi3.Operation, is31 bool) ([]k args[p.Value.Name] = v query = query || p.Value.In == openapi3.ParameterInQuery + + if where != nil { + where[p.Value.Name] = strings.Trim(string(mustJSON(v)), `"`) + } } if query && deepArray != "" { @@ -258,7 +277,7 @@ func valueCases(item *openapi3.PathItem, op *openapi3.Operation, is31 bool) ([]k break } - k := kase{Name: fmt.Sprintf("response %d %s", status, v.name), Op: op.OperationID, Args: mustJSON(args), Response: r, Exact: true} + k := kase{Name: fmt.Sprintf("response %d %s", status, v.name), Op: op.OperationID, Args: mustJSON(args), Response: r, Exact: true, Request: mustJSON(sent)} if isResult { k.Result = v.raw } else { @@ -279,14 +298,14 @@ func valueCases(item *openapi3.PathItem, op *openapi3.Operation, is31 bool) ([]k // An optional body of null may go out as null or as none: the mock // checks only that the request is valid. - want := mustJSON(map[string]any{"body": b.raw}) + want := map[string]any{"query": sent["query"], "header": sent["header"], "body": b.raw} if string(b.raw) == "null" && !op.RequestBody.Value.Required { - want = nil + delete(want, "body") } cases = append(cases, kase{ Name: "request " + b.name, Op: op.OperationID, Args: mustJSON(a), Exact: true, - Request: want, Response: answer, + Request: mustJSON(want), Response: answer, }) } diff --git a/templates/typescript-fetch/README.md b/templates/typescript-fetch/README.md index 22510e5..a4f739a 100644 --- a/templates/typescript-fetch/README.md +++ b/templates/typescript-fetch/README.md @@ -213,6 +213,7 @@ of lists of models, a map of dates, a map of lists of models. | optional and nullable | `T \| null`: `null` is sent and read as `null`, a property left out stays out | | `additionalProperties` next to properties | an index signature; the other keys go through both ways, converted as their schema says | | `binary` property of a JSON model | `string`: JSON holds no bytes, a server writes their text | +| a schema of an array, a map or a scalar | a type alias that converts as its value does: `type Pets = Array`, `type Day = Date` | | `oneOf` / `anyOf` | the member the discriminator names (its mapping, else the schema's name), one every member pins a property for, else the member it fits best | | Request | Sent | diff --git a/testdata/e2e/README.md b/testdata/e2e/README.md index 05f09f7..6f7f606 100644 --- a/testdata/e2e/README.md +++ b/testdata/e2e/README.md @@ -113,8 +113,11 @@ same cases. `error`'s model for a 4xx/5xx, or a 2xx whose body is not the result's. - A **request** value is `args.body` and must arrive as `request.body`; an optional body of null may also arrive as none. -- Required parameters get a simple value. An operation without one (an - array in the path, a form body, a HEAD) is left out; the run logs which. +- Required parameters get a simple value and must arrive as it; an + optional query or header parameter must not arrive at all (a header + the HTTP client sends itself, as `Accept-Language`, aside). An operation + without a value for a required one (an array in the path, a form body, a + HEAD) is left out; the run logs which. - They compare **exactly**: no other keys, and null is not absent. go-fetch and typescript-fetch must pass every case. A failing case of a @@ -146,8 +149,11 @@ parsed as JSON where the spec declares a body for the status (`bodies` in the input). A converted round trip hides a `FromJSON` and a `ToJSON` that both leave a value alone, so the runner also checks each argument and result against the client's types: a model holds its TypeScript property -names, a `Date` stands where the interface says. The runner puts back -the read-only properties a model's `ToJSON` leaves out of a result. +names, a `Date` stands where the interface says, and a day (`format: +date`) is at local midnight, which a day parsed as UTC is not east of UTC, +where the runners run. The runner makes a day argument as its users do, +with the client's `parseDate`, and puts back the read-only properties a +model's `ToJSON` leaves out of a result. A case one generator cannot pass has `skip: { : why }`. A spec whose client does not compile at all has the same `skip` at the top of its diff --git a/testdata/e2e/runners/ts/runner.ts b/testdata/e2e/runners/ts/runner.ts index da32326..667607c 100644 --- a/testdata/e2e/runners/ts/runner.ts +++ b/testdata/e2e/runners/ts/runner.ts @@ -64,8 +64,18 @@ function methods(dir: string): { methods: Method[]; requests: Record; base?: string; open?: boolean; alias?: string; wire?: Record; readonly?: Set }; +// their JSON names, the read-only ones and the days (format: date), or a +// type alias. +type Decl = { + fields?: Record; + base?: string; + open?: boolean; + alias?: string; + wire?: Record; + readonly?: Set; + days?: Set; + day?: boolean; +}; // models reads the models' interfaces and type aliases from the sources. function models(dir: string): Record { @@ -75,10 +85,15 @@ function models(dir: string): Record { for (const file of fs.existsSync(d) ? fs.readdirSync(d) : []) { const src: string = fs.readFileSync(path.join(d, file), "utf8"); - // A FromJSON reads each property's JSON name: "name": ... json["wire"]. + // A FromJSON reads each property's JSON name: "name": ... json["wire"], + // a day with parseDate. const wire: Record = {}; - for (const f of src.matchAll(/^ "([^"]+)": .*?json\["([^"]+)"\]/gm)) { - wire[f[1]] = f[2]; + const days = new Set(); + for (const f of src.matchAll(/^ "([^"]+)": (.*?json\["([^"]+)"\].*)$/gm)) { + wire[f[1]] = f[3]; + if (/\bparseDate\b/.test(f[2])) { + days.add(f[1]); + } } for (const m of src.matchAll(/^export interface (\w+)(?: extends (\w+))? \{([\s\S]*?)\n\}/gm)) { @@ -94,11 +109,11 @@ function models(dir: string): Record { // An interface without properties, as TypeScript's {}, takes any object. const open = /^ \[key: string\]/m.test(m[3]) || (Object.keys(fields).length === 0 && !m[2]); - out[m[1]] = { fields, base: m[2], open, wire, readonly }; + out[m[1]] = { fields, base: m[2], open, wire, readonly, days }; } for (const m of src.matchAll(/^export type (\w+) = (.+);$/gm)) { - out[m[1]] ??= { alias: m[2] }; + out[m[1]] ??= { alias: m[2], day: /json == null \? json : [^\n]*\bparseDate\b/.test(src) }; } } @@ -129,17 +144,19 @@ const show = (v: any): string => (v instanceof Date ? "Date " + v.toISOString() // shape says where v is not of the client's type t, or "": an object of a // model with a key its interface lacks (a wire name FromJSON left), a -// string where a Date belongs. A FromJSON and a ToJSON that both leave a -// value alone pass a round trip; they do not pass this. Scalars count in -// results only: the runner hands arguments over as the scenario has them. -function shape(decls: Record, t: string, v: any, at: string, scalars: boolean): string { +// string where a Date belongs, a day (format: date, day) other than at +// local midnight, as one parsed as UTC is west of it. A FromJSON and a +// ToJSON that both leave a value alone pass a round trip; they do not pass +// this. Scalars count in results only: the runner hands arguments over as +// the scenario has them. +function shape(decls: Record, t: string, v: any, at: string, scalars: boolean, day = false): string { if (v === null || v === undefined) { return ""; } const alts = members(t).filter((x) => x !== "null" && x !== "undefined"); if (alts.length > 1) { - const errs = alts.map((x) => shape(decls, x, v, at, scalars)); + const errs = alts.map((x) => shape(decls, x, v, at, scalars, day)); return errs.every(Boolean) ? errs[0] : ""; } @@ -154,7 +171,7 @@ function shape(decls: Record, t: string, v: any, at: string, scala let i = 0; for (const x of v) { - const e = shape(decls, m[2], x, `${at}[${i++}]`, scalars); + const e = shape(decls, m[2], x, `${at}[${i++}]`, scalars, day); if (e) { return e; } @@ -169,7 +186,7 @@ function shape(decls: Record, t: string, v: any, at: string, scala } for (const [k, x] of Object.entries(v)) { - const e = shape(decls, m[1], x, `${at}.${k}`, scalars); + const e = shape(decls, m[1], x, `${at}.${k}`, scalars, day); if (e) { return e; } @@ -179,7 +196,13 @@ function shape(decls: Record, t: string, v: any, at: string, scala } if (t === "Date") { - return v instanceof Date && !isNaN(v.getTime()) ? "" : `${at}: want a Date, got ${show(v)}`; + if (!(v instanceof Date) || isNaN(v.getTime())) { + return `${at}: want a Date, got ${show(v)}`; + } + + const midnight = v.getHours() + v.getMinutes() + v.getSeconds() + v.getMilliseconds() === 0; + + return !day || midnight ? "" : `${at}: want a day at local midnight, got ${v.toString()}`; } if (t === "Blob") { @@ -196,7 +219,7 @@ function shape(decls: Record, t: string, v: any, at: string, scala } if (d.alias !== undefined) { - return d.alias.startsWith("typeof ") ? "" : shape(decls, d.alias, v, at, scalars); + return d.alias.startsWith("typeof ") ? "" : shape(decls, d.alias, v, at, scalars, day || !!d.day); } // An interface without properties is TypeScript's {}: any value but null. @@ -227,9 +250,11 @@ function shape(decls: Record, t: string, v: any, at: string, scala function fits(decls: Record, t: string, v: any, at: string, scalars: boolean): string { const d = decls[t]; const fields: Record = {}; + const days = new Set(); let open = false; for (let c: Decl | undefined = d; c; c = c.base ? decls[c.base] : undefined) { Object.assign(fields, { ...c.fields, ...fields }); + c.days?.forEach((f) => days.add(f)); open ||= !!c.open; } @@ -242,7 +267,7 @@ function fits(decls: Record, t: string, v: any, at: string, scalar return `${at}: ${k} is no property of ${t}`; } - const e = shape(decls, fields[k], x, `${at}.${k}`, scalars); + const e = shape(decls, fields[k], x, `${at}.${k}`, scalars, days.has(k)); if (e) { return e; } @@ -276,8 +301,9 @@ function fromWire(client: any, t: string, v: any): any { return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, fromWire(client, m![1], x)])); } + // A day as its users make one: local midnight. if (t === "Date") { - return new Date(v); + return /^\d{4}-\d{2}-\d{2}$/.test(v) ? client.parseDate(v) : new Date(v); } const from = client[t + "FromJSON"]; @@ -465,7 +491,7 @@ async function callCase(client: Client, info: ReturnType, decls: try { const result = m.request ? await api[m.name](params) : await api[m.name](); - const bad = shape(decls, m.result, result, "result", true); + const bad = shape(decls, m.result, result, "result", true, m.day); if (bad) { return { fail: "the result is not of its type: " + bad }; } From 4b46126ec958908d37f371c4aba0351697e87e4c Mon Sep 17 00:00:00 2001 From: Filip Seman Date: Mon, 26 Oct 2026 19:36:24 +0100 Subject: [PATCH 07/18] fix(typescript-fetch): convert inline unions of a body, of lists, of any - An inline union as a request body converts as one in a property does: the parameter now carries its members to the converter. - bestFit picks a list member too, by all its items: `Array | Array` converts each item. - A union with an inline object is typed any; it now also stays as it came, not converted by its one model member. --- internal/generator/typescript/convert.go | 40 +++++++++++++++------ internal/generator/typescript/fetch.go | 4 +-- templates/typescript-fetch/runtime.mustache | 21 ++++++----- 3 files changed, 43 insertions(+), 22 deletions(-) diff --git a/internal/generator/typescript/convert.go b/internal/generator/typescript/convert.go index 0222e02..29c0db5 100644 --- a/internal/generator/typescript/convert.go +++ b/internal/generator/typescript/convert.go @@ -170,19 +170,26 @@ func (c *converter) discriminated(members []*codegen.CodegenProperty, x string) // and the fewest it does not. A scalar member (Stripe's id next to the // object it expands to) takes a value that is no object. func (c *converter) bestFit(members []*codegen.CodegenProperty, x string) string { - var models [][]*codegen.CodegenModel + var models []fit for _, m := range members { + // A list of a model fits a list whose every item fits the model. + array := m.IsArray && m.Items != nil + if array { + m = m.Items + } + model := c.g.byName[c.g.modelOf(m)] + // An inline object makes the union any: it stays as it came. switch { - case model == nil && m.IsPrimitiveType && !m.IsContainer: + case model == nil && m.IsPrimitiveType && !m.IsContainer && !m.IsFreeFormObject: continue case model == nil: return x } - models = append(models, []*codegen.CodegenModel{model}) + models = append(models, fit{parts: []*codegen.CodegenModel{model}, array: array}) } if len(models) == 0 { @@ -192,17 +199,23 @@ func (c *converter) bestFit(members []*codegen.CodegenProperty, x string) string return c.rt + "bestFit(" + x + ", " + c.fits(models) + ") ?? " + x } +// fit is a member of a union for the runtime's bestFit: a model, or the +// intersection of several (an allOf), or a list of one (array). +type fit struct { + parts []*codegen.CodegenModel + array bool +} + // fits lists members for the runtime's bestFit: each one's required keys, -// its keys and its converter, in JSON (from) or in TypeScript (to). A -// member is a model, or the intersection of several (an allOf), converted -// by each and merged. -func (c *converter) fits(members [][]*codegen.CodegenModel) string { +// its keys, its converter, in JSON (from) or in TypeScript (to), and +// whether it is a list. An intersection converts by each model, merged. +func (c *converter) fits(members []fit) string { cases := make([]string, 0, len(members)) - for _, parts := range members { + for _, member := range members { var required, known, fns []string - for _, m := range parts { + for _, m := range member.parts { for _, v := range c.keyVars(m) { name := strconv.Quote(v.BaseName) if c.to { @@ -227,7 +240,12 @@ func (c *converter) fits(members [][]*codegen.CodegenModel) string { fn = "(v: any) => ({ ..." + strings.Join(fns, "(v), ...") + "(v) })" } - cases = append(cases, "[["+strings.Join(required, ", ")+"], ["+strings.Join(known, ", ")+"], "+fn+"]") + array := "" + if member.array { + array = ", true" + } + + cases = append(cases, "[["+strings.Join(required, ", ")+"], ["+strings.Join(known, ", ")+"], "+fn+array+"]") } return "[" + strings.Join(cases, ", ") + "]" @@ -360,6 +378,6 @@ func (c *converter) varToJSON(p *codegen.CodegenProperty) string { func asProperty(p *codegen.CodegenParameter) *codegen.CodegenProperty { return &codegen.CodegenProperty{ Datatype: p.DataType, IsModel: p.IsModel, IsArray: p.IsArray, IsMap: p.IsMap, IsContainer: p.IsContainer, - Items: p.Items, IsDateType: p.IsDateType, IsDateTimeType: p.IsDateTimeType, Ref: p.Ref, + Items: p.Items, IsDateType: p.IsDateType, IsDateTimeType: p.IsDateTimeType, Ref: p.Ref, ComposedSchemas: p.ComposedSchemas, } } diff --git a/internal/generator/typescript/fetch.go b/internal/generator/typescript/fetch.go index bf387fc..7199946 100644 --- a/internal/generator/typescript/fetch.go +++ b/internal/generator/typescript/fetch.go @@ -360,7 +360,7 @@ func (g *FetchGenerator) fitMembers(cm *codegen.CodegenModel) { // A member is a model, or the intersection of an allOf ("A & B"), whose // models all import. - var members [][]*codegen.CodegenModel + var members []fit for _, member := range cm.OneOf { var parts []*codegen.CodegenModel @@ -375,7 +375,7 @@ func (g *FetchGenerator) fitMembers(cm *codegen.CodegenModel) { continue } - members = append(members, parts) + members = append(members, fit{parts: parts}) for _, m := range parts { if !slices.Contains(cm.OneOfModels, m.Classname) { diff --git a/templates/typescript-fetch/runtime.mustache b/templates/typescript-fetch/runtime.mustache index 2d438ad..e162892 100644 --- a/templates/typescript-fetch/runtime.mustache +++ b/templates/typescript-fetch/runtime.mustache @@ -536,24 +536,27 @@ export function otherKeys(value: any, keys: string[]): { [key: string]: any } { /** * A member of a union converted by the model it fits best: of the models whose required keys the - * value has, the one that declares the most of its keys and the fewest it does not. undefined when - * the value is no object (an id next to the object it expands to) or fits none. + * value has, the one that declares the most of its keys and the fewest it does not. A list fits a + * list member by all its items. undefined when the value is no object (an id next to the object it + * expands to) or fits none. */ -export function bestFit(value: any, models: Array<[string[], string[], (value: any) => any]>): any { - if (value === null || typeof value !== "object" || Array.isArray(value)) { +export function bestFit(value: any, models: Array<[string[], string[], (value: any) => any, boolean?]>): any { + const list = Array.isArray(value); + const items: any[] = list ? value : [value]; + if (value === null || typeof value !== "object" || items.some((item) => item === null || typeof item !== "object" || Array.isArray(item))) { return undefined; } - const keys = Object.keys(value); let best: ((value: any) => any) | undefined; let score = -Infinity; - for (const [required, known, fn] of models) { - const fit = keys.reduce((n, key) => n + (known.includes(key) ? 1 : -1), 0); - if (required.every((key) => key in value) && fit > score) { + for (const [required, known, fn, array] of models) { + const fits = !!array === list && items.every((item) => required.every((key) => key in item)); + const fit = items.reduce((n, item) => n + Object.keys(item).reduce((m, key) => m + (known.includes(key) ? 1 : -1), 0), 0); + if (fits && fit > score) { best = fn; score = fit; } } - return best?.(value); + return best && (list ? value.map(best) : best(value)); } export function mapValues(data: any, fn: (item: any) => any): { [key: string]: any } { From f1fa15190b728a5405cf7f189e34370fc3fb619f Mon Sep 17 00:00:00 2001 From: Filip Seman Date: Mon, 26 Oct 2026 19:36:24 +0100 Subject: [PATCH 08/18] test(e2e): expose union bodies, empty headers and CRLF event streams From the round-2 harness critic, each shown by a mutation the e2e passed: - Union members with snake_case names (composition-oneof-inline): with the same names in JSON and TypeScript a union body sent unconverted looked right. The runner now builds a union argument from the member whose JSON names hold the value's keys, and writes a union result with the member whose type the value fits; it also reads `T | null | undefined ` as T. - An empty header and cookie must still be sent. - Server-sent events with CRLF line ends, an id that stays across events. - A generated case is named after its operation. --- internal/e2e/values_test.go | 4 +- testdata/e2e/runners/ts/runner.ts | 40 ++++++++++++++++--- .../e2e/scenarios/parameters-locations.yaml | 6 +++ testdata/e2e/scenarios/responses-stream.yaml | 9 +++++ testdata/e2e/scenarios/unions-inline.yaml | 10 ++--- .../feature/composition-oneof-inline.yaml | 12 +++--- 6 files changed, 63 insertions(+), 18 deletions(-) create mode 100644 testdata/e2e/scenarios/responses-stream.yaml diff --git a/internal/e2e/values_test.go b/internal/e2e/values_test.go index 63a2666..02f132d 100644 --- a/internal/e2e/values_test.go +++ b/internal/e2e/values_test.go @@ -277,7 +277,7 @@ func valueCases(item *openapi3.PathItem, op *openapi3.Operation, is31 bool) ([]k break } - k := kase{Name: fmt.Sprintf("response %d %s", status, v.name), Op: op.OperationID, Args: mustJSON(args), Response: r, Exact: true, Request: mustJSON(sent)} + k := kase{Name: fmt.Sprintf("%s response %d %s", op.OperationID, status, v.name), Op: op.OperationID, Args: mustJSON(args), Response: r, Exact: true, Request: mustJSON(sent)} if isResult { k.Result = v.raw } else { @@ -304,7 +304,7 @@ func valueCases(item *openapi3.PathItem, op *openapi3.Operation, is31 bool) ([]k } cases = append(cases, kase{ - Name: "request " + b.name, Op: op.OperationID, Args: mustJSON(a), Exact: true, + Name: op.OperationID + " request " + b.name, Op: op.OperationID, Args: mustJSON(a), Exact: true, Request: mustJSON(want), Response: answer, }) } diff --git a/testdata/e2e/runners/ts/runner.ts b/testdata/e2e/runners/ts/runner.ts index 667607c..f7cec62 100644 --- a/testdata/e2e/runners/ts/runner.ts +++ b/testdata/e2e/runners/ts/runner.ts @@ -276,15 +276,37 @@ function fits(decls: Record, t: string, v: any, at: string, scalar return ""; } -const nullable = (t: string): string => t.replace(/ \| null$/, "").replace(/ \| undefined$/, ""); +// nullable is type t without its null and undefined members. +const nullable = (t: string): string => + members(t) + .filter((x) => x !== "null" && x !== "undefined") + .join(" | "); // fromWire turns a wire value into what the client takes for type t. -function fromWire(client: any, t: string, v: any): any { +function fromWire(client: any, decls: Record, t: string, v: any): any { t = nullable(t); if (v === null || v === undefined) { return v; } + // An inline union: the member model whose JSON names hold all of the + // value's keys, else the value as the scenario has it. + const alts = members(t).filter((x) => x !== "null" && x !== "undefined"); + if (alts.length > 1) { + const wire = (x: string): string[] => { + const out: string[] = []; + for (let d: Decl | undefined = decls[x]; d?.fields; d = d.base ? decls[d.base] : undefined) { + out.push(...Object.keys(d.fields).map((f) => d!.wire?.[f] ?? f)); + } + + return out; + }; + + const alt = typeof v === "object" && !Array.isArray(v) ? alts.find((x) => decls[x]?.fields && Object.keys(v).every((k) => wire(x).includes(k))) : undefined; + + return alt === undefined ? v : fromWire(client, decls, alt, v); + } + if (v && typeof v === "object" && (v.$file || v.$bytes)) { const c = v.$file ?? v.$bytes; const data = c.base64 !== undefined ? Buffer.from(c.base64, "base64") : Buffer.from(c.text ?? "", "utf8"); @@ -294,11 +316,11 @@ function fromWire(client: any, t: string, v: any): any { let m: RegExpExecArray | null; if ((m = /^Array<(.+)>$/.exec(t)) && Array.isArray(v)) { - return v.map((x) => fromWire(client, m![1], x)); + return v.map((x) => fromWire(client, decls, m![1], x)); } if ((m = /^\{ \[key: string\]: (.+); \}$/.exec(t)) && typeof v === "object") { - return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, fromWire(client, m![1], x)])); + return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, fromWire(client, decls, m![1], x)])); } // A day as its users make one: local midnight. @@ -319,6 +341,14 @@ async function toWire(client: any, decls: Record, t: string, v: an return v ?? null; } + // An inline union has no ToJSON: its member the value is one of does. + const alts = members(t).filter((x) => x !== "null" && x !== "undefined"); + if (alts.length > 1) { + const alt = alts.find((x) => shape(decls, x, v, "", true) === ""); + + return alt === undefined ? v : toWire(client, decls, alt, v, day); + } + if (v instanceof Blob) { return Buffer.from(await v.arrayBuffer()).toString("base64"); } @@ -474,7 +504,7 @@ async function callCase(client: Client, info: ReturnType, decls: } used.add(name); - params[field] = fromWire(client.module, type, args[name]); + params[field] = fromWire(client.module, decls, type, args[name]); const bad = shape(decls, type, params[field], "arg " + name, false); if (bad) { diff --git a/testdata/e2e/scenarios/parameters-locations.yaml b/testdata/e2e/scenarios/parameters-locations.yaml index 79b936f..2939e45 100644 --- a/testdata/e2e/scenarios/parameters-locations.yaml +++ b/testdata/e2e/scenarios/parameters-locations.yaml @@ -17,3 +17,9 @@ cases: request: { query: { verbose: $absent }, header: { X-Request-Id: $absent }, cookie: { sessionId: $absent } } response: { json: {} } result: {} + - name: empty header and cookie still sent + op: getItem + args: { orgId: o, itemId: 0, X-Request-Id: "", sessionId: "" } + request: { path: /orgs/o/items/0, header: { X-Request-Id: "" }, cookie: { sessionId: "" } } + response: { json: {} } + result: {} diff --git a/testdata/e2e/scenarios/responses-stream.yaml b/testdata/e2e/scenarios/responses-stream.yaml new file mode 100644 index 0000000..11ad138 --- /dev/null +++ b/testdata/e2e/scenarios/responses-stream.yaml @@ -0,0 +1,9 @@ +# The FastAPI backend streams with \n line ends; these cases the mock serves. +spec: ../../specs/feature/responses-stream.yaml +skip: { dart-fetch: "does not compile: lib/src/apis/default_api.dart:70: Expected an identifier." } +cases: + - name: server-sent events with CRLF line ends, an id that stays + op: rawEvents + # offSpec: the validator reads no text/event-stream + response: { header: { Content-Type: text/event-stream }, text: "event: tick\r\nid: 1\r\ndata: a\r\n\r\ndata: b\r\n\r\n", offSpec: true } + result: [{ event: tick, id: "1", data: a }, { id: "1", data: b }] diff --git a/testdata/e2e/scenarios/unions-inline.yaml b/testdata/e2e/scenarios/unions-inline.yaml index c6835a8..ad3cfde 100644 --- a/testdata/e2e/scenarios/unions-inline.yaml +++ b/testdata/e2e/scenarios/unions-inline.yaml @@ -3,8 +3,8 @@ skip: { dart-fetch: "does not compile: lib/src/apis/default_api.dart:82: Expecte cases: - name: inline union body, property and items op: admit - args: { id: s1, body: { kind: dog, barkVolume: 1.5 } } - request: { path: /shelters/s1/pets, body: { kind: dog, barkVolume: 1.5 } } - response: { json: { favourite: { kind: cat, lives: 3 }, location: { lat: 1, lon: 2 }, residents: [{ kind: dog, barkVolume: 2 }] } } - result: { favourite: { kind: cat, lives: 3 }, location: { lat: 1, lon: 2 }, residents: [{ kind: dog, barkVolume: 2 }] } - - { name: inline union response, op: randomPet, response: { json: { kind: cat, lives: 9 } }, result: { kind: cat, lives: 9 } } + args: { id: s1, body: { pet_kind: dog, bark_volume: 1.5 } } + request: { path: /shelters/s1/pets, body: { pet_kind: dog, bark_volume: 1.5 } } + response: { json: { favourite: { pet_kind: cat, lives: 3 }, location: { lat: 1, lon: 2 }, residents: [{ pet_kind: dog, bark_volume: 2 }] } } + result: { favourite: { pet_kind: cat, lives: 3 }, location: { lat: 1, lon: 2 }, residents: [{ pet_kind: dog, bark_volume: 2 }] } + - { name: inline union response, op: randomPet, response: { json: { pet_kind: cat, lives: 9 } }, result: { pet_kind: cat, lives: 9 } } diff --git a/testdata/specs/feature/composition-oneof-inline.yaml b/testdata/specs/feature/composition-oneof-inline.yaml index bf6d51a..abc6f54 100644 --- a/testdata/specs/feature/composition-oneof-inline.yaml +++ b/testdata/specs/feature/composition-oneof-inline.yaml @@ -2,7 +2,7 @@ openapi: 3.0.3 info: title: Inline oneOf version: 1.0.0 - description: oneOf and anyOf written in place, in a property, an array item, a request body and a response, not as a schema of their own; the members tell themselves apart by a kind of one value, or are objects of their own. + description: oneOf and anyOf written in place, in a property, an array item, a request body and a response, not as a schema of their own; the members tell themselves apart by a pet_kind of one value, or are objects of their own. paths: /shelters/{id}/pets: post: @@ -42,16 +42,16 @@ components: schemas: Cat: type: object - required: [kind, lives] + required: [pet_kind, lives] properties: - kind: { type: string, enum: [cat] } + pet_kind: { type: string, enum: [cat] } lives: { type: integer } Dog: type: object - required: [kind, barkVolume] + required: [pet_kind, bark_volume] properties: - kind: { type: string, enum: [dog] } - barkVolume: { type: number } + pet_kind: { type: string, enum: [dog] } + bark_volume: { type: number } Shelter: type: object required: [favourite] From 0a451a23b12c8c63aa220017aafbdc2819955ae8 Mon Sep 17 00:00:00 2001 From: Filip Seman Date: Mon, 26 Oct 2026 19:40:46 +0100 Subject: [PATCH 09/18] fix(typescript-fetch): leave unset properties out of parameters, build every option - An object parameter or form field sent its unset optional properties as the text "undefined" (`g=a,1,b,undefined`, `h[b]=undefined`, a header, an urlencoded field): styleParam, formFields and querystring now leave them out. - A `format: date` request body went out as a UTC date-time: the body parameter now carries the date flags, as every other parameter does. - With useSingleRequestParameter=false, required arguments come first (an optional one before a required one does not compile), and the method keeps no local variable a parameter could be named like. - With withoutRuntimeChecks, a model or alias imports the types it names and a oneOf model no functions its members do not have. --- internal/generator/typescript/fetch.go | 23 +++++++++++++++++++ internal/parser/operation.go | 2 ++ templates/typescript-fetch/apis.mustache | 5 ++-- .../typescript-fetch/modelGeneric.mustache | 4 ++++ .../typescript-fetch/modelOneOf.mustache | 4 ++++ templates/typescript-fetch/runtime.mustache | 10 +++++--- 6 files changed, 42 insertions(+), 6 deletions(-) diff --git a/internal/generator/typescript/fetch.go b/internal/generator/typescript/fetch.go index 7199946..aaaabf4 100644 --- a/internal/generator/typescript/fetch.go +++ b/internal/generator/typescript/fetch.go @@ -268,6 +268,13 @@ func (g *FetchGenerator) alias(cm *codegen.CodegenModel) { cm.VendorExtensions["x-ts-alias-from"] = from.expr(value, "json") cm.VendorExtensions["x-ts-alias-to"] = to.expr(value, "value") + // The value's type names its models, converted or not. + for p := value; p != nil; p = p.Items { + if g.byName[p.DataType] != nil { + from.used[p.DataType] = true + } + } + delete(from.used, cm.Classname) cm.Imports = addImports(cm.Imports, from.used) } @@ -394,6 +401,15 @@ func (g *FetchGenerator) fitMembers(cm *codegen.CodegenModel) { cm.VendorExtensions["x-ts-fit-to"] = to.fits(members) } +// optional is 1 for an optional parameter, 0 for a required one. +func optional(p *codegen.CodegenParameter) int { + if p.Required { + return 0 + } + + return 1 +} + // key is name as the key of an object literal. "__proto__" would set the // prototype, not a property: it is computed. func key(name string) string { @@ -420,6 +436,13 @@ func addImports(imports []string, used map[string]bool) []string { // PostProcessOperations post-processes operations for TypeScript-Fetch. func (g *FetchGenerator) PostProcessOperations(operations []*codegen.CodegenOperation) []*codegen.CodegenOperation { for _, op := range operations { + // Positional arguments: no required one after an optional one. + if !g.UseSingleRequestParameter { + slices.SortStableFunc(op.AllParams, func(a, b *codegen.CodegenParameter) int { + return optional(a) - optional(b) + }) + } + g.escapeOperationId(op) g.updateOperationParameterForEnum(op) g.addOperationObjectResponseInformation(op) diff --git a/internal/parser/operation.go b/internal/parser/operation.go index 2252469..38c20af 100644 --- a/internal/parser/operation.go +++ b/internal/parser/operation.go @@ -537,6 +537,8 @@ func (p *Parser) inlineBody(bodyParam *codegen.CodegenParameter, schema *openapi bodyParam.IsNumber = prop.IsNumber bodyParam.IsInteger = prop.IsInteger bodyParam.IsBoolean = prop.IsBoolean + bodyParam.IsDate, bodyParam.IsDateType = prop.IsDate, prop.IsDateType + bodyParam.IsDateTime, bodyParam.IsDateTimeType = prop.IsDateTime, prop.IsDateTimeType bodyParam.Items = prop.Items bodyParam.ComposedModels = prop.ComposedModels diff --git a/templates/typescript-fetch/apis.mustache b/templates/typescript-fetch/apis.mustache index d82da1c..2d51039 100644 --- a/templates/typescript-fetch/apis.mustache +++ b/templates/typescript-fetch/apis.mustache @@ -320,9 +320,8 @@ export class {{classname}} extends runtime.BaseAPI { {{^useSingleRequestParameter}} async {{nickname}}({{#allParams}}{{paramName}}{{^required}}?{{/required}}: {{#isEnum}}{{{datatypeWithEnum}}}{{/isEnum}}{{^isEnum}}{{{dataType}}}{{#isNullable}} | null{{/isNullable}}{{/isEnum}}, {{/allParams}}initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<{{>apisReturnType}}{{#returnType}}{{#isResponseOptional}} | null | undefined {{/isResponseOptional}}{{/returnType}}{{^returnType}}void{{/returnType}}> { {{#returnType}} - const response = await this.{{nickname}}Raw({{#hasAllParams}}{ {{#allParams}}{{paramName}}: {{paramName}}{{^-last}}, {{/-last}}{{/allParams}} }, {{/hasAllParams}}initOverrides); - - return await response.value(); + // No local variable: a parameter may be named like one. + return await (await this.{{nickname}}Raw({{#hasAllParams}}{ {{#allParams}}{{paramName}}: {{paramName}}{{^-last}}, {{/-last}}{{/allParams}} }, {{/hasAllParams}}initOverrides)).value(); {{/returnType}} {{^returnType}} await this.{{nickname}}Raw({{#hasAllParams}}{ {{#allParams}}{{paramName}}: {{paramName}}{{^-last}}, {{/-last}}{{/allParams}} }, {{/hasAllParams}}initOverrides); diff --git a/templates/typescript-fetch/modelGeneric.mustache b/templates/typescript-fetch/modelGeneric.mustache index 5f67fd4..32c6757 100644 --- a/templates/typescript-fetch/modelGeneric.mustache +++ b/templates/typescript-fetch/modelGeneric.mustache @@ -12,18 +12,22 @@ import { {{/usesDateHelpers}} } from '../runtime{{importFileExtension}}'; +{{/withoutRuntimeChecks}} {{#hasImports}} {{#tsImports}} import type { {{{classname}}} } from './{{filename}}{{importFileExtension}}'; +{{^withoutRuntimeChecks}} import { {{classname}}FromJSON, {{classname}}FromJSONTyped, {{classname}}ToJSON, {{classname}}ToJSONTyped, } from './{{filename}}{{importFileExtension}}'; +{{/withoutRuntimeChecks}} {{/tsImports}} {{/hasImports}} +{{^withoutRuntimeChecks}} {{#vendorExtensions.x-ts-mapped}} import { type {{modelName}}, {{modelName}}FromJSONTyped, {{modelName}}ToJSON, {{modelName}}ToJSONTyped } from './{{modelFilename}}{{importFileExtension}}'; {{/vendorExtensions.x-ts-mapped}} diff --git a/templates/typescript-fetch/modelOneOf.mustache b/templates/typescript-fetch/modelOneOf.mustache index 2a3e9dc..8479242 100644 --- a/templates/typescript-fetch/modelOneOf.mustache +++ b/templates/typescript-fetch/modelOneOf.mustache @@ -4,16 +4,19 @@ import { bestFit, parseDate, parseDateTime, serializeDate, serializeDateTime } f {{#hasImports}} {{#oneOfImports}} import type { {{{classname}}} } from './{{filename}}{{importFileExtension}}'; +{{^withoutRuntimeChecks}} import { instanceOf{{{classname}}}, {{{classname}}}FromJSON, {{{classname}}}FromJSONTyped, {{{classname}}}ToJSON, } from './{{filename}}{{importFileExtension}}'; +{{/withoutRuntimeChecks}} {{/oneOfImports}} {{/hasImports}} {{>modelOneOfInterfaces}} +{{^withoutRuntimeChecks}} export function {{classname}}FromJSON(json: any): {{classname}} { @@ -279,3 +282,4 @@ export function {{classname}}ToJSONTyped(value?: {{classname}} | null, ignoreDis return value; {{/discriminator}} } +{{/withoutRuntimeChecks}} diff --git a/templates/typescript-fetch/runtime.mustache b/templates/typescript-fetch/runtime.mustache index e162892..3f0d20c 100644 --- a/templates/typescript-fetch/runtime.mustache +++ b/templates/typescript-fetch/runtime.mustache @@ -370,7 +370,9 @@ export interface RequestOpts { } export function querystring(params: HTTPQuery, prefix: string = ""): string { + // A property left unset (an object's, deepObject style) is left out. return Object.keys(params) + .filter(key => params[key] !== undefined) .map(key => querystringSingleKey(key, params[key], prefix)) .filter(part => part.length > 0) .join("&"); @@ -406,7 +408,7 @@ export function styleParam(name: string, value: any, style: string, explode: boo const items: string[] = Array.isArray(value) ? value.map((v) => encode(String(v))) : object - ? Object.entries(value).map(([k, v]) => encode(k) + (explode ? "=" : ",") + encode(String(v))) + ? Object.entries(value).filter(([, v]) => v !== undefined).map(([k, v]) => encode(k) + (explode ? "=" : ",") + encode(String(v))) : [encode(String(value))]; switch (style) { case "label": @@ -436,13 +438,15 @@ export function formFields(name: string, value: any, style: string, explode: boo if (value === null || typeof value !== "object") { return [[name, String(value)]]; } + // A property left unset is left out. + const entries = Object.entries(value).filter(([, v]) => v !== undefined); if (explode) { return Array.isArray(value) ? value.map((v): [string, string] => [name, String(v)]) - : Object.entries(value).map(([k, v]): [string, string] => [k, String(v)]); + : entries.map(([k, v]): [string, string] => [k, String(v)]); } const delimiter = style === "spaceDelimited" ? " " : style === "pipeDelimited" ? "|" : ","; - return [[name, (Array.isArray(value) ? value : Object.entries(value).flat()).join(delimiter)]]; + return [[name, (Array.isArray(value) ? value : entries.flat()).join(delimiter)]]; } function deepObjectFields(name: string, value: any): Array<[string, string]> { From b27cde5ebf768825b6b4a0b3cd826f41c4532d4f Mon Sep 17 00:00:00 2001 From: Filip Seman Date: Mon, 26 Oct 2026 19:40:46 +0100 Subject: [PATCH 10/18] test(e2e): compile every typescript-fetch option, leave properties unset - TestTypeScriptOptions generates every feature spec with each boolean option of typescript-fetch flipped and compiles them strict; two options produced clients that did not compile. - The color of parameters-object-styles and the filter of parameters-deepobject gain an optional property the cases leave unset. - dates gains a request body that is a day. --- internal/e2e/typescript_test.go | 56 +++++++++++++++++++ testdata/e2e/README.md | 4 +- .../composition-oneof-inline/shelter_test.go | 16 +++--- testdata/specs/feature/dates.yaml | 13 +++++ .../specs/feature/parameters-deepobject.yaml | 2 + .../feature/parameters-object-styles.yaml | 1 + 6 files changed, 83 insertions(+), 9 deletions(-) diff --git a/internal/e2e/typescript_test.go b/internal/e2e/typescript_test.go index 0dbcc78..17b81cf 100644 --- a/internal/e2e/typescript_test.go +++ b/internal/e2e/typescript_test.go @@ -106,3 +106,59 @@ func runTypeScript(t *testing.T, suites []suite) map[string]map[int]outcome { return outcomes } + +// TestTypeScriptOptions compiles the client of every feature spec with each +// boolean option of typescript-fetch flipped, strict: an option the +// scenarios do not run must still give a client that builds. +func TestTypeScriptOptions(t *testing.T) { + requireE2E(t) + + if only := os.Getenv("E2E_GENERATOR"); only != "" && only != "typescript-fetch" { + t.Skip("only " + only) + } + + tsc, err := exec.LookPath("tsc") + if err != nil { + t.Fatal("the typescript-fetch runner compiles with tsc (npm i -g typescript)") + } + + specs, err := filepath.Glob("../../testdata/specs/feature/*.yaml") + if err != nil { + t.Fatal(err) + } + + dir := t.TempDir() + + options := []string{ + "withInterfaces=true", "useSingleRequestParameter=false", "prefixParameterInterfaces=true", + "withoutRuntimeChecks=true", "stringEnums=true", "validationAttributes=true", + } + + for _, opt := range options { + name, _, _ := strings.Cut(opt, "=") + + for _, spec := range specs { + err := gen.Generate(gen.Options{ + InputSpec: spec, + OutputDir: filepath.Join(dir, name, strings.TrimSuffix(filepath.Base(spec), ".yaml")), + GeneratorName: "typescript-fetch", + TemplateDir: "../../templates/typescript-fetch", + AdditionalProperties: []string{opt}, + SkipValidation: true, + }) + if err != nil { + t.Fatalf("generate %s with %s: %v", spec, opt, err) + } + } + } + + write(t, filepath.Join(dir, "tsconfig.json"), `{"compilerOptions": {"target": "ES2022", "module": "commonjs", "lib": ["ES2022", "DOM"], + "strict": true, "skipLibCheck": true, "noEmit": true}, "include": ["**/*.ts"]}`) + + build := exec.Command(tsc, "-p", ".") + build.Dir = dir + + if out, err := build.CombinedOutput(); err != nil { + t.Errorf("tsc: %v\n%s", err, out) + } +} diff --git a/testdata/e2e/README.md b/testdata/e2e/README.md index 6f7f606..25ab581 100644 --- a/testdata/e2e/README.md +++ b/testdata/e2e/README.md @@ -140,7 +140,9 @@ what the program prints with the scenarios. | `runners/dart` | dart-fetch, on the VM with `dart:mirrors`: named parameters, `fromJson`/`toJson`; a client that does not compile fails only its own suite | Runners run with `TZ=Europe/Bratislava`, so a client that reads or writes -local time shows it. +local time shows it. The typescript-fetch clients of the feature specs also +compile, strict, with each of its boolean options flipped +(`TestTypeScriptOptions`). typescript-fetch sends an OAuth2 token as the whole `Authorization` header, so its runner returns `Bearer ` for an OAuth2 scheme (`schemes` in the diff --git a/testdata/gofetch/composition-oneof-inline/shelter_test.go b/testdata/gofetch/composition-oneof-inline/shelter_test.go index 2db2522..40c91f8 100644 --- a/testdata/gofetch/composition-oneof-inline/shelter_test.go +++ b/testdata/gofetch/composition-oneof-inline/shelter_test.go @@ -9,7 +9,7 @@ import ( // TestInlineUnion decodes inline unions into their variants and encodes // them back unchanged. func TestInlineUnion(t *testing.T) { - const in = `{"favourite":{"kind":"cat","lives":3},"residents":[{"kind":"dog","barkVolume":2}]}` + const in = `{"favourite":{"pet_kind":"cat","lives":3},"residents":[{"pet_kind":"dog","bark_volume":2}]}` var s Shelter if err := json.Unmarshal([]byte(in), &s); err != nil { @@ -37,7 +37,7 @@ func TestInlineUnion(t *testing.T) { // TestInlineUnionBody sends the variant set with From as the body. func TestInlineUnionBody(t *testing.T) { var body AdmitBody - if err := body.FromDog(Dog{Kind: "dog", BarkVolume: 1.5}); err != nil { + if err := body.FromDog(Dog{PetKind: "dog", BarkVolume: 1.5}); err != nil { t.Fatal(err) } @@ -47,12 +47,12 @@ func TestInlineUnionBody(t *testing.T) { } got, _ := io.ReadAll(req.Body) - if want := `{"kind":"dog","barkVolume":1.5}`; string(got) != want { + if want := `{"pet_kind":"dog","bark_volume":1.5}`; string(got) != want { t.Errorf("body %s, want %s", got, want) } } -// TestImpliedDiscriminator fills in and checks the kind each variant pins, +// TestImpliedDiscriminator fills in and checks the pet_kind each variant pins, // and dispatches on it. func TestImpliedDiscriminator(t *testing.T) { var f ShelterFavourite @@ -60,12 +60,12 @@ func TestImpliedDiscriminator(t *testing.T) { t.Fatal(err) } - if b, _ := json.Marshal(f); string(b) != `{"kind":"dog","barkVolume":3}` { + if b, _ := json.Marshal(f); string(b) != `{"pet_kind":"dog","bark_volume":3}` { t.Errorf("FromDog = %s", b) } - // Every key a Cat requires is there, but the kind says dog. - _ = json.Unmarshal([]byte(`{"kind":"dog","lives":1,"barkVolume":3}`), &f) + // Every key a Cat requires is there, but the pet_kind says dog. + _ = json.Unmarshal([]byte(`{"pet_kind":"dog","lives":1,"bark_volume":3}`), &f) if _, err := f.AsCat(); err == nil { t.Error("AsCat of a dog succeeded") } @@ -80,7 +80,7 @@ func TestImpliedDiscriminator(t *testing.T) { // apart by its required properties. func TestInlineObjectVariants(t *testing.T) { var s Shelter - if err := json.Unmarshal([]byte(`{"favourite":{"kind":"cat","lives":1},"location":{"lat":1.5,"lon":2}}`), &s); err != nil { + if err := json.Unmarshal([]byte(`{"favourite":{"pet_kind":"cat","lives":1},"location":{"lat":1.5,"lon":2}}`), &s); err != nil { t.Fatal(err) } diff --git a/testdata/specs/feature/dates.yaml b/testdata/specs/feature/dates.yaml index 50d6a77..091a0e3 100644 --- a/testdata/specs/feature/dates.yaml +++ b/testdata/specs/feature/dates.yaml @@ -67,6 +67,19 @@ paths: items: type: string format: date-time + /day: + put: + operationId: putDay + requestBody: + required: true + content: + application/json: + schema: + type: string + format: date + responses: + "204": + description: stored /holidays: put: operationId: putHolidays diff --git a/testdata/specs/feature/parameters-deepobject.yaml b/testdata/specs/feature/parameters-deepobject.yaml index 411a1c9..d841262 100644 --- a/testdata/specs/feature/parameters-deepobject.yaml +++ b/testdata/specs/feature/parameters-deepobject.yaml @@ -19,6 +19,8 @@ paths: type: string minPrice: type: number + maxPrice: + type: number responses: "200": description: ok diff --git a/testdata/specs/feature/parameters-object-styles.yaml b/testdata/specs/feature/parameters-object-styles.yaml index 0316ea7..ef65014 100644 --- a/testdata/specs/feature/parameters-object-styles.yaml +++ b/testdata/specs/feature/parameters-object-styles.yaml @@ -30,6 +30,7 @@ components: type: object required: [B, G, R] properties: + A: { type: number } B: { type: integer } G: { type: integer } R: { type: integer } From 6842d060727c5b749cd86e731c037e9b3d507b93 Mon Sep 17 00:00:00 2001 From: Filip Seman Date: Mon, 26 Oct 2026 19:56:12 +0100 Subject: [PATCH 11/18] fix(typescript-fetch): tell union members by int and multi-value tags, read binary any - An inline union discriminates by a tag every member pins to values of its own, an int Literal or a Literal of several values too: before, only one string value counted, and the members, alike by their keys, converted as the first (a day sent as a date-time, 422). - A union model compares its discriminator as text, so an int tag matches its mapping (`case "1"` did not compile against `1 | 2`), and a member writes and reads its own tag instead of the mapping's text. - An any result the server sends as an image or as octets is a file, not text that loses its bytes (FastAPI's documented binary response). --- internal/generator/typescript/convert.go | 41 ++++++++++++------- internal/generator/typescript/fetch.go | 4 +- templates/typescript-fetch/README.md | 28 ++++++------- templates/typescript-fetch/apis.mustache | 4 ++ .../typescript-fetch/modelGeneric.mustache | 6 +-- .../typescript-fetch/modelOneOf.mustache | 8 ++-- templates/typescript-fetch/runtime.mustache | 8 ++++ 7 files changed, 62 insertions(+), 37 deletions(-) diff --git a/internal/generator/typescript/convert.go b/internal/generator/typescript/convert.go index 29c0db5..d811001 100644 --- a/internal/generator/typescript/convert.go +++ b/internal/generator/typescript/convert.go @@ -123,12 +123,14 @@ func (c *converter) union(p *codegen.CodegenProperty, x string) string { return c.bestFit(members, x) } -// discriminated converts x by a property every member of the union pins to -// a string of its own, or is "". +// discriminated converts x by a property every member of the union pins +// to values of its own (a Literal, a string or an int, of one value or +// several), or is "". func (c *converter) discriminated(members []*codegen.CodegenProperty, x string) string { var ( key string cases []string + seen = map[string]bool{} ) used := map[string]bool{} @@ -146,14 +148,16 @@ func (c *converter) discriminated(members []*codegen.CodegenProperty, x string) key = pin.key - value := strconv.Quote(fmt.Sprint(pin.value)) + for _, v := range pin.values { + if seen[v] { + return "" + } - if slices.ContainsFunc(cases, func(s string) bool { return strings.HasPrefix(s, value+":") }) { - return "" + seen[v] = true + cases = append(cases, strconv.Quote(v)+": "+model.Classname+c.fn()) } used[model.Classname] = true - cases = append(cases, value+": "+model.Classname+c.fn()) } if len(cases) == 0 { @@ -276,21 +280,28 @@ func (c *converter) keyVars(m *codegen.CodegenModel) []*codegen.CodegenProperty } type pin struct { - key string - value any + key string + values []string } -// pinned finds the property of m that holds one string value, named key -// in JSON (to false) or in TypeScript (to true) when key is not "". +// pinned finds the required property of m that holds a string or a number +// of its own (an enum of one value or a few), named key in JSON (to false) +// or in TypeScript (to true) when key is not "". The values are as the +// runtime's discriminate looks them up: as text. func pinned(m *codegen.CodegenModel, key string, to bool) *pin { for _, v := range m.Vars { values, _ := v.AllowableValues["values"].([]any) - if !v.IsEnum || !v.Required || v.IsNullable || len(values) != 1 { + if !v.IsEnum || !v.Required || v.IsNullable || len(values) == 0 { continue } - if _, ok := values[0].(string); !ok { - continue + var text []string + + for _, value := range values { + switch value.(type) { + case string, float64, int, int64: + text = append(text, fmt.Sprint(value)) + } } name := v.BaseName @@ -298,8 +309,8 @@ func pinned(m *codegen.CodegenModel, key string, to bool) *pin { name = v.Name } - if key == "" || key == name { - return &pin{name, values[0]} + if len(text) == len(values) && (key == "" || key == name) { + return &pin{name, text} } } diff --git a/internal/generator/typescript/fetch.go b/internal/generator/typescript/fetch.go index aaaabf4..8ec7946 100644 --- a/internal/generator/typescript/fetch.go +++ b/internal/generator/typescript/fetch.go @@ -611,7 +611,8 @@ func (g *FetchGenerator) stream(op *codegen.CodegenOperation, from *converter) { // responseKinds marks how op reads its result: x-ts-text when the result // may come as text (a string, or any body the server does not send as -// JSON), x-ts-other-2xx the statuses whose body is of another type than the +// JSON), x-ts-any when it may also come as a file (an any the server +// sends as an image or as octets), x-ts-other-2xx the statuses whose body is of another type than the // result, isResponseOptional when a 2xx comes without a body (a 204 beside // a 200, the answer to a HEAD), which reads as null. func (g *FetchGenerator) responseKinds(op *codegen.CodegenOperation) { @@ -620,6 +621,7 @@ func (g *FetchGenerator) responseKinds(op *codegen.CodegenOperation) { } op.VendorExtensions["x-ts-text"] = op.ReturnType == "string" || op.ReturnType == "any" + op.VendorExtensions["x-ts-any"] = op.ReturnType == "any" op.IsResponseOptional = op.HttpMethod == "HEAD" var other []string diff --git a/templates/typescript-fetch/README.md b/templates/typescript-fetch/README.md index a4f739a..5adfadb 100644 --- a/templates/typescript-fetch/README.md +++ b/templates/typescript-fetch/README.md @@ -207,14 +207,14 @@ export function PetFromJSON(json: any): Pet { With runtime checks, `FromJSON` and `ToJSON` convert at every depth: a list of lists of models, a map of dates, a map of lists of models. -| Schema | TypeScript | -| ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | -| `date` / `date-time` | `Date`: a day at local midnight; an instant to the millisecond, sent in UTC (a time without an offset is read as local time) | -| optional and nullable | `T \| null`: `null` is sent and read as `null`, a property left out stays out | -| `additionalProperties` next to properties | an index signature; the other keys go through both ways, converted as their schema says | -| `binary` property of a JSON model | `string`: JSON holds no bytes, a server writes their text | -| a schema of an array, a map or a scalar | a type alias that converts as its value does: `type Pets = Array`, `type Day = Date` | -| `oneOf` / `anyOf` | the member the discriminator names (its mapping, else the schema's name), one every member pins a property for, else the member it fits best | +| Schema | TypeScript | +| ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `date` / `date-time` | `Date`: a day at local midnight; an instant to the millisecond, sent in UTC (a time without an offset is read as local time) | +| optional and nullable | `T \| null`: `null` is sent and read as `null`, a property left out stays out | +| `additionalProperties` next to properties | an index signature; the other keys go through both ways, converted as their schema says | +| `binary` property of a JSON model | `string`: JSON holds no bytes, a server writes their text | +| a schema of an array, a map or a scalar | a type alias that converts as its value does: `type Pets = Array`, `type Day = Date` | +| `oneOf` / `anyOf` | the member the discriminator names (its mapping, else the schema's name), one every member pins a property for (a string or an int, of one value or several), else the member it fits best | | Request | Sent | | --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -223,12 +223,12 @@ of lists of models, a map of dates, a map of lists of models. | urlencoded form field | by its `encoding`: a list a field per item (or joined by its delimiter with `explode: false`), `deepObject` key by key (`a[b][0]=x`) | | multipart field | a list a part per item, an object as a JSON part (a file named `blob`: `FormData` gives a part a type only as a file); a form with a file goes as multipart | -| Response | Result | -| ---------------------------------------------------- | --------------------------------------------------------------------------------------- | -| a 2xx without a body (a 204, a HEAD) | `null` | -| a 2xx of another body than the result's | a `ResponseError`, its body unread | -| a string or `any` the server sends as text | the text; a number or boolean sent as text is parsed | -| JSON Lines, `text/event-stream` (3.2's `itemSchema`) | `AsyncIterable`, of `ServerSentEvent` (`data`, `event`, `id`, `retry`) for events | +| Response | Result | +| ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | +| a 2xx without a body (a 204, a HEAD) | `null` | +| a 2xx of another body than the result's | a `ResponseError`, its body unread | +| a string or `any` the server sends as text | the text; a number or boolean sent as text is parsed; an `any` sent as an image or as octets is a file | +| JSON Lines, `text/event-stream` (3.2's `itemSchema`) | `AsyncIterable`, of `ServerSentEvent` (`data`, `event`, `id`, `retry`) for events | ```typescript for await (const event of await api.chat({ prompt: { text: "hi" } })) { diff --git a/templates/typescript-fetch/apis.mustache b/templates/typescript-fetch/apis.mustache index 2d51039..c661f31 100644 --- a/templates/typescript-fetch/apis.mustache +++ b/templates/typescript-fetch/apis.mustache @@ -296,6 +296,10 @@ export class {{classname}} extends runtime.BaseAPI { {{#vendorExtensions.x-ts-text}} if (this.isJsonMime(response.headers.get('content-type'))) { return new runtime.JSONApiResponse<{{{returnType}}}>(response); +{{#vendorExtensions.x-ts-any}} + } else if (!runtime.isTextMime(response.headers.get('content-type'))) { + return new runtime.BlobApiResponse(response) as any; +{{/vendorExtensions.x-ts-any}} } else { return new runtime.TextApiResponse(response) as any; } diff --git a/templates/typescript-fetch/modelGeneric.mustache b/templates/typescript-fetch/modelGeneric.mustache index 32c6757..97794c6 100644 --- a/templates/typescript-fetch/modelGeneric.mustache +++ b/templates/typescript-fetch/modelGeneric.mustache @@ -96,12 +96,12 @@ export function {{classname}}FromJSONTyped(json: any, ignoreDiscriminator: boole {{#discriminator}} if (!ignoreDiscriminator) { {{#discriminator.mappedModels}} - if (json["{{discriminator.propertyBaseName}}"] === "{{mappingName}}") { + if (String(json["{{discriminator.propertyBaseName}}"]) === "{{mappingName}}") { return {{modelName}}FromJSONTyped(json, ignoreDiscriminator); } {{/discriminator.mappedModels}} {{#hasSelfReferencingDiscriminatorMapping}} - if (json["{{discriminator.propertyBaseName}}"] === "{{selfReferencingDiscriminatorMapping.mappingName}}") { + if (String(json["{{discriminator.propertyBaseName}}"]) === "{{selfReferencingDiscriminatorMapping.mappingName}}") { return {{selfReferencingDiscriminatorMapping.modelName}}FromJSONTyped(json, true); } {{/hasSelfReferencingDiscriminatorMapping}} @@ -136,7 +136,7 @@ export function {{classname}}ToJSONTyped(value?: {{#hasReadOnly}}Omit<{{classnam } {{#discriminator}} if (!ignoreDiscriminator) { - switch (value["{{discriminator.propertyName}}"]) { + switch (String(value["{{discriminator.propertyName}}"])) { {{#discriminator.mappedModels}} case "{{mappingName}}": return {{modelName}}ToJSONTyped(value as {{modelName}}, ignoreDiscriminator); diff --git a/templates/typescript-fetch/modelOneOf.mustache b/templates/typescript-fetch/modelOneOf.mustache index 8479242..8f51ab1 100644 --- a/templates/typescript-fetch/modelOneOf.mustache +++ b/templates/typescript-fetch/modelOneOf.mustache @@ -28,10 +28,10 @@ export function {{classname}}FromJSONTyped(json: any, ignoreDiscriminator: boole return json; } {{#discriminator}} - switch (json["{{discriminator.propertyBaseName}}"]) { + switch (String(json["{{discriminator.propertyBaseName}}"])) { {{#discriminator.mappedModels}} case "{{mappingName}}": - return Object.assign({}, {{modelName}}FromJSONTyped(json, true), { {{discriminator.propertyName}}: "{{mappingName}}" } as const); + return {{modelName}}FromJSONTyped(json, true); {{/discriminator.mappedModels}} default: return json; @@ -161,10 +161,10 @@ export function {{classname}}ToJSONTyped(value?: {{classname}} | null, ignoreDis return value; } {{#discriminator}} - switch (value["{{discriminator.propertyName}}"]) { + switch (String(value["{{discriminator.propertyName}}"])) { {{#discriminator.mappedModels}} case "{{mappingName}}": - return Object.assign({}, {{modelName}}ToJSON(value), { "{{discriminator.propertyBaseName}}": "{{mappingName}}" } as const); + return {{modelName}}ToJSON(value); {{/discriminator.mappedModels}} default: return value; diff --git a/templates/typescript-fetch/runtime.mustache b/templates/typescript-fetch/runtime.mustache index 3f0d20c..6dc864a 100644 --- a/templates/typescript-fetch/runtime.mustache +++ b/templates/typescript-fetch/runtime.mustache @@ -851,6 +851,14 @@ async function* serverSentEvents(raw: Response, transformer: (value: any) => any } } +/** + * Whether a body of the media type is text (of no media type too): an any result the server sends as + * an image or as octets is a file, not text. + */ +export function isTextMime(mime: string | null | undefined): boolean { + return !mime || /^text\/|json|xml|yaml|csv|javascript|x-www-form-urlencoded/i.test(mime); +} + export class TextApiResponse { constructor(public raw: Response) {} From c8eaef7afbc34bbf6d89506f175cad2a52272800 Mon Sep 17 00:00:00 2001 From: Filip Seman Date: Mon, 26 Oct 2026 19:56:12 +0100 Subject: [PATCH 12/18] fix(go-fetch): read an any sent as an image or as octets as bytes An any result decoded as text whatever its media type, so the bytes of FastAPI's documented binary response (`image/png` without a schema) came back changed. Text media types still decode as text. --- templates/go-fetch/README.md | 10 +++++----- templates/go-fetch/runtime.mustache | 17 +++++++++++++---- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/templates/go-fetch/README.md b/templates/go-fetch/README.md index 16552ec..12009bf 100644 --- a/templates/go-fetch/README.md +++ b/templates/go-fetch/README.md @@ -45,11 +45,11 @@ if errors.As(err, &rerr) { } ``` -| Method | Does | -| ------------ | -------------------------------------------------------------------------------------------------------------------------- | -| `Xxx` | sends and decodes the body (JSON, text for a string or for an `any` the server does not send as JSON, `[]byte` for binary) | -| `XxxRaw` | sends; returns a 2xx `*http.Response` with its body unread | -| `XxxRequest` | builds the `*http.Request` without sending it | +| Method | Does | +| ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `Xxx` | sends and decodes the body (JSON, text for a string or for an `any` the server sends as text, `[]byte` for binary and for an `any` sent as an image or as octets) | +| `XxxRaw` | sends; returns a 2xx `*http.Response` with its body unread | +| `XxxRequest` | builds the `*http.Request` without sending it | When a 2xx status has no body next to one that has (a 204 beside a 200), or the result is nullable, the result is a pointer, nil for the bodiless status diff --git a/templates/go-fetch/runtime.mustache b/templates/go-fetch/runtime.mustache index 7467573..ebb7b3f 100644 --- a/templates/go-fetch/runtime.mustache +++ b/templates/go-fetch/runtime.mustache @@ -404,12 +404,16 @@ func other2xx(resp *http.Response, err error, model func(status int) (any, []str return nil, responseError(resp, model) } -var jsonMime = regexp.MustCompile(`(?i)^(application/json|[^;/ \t]+/[^;/ \t]+[+]json)[ \t]*(;.*)?$`) +var ( + jsonMime = regexp.MustCompile(`(?i)^(application/json|[^;/ \t]+/[^;/ \t]+[+]json)[ \t]*(;.*)?$`) + textMime = regexp.MustCompile(`(?i)^text/|json|xml|yaml|csv|javascript|x-www-form-urlencoded`) +) // decodeJSON reads and closes resp's body into a T: JSON, or the text itself // for a string, or an any, the server does not send as JSON (an any typed -// only by an empty schema next to a text media type). An empty body is T's -// zero, nil for a pointer. +// only by an empty schema next to a text media type), the bytes for an any +// the server sends as an image or as octets. An empty body is T's zero, nil +// for a pointer. func decodeJSON[T any](resp *http.Response, err error) (T, error) { var out T if err != nil { @@ -430,9 +434,14 @@ func decodeJSON[T any](resp *http.Response, err error) (T, error) { return out, nil case *any: - if ct != "" { + switch { + case textMime.MatchString(ct): *p = string(body) + return out, nil + case ct != "": + *p = body + return out, nil } } From c0294bc069505e0a34ee89ca10adf30514154e22 Mon Sep 17 00:00:00 2001 From: Filip Seman Date: Mon, 26 Oct 2026 19:56:12 +0100 Subject: [PATCH 13/18] test(e2e): cover int and multi-value union tags, binary any, +json bodies From round 3 of the typescript-fetch gauntlet: - The FastAPI backend gains unions tagged by an int and by a Literal of several values, as a body, a result and a schema of their own, and a binary response without a schema. - request-body-other-media gains a merge-patch+json body answered with problem+json: no +json media type was exercised. - A server-sent event after a named one must carry no name. - The TypeScript runner checks an enum holds one of its values, and picks a union member for an argument by its keys and its tags. --- testdata/e2e/README.md | 20 +- testdata/e2e/backends/fastapi/app.py | 59 ++++ testdata/e2e/runners/ts/runner.ts | 21 +- testdata/e2e/scenarios/fastapi.yaml | 19 ++ .../scenarios/request-body-other-media.yaml | 6 + testdata/e2e/scenarios/responses-stream.yaml | 2 +- testdata/e2e/specs/fastapi.json | 310 ++++++++++++++++++ .../feature/request-body-other-media.yaml | 18 + 8 files changed, 442 insertions(+), 13 deletions(-) diff --git a/testdata/e2e/README.md b/testdata/e2e/README.md index 25ab581..4f96655 100644 --- a/testdata/e2e/README.md +++ b/testdata/e2e/README.md @@ -145,16 +145,16 @@ compile, strict, with each of its boolean options flipped (`TestTypeScriptOptions`). typescript-fetch sends an OAuth2 token as the whole `Authorization` header, -so its runner returns `Bearer ` for an OAuth2 scheme (`schemes` in the -input). Its error has no decoded model: the runner reports the error body -parsed as JSON where the spec declares a body for the status (`bodies` in -the input). A converted round trip hides a `FromJSON` and a `ToJSON` that -both leave a value alone, so the runner also checks each argument and -result against the client's types: a model holds its TypeScript property -names, a `Date` stands where the interface says, and a day (`format: -date`) is at local midnight, which a day parsed as UTC is not east of UTC, -where the runners run. The runner makes a day argument as its users do, -with the client's `parseDate`, and puts back the read-only properties a +so its runner returns `Bearer ` for an OAuth2 scheme (`schemes` in +the input). Its error has no decoded model: the runner reports the error +body parsed as JSON where the spec declares a body for the status (`bodies` +in the input). A converted round trip hides a `FromJSON` and a `ToJSON` that +both leave a value alone, so the runner also checks each argument and result +against the client's types: a model holds its TypeScript property names, an +enum one of its values, a `Date` stands where the interface says, and a day +(`format: date`) is at local midnight, which a day parsed as UTC is not east +of UTC, where the runners run. The runner makes a day argument as its users +do, with the client's `parseDate`, and puts back the read-only properties a model's `ToJSON` leaves out of a result. A case one generator cannot pass has `skip: { : why }`. A spec diff --git a/testdata/e2e/backends/fastapi/app.py b/testdata/e2e/backends/fastapi/app.py index 0c95e9a..771a087 100644 --- a/testdata/e2e/backends/fastapi/app.py +++ b/testdata/e2e/backends/fastapi/app.py @@ -54,6 +54,7 @@ Field, HttpUrl, NaiveDatetime, + RootModel, SecretStr, Tag, computed_field, @@ -551,6 +552,54 @@ def echo_stamps(body: Stamps) -> Stamps: return body +# Unions told apart by an int Literal and by a Literal of several values: +# pydantic writes their discriminator with a mapping, and a member alone +# cannot be told from the other by its keys. +class Rev1(Strict): + rev: Literal[1] + at: dt.datetime + + +class Rev2(Strict): + rev: Literal[2] + at: dt.date + + +Rev = Annotated[Union[Rev1, Rev2], Field(discriminator="rev")] + + +class Revs(RootModel[Rev]): + pass + + +class Opened(Strict): + kind: Literal["opened", "reopened"] + at: dt.datetime + + +class Closed(Strict): + kind: Literal["closed"] + at: dt.date + + +Change = Annotated[Union[Opened, Closed], Field(discriminator="kind")] + + +@app.post("/echo/rev", tags=["echo"], operation_id="echoRev") +def echo_rev(body: Annotated[Rev, Body()]) -> Rev: + return body + + +@app.post("/echo/revs", tags=["echo"], operation_id="echoRevs") +def echo_revs(body: Revs) -> Revs: + return body + + +@app.post("/echo/change", tags=["echo"], operation_id="echoChange") +def echo_change(body: Annotated[Change, Body()]) -> Change: + return body + + @app.get("/stamps", tags=["echo"], operation_id="stampsQuery") def stamps_query( days: Annotated[list[dt.date], Query()] = [], @@ -850,6 +899,16 @@ def download_file(name: str): return Response(FILES[name], media_type="application/octet-stream") +# FastAPI's documented binary response: a media type without a schema, next +# to the JSON of its default response class. The bytes are no UTF-8. +LOGO = bytes.fromhex("89504e470d0a1a0a0000000d49484452ff") + + +@app.get("/logo.png", tags=["files"], operation_id="getLogo", responses={200: {"content": {"image/png": {}}}}) +def get_logo() -> Response: + return Response(LOGO, media_type="image/png") + + @app.get("/export.csv", tags=["files"], operation_id="exportCsv", response_class=PlainTextResponse, responses={200: {"content": {"text/csv": {"schema": {"type": "string"}}}}}) def export_csv() -> PlainTextResponse: diff --git a/testdata/e2e/runners/ts/runner.ts b/testdata/e2e/runners/ts/runner.ts index f7cec62..cfbd17c 100644 --- a/testdata/e2e/runners/ts/runner.ts +++ b/testdata/e2e/runners/ts/runner.ts @@ -75,6 +75,7 @@ type Decl = { readonly?: Set; days?: Set; day?: boolean; + values?: string[]; }; // models reads the models' interfaces and type aliases from the sources. @@ -112,6 +113,12 @@ function models(dir: string): Record { out[m[1]] = { fields, base: m[2], open, wire, readonly, days }; } + // An enum's values, as text: a union member is told by them too. + for (const m of src.matchAll(/^export const (\w+) = \{\n([\s\S]*?)\n\} as const;$/gm)) { + // A value is a TypeScript literal ('it\'s', 1): evaluated, as text. + out[m[1]] = { values: [...m[2].matchAll(/^\s+\w+: (.+?),?$/gm)].map((v) => String(new Function("return " + v[1])())) }; + } + for (const m of src.matchAll(/^export type (\w+) = (.+);$/gm)) { out[m[1]] ??= { alias: m[2], day: /json == null \? json : [^\n]*\bparseDate\b/.test(src) }; } @@ -218,6 +225,11 @@ function shape(decls: Record, t: string, v: any, at: string, scala return ""; } + // An enum: one of its values. + if (d.values !== undefined) { + return d.values.includes(String(v)) ? "" : `${at}: want one of ${d.values.join(", ")}, got ${show(v)}`; + } + if (d.alias !== undefined) { return d.alias.startsWith("typeof ") ? "" : shape(decls, d.alias, v, at, scalars, day || !!d.day); } @@ -302,7 +314,10 @@ function fromWire(client: any, decls: Record, t: string, v: any): return out; }; - const alt = typeof v === "object" && !Array.isArray(v) ? alts.find((x) => decls[x]?.fields && Object.keys(v).every((k) => wire(x).includes(k))) : undefined; + // Its keys, and its enums (a tag) once converted. + const fits = (x: string): boolean => + !!decls[x]?.fields && Object.keys(v).every((k) => wire(x).includes(k)) && shape(decls, x, fromWire(client, decls, x, v), "", false) === ""; + const alt = typeof v === "object" && !Array.isArray(v) ? alts.find(fits) : undefined; return alt === undefined ? v : fromWire(client, decls, alt, v); } @@ -506,7 +521,9 @@ async function callCase(client: Client, info: ReturnType, decls: used.add(name); params[field] = fromWire(client.module, decls, type, args[name]); - const bad = shape(decls, type, params[field], "arg " + name, false); + // A union argument is checked as its member is picked; one that fits + // no member goes as the scenario has it (a body the server refuses). + const bad = members(nullable(type)).length > 1 ? "" : shape(decls, type, params[field], "arg " + name, false); if (bad) { return { fail: "FromJSON: " + bad }; } diff --git a/testdata/e2e/scenarios/fastapi.yaml b/testdata/e2e/scenarios/fastapi.yaml index cfa9b44..01a956a 100644 --- a/testdata/e2e/scenarios/fastapi.yaml +++ b/testdata/e2e/scenarios/fastapi.yaml @@ -129,6 +129,22 @@ cases: op: echoStamps args: { body: { naive: "2024-03-01T10:00:00Z", aware: "2024-03-01T10:00:00Z", clock: "10:00:00", span: PT0S } } error: { status: 422, model: { detail: [{ type: timezone_naive }] } } + - name: a union tagged by an int, the second member + op: echoRev + args: { body: { rev: 2, at: "2024-01-02" } } + result: { rev: 2, at: "2024-01-02" } + - name: a union tagged by an int as a schema of its own + op: echoRevs + args: { body: { rev: 2, at: "2024-01-02" } } + result: { rev: 2, at: "2024-01-02" } + - name: a union tag of several values, the other member + op: echoChange + args: { body: { kind: closed, at: "2024-01-02" } } + result: { kind: closed, at: "2024-01-02" } + - name: a union tag of several values, its second value + op: echoChange + args: { body: { kind: reopened, at: "2024-01-02T03:04:05Z" } } + result: { kind: reopened, at: "2024-01-02T03:04:05Z" } - name: a naive time in the response and the query, a date list in the query op: stampsQuery args: { days: ["2024-01-31", "2024-02-29"], since: "2024-03-01T10:00:00" } @@ -434,6 +450,9 @@ cases: op: downloadFile args: { name: docs/ΕΎ.bin } result: AP8= + - name: a binary response without a schema, next to JSON + op: getLogo + result: iVBORw0KGgoAAAANSUhEUv8= - name: text/csv response op: exportCsv result: "sku,name\nABC-0001,Box\nABC-0002,Bag\n" diff --git a/testdata/e2e/scenarios/request-body-other-media.yaml b/testdata/e2e/scenarios/request-body-other-media.yaml index c49cd24..3a6508f 100644 --- a/testdata/e2e/scenarios/request-body-other-media.yaml +++ b/testdata/e2e/scenarios/request-body-other-media.yaml @@ -14,6 +14,12 @@ cases: request: { header: { Content-Type: application/octet-stream }, body: { $base64: AAH/ } } response: { base64: /wEA } result: /wEA + - name: a +json body and result + op: patchNote + args: { id: n1, body: { author: bo } } + request: { header: { Content-Type: application/merge-patch+json }, body: { author: bo } } + response: { header: { Content-Type: application/problem+json }, text: '{"author":"bo","pages":1}' } + result: { author: bo, pages: 1 } - name: multipart with an object part op: postReport args: { title: q3, meta: { author: ann, pages: 3 }, attachments: [{ $file: { name: a.txt, text: a } }] } diff --git a/testdata/e2e/scenarios/responses-stream.yaml b/testdata/e2e/scenarios/responses-stream.yaml index 11ad138..8e0782b 100644 --- a/testdata/e2e/scenarios/responses-stream.yaml +++ b/testdata/e2e/scenarios/responses-stream.yaml @@ -6,4 +6,4 @@ cases: op: rawEvents # offSpec: the validator reads no text/event-stream response: { header: { Content-Type: text/event-stream }, text: "event: tick\r\nid: 1\r\ndata: a\r\n\r\ndata: b\r\n\r\n", offSpec: true } - result: [{ event: tick, id: "1", data: a }, { id: "1", data: b }] + result: [{ event: tick, id: "1", data: a }, { id: "1", data: b, event: $absent }] diff --git a/testdata/e2e/specs/fastapi.json b/testdata/e2e/specs/fastapi.json index 7460375..8a28fde 100644 --- a/testdata/e2e/specs/fastapi.json +++ b/testdata/e2e/specs/fastapi.json @@ -292,6 +292,27 @@ "title": "Circle", "type": "object" }, + "Closed": { + "additionalProperties": false, + "properties": { + "at": { + "format": "date", + "title": "At", + "type": "string" + }, + "kind": { + "const": "closed", + "title": "Kind", + "type": "string" + } + }, + "required": [ + "kind", + "at" + ], + "title": "Closed", + "type": "object" + }, "Color": { "enum": [ "red", @@ -1490,6 +1511,30 @@ "title": "Open", "type": "object" }, + "Opened": { + "additionalProperties": false, + "properties": { + "at": { + "format": "date-time", + "title": "At", + "type": "string" + }, + "kind": { + "enum": [ + "opened", + "reopened" + ], + "title": "Kind", + "type": "string" + } + }, + "required": [ + "kind", + "at" + ], + "title": "Opened", + "type": "object" + }, "Order": { "additionalProperties": false, "properties": { @@ -1877,6 +1922,66 @@ ], "title": "Retry" }, + "Rev1": { + "additionalProperties": false, + "properties": { + "at": { + "format": "date-time", + "title": "At", + "type": "string" + }, + "rev": { + "const": 1, + "title": "Rev", + "type": "integer" + } + }, + "required": [ + "rev", + "at" + ], + "title": "Rev1", + "type": "object" + }, + "Rev2": { + "additionalProperties": false, + "properties": { + "at": { + "format": "date", + "title": "At", + "type": "string" + }, + "rev": { + "const": 2, + "title": "Rev", + "type": "integer" + } + }, + "required": [ + "rev", + "at" + ], + "title": "Rev2", + "type": "object" + }, + "Revs": { + "discriminator": { + "mapping": { + "1": "#/components/schemas/Rev1", + "2": "#/components/schemas/Rev2" + }, + "propertyName": "rev" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/Rev1" + }, + { + "$ref": "#/components/schemas/Rev2" + } + ], + "title": "Revs" + }, "Scalars-Input": { "additionalProperties": false, "description": "Every scalar format pydantic writes into a schema.", @@ -2881,6 +2986,79 @@ ] } }, + "/echo/change": { + "post": { + "operationId": "echoChange", + "requestBody": { + "content": { + "application/json": { + "schema": { + "discriminator": { + "mapping": { + "closed": "#/components/schemas/Closed", + "opened": "#/components/schemas/Opened", + "reopened": "#/components/schemas/Opened" + }, + "propertyName": "kind" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/Opened" + }, + { + "$ref": "#/components/schemas/Closed" + } + ], + "title": "Body" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "discriminator": { + "mapping": { + "closed": "#/components/schemas/Closed", + "opened": "#/components/schemas/Opened", + "reopened": "#/components/schemas/Opened" + }, + "propertyName": "kind" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/Opened" + }, + { + "$ref": "#/components/schemas/Closed" + } + ], + "title": "Response Echochange" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Echo Change", + "tags": [ + "echo" + ] + } + }, "/echo/containers": { "post": { "operationId": "echoContainers", @@ -3386,6 +3564,118 @@ ] } }, + "/echo/rev": { + "post": { + "operationId": "echoRev", + "requestBody": { + "content": { + "application/json": { + "schema": { + "discriminator": { + "mapping": { + "1": "#/components/schemas/Rev1", + "2": "#/components/schemas/Rev2" + }, + "propertyName": "rev" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/Rev1" + }, + { + "$ref": "#/components/schemas/Rev2" + } + ], + "title": "Body" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "discriminator": { + "mapping": { + "1": "#/components/schemas/Rev1", + "2": "#/components/schemas/Rev2" + }, + "propertyName": "rev" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/Rev1" + }, + { + "$ref": "#/components/schemas/Rev2" + } + ], + "title": "Response Echorev" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Echo Rev", + "tags": [ + "echo" + ] + } + }, + "/echo/revs": { + "post": { + "operationId": "echoRevs", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Revs" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Revs" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Echo Revs", + "tags": [ + "echo" + ] + } + }, "/echo/scalars": { "post": { "operationId": "echoScalars", @@ -4672,6 +4962,26 @@ ] } }, + "/logo.png": { + "get": { + "operationId": "getLogo", + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + }, + "image/png": {} + }, + "description": "Successful Response" + } + }, + "summary": "Get Logo", + "tags": [ + "files" + ] + } + }, "/maybe": { "get": { "operationId": "maybeItem", diff --git a/testdata/specs/feature/request-body-other-media.yaml b/testdata/specs/feature/request-body-other-media.yaml index 3c814ee..c83ed2b 100644 --- a/testdata/specs/feature/request-body-other-media.yaml +++ b/testdata/specs/feature/request-body-other-media.yaml @@ -42,6 +42,24 @@ paths: schema: type: string format: binary + /notes/{id}: + patch: + operationId: patchNote + parameters: + - { name: id, in: path, required: true, schema: { type: string } } + requestBody: + required: true + content: + application/merge-patch+json: + schema: + $ref: "#/components/schemas/Meta" + responses: + "200": + description: patched + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Meta" /reports: post: operationId: postReport From bfb3de8350b583c04c9755530d2320c924a883fe Mon Sep 17 00:00:00 2001 From: Filip Seman Date: Mon, 26 Oct 2026 20:14:31 +0100 Subject: [PATCH 14/18] fix(typescript-fetch): keep a union's tag, convert list alias members From the final review of the gauntlet fixes: - A oneOf with a discriminator sets the tag on its member again, the member's own value first: a member that does not declare the tag lost it both ways once the mapping's text stopped being forced. - An alias of a list of models as a union member is a list for bestFit; another alias (a scalar, a map) is left out, not called on an object (`json.map is not a function`). - A $ref to a union model is typed as that model. - An int tag of a million or more matches its mapping (1000000, not 1e+06); a media type is text by its subtype (`xlsx` is no xml text). --- internal/generator/typescript/convert.go | 34 ++++++++++-- internal/generator/typescript/fetch.go | 54 +++++++++++++------ templates/typescript-fetch/apis.mustache | 2 +- .../typescript-fetch/modelOneOf.mustache | 6 +-- templates/typescript-fetch/runtime.mustache | 2 +- 5 files changed, 74 insertions(+), 24 deletions(-) diff --git a/internal/generator/typescript/convert.go b/internal/generator/typescript/convert.go index d811001..0065da1 100644 --- a/internal/generator/typescript/convert.go +++ b/internal/generator/typescript/convert.go @@ -193,7 +193,13 @@ func (c *converter) bestFit(members []*codegen.CodegenProperty, x string) string return x } - models = append(models, fit{parts: []*codegen.CodegenModel{model}, array: array}) + f, ok := c.g.fitOf(model) + if !ok || array && f.array { + continue + } + + f.array = f.array || array + models = append(models, f) } if len(models) == 0 { @@ -210,6 +216,24 @@ type fit struct { array bool } +// fitOf is model m as a member for bestFit: a model as itself, an alias of +// a list of models as that list; false for another alias (a scalar or a +// map), whose value bestFit cannot tell. +func (g *FetchGenerator) fitOf(m *codegen.CodegenModel) (fit, bool) { + v := aliasOf(m) + if v == nil { + return fit{parts: []*codegen.CodegenModel{m}}, true + } + + if v.IsArray && v.Items != nil { + if item := g.byName[g.modelOf(v.Items)]; item != nil && aliasOf(item) == nil { + return fit{parts: []*codegen.CodegenModel{item}, array: true}, true + } + } + + return fit{}, false +} + // fits lists members for the runtime's bestFit: each one's required keys, // its keys, its converter, in JSON (from) or in TypeScript (to), and // whether it is a list. An intersection converts by each model, merged. @@ -298,9 +322,11 @@ func pinned(m *codegen.CodegenModel, key string, to bool) *pin { var text []string for _, value := range values { - switch value.(type) { - case string, float64, int, int64: - text = append(text, fmt.Sprint(value)) + switch v := value.(type) { + case float64: // as JavaScript writes it: 1000000, not 1e+06 + text = append(text, strconv.FormatFloat(v, 'f', -1, 64)) + case string, int, int64: + text = append(text, fmt.Sprint(v)) } } diff --git a/internal/generator/typescript/fetch.go b/internal/generator/typescript/fetch.go index 8ec7946..1fed818 100644 --- a/internal/generator/typescript/fetch.go +++ b/internal/generator/typescript/fetch.go @@ -200,6 +200,14 @@ func (g *FetchGenerator) convertVars(cm *codegen.CodegenModel, models []*codegen for _, v := range cm.Vars { textBinary(v) + // A $ref to a union is typed as the union model, not its members + // joined: the model is what converts it, its tag included. + if v.Ref != "" && !v.IsContainer { + if m := g.modelOf(v); m != "" && m != v.Datatype { + v.DatatypeWithEnum, from.used[m] = m, true + } + } + v.VendorExtensions = own(v.VendorExtensions) v.VendorExtensions["x-ts-from-json"] = key(v.Name) + ": " + from.varFromJSON(v) @@ -243,20 +251,8 @@ func (g *FetchGenerator) convertVars(cm *codegen.CodegenModel, models []*codegen // and x-ts-alias-to the conversions. A property of that schema is typed as // the value already; a body or a result is typed as the model. func (g *FetchGenerator) alias(cm *codegen.CodegenModel) { - if cm.HasVars || cm.HasOneOf || cm.IsEnum || len(cm.AllOf) > 0 || len(cm.AnyOf) > 0 || cm.Discriminator != nil { - return - } - - var value *codegen.CodegenProperty - - switch { - case cm.IsArray && cm.Items != nil: - value = &codegen.CodegenProperty{IsArray: true, IsContainer: true, Items: cm.Items, DataType: "Array<" + cm.Items.DataType + ">"} - case cm.AdditionalProperties != nil: - value = &codegen.CodegenProperty{IsMap: true, IsContainer: true, Items: cm.AdditionalProperties, DataType: "{ [key: string]: " + cm.AdditionalProperties.DataType + "; }"} - case cm.IsPrimitiveType && cm.DataType != "" && cm.DataType != "any": - value = &codegen.CodegenProperty{DataType: cm.DataType, IsDateType: cm.IsDate && cm.DataType == "Date", IsDateTimeType: cm.IsDateTime && cm.DataType == "Date"} - default: + value := aliasOf(cm) + if value == nil { return } @@ -279,6 +275,25 @@ func (g *FetchGenerator) alias(cm *codegen.CodegenModel) { cm.Imports = addImports(cm.Imports, from.used) } +// aliasOf is the value cm stands for when it only names an array, a map or +// a scalar, or nil. +func aliasOf(cm *codegen.CodegenModel) *codegen.CodegenProperty { + if cm.HasVars || cm.HasOneOf || cm.IsEnum || len(cm.AllOf) > 0 || len(cm.AnyOf) > 0 || cm.Discriminator != nil { + return nil + } + + switch { + case cm.IsArray && cm.Items != nil: + return &codegen.CodegenProperty{IsArray: true, IsContainer: true, Items: cm.Items, DataType: "Array<" + cm.Items.DataType + ">"} + case cm.AdditionalProperties != nil: + return &codegen.CodegenProperty{IsMap: true, IsContainer: true, Items: cm.AdditionalProperties, DataType: "{ [key: string]: " + cm.AdditionalProperties.DataType + "; }"} + case cm.IsPrimitiveType && cm.DataType != "" && cm.DataType != "any": + return &codegen.CodegenProperty{DataType: cm.DataType, IsDateType: cm.IsDate && cm.DataType == "Date", IsDateTimeType: cm.IsDateTime && cm.DataType == "Date"} + } + + return nil +} + // descendants are the models that extend cm through allOf, at any depth. func descendants(cm *codegen.CodegenModel, models []*codegen.CodegenModel) []*codegen.CodegenModel { var out []*codegen.CodegenModel @@ -382,7 +397,16 @@ func (g *FetchGenerator) fitMembers(cm *codegen.CodegenModel) { continue } - members = append(members, fit{parts: parts}) + f, ok := g.fitOf(parts[0]) + if len(parts) > 1 { + f, ok = fit{parts: parts}, !slices.ContainsFunc(parts, func(m *codegen.CodegenModel) bool { return aliasOf(m) != nil }) + } + + if !ok { + continue + } + + members = append(members, f) for _, m := range parts { if !slices.Contains(cm.OneOfModels, m.Classname) { diff --git a/templates/typescript-fetch/apis.mustache b/templates/typescript-fetch/apis.mustache index c661f31..07e355d 100644 --- a/templates/typescript-fetch/apis.mustache +++ b/templates/typescript-fetch/apis.mustache @@ -324,7 +324,7 @@ export class {{classname}} extends runtime.BaseAPI { {{^useSingleRequestParameter}} async {{nickname}}({{#allParams}}{{paramName}}{{^required}}?{{/required}}: {{#isEnum}}{{{datatypeWithEnum}}}{{/isEnum}}{{^isEnum}}{{{dataType}}}{{#isNullable}} | null{{/isNullable}}{{/isEnum}}, {{/allParams}}initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<{{>apisReturnType}}{{#returnType}}{{#isResponseOptional}} | null | undefined {{/isResponseOptional}}{{/returnType}}{{^returnType}}void{{/returnType}}> { {{#returnType}} - // No local variable: a parameter may be named like one. + {{! No local variable: a parameter may be named like one. }} return await (await this.{{nickname}}Raw({{#hasAllParams}}{ {{#allParams}}{{paramName}}: {{paramName}}{{^-last}}, {{/-last}}{{/allParams}} }, {{/hasAllParams}}initOverrides)).value(); {{/returnType}} {{^returnType}} diff --git a/templates/typescript-fetch/modelOneOf.mustache b/templates/typescript-fetch/modelOneOf.mustache index 8f51ab1..2cac4ce 100644 --- a/templates/typescript-fetch/modelOneOf.mustache +++ b/templates/typescript-fetch/modelOneOf.mustache @@ -31,7 +31,7 @@ export function {{classname}}FromJSONTyped(json: any, ignoreDiscriminator: boole switch (String(json["{{discriminator.propertyBaseName}}"])) { {{#discriminator.mappedModels}} case "{{mappingName}}": - return {{modelName}}FromJSONTyped(json, true); + return Object.assign({ "{{discriminator.propertyName}}": json["{{discriminator.propertyBaseName}}"] }, {{modelName}}FromJSONTyped(json, true)); {{/discriminator.mappedModels}} default: return json; @@ -161,10 +161,10 @@ export function {{classname}}ToJSONTyped(value?: {{classname}} | null, ignoreDis return value; } {{#discriminator}} - switch (String(value["{{discriminator.propertyName}}"])) { + switch (String((value as any)["{{discriminator.propertyName}}"])) { {{#discriminator.mappedModels}} case "{{mappingName}}": - return {{modelName}}ToJSON(value); + return Object.assign({ "{{discriminator.propertyBaseName}}": (value as any)["{{discriminator.propertyName}}"] }, {{modelName}}ToJSON(value)); {{/discriminator.mappedModels}} default: return value; diff --git a/templates/typescript-fetch/runtime.mustache b/templates/typescript-fetch/runtime.mustache index 6dc864a..8b5b078 100644 --- a/templates/typescript-fetch/runtime.mustache +++ b/templates/typescript-fetch/runtime.mustache @@ -856,7 +856,7 @@ async function* serverSentEvents(raw: Response, transformer: (value: any) => any * an image or as octets is a file, not text. */ export function isTextMime(mime: string | null | undefined): boolean { - return !mime || /^text\/|json|xml|yaml|csv|javascript|x-www-form-urlencoded/i.test(mime); + return !mime || /^text\/|[/+-](json|ndjson|jsonl|xml|yaml|csv|javascript|x-www-form-urlencoded)\b/i.test(mime); } export class TextApiResponse { From 1ecf9761f6320d39186a099037bbf2b3441d0e38 Mon Sep 17 00:00:00 2001 From: Filip Seman Date: Mon, 26 Oct 2026 20:14:31 +0100 Subject: [PATCH 15/18] fix(go-fetch): tell a text media type by its subtype An xlsx download (`...spreadsheetml.sheet`) holds "xml" in its media type and read as text; an any result is text only for text/* or a json, xml, yaml, csv, javascript or form subtype. --- templates/go-fetch/runtime.mustache | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/go-fetch/runtime.mustache b/templates/go-fetch/runtime.mustache index ebb7b3f..9c94153 100644 --- a/templates/go-fetch/runtime.mustache +++ b/templates/go-fetch/runtime.mustache @@ -406,7 +406,7 @@ func other2xx(resp *http.Response, err error, model func(status int) (any, []str var ( jsonMime = regexp.MustCompile(`(?i)^(application/json|[^;/ \t]+/[^;/ \t]+[+]json)[ \t]*(;.*)?$`) - textMime = regexp.MustCompile(`(?i)^text/|json|xml|yaml|csv|javascript|x-www-form-urlencoded`) + textMime = regexp.MustCompile(`(?i)^text/|[/+-](json|ndjson|jsonl|xml|yaml|csv|javascript|x-www-form-urlencoded)\b`) ) // decodeJSON reads and closes resp's body into a T: JSON, or the text itself From dd5919663afbb9f7e27785581ee8b3e7f67f7bf8 Mon Sep 17 00:00:00 2001 From: Filip Seman Date: Mon, 26 Oct 2026 20:14:31 +0100 Subject: [PATCH 16/18] test(e2e): cover union tags no member declares and list alias members - composition-oneof-scalars gains a union of a list alias and a model and a discriminated union whose members do not declare the tag. - A generated case expects a string parameter as it is (not JSON-escaped) and the properties of an optional exploded query object absent. - The TypeScript runner lets a union's tag stand on its member. --- internal/e2e/values_test.go | 32 +++++++++++++- testdata/e2e/runners/ts/runner.ts | 15 ++++++- .../scenarios/composition-oneof-scalars.yaml | 9 ++++ .../feature/composition-oneof-scalars.yaml | 42 +++++++++++++++++++ 4 files changed, 96 insertions(+), 2 deletions(-) create mode 100644 testdata/e2e/scenarios/composition-oneof-scalars.yaml diff --git a/internal/e2e/values_test.go b/internal/e2e/values_test.go index 02f132d..d770236 100644 --- a/internal/e2e/values_test.go +++ b/internal/e2e/values_test.go @@ -153,6 +153,27 @@ func servable(t *testing.T, spec string) (string, *openapi3.T) { return out, doc } +// absentProperties marks absent the keys an optional exploded query object +// goes under: its properties' (deepObject: name[property]), as a client +// that writes an unset one sends them. +func absentProperties(where map[string]any, p *openapi3.Parameter) { + sm, err := p.SerializationMethod() + if err != nil || p.In != openapi3.ParameterInQuery || !sm.Explode || p.Schema == nil || p.Schema.Value == nil { + return + } + + for prop := range p.Schema.Value.Properties { + key := prop + if sm.Style == openapi3.SerializationDeepObject { + key = p.Name + "[" + prop + "]" + } + + if _, named := where[key]; !named { + where[key] = "$absent" + } + } +} + // clientHeaders are the headers an HTTP client may send on its own. var clientHeaders = map[string]bool{"Accept": true, "Accept-Language": true, "Accept-Encoding": true, "User-Agent": true} @@ -196,6 +217,10 @@ func valueCases(item *openapi3.PathItem, op *openapi3.Operation, is31 bool) ([]k where[p.Value.Name] = "$absent" } + if where != nil { + absentProperties(where, p.Value) + } + continue } @@ -208,7 +233,12 @@ func valueCases(item *openapi3.PathItem, op *openapi3.Operation, is31 bool) ([]k query = query || p.Value.In == openapi3.ParameterInQuery if where != nil { - where[p.Value.Name] = strings.Trim(string(mustJSON(v)), `"`) + text, ok := v.(string) + if !ok { + text = string(mustJSON(v)) // a number as JSON writes it, as JavaScript does + } + + where[p.Value.Name] = text } } diff --git a/testdata/e2e/runners/ts/runner.ts b/testdata/e2e/runners/ts/runner.ts index cfbd17c..3ae5dc3 100644 --- a/testdata/e2e/runners/ts/runner.ts +++ b/testdata/e2e/runners/ts/runner.ts @@ -76,6 +76,7 @@ type Decl = { days?: Set; day?: boolean; values?: string[]; + tag?: string; }; // models reads the models' interfaces and type aliases from the sources. @@ -120,7 +121,13 @@ function models(dir: string): Record { } for (const m of src.matchAll(/^export type (\w+) = (.+);$/gm)) { - out[m[1]] ??= { alias: m[2], day: /json == null \? json : [^\n]*\bparseDate\b/.test(src) }; + // A union with a discriminator sets its tag on the member, which + // may not declare it: Object.assign({ "kind": json["kind"] }, ...). + out[m[1]] ??= { + alias: m[2], + day: /json == null \? json : [^\n]*\bparseDate\b/.test(src), + tag: /Object\.assign\(\{ "([^"]+)": json\[/.exec(src)?.[1], + }; } } @@ -231,6 +238,12 @@ function shape(decls: Record, t: string, v: any, at: string, scala } if (d.alias !== undefined) { + if (d.tag !== undefined && v !== null && typeof v === "object" && d.tag in v) { + const { [d.tag]: _, ...member } = v; + + return shape(decls, d.alias, member, at, scalars, day || !!d.day); + } + return d.alias.startsWith("typeof ") ? "" : shape(decls, d.alias, v, at, scalars, day || !!d.day); } diff --git a/testdata/e2e/scenarios/composition-oneof-scalars.yaml b/testdata/e2e/scenarios/composition-oneof-scalars.yaml new file mode 100644 index 0000000..579fcce --- /dev/null +++ b/testdata/e2e/scenarios/composition-oneof-scalars.yaml @@ -0,0 +1,9 @@ +spec: ../../specs/feature/composition-oneof-scalars.yaml +skip: { dart-fetch: "does not compile: lib/src/models/day_or_count.dart:10: Target of URI doesn't exist: 'model_date_time.dart'." } +cases: + - name: a list alias member and a tag no member declares + op: echoHolder + args: { body: { tagsOrTag: [{ tag_name: a, added_on: "2024-01-02" }], animal: { kind: dog, bark_volume: 3 } } } + request: { body: { tagsOrTag: [{ tag_name: a, added_on: "2024-01-02" }], animal: { kind: dog, bark_volume: 3 } } } + response: { json: { tagsOrTag: { tag_name: b, added_on: "2024-01-03" }, animal: { kind: cat, meow_count: 2 } } } + result: { tagsOrTag: { tag_name: b, added_on: "2024-01-03" }, animal: { kind: cat, meow_count: 2 } } diff --git a/testdata/specs/feature/composition-oneof-scalars.yaml b/testdata/specs/feature/composition-oneof-scalars.yaml index d631a07..c2183f9 100644 --- a/testdata/specs/feature/composition-oneof-scalars.yaml +++ b/testdata/specs/feature/composition-oneof-scalars.yaml @@ -33,6 +33,10 @@ components: $ref: "#/components/schemas/ColorOrBox" pet: $ref: "#/components/schemas/Pet" + tagsOrTag: + $ref: "#/components/schemas/TagsOrTag" + animal: + $ref: "#/components/schemas/Animal" DayOrCount: oneOf: - type: string @@ -79,3 +83,41 @@ components: type: boolean dog-name: type: string + # a list alias and a model: a list goes item by item + Tag: + type: object + required: [tag_name] + properties: + tag_name: + type: string + added_on: + type: string + format: date + Tags: + type: array + items: + $ref: "#/components/schemas/Tag" + TagsOrTag: + oneOf: + - $ref: "#/components/schemas/Tags" + - $ref: "#/components/schemas/Tag" + # members that do not declare the discriminator they are told by + Animal: + oneOf: + - $ref: "#/components/schemas/Barker" + - $ref: "#/components/schemas/Meower" + discriminator: + propertyName: kind + mapping: + dog: "#/components/schemas/Barker" + cat: "#/components/schemas/Meower" + Barker: + type: object + properties: + bark_volume: + type: integer + Meower: + type: object + properties: + meow_count: + type: integer From 470a9221e03208dffa248dad0bb0891bd21b0605 Mon Sep 17 00:00:00 2001 From: Filip Seman Date: Mon, 26 Oct 2026 20:46:54 +0100 Subject: [PATCH 17/18] fix(typescript-fetch): null only for the statuses declared without a body An empty body read as null for every response broke clients that rely on it throwing. The xxxRaw method now returns null by status: a 2xx the spec declares without a body, every 2xx of a HEAD. Any other empty body stays a JSON parse error, and the body is no longer read twice. --- internal/generator/typescript/fetch.go | 17 +++++++++++------ templates/typescript-fetch/README.md | 3 ++- templates/typescript-fetch/apis.mustache | 6 ++++++ templates/typescript-fetch/runtime.mustache | 7 +------ 4 files changed, 20 insertions(+), 13 deletions(-) diff --git a/internal/generator/typescript/fetch.go b/internal/generator/typescript/fetch.go index 1fed818..bbbc553 100644 --- a/internal/generator/typescript/fetch.go +++ b/internal/generator/typescript/fetch.go @@ -637,8 +637,9 @@ func (g *FetchGenerator) stream(op *codegen.CodegenOperation, from *converter) { // may come as text (a string, or any body the server does not send as // JSON), x-ts-any when it may also come as a file (an any the server // sends as an image or as octets), x-ts-other-2xx the statuses whose body is of another type than the -// result, isResponseOptional when a 2xx comes without a body (a 204 beside -// a 200, the answer to a HEAD), which reads as null. +// result, isResponseOptional and x-ts-no-body the statuses that come without +// a body (a 204 beside a 200, every 2xx of a HEAD), which read as null. An +// empty body of any other status stays a parse error. func (g *FetchGenerator) responseKinds(op *codegen.CodegenOperation) { if op.ReturnType == "" || op.VendorExtensions["x-ts-stream"] != nil { return @@ -646,9 +647,8 @@ func (g *FetchGenerator) responseKinds(op *codegen.CodegenOperation) { op.VendorExtensions["x-ts-text"] = op.ReturnType == "string" || op.ReturnType == "any" op.VendorExtensions["x-ts-any"] = op.ReturnType == "any" - op.IsResponseOptional = op.HttpMethod == "HEAD" - var other []string + var other, none []string for _, r := range op.Responses { if _, err := strconv.Atoi(r.Code); err != nil || !r.Is2xx { @@ -656,8 +656,8 @@ func (g *FetchGenerator) responseKinds(op *codegen.CodegenOperation) { } switch { - case r.DataType == "": - op.IsResponseOptional = true + case r.DataType == "" || op.HttpMethod == "HEAD": + none = append(none, r.Code) case op.ReturnType != "any" && r.DataType != op.ReturnType && (r.ReturnProperty == nil || !reflect.DeepEqual(r.ReturnProperty, op.ReturnProperty)): other = append(other, r.Code) } @@ -666,6 +666,11 @@ func (g *FetchGenerator) responseKinds(op *codegen.CodegenOperation) { if len(other) > 0 { op.VendorExtensions["x-ts-other-2xx"] = "[" + strings.Join(other, ", ") + "]" } + + if len(none) > 0 { + op.IsResponseOptional = true + op.VendorExtensions["x-ts-no-body"] = "[" + strings.Join(none, ", ") + "]" + } } // processCodeGenModel processes a model for TypeScript-Fetch specific transformations. diff --git a/templates/typescript-fetch/README.md b/templates/typescript-fetch/README.md index 5adfadb..e17e23d 100644 --- a/templates/typescript-fetch/README.md +++ b/templates/typescript-fetch/README.md @@ -225,7 +225,8 @@ of lists of models, a map of dates, a map of lists of models. | Response | Result | | ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | -| a 2xx without a body (a 204, a HEAD) | `null` | +| a 2xx the spec declares without a body (a 204), HEAD | `null` | +| an empty body where the spec declares one | a JSON parse error, not `null` | | a 2xx of another body than the result's | a `ResponseError`, its body unread | | a string or `any` the server sends as text | the text; a number or boolean sent as text is parsed; an `any` sent as an image or as octets is a file | | JSON Lines, `text/event-stream` (3.2's `itemSchema`) | `AsyncIterable`, of `ServerSentEvent` (`data`, `event`, `id`, `retry`) for events | diff --git a/templates/typescript-fetch/apis.mustache b/templates/typescript-fetch/apis.mustache index 07e355d..bfef3b9 100644 --- a/templates/typescript-fetch/apis.mustache +++ b/templates/typescript-fetch/apis.mustache @@ -279,6 +279,12 @@ export class {{classname}} extends runtime.BaseAPI { throw new runtime.ResponseError(response, `HTTP ${response.status}: another body than the result's`); } {{/vendorExtensions.x-ts-other-2xx}} +{{#vendorExtensions.x-ts-no-body}} + // A 2xx without a body (a 204 beside a 200, the answer to a HEAD) is null. + if ({{{.}}}.includes(response.status)) { + return { raw: response, value: async () => null } as any; + } +{{/vendorExtensions.x-ts-no-body}} {{#returnType}} {{#vendorExtensions.x-ts-stream}} diff --git a/templates/typescript-fetch/runtime.mustache b/templates/typescript-fetch/runtime.mustache index 8b5b078..3495aea 100644 --- a/templates/typescript-fetch/runtime.mustache +++ b/templates/typescript-fetch/runtime.mustache @@ -634,13 +634,8 @@ export interface ResponseTransformer { export class JSONApiResponse { constructor(public raw: Response, private transformer: ResponseTransformer = (jsonValue: any) => jsonValue) {} - /** - * The body parsed and converted; null when there is none, as a 204 next to a 200 with a body - * or the answer to a HEAD has. - */ async value(): Promise { - const text = await this.raw.text(); - return text === "" ? (null as T) : this.transformer(JSON.parse(text)); + return this.transformer(await this.raw.json()); } } From bf58db431bb044858f324aaa3dff50ee788baf92 Mon Sep 17 00:00:00 2001 From: Filip Seman Date: Mon, 26 Oct 2026 20:46:54 +0100 Subject: [PATCH 18/18] test(e2e): an empty body where the spec declares one fails --- internal/e2e/e2e_test.go | 5 +++++ internal/e2e/scenario_test.go | 1 + testdata/e2e/README.md | 1 + testdata/e2e/scenarios/responses.yaml | 8 ++++++++ 4 files changed, 15 insertions(+) diff --git a/internal/e2e/e2e_test.go b/internal/e2e/e2e_test.go index 1d76300..9eacdfe 100644 --- a/internal/e2e/e2e_test.go +++ b/internal/e2e/e2e_test.go @@ -310,6 +310,11 @@ func caseDiffs(k kase, o outcome, m *mock, i int, r rules) []string { } switch { + case k.Fails: + if o.Fail == "" { + diffs = append(diffs, "call: want it to fail, got result "+string(o.Result)) + } + case o.Fail != "": diffs = append(diffs, "call failed: "+o.Fail) case k.Error != nil: diff --git a/internal/e2e/scenario_test.go b/internal/e2e/scenario_test.go index 87c3417..44d6c41 100644 --- a/internal/e2e/scenario_test.go +++ b/internal/e2e/scenario_test.go @@ -52,6 +52,7 @@ type kase struct { Response *mockResponse `json:"response"` Result json.RawMessage `json:"result"` // null is a null result, absent is unchecked Error json.RawMessage `json:"error"` + Fails bool `json:"fails"` // the client cannot read the answer: the call fails, not as an HTTP error // Exact compares the result, the error and the request's body with no // other keys allowed: a generated case (values_test.go) sets it. diff --git a/testdata/e2e/README.md b/testdata/e2e/README.md index 4f96655..4d42343 100644 --- a/testdata/e2e/README.md +++ b/testdata/e2e/README.md @@ -61,6 +61,7 @@ cases: response: { status: 200, json: { id: "42" } } # mock only; or text, base64, header result: { id: "42" } # the client's result as wire JSON; null is null error: { status: 404, model: {...}, body: "..." } + fails: true # the client cannot read the answer (not an HTTP error) save: { token: access_token } # ${token} in later args and auth skip: { go-fetch: why it fails } # a known gap; fails once it passes ``` diff --git a/testdata/e2e/scenarios/responses.yaml b/testdata/e2e/scenarios/responses.yaml index 226c28d..52c1588 100644 --- a/testdata/e2e/scenarios/responses.yaml +++ b/testdata/e2e/scenarios/responses.yaml @@ -14,6 +14,14 @@ cases: - { name: 204 no body, op: deleteTask, args: { id: "1" }, response: { status: 204 } } - { name: 201 with a body, op: createTask, args: { id: "1" }, response: { status: 201, json: { id: "1" } }, result: { id: "1" } } - { name: 202 without a body, op: createTask, args: { id: "1" }, response: { status: 202 }, result: null } + - name: an empty body where the spec declares one is no null + op: createTask + args: { id: "1" } + response: { status: 201, header: { Content-Type: application/json }, text: "", offSpec: true } + fails: true + skip: + go-fetch: an empty body is the result's zero, nil for a pointer + dart-fetch: an empty body is null - name: undeclared status keeps the text body op: deleteTask args: { id: "1" }