diff --git a/internal/e2e/e2e_test.go b/internal/e2e/e2e_test.go index c257871..9eacdfe 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 @@ -277,20 +285,36 @@ func caseDiffs(k kase, o outcome, m *mock, i int) []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, true)...) + 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, false)...) + diffs = append(diffs, match("request", decodeJSON(k.Request), got, r)...) } } 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: @@ -305,12 +329,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..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. @@ -157,6 +158,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..17b81cf 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,109 @@ 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 +} + +// 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/internal/e2e/values_test.go b/internal/e2e/values_test.go index e2dbc49..d770236 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. @@ -158,6 +153,30 @@ 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} + // 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 @@ -174,6 +193,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) { @@ -185,7 +208,19 @@ 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" + } + + if where != nil { + absentProperties(where, p.Value) + } + continue } @@ -196,6 +231,15 @@ 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 { + text, ok := v.(string) + if !ok { + text = string(mustJSON(v)) // a number as JSON writes it, as JavaScript does + } + + where[p.Value.Name] = text + } } if query && deepArray != "" { @@ -263,7 +307,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("%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 { @@ -284,14 +328,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, + Name: op.OperationID + " request " + b.name, Op: op.OperationID, Args: mustJSON(a), Exact: true, + Request: mustJSON(want), Response: answer, }) } 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..c4c3d9d 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", "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/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/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/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..0065da1 --- /dev/null +++ b/internal/generator/typescript/convert.go @@ -0,0 +1,420 @@ +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 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{} + + 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 + + for _, v := range pin.values { + if seen[v] { + return "" + } + + seen[v] = true + cases = append(cases, strconv.Quote(v)+": "+model.Classname+c.fn()) + } + + used[model.Classname] = true + } + + 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 []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 && !m.IsFreeFormObject: + continue + case model == nil: + return x + } + + 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 { + return x + } + + 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 +} + +// 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. +func (c *converter) fits(members []fit) string { + cases := make([]string, 0, len(members)) + + for _, member := range members { + var required, known, fns []string + + for _, m := range member.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) + } + + if v.Required && !slices.Contains(required, name) { + required = append(required, name) + } + } + + c.used[m.Classname] = true + fns = append(fns, m.Classname+c.fn()) + } + + fn := fns[0] + if len(fns) > 1 { + fn = "(v: any) => ({ ..." + strings.Join(fns, "(v), ...") + "(v) })" + } + + array := "" + if member.array { + array = ", true" + } + + cases = append(cases, "[["+strings.Join(required, ", ")+"], ["+strings.Join(known, ", ")+"], "+fn+array+"]") + } + + 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 + } + + return m.Vars + } + + var vars []*codegen.CodegenProperty + + 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}) + } + } + } + + return vars +} + +type pin struct { + key string + values []string +} + +// 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) == 0 { + continue + } + + var text []string + + for _, value := range values { + 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)) + } + } + + name := v.BaseName + if to { + name = v.Name + } + + if len(text) == len(values) && (key == "" || key == name) { + return &pin{name, text} + } + } + + 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, ComposedSchemas: p.ComposedSchemas, + } +} 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..bbbc553 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,517 @@ 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.alias(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) + + // 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) + 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) +} + +// 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) { + value := aliasOf(cm) + if value == nil { + 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") + + // 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) +} + +// 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 + + 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 + } + + // A member is a model, or the intersection of an allOf ("A & B"), whose + // models all import. + var members []fit + + 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 + } + + 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) { + 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} + + cm.VendorExtensions = own(cm.VendorExtensions) + cm.VendorExtensions["x-ts-fit-from"] = from.fits(members) + 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 { + 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 { + // 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) + 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-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 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 + } + + op.VendorExtensions["x-ts-text"] = op.ReturnType == "string" || op.ReturnType == "any" + op.VendorExtensions["x-ts-any"] = op.ReturnType == "any" + + var other, none []string + + for _, r := range op.Responses { + if _, err := strconv.Atoi(r.Code); err != nil || !r.Is2xx { + continue + } + + switch { + 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) + } + } + + 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. func (g *FetchGenerator) processCodeGenModel(cm *codegen.CodegenModel) { // Process enum names @@ -224,6 +725,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 +779,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 +799,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/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..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 + } } } @@ -92,19 +96,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..afdd927 100644 --- a/internal/parser/model.go +++ b/internal/parser/model.go @@ -1,8 +1,10 @@ package parser import ( + "maps" "slices" "sort" + "strings" "github.com/getkin/kin-openapi/openapi3" "github.com/xseman/openapi-generator/internal/codegen" @@ -134,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 @@ -164,6 +167,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 { @@ -310,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) } @@ -329,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/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..38c20af 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, @@ -536,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/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..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. @@ -225,6 +246,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 } @@ -365,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 @@ -480,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 @@ -567,7 +579,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..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 @@ -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/templates/go-fetch/runtime.mustache b/templates/go-fetch/runtime.mustache index 7467573..9c94153 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|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 // 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 } } diff --git a/templates/typescript-fetch/README.md b/templates/typescript-fetch/README.md index 198e4be..e17e23d 100644 --- a/templates/typescript-fetch/README.md +++ b/templates/typescript-fetch/README.md @@ -202,6 +202,50 @@ 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 | +| 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 | +| --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 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 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 | + +```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 +323,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..bfef3b9 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,49 @@ 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}} +{{#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}} + 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); +{{#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; } -{{/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); @@ -414,23 +330,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); - - {{#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}} + {{! 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); @@ -442,21 +343,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/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 6445e0a..97794c6 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, @@ -17,22 +12,26 @@ 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}} -{{#discriminator}} -{{#discriminator.mappedModels}} +{{^withoutRuntimeChecks}} +{{#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}} @@ -97,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}} @@ -113,69 +112,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}} @@ -195,13 +136,11 @@ 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); {{/discriminator.mappedModels}} - default: - return value; } } {{/discriminator}} @@ -209,65 +148,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..2cac4ce 100644 --- a/templates/typescript-fetch/modelOneOf.mustache +++ b/templates/typescript-fetch/modelOneOf.mustache @@ -1,19 +1,22 @@ {{^withoutRuntimeChecks}} -import { parseDate, parseDateTime, serializeDate, serializeDateTime } from '../runtime{{importFileExtension}}'; +import { bestFit, parseDate, parseDateTime, serializeDate, serializeDateTime } from '../runtime{{importFileExtension}}'; {{/withoutRuntimeChecks}} {{#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}} { @@ -25,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 Object.assign({ "{{discriminator.propertyName}}": json["{{discriminator.propertyBaseName}}"] }, {{modelName}}FromJSONTyped(json, true)); {{/discriminator.mappedModels}} default: return json; @@ -40,11 +43,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 +65,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 +111,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 +148,7 @@ export function {{classname}}FromJSONTyped(json: any, ignoreDiscriminator: boole return json; } {{/hasOneOfArrays}} - return {} as any; + return json; {{/discriminator}} } @@ -157,10 +161,10 @@ export function {{classname}}ToJSONTyped(value?: {{classname}} | null, ignoreDis return value; } {{#discriminator}} - switch (value["{{discriminator.propertyName}}"]) { + switch (String((value as any)["{{discriminator.propertyName}}"])) { {{#discriminator.mappedModels}} case "{{mappingName}}": - return Object.assign({}, {{modelName}}ToJSON(value), { "{{discriminator.propertyBaseName}}": "{{mappingName}}" } as const); + return Object.assign({ "{{discriminator.propertyBaseName}}": (value as any)["{{discriminator.propertyName}}"] }, {{modelName}}ToJSON(value)); {{/discriminator.mappedModels}} default: return value; @@ -172,11 +176,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 +279,7 @@ export function {{classname}}ToJSONTyped(value?: {{classname}} | null, ignoreDis return value; } {{/hasOneOfArrays}} - return {}; + return value; {{/discriminator}} } +{{/withoutRuntimeChecks}} 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}} diff --git a/templates/typescript-fetch/runtime.mustache b/templates/typescript-fetch/runtime.mustache index ad674b9..3495aea 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("&"); @@ -396,6 +398,65 @@ 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).filter(([, v]) => v !== undefined).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)]]; + } + // 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)]) + : entries.map(([k, v]): [string, string] => [k, String(v)]); + } + const delimiter = style === "spaceDelimited" ? " " : style === "pipeDelimited" ? "|" : ","; + return [[name, (Array.isArray(value) ? value : entries.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 +515,54 @@ 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. 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, 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; + } + let best: ((value: any) => any) | undefined; + let score = -Infinity; + 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 && (list ? value.map(best) : 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)) { @@ -613,6 +722,138 @@ 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; + } + } +} + +/** + * 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|ndjson|jsonl|xml|yaml|csv|javascript|x-www-form-urlencoded)\b/i.test(mime); +} + export class TextApiResponse { constructor(public raw: Response) {} diff --git a/testdata/e2e/README.md b/testdata/e2e/README.md index 80a38e3..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 ``` @@ -91,8 +92,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 @@ -112,13 +114,16 @@ 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 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,28 +134,42 @@ 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. +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 -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. +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 +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/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 41d0b96..3ae5dc3 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,15 +62,279 @@ function methods(dir: string): { methods: Method[]; requests: Record t.replace(/ \| null$/, "").replace(/ \| undefined$/, ""); +// Decl is a model of a client: an interface's properties (with those of +// the interface it extends, and whether an index signature takes others), +// 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; + values?: string[]; + tag?: string; +}; + +// 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"], + // a day with parseDate. + const wire: Record = {}; + 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)) { + 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, 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)) { + // 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], + }; + } + } + + 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 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, day)); + + 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, day); + 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, day); + if (e) { + return e; + } + } + + return ""; + } + + if (t === "Date") { + 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") { + 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 ""; + } + + // 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) { + 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); + } + + // 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 = {}; + 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; + } + + 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, days.has(k)); + if (e) { + return e; + } + } + + return ""; +} + +// 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; + }; + + // 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); + } + 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"); @@ -77,15 +344,16 @@ 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. 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"]; @@ -93,38 +361,101 @@ 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; } + // 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"); } 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; } + // 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") { + const out = to(v); + await restore(client, decls, t, v, out); + + return out; + } + if (v instanceof Date) { - return v.toISOString(); + return day ? client.serializeDate(v) : v.toISOString(); } - const to = client[t + "ToJSON"]; + return v; +} + +// 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; + } - return typeof to === "function" ? to(v) : v; + 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; + } + + 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 +481,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, @@ -201,7 +532,14 @@ async function callCase(client: Client, info: ReturnType, suite: } used.add(name); - params[field] = fromWire(client.module, type, args[name]); + params[field] = fromWire(client.module, decls, type, args[name]); + + // 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 }; + } } for (const name of Object.keys(args)) { @@ -212,16 +550,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, m.day); + 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 +587,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 +624,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/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/e2e/scenarios/discriminator-allof-no-mapping.yaml b/testdata/e2e/scenarios/discriminator-allof-no-mapping.yaml new file mode 100644 index 0000000..90ea954 --- /dev/null +++ b/testdata/e2e/scenarios/discriminator-allof-no-mapping.yaml @@ -0,0 +1,19 @@ +spec: ../../specs/feature/discriminator-allof-no-mapping.yaml +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/e2e/scenarios/fastapi.yaml b/testdata/e2e/scenarios/fastapi.yaml index 7227035..01a956a 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: @@ -132,11 +129,27 @@ 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" } 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 +175,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 +184,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 +306,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 +320,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 +365,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 +401,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 +418,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 +430,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" } } } @@ -449,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" @@ -480,7 +484,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 +495,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 +524,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 +543,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..2939e45 100644 --- a/testdata/e2e/scenarios/parameters-locations.yaml +++ b/testdata/e2e/scenarios/parameters-locations.yaml @@ -11,10 +11,15 @@ 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 } 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/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..3a6508f 100644 --- a/testdata/e2e/scenarios/request-body-other-media.yaml +++ b/testdata/e2e/scenarios/request-body-other-media.yaml @@ -14,9 +14,15 @@ 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 } }] } 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-stream.yaml b/testdata/e2e/scenarios/responses-stream.yaml new file mode 100644 index 0000000..8e0782b --- /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, event: $absent }] diff --git a/testdata/e2e/scenarios/responses.yaml b/testdata/e2e/scenarios/responses.yaml index 32bb404..52c1588 100644 --- a/testdata/e2e/scenarios/responses.yaml +++ b/testdata/e2e/scenarios/responses.yaml @@ -13,7 +13,15 @@ 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: 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" } 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/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/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/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/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-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] diff --git a/testdata/specs/feature/composition-oneof-scalars.yaml b/testdata/specs/feature/composition-oneof-scalars.yaml new file mode 100644 index 0000000..c2183f9 --- /dev/null +++ b/testdata/specs/feature/composition-oneof-scalars.yaml @@ -0,0 +1,123 @@ +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" + tagsOrTag: + $ref: "#/components/schemas/TagsOrTag" + animal: + $ref: "#/components/schemas/Animal" + 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 + # 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 diff --git a/testdata/specs/feature/dates.yaml b/testdata/specs/feature/dates.yaml index 3d58839..091a0e3 100644 --- a/testdata/specs/feature/dates.yaml +++ b/testdata/specs/feature/dates.yaml @@ -43,6 +43,58 @@ 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 + /day: + put: + operationId: putDay + requestBody: + required: true + content: + application/json: + schema: + type: string + format: date + responses: + "204": + description: stored + /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 +125,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..78baba3 --- /dev/null +++ b/testdata/specs/feature/discriminator-allof-no-mapping.yaml @@ -0,0 +1,61 @@ +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 + born_on: + type: string + format: date + 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/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] 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-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 9679d0a..ef65014 100644 --- a/testdata/specs/feature/parameters-object-styles.yaml +++ b/testdata/specs/feature/parameters-object-styles.yaml @@ -25,10 +25,12 @@ 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 } + A: { type: number } 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/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 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 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"