From b269f86ca37c9b9a53f0fb2bcbe0d053304f52a3 Mon Sep 17 00:00:00 2001 From: adelnizamutdinov Date: Tue, 29 Sep 2026 06:12:45 +0300 Subject: [PATCH] Generate typed Dart clients and models from OpenAPI --- .github/workflows/ci.yaml | 3 + .gitignore | 3 + README.md | 16 +- internal/cli/cli.go | 13 +- internal/cli/cli_test.go | 2 +- internal/dartemit/api.go | 271 +++++++++++++++ internal/dartemit/dartemit.go | 312 ++++++++++++++++++ internal/dartemit/models.go | 302 +++++++++++++++++ internal/openapi/openapi.go | 2 + moon.yml | 32 ++ oasmith_test.go | 9 + testdata/dart-client/bin/check.dart | 58 ++++ testdata/dart-client/pubspec.lock | 93 ++++++ testdata/dart-client/pubspec.yaml | 6 + testdata/golden/public-client-dart/api.dart | 148 +++++++++ .../golden/public-client-dart/models.dart | 244 ++++++++++++++ 16 files changed, 1506 insertions(+), 8 deletions(-) create mode 100644 internal/dartemit/api.go create mode 100644 internal/dartemit/dartemit.go create mode 100644 internal/dartemit/models.go create mode 100644 testdata/dart-client/bin/check.dart create mode 100644 testdata/dart-client/pubspec.lock create mode 100644 testdata/dart-client/pubspec.yaml create mode 100644 testdata/golden/public-client-dart/api.dart create mode 100644 testdata/golden/public-client-dart/models.dart diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 25683e6..75df67e 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -22,6 +22,9 @@ jobs: with: go-version-file: go.mod cache: false + - uses: dart-lang/setup-dart@v1 + with: + sdk: 3.13.4 - name: Restore Go modules, compiler, and linter caches id: go-cache uses: actions/cache@v6 diff --git a/.gitignore b/.gitignore index 94649b9..5051869 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,6 @@ dump-*.json /testdata/rust-client/target/ /testdata/rust-client/src/public/ /testdata/rust-client/src/models/ +/testdata/dart-client/.dart_tool/ +/testdata/dart-client/lib/public/ +/testdata/dart-client/lib/models/ diff --git a/README.md b/README.md index 2ba0475..08b89e8 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # OASmith -OASmith generates focused Go, TypeScript, and Rust code from OpenAPI YAML or JSON +OASmith generates focused Go, TypeScript, Rust, and Dart code from OpenAPI YAML or JSON documents. It supports focused generation modes without the runtime and configuration surface of a general-purpose OpenAPI generator. @@ -14,6 +14,8 @@ surface of a general-purpose OpenAPI generator. | `client` | `typescript` | TypeScript types and HTTP client | | `types` | `rust` | Serde models in `mod.rs` | | `client` | `rust` | Serde models and a Reqwest client in `mod.rs` | +| `types` | `dart` | Dart JSON models in `models.dart` | +| `client` | `dart` | Dart JSON models and an injectable HTTP client in `models.dart` and `api.dart` | OASmith handles the OpenAPI schema and operation subset covered by its fixture suite, including objects, arrays, enums, `oneOf` discriminators, parameters, @@ -41,7 +43,7 @@ Every invocation requires: - `--openapi`: input OpenAPI YAML or JSON document; - `--mode`: `types` or `client`; -- `--lang`: `go`, `typescript`, or `rust`, subject to the supported pairs above; +- `--lang`: `go`, `typescript`, `rust`, or `dart`, subject to the supported pairs above; - `--out`: generated output directory. JSON input is supported alongside YAML. The document syntax is accepted @@ -50,6 +52,16 @@ directly, so `.json` and `.yaml` file names work with the same command. TypeScript output is written directly from the embedded templates without external tools. +Dart client output uses `package:http` and accepts an application-owned +`send(http.BaseRequest)` function. Pass the application's authenticated transport +to preserve its proxy, tracing, certificate, and cancellation behavior. The +optional `responseError` callback translates non-success statuses to the +application's error types. Operation methods encode paths and query parameters, +decode typed JSON models, and return SSE responses as streams for the caller to +frame. JSON response reads have a 4 MiB bound. +The Dart SDK is required when emitting Dart output so OASmith can format the +generated files. + Generated clients require an explicit client base URL and use it for every operation. OpenAPI server declarations do not change the runtime destination. diff --git a/internal/cli/cli.go b/internal/cli/cli.go index ec07319..93d898e 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -5,6 +5,7 @@ import ( "flag" "fmt" + "github.com/responsibleapi/oasmith/internal/dartemit" "github.com/responsibleapi/oasmith/internal/goemit" "github.com/responsibleapi/oasmith/internal/openapi" "github.com/responsibleapi/oasmith/internal/rustemit" @@ -30,6 +31,8 @@ func Run(args []string) error { return err } switch { + case opts.Lang == "dart": + return dartemit.Emit(doc, dartemit.Options{OutDir: opts.Out}, opts.Mode == "client") case opts.Lang == "rust": return rustemit.Emit(doc, rustemit.Options{OutDir: opts.Out}, opts.Mode == "client") case opts.Mode == "types" && opts.Lang == "go": @@ -39,7 +42,7 @@ func Run(args []string) error { case opts.Mode == "client" && opts.Lang == "typescript": return tsemit.Emit(doc, tsemit.Options{OutDir: opts.Out}) default: - return fmt.Errorf("unsupported --mode/--lang pair %q/%q; valid pairs are types/go, client/go, client/typescript, types/rust, and client/rust", opts.Mode, opts.Lang) + return fmt.Errorf("unsupported --mode/--lang pair %q/%q; valid pairs are types/go, client/go, client/typescript, types/rust, client/rust, types/dart, and client/dart", opts.Mode, opts.Lang) } } @@ -52,15 +55,15 @@ func Parse(args []string) (Options, error) { fs.StringVar(&opts.Lang, "lang", "", "output language") fs.StringVar(&opts.Out, "out", "", "output directory") if err := fs.Parse(args); err != nil { - return Options{}, fmt.Errorf("usage: oasmith --openapi --mode --lang --out ") + return Options{}, fmt.Errorf("usage: oasmith --openapi --mode --lang --out ") } if opts.OpenAPI == "" || opts.Mode == "" || opts.Lang == "" || opts.Out == "" { - return Options{}, fmt.Errorf("usage: oasmith --openapi --mode --lang --out ") + return Options{}, fmt.Errorf("usage: oasmith --openapi --mode --lang --out ") } - if ((opts.Mode == "types" || opts.Mode == "client") && opts.Lang == "rust") || + if ((opts.Mode == "types" || opts.Mode == "client") && (opts.Lang == "rust" || opts.Lang == "dart")) || (opts.Mode == "types" && opts.Lang == "go") || (opts.Mode == "client" && (opts.Lang == "go" || opts.Lang == "typescript")) { return opts, nil } - return Options{}, fmt.Errorf("unsupported --mode/--lang pair %q/%q; valid pairs are types/go, client/go, client/typescript, types/rust, and client/rust", opts.Mode, opts.Lang) + return Options{}, fmt.Errorf("unsupported --mode/--lang pair %q/%q; valid pairs are types/go, client/go, client/typescript, types/rust, client/rust, types/dart, and client/dart", opts.Mode, opts.Lang) } diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 91fd6e0..02d570c 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -31,7 +31,7 @@ func TestParseRejectsInvalidModeLangPair(t *testing.T) { if err == nil { t.Fatal("Parse invalid mode/lang succeeded") } - if !strings.Contains(err.Error(), "valid pairs are types/go, client/go, client/typescript, types/rust, and client/rust") { + if !strings.Contains(err.Error(), "valid pairs are types/go, client/go, client/typescript, types/rust, client/rust, types/dart, and client/dart") { t.Fatalf("Parse error = %q, want valid pair message", err.Error()) } } diff --git a/internal/dartemit/api.go b/internal/dartemit/api.go new file mode 100644 index 0000000..df4e0c4 --- /dev/null +++ b/internal/dartemit/api.go @@ -0,0 +1,271 @@ +package dartemit + +import ( + "fmt" + "strings" + + "github.com/responsibleapi/oasmith/internal/clientgen" +) + +func (e *emitter) apiSource(operations []clientgen.Operation) string { + var out strings.Builder + out.WriteString(`// Generated by OASmith. Do not edit. +import 'dart:convert'; +import 'package:http/http.dart' as http; +import 'models.dart'; +export 'models.dart'; + +class ApiStatusException implements Exception { + const ApiStatusException(this.statusCode); + final int statusCode; + @override String toString() => 'API returned HTTP $statusCode'; +} + +class PublicApiClient { + PublicApiClient({required this.baseUri, required this.send, this.responseError, this.readBody}); + final Uri baseUri; + final Future Function(http.BaseRequest) send; + final Future Function(http.StreamedResponse)? responseError; + final Future> Function(http.StreamedResponse, int)? readBody; + + Uri _uri(String path, Map query) { + if (!path.startsWith('/') || path.startsWith('//')) throw const FormatException('API path must be absolute'); + final uri = baseUri.resolve(path).replace(queryParameters: query.isEmpty ? null : query); + if (uri.origin != baseUri.origin) throw const FormatException('API path escapes configured origin'); + return uri; + } + String _wire(Object? value) { + final encoded = encodePublicJson(value); + if (encoded is String) return encoded; + if (encoded is num || encoded is bool) return encoded.toString(); + throw const FormatException('API parameter must be scalar'); + } + Future _error(http.StreamedResponse response) async => + responseError == null ? ApiStatusException(response.statusCode) : responseError!(response); + Future> _readBounded(http.StreamedResponse response) async { + if (readBody != null) return readBody!(response, 4 << 20); + final bytes = []; + await for (final chunk in response.stream) { + if (bytes.length + chunk.length > (4 << 20)) throw const FormatException('API response exceeds 4 MiB'); + bytes.addAll(chunk); + } + return bytes; + } + +`) + for _, op := range operations { + e.writeOperation(&out, op) + } + out.WriteString("}\n") + return out.String() +} + +type apiParam struct { + Name string + Wire string + Kind string + Type string + Required bool + Array bool +} + +func (e *emitter) operationParams(op clientgen.Operation) []apiParam { + var params []apiParam + used := map[string]bool{} + for _, p := range op.Route.Operation.Parameters { + if p.Schema == nil { + e.err = fmt.Errorf("dart operation %s parameter %s missing schema", op.Route.Operation.OperationID, p.Name) + continue + } + name := fieldName(p.Name) + if used[name] { + e.err = fmt.Errorf("dart operation %s has colliding parameter %s", op.Route.Operation.OperationID, name) + continue + } + used[name] = true + params = append(params, apiParam{Name: name, Wire: p.Name, Kind: p.In, Type: e.dartType(p.Schema, typeName(op.Route.Operation.OperationID)+typeName(p.Name)), Required: p.Required, Array: p.Schema.IsArray()}) + } + if op.RequestBody.JSON != nil { + params = append(params, apiParam{Name: "body", Kind: "json", Type: e.dartType(op.RequestBody.JSON.Schema, typeName(op.Route.Operation.OperationID)+"Body"), Required: op.RequestBody.JSON.Required}) + } + if op.RequestBody.Raw != nil { + params = append(params, apiParam{Name: "body", Kind: "raw", Type: "List", Required: op.RequestBody.Raw.Required}) + } + if op.RequestBody.Multipart != nil { + e.err = fmt.Errorf("dart operation %s has unsupported multipart request body", op.Route.Operation.OperationID) + } + return params +} + +func (e *emitter) operationResult(op clientgen.Operation) (string, *clientgen.Response, bool) { + var body *clientgen.Response + hasEmpty := false + for i := range op.Responses { + response := &op.Responses[i] + if response.Status < 200 || response.Status >= 300 { + continue + } + if response.SSE { + return "http.StreamedResponse", response, true + } + if response.Schema == nil { + hasEmpty = true + continue + } + if body == nil { + body = response + continue + } + old := e.dartType(body.Schema, typeName(op.Route.Operation.OperationID)+"Response") + current := e.dartType(response.Schema, typeName(op.Route.Operation.OperationID)+"Response") + if old != current { + e.err = fmt.Errorf("dart operation %s has differing success response types %s and %s", op.Route.Operation.OperationID, old, current) + } + } + if body == nil { + return "void", nil, false + } + if hasEmpty { + e.err = fmt.Errorf("dart operation %s mixes empty and typed successful responses", op.Route.Operation.OperationID) + } + if body.JSON() { + return e.dartType(body.Schema, typeName(op.Route.Operation.OperationID)+"Response"), body, false + } + if body.Text() { + return "String", body, false + } + return "List", body, false +} + +func (e *emitter) writeOperation(out *strings.Builder, op clientgen.Operation) { + id := op.Route.Operation.OperationID + params := e.operationParams(op) + result, body, sse := e.operationResult(op) + e.writeSignature(out, id, result, params) + e.writeQuery(out, params) + e.writeRequest(out, op, params) + e.writeHeaders(out, params) + e.writeBody(out, op, params) + out.WriteString(" final response = await send(request);\n") + out.WriteString(" if (!const {") + for _, response := range op.Responses { + if response.Status >= 200 && response.Status < 300 { + fmt.Fprintf(out, "%d,", response.Status) + } + } + out.WriteString("}.contains(response.statusCode)) throw await _error(response);\n") + e.writeResult(out, id, body, sse) +} + +func (e *emitter) writeSignature(out *strings.Builder, id, result string, params []apiParam) { + fmt.Fprintf(out, " Future<%s> %s(", result, id) + if len(params) > 0 { + out.WriteString("{") + for _, p := range params { + typ := p.Type + if !p.Required && !strings.HasSuffix(typ, "?") { + typ += "?" + } + if p.Required { + out.WriteString("required ") + } + fmt.Fprintf(out, "%s %s,", typ, p.Name) + } + out.WriteString("}") + } + out.WriteString(") async {\n") +} + +func (e *emitter) writeQuery(out *strings.Builder, params []apiParam) { + out.WriteString(" final query = {};\n") + for _, p := range params { + if p.Kind != "query" { + continue + } + if !p.Required { + fmt.Fprintf(out, " if (%s != null) ", p.Name) + } else { + out.WriteString(" ") + } + if p.Array { + fmt.Fprintf(out, "query[%s] = %s.map(_wire).toList();\n", quote(p.Wire), p.Name) + } else { + fmt.Fprintf(out, "query[%s] = _wire(%s);\n", quote(p.Wire), p.Name) + } + } +} + +func (e *emitter) writeRequest(out *strings.Builder, op clientgen.Operation, params []apiParam) { + path := op.Route.Path + for _, p := range params { + if p.Kind == "path" { + path = strings.ReplaceAll(path, "{"+p.Wire+"}", "${Uri.encodeComponent(_wire("+p.Name+"))}") + } + } + if strings.Contains(path, "/{") { + e.err = fmt.Errorf("dart operation %s has unresolved path parameter in %q", op.Route.Operation.OperationID, path) + } + fmt.Fprintf(out, " final request = http.Request(%s, _uri('%s', query));\n", quote(op.Route.Method), path) + if op.Accept != "" { + fmt.Fprintf(out, " request.headers['Accept'] = %s;\n", quote(op.Accept)) + } +} + +func (e *emitter) writeHeaders(out *strings.Builder, params []apiParam) { + for _, p := range params { + if p.Kind != "header" { + continue + } + if !p.Required { + fmt.Fprintf(out, " if (%s != null) ", p.Name) + } else { + out.WriteString(" ") + } + fmt.Fprintf(out, "request.headers[%s] = _wire(%s);\n", quote(p.Wire), p.Name) + } +} + +func (e *emitter) writeBody(out *strings.Builder, op clientgen.Operation, params []apiParam) { + for _, p := range params { + switch p.Kind { + case "json": + if !p.Required { + out.WriteString(" if (body != null) {\n") + } + out.WriteString(" request.headers['Content-Type'] = 'application/json';\n request.body = jsonEncode(encodePublicJson(body));\n") + if !p.Required { + out.WriteString(" }\n") + } + case "raw": + if !p.Required { + out.WriteString(" if (body != null) {\n") + } + fmt.Fprintf(out, " request.headers['Content-Type'] = %s;\n request.bodyBytes = body;\n", quote(op.RequestBody.Raw.MediaType)) + if !p.Required { + out.WriteString(" }\n") + } + } + } +} + +func (e *emitter) writeResult(out *strings.Builder, id string, body *clientgen.Response, sse bool) { + if sse { + out.WriteString(" return response;\n }\n\n") + return + } + if body == nil { + out.WriteString(" await _readBounded(response);\n return;\n }\n\n") + return + } + out.WriteString(" final bytes = await _readBounded(response);\n") + switch { + case body.JSON(): + expr := e.decodeExpr(body.Schema, "jsonDecode(utf8.decode(bytes))", typeName(id)+"Response") + fmt.Fprintf(out, " return %s;\n", expr) + case body.Text(): + out.WriteString(" return utf8.decode(bytes);\n") + default: + out.WriteString(" return bytes;\n") + } + out.WriteString(" }\n\n") +} diff --git a/internal/dartemit/dartemit.go b/internal/dartemit/dartemit.go new file mode 100644 index 0000000..a76a5e4 --- /dev/null +++ b/internal/dartemit/dartemit.go @@ -0,0 +1,312 @@ +// Package dartemit emits typed Dart models and an injectable HTTP client. +package dartemit + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "slices" + "sort" + "strconv" + "strings" + "time" + "unicode" + + "github.com/responsibleapi/oasmith/internal/clientgen" + "github.com/responsibleapi/oasmith/internal/openapi" +) + +// Options configures Dart output emission. +type Options struct{ OutDir string } + +type emitter struct { + doc *openapi.Document + names map[*openapi.Schema]string + definitions map[string]*openapi.Schema + implemented map[string][]string + err error +} + +// Emit writes Dart models and, in client mode, operation methods for doc. +func Emit(doc *openapi.Document, opts Options, client bool) error { + e := &emitter{doc: doc, names: map[*openapi.Schema]string{}, definitions: map[string]*openapi.Schema{}, implemented: map[string][]string{}} + for _, name := range doc.SchemaNames() { + if name == "string" || name == "String" { + continue + } + e.register(name, doc.Components.Schemas[name]) + } + operations, err := e.discoverOperations(client) + if err != nil { + return err + } + if err := e.discoverAll(); err != nil { + return err + } + models := e.modelsSource() + if e.err != nil { + return e.err + } + return e.writeFiles(opts.OutDir, models, operations, client) +} + +func (e *emitter) discoverOperations(client bool) ([]clientgen.Operation, error) { + if !client { + return nil, nil + } + operations, err := clientgen.Analyze(e.doc) + if err != nil { + return nil, fmt.Errorf("analyze dart client operations: %w", err) + } + for _, op := range operations { + if op.RequestBody.JSON != nil { + e.dartType(op.RequestBody.JSON.Schema, typeName(op.Route.Operation.OperationID)+"Body") + } + for _, param := range op.Route.Operation.Parameters { + e.dartType(param.Schema, typeName(op.Route.Operation.OperationID)+typeName(param.Name)) + } + for _, response := range op.Responses { + if response.Schema != nil { + e.dartType(response.Schema, typeName(op.Route.Operation.OperationID)+"Response") + } + } + } + return operations, e.err +} + +func (e *emitter) discoverAll() error { + // Type discovery also registers inline object and union definitions. + for i := range 1000 { + before := len(e.definitions) + names := e.sortedNames() + for _, name := range names { + e.discover(name, e.definitions[name]) + } + if e.err != nil { + return e.err + } + if len(e.definitions) == before { + return nil + } + if i == 999 { + return fmt.Errorf("dart schema discovery did not converge") + } + } + return nil +} + +func (e *emitter) writeFiles(outDir, models string, operations []clientgen.Operation, client bool) error { + if err := os.MkdirAll(outDir, 0o750); err != nil { + return fmt.Errorf("create dart output directory %q: %w", outDir, err) + } + modelsPath := filepath.Join(outDir, "models.dart") + if err := os.WriteFile(modelsPath, []byte(models), 0o600); err != nil { + return fmt.Errorf("write dart models: %w", err) + } + paths := []string{modelsPath} + if client { + api := e.apiSource(operations) + if e.err != nil { + return e.err + } + apiPath := filepath.Join(outDir, "api.dart") + if err := os.WriteFile(apiPath, []byte(api), 0o600); err != nil { + return fmt.Errorf("write dart client: %w", err) + } + paths = append(paths, apiPath) + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + // #nosec G204 -- The executable is fixed and paths are passed as arguments without a shell. + command := exec.CommandContext(ctx, "dart", append([]string{"format", "--output=write"}, paths...)...) + if output, err := command.CombinedOutput(); err != nil { + return fmt.Errorf("format dart output: %w: %s", err, strings.TrimSpace(string(output))) + } + return nil +} + +func (e *emitter) sortedNames() []string { + names := make([]string, 0, len(e.definitions)) + for name := range e.definitions { + names = append(names, name) + } + sort.Strings(names) + return names +} + +func (e *emitter) register(name string, schema *openapi.Schema) string { + if schema == nil { + return "Object" + } + if existing := e.names[schema]; existing != "" { + return existing + } + name = typeName(name) + if name == "" { + e.err = fmt.Errorf("dart schema has empty type name") + return "Object" + } + if prior := e.definitions[name]; prior != nil && prior != schema { + index := 2 + for e.definitions[fmt.Sprintf("%s%d", name, index)] != nil { + index++ + } + name = fmt.Sprintf("%s%d", name, index) + } + e.names[schema] = name + e.definitions[name] = schema + return name +} + +func (e *emitter) resolve(schema *openapi.Schema) *openapi.Schema { + if schema == nil { + return nil + } + if schema.Ref == "" { + return schema + } + ref := openapi.RefName(schema.Ref) + resolved := e.doc.Components.Schemas[ref] + if resolved == nil { + e.err = fmt.Errorf("dart unresolved schema reference %q", schema.Ref) + } + return resolved +} + +func (e *emitter) dartType(schema *openapi.Schema, hint string) string { + if schema == nil { + e.err = fmt.Errorf("dart missing schema for %s", hint) + return "Object?" + } + if schema.Ref != "" { + name := openapi.RefName(schema.Ref) + if e.doc.Components.Schemas[name] == nil { + e.err = fmt.Errorf("dart unresolved schema reference %q", schema.Ref) + } + if name == "string" || name == "String" { + return "String" + } + return typeName(name) + } + if len(schema.AllOf) > 0 || len(schema.AnyOf) > 0 { + e.err = fmt.Errorf("dart schema %s uses unsupported allOf/anyOf composition", hint) + return "Object?" + } + nullable := schema.Type.Has("null") + var base string + switch { + case len(schema.OneOf) > 0 || len(schema.Enum) > 0 || (schema.IsObject() && len(schema.Properties) > 0): + base = e.register(hint, schema) + case schema.Type.Has("array"): + if schema.Items == nil { + e.err = fmt.Errorf("dart array %s has no items", hint) + return "List" + } + base = "List<" + e.dartType(schema.Items, hint+"Item") + ">" + case schema.Type.Has("string"): + base = "String" + case schema.Type.Has("integer"): + base = "int" + case schema.Type.Has("number"): + base = "double" + case schema.Type.Has("boolean"): + base = "bool" + case schema.Type.Has("object"): + base = "Map" + case schema.Type.Has("null"): + return "Object?" + default: + e.err = fmt.Errorf("dart unsupported schema for %s: type %v", hint, schema.Type) + return "Object?" + } + if nullable { + return base + "?" + } + return base +} + +func (e *emitter) discover(name string, schema *openapi.Schema) { + if schema == nil { + e.err = fmt.Errorf("dart schema %s is nil", name) + return + } + if schema.AdditionalProperties != nil { + if _, ok := schema.AdditionalProperties.(bool); !ok { + e.err = fmt.Errorf("dart schema %s has unsupported typed additionalProperties", name) + return + } + } + if schema.IsOneOf() { + for index, item := range schema.OneOf { + variant := e.dartType(item, name+fmt.Sprintf("Variant%d", index+1)) + if item.Ref != "" { + variant = typeName(openapi.RefName(item.Ref)) + } + if variant == name { + e.err = fmt.Errorf("dart union %s references itself", name) + return + } + if !strings.Contains(variant, "<") && !strings.HasSuffix(variant, "?") { + e.implemented[variant] = appendUnique(e.implemented[variant], name) + } + } + } + for _, key := range schema.SortedPropertyNames() { + e.dartType(schema.Properties[key], name+typeName(key)) + } + if schema.Items != nil { + e.dartType(schema.Items, name+"Item") + } +} + +func appendUnique(values []string, value string) []string { + if slices.Contains(values, value) { + return values + } + return append(values, value) +} + +func typeName(raw string) string { + var parts []string + var current []rune + flush := func() { + if len(current) > 0 { + parts = append(parts, string(current)) + current = nil + } + } + for _, ch := range raw { + if unicode.IsLetter(ch) || unicode.IsDigit(ch) { + current = append(current, ch) + } else { + flush() + } + } + flush() + if len(parts) == 0 { + return "" + } + for i, part := range parts { + parts[i] = strings.ToUpper(part[:1]) + part[1:] + } + return strings.Join(parts, "") +} + +var dartKeywords = map[string]bool{"assert": true, "break": true, "case": true, "catch": true, "class": true, "const": true, "continue": true, "default": true, "do": true, "else": true, "enum": true, "extends": true, "false": true, "final": true, "finally": true, "for": true, "if": true, "in": true, "is": true, "new": true, "null": true, "return": true, "super": true, "switch": true, "this": true, "throw": true, "true": true, "try": true, "var": true, "void": true, "while": true, "with": true, "yield": true} + +func fieldName(raw string) string { + name := typeName(raw) + if name == "" { + return "value" + } + // Preserve initialisms in public schema names only; fields are idiomatic lower camel. + name = strings.ToLower(name[:1]) + name[1:] + if dartKeywords[name] { + return name + "Value" + } + return name +} + +func quote(value string) string { return strconv.Quote(value) } diff --git a/internal/dartemit/models.go b/internal/dartemit/models.go new file mode 100644 index 0000000..5997df2 --- /dev/null +++ b/internal/dartemit/models.go @@ -0,0 +1,302 @@ +package dartemit + +import ( + "fmt" + "sort" + "strings" + + "github.com/responsibleapi/oasmith/internal/openapi" +) + +func (e *emitter) modelsSource() string { + var out strings.Builder + out.WriteString("// Generated by OASmith. Do not edit.\n\n") + out.WriteString("abstract interface class PublicJsonValue { Object? toJson(); }\n\n") + out.WriteString(`Map expectPublicMap(Object? value) { + if (value is! Map) throw const FormatException('expected JSON object'); + final result = {}; + for (final entry in value.entries) { + if (entry.key is! String) throw const FormatException('expected JSON string key'); + result[entry.key as String] = entry.value; + } + return result; +} +List expectPublicList(Object? value) { + if (value is! List) throw const FormatException('expected JSON array'); + return value; +} +String expectPublicString(Object? value) { + if (value is! String) throw const FormatException('expected JSON string'); + return value; +} +int expectPublicInt(Object? value) { + if (value is! int) throw const FormatException('expected JSON integer'); + return value; +} +double expectPublicDouble(Object? value) { + if (value is! num) throw const FormatException('expected JSON number'); + return value.toDouble(); +} +bool expectPublicBool(Object? value) { + if (value is! bool) throw const FormatException('expected JSON boolean'); + return value; +} +Object? requirePublicField(Map value, String key) { + if (!value.containsKey(key)) throw FormatException('missing required field: $key'); + return value[key]; +} +Object? encodePublicJson(Object? value) { + if (value is PublicJsonValue) return value.toJson(); + if (value is List) return value.map(encodePublicJson).toList(); + if (value is Map) return value.map((key, item) => MapEntry(key.toString(), encodePublicJson(item))); + return value; +} + +`) + names := e.sortedNames() + // Unions are declared before variants, so sealed interfaces are available. + for _, name := range names { + if e.definitions[name].IsOneOf() { + e.writeUnion(&out, name, e.definitions[name]) + } + } + for _, name := range names { + schema := e.definitions[name] + if schema.IsOneOf() { + continue + } + switch { + case len(schema.Enum) > 0: + e.writeEnum(&out, name, schema) + case schema.IsObject() && len(schema.Properties) > 0: + e.writeObject(&out, name, schema) + default: + e.writeAlias(&out, name, schema) + } + } + return out.String() +} + +func (e *emitter) writeAlias(out *strings.Builder, name string, schema *openapi.Schema) { + typ := e.dartType(schema, name) + if typ == name { + e.err = fmt.Errorf("dart unsupported alias %s", name) + return + } + fmt.Fprintf(out, "typedef %s = %s;\n", name, typ) + fmt.Fprintf(out, "%s decode%s(Object? value) => %s;\n\n", name, name, e.decodeExpr(schema, "value", name)) +} + +func (e *emitter) writeEnum(out *strings.Builder, name string, schema *openapi.Schema) { + fmt.Fprintf(out, "enum %s implements PublicJsonValue {\n", name) + used := map[string]bool{} + for _, value := range schema.Enum { + member := fieldName(value) + if used[member] { + e.err = fmt.Errorf("dart enum %s has duplicate case %q", name, member) + return + } + used[member] = true + fmt.Fprintf(out, " %s(%s),\n", member, quote(value)) + } + fmt.Fprintf(out, " ;\n const %s(this.value);\n final String value;\n", name) + fmt.Fprintf(out, " static %s fromJson(Object? value) {\n for (final item in values) { if (item.value == value) return item; }\n throw FormatException('unknown %s: $value');\n }\n", name, name) + out.WriteString(" @override String toJson() => value;\n}\n\n") +} + +func (e *emitter) writeObject(out *strings.Builder, name string, schema *openapi.Schema) { + interfaces := []string{"PublicJsonValue"} + interfaces = append(interfaces, e.implemented[name]...) + sort.Strings(interfaces) + fmt.Fprintf(out, "class %s implements %s {\n", name, strings.Join(interfaces, ", ")) + required := schema.RequiredSet() + keys := schema.SortedPropertyNames() + for _, key := range keys { + typ := e.dartType(schema.Properties[key], name+typeName(key)) + if !required[key] && !strings.HasSuffix(typ, "?") { + typ += "?" + } + fmt.Fprintf(out, " final %s %s;\n", typ, fieldName(key)) + } + fmt.Fprintf(out, " const %s({", name) + for _, key := range keys { + if required[key] { + out.WriteString("required ") + } + fmt.Fprintf(out, "this.%s,", fieldName(key)) + } + out.WriteString("});\n") + fmt.Fprintf(out, " factory %s.fromJson(Object? raw) {\n final map = expectPublicMap(raw);\n", name) + if strict, ok := schema.AdditionalProperties.(bool); ok && !strict { + out.WriteString(" for (final key in map.keys) {\n if (!const {") + for _, key := range keys { + fmt.Fprintf(out, "%s,", quote(key)) + } + out.WriteString("}.contains(key)) throw FormatException('unexpected JSON field: $key');\n }\n") + } + for _, key := range keys { + prop := schema.Properties[key] + source := fmt.Sprintf("map[%s]", quote(key)) + if required[key] { + source = fmt.Sprintf("requirePublicField(map,%s)", quote(key)) + } + expr := e.decodeExpr(prop, source, name+typeName(key)) + if !required[key] && !strings.HasSuffix(e.dartType(prop, name+typeName(key)), "?") { + expr = "(" + source + " == null ? null : " + expr + ")" + } + if prop.Const != nil { + if value, ok := prop.Const.(string); ok { + fmt.Fprintf(out, " if (%s != %s) throw const FormatException('invalid %s discriminator');\n", source, quote(value), key) + } + } + fmt.Fprintf(out, " final %s = %s;\n", fieldName(key), expr) + } + fmt.Fprintf(out, " return %s(", name) + for _, key := range keys { + fmt.Fprintf(out, "%s: %s,", fieldName(key), fieldName(key)) + } + out.WriteString(");\n }\n") + out.WriteString(" @override Map toJson() => {") + for _, key := range keys { + field := fieldName(key) + if required[key] { + fmt.Fprintf(out, "%s: encodePublicJson(%s),", quote(key), field) + } else { + fmt.Fprintf(out, "if (%s != null) %s: encodePublicJson(%s),", field, quote(key), field) + } + } + out.WriteString("};\n}\n\n") +} + +func (e *emitter) writeUnion(out *strings.Builder, name string, schema *openapi.Schema) { + interfaces := append([]string{"PublicJsonValue"}, e.implemented[name]...) + sort.Strings(interfaces) + fmt.Fprintf(out, "sealed class %s implements %s {\n const %s();\n factory %s.fromJson(Object? value) {\n final map = expectPublicMap(value);\n", name, strings.Join(interfaces, ", "), name, name) + tag := e.unionTag(schema) + if tag != "" { + fmt.Fprintf(out, " switch (map[%s]) {\n", quote(tag)) + for index, item := range schema.OneOf { + resolved := e.resolve(item) + variant := e.dartType(item, name+fmt.Sprintf("Variant%d", index+1)) + values := e.unionValues(schema, item, resolved, tag) + sort.Strings(values) + for _, value := range values { + fmt.Fprintf(out, " case %s:\n", quote(value)) + } + if len(values) > 0 { + fmt.Fprintf(out, " return %s;\n", e.decodeExpr(item, "value", variant)) + } + } + fmt.Fprintf(out, " default: throw FormatException('unknown %s discriminator: ${map[%s]}');\n }\n", name, quote(tag)) + } else { + for _, item := range schema.OneOf { + fmt.Fprintf(out, " try { return %s; } on FormatException { /* next variant */ }\n", e.decodeExpr(item, "value", name)) + } + fmt.Fprintf(out, " throw const FormatException('no %s variant matched');\n", name) + } + out.WriteString(" }\n}\n\n") +} + +func (e *emitter) unionTag(schema *openapi.Schema) string { + if schema.Discriminator != nil { + return schema.Discriminator.PropertyName + } + for _, item := range schema.OneOf { + resolved := e.resolve(item) + if resolved == nil { + return "" + } + for key, property := range resolved.Properties { + if property.Const == nil { + continue + } + all := true + for _, other := range schema.OneOf { + candidate := e.resolve(other) + if candidate == nil || candidate.Properties[key] == nil || candidate.Properties[key].Const == nil { + all = false + break + } + } + if all { + return key + } + } + } + return "" +} + +func (e *emitter) unionValues(union, item, resolved *openapi.Schema, tag string) []string { + var values []string + if union.Discriminator != nil { + for value, ref := range union.Discriminator.Mapping { + if item.Ref != "" && openapi.RefName(ref) == openapi.RefName(item.Ref) { + values = append(values, value) + } + } + } + if len(values) > 0 { + return values + } + if resolved != nil && resolved.Properties[tag] != nil { + if value, ok := resolved.Properties[tag].Const.(string); ok { + return []string{value} + } + if len(resolved.Properties[tag].Enum) > 0 { + return resolved.Properties[tag].Enum + } + } + if item.Ref != "" { + return []string{openapi.RefName(item.Ref)} + } + e.err = fmt.Errorf("dart union variant has no %s discriminator", tag) + return nil +} + +func (e *emitter) decodeExpr(schema *openapi.Schema, value, hint string) string { + if schema == nil { + e.err = fmt.Errorf("dart missing decode schema for %s", hint) + return value + } + if schema.Ref != "" { + name := openapi.RefName(schema.Ref) + if name == "string" || name == "String" { + return "expectPublicString(" + value + ")" + } + resolved := e.resolve(schema) + if resolved == nil { + return value + } + if resolved.IsOneOf() || len(resolved.Enum) > 0 || (resolved.IsObject() && len(resolved.Properties) > 0) { + return typeName(name) + ".fromJson(" + value + ")" + } + return "decode" + typeName(name) + "(" + value + ")" + } + nullable := schema.Type.Has("null") + var expr string + switch { + case len(schema.OneOf) > 0 || len(schema.Enum) > 0 || (schema.IsObject() && len(schema.Properties) > 0): + expr = e.register(hint, schema) + ".fromJson(" + value + ")" + case schema.Type.Has("array"): + expr = "expectPublicList(" + value + ").map((item) => " + e.decodeExpr(schema.Items, "item", hint+"Item") + ").toList()" + case schema.Type.Has("string"): + expr = "expectPublicString(" + value + ")" + case schema.Type.Has("integer"): + expr = "expectPublicInt(" + value + ")" + case schema.Type.Has("number"): + expr = "expectPublicDouble(" + value + ")" + case schema.Type.Has("boolean"): + expr = "expectPublicBool(" + value + ")" + case schema.Type.Has("object"): + expr = "expectPublicMap(" + value + ")" + case schema.Type.Has("null"): + return "null" + default: + e.err = fmt.Errorf("dart unsupported decode for %s", hint) + return value + } + if nullable { + return "(" + value + " == null ? null : " + expr + ")" + } + return expr +} diff --git a/internal/openapi/openapi.go b/internal/openapi/openapi.go index 60a0d25..f8295cb 100644 --- a/internal/openapi/openapi.go +++ b/internal/openapi/openapi.go @@ -117,6 +117,8 @@ type Schema struct { MinItems *int `yaml:"minItems"` MaxItems *int `yaml:"maxItems"` OneOf []*Schema `yaml:"oneOf"` + AllOf []*Schema `yaml:"allOf"` + AnyOf []*Schema `yaml:"anyOf"` Discriminator *Discriminator `yaml:"discriminator"` ContentMediaType string `yaml:"contentMediaType"` ContentSchema *Schema `yaml:"contentSchema"` diff --git a/moon.yml b/moon.yml index 19967b2..68e487d 100644 --- a/moon.yml +++ b/moon.yml @@ -55,6 +55,7 @@ tasks: - ~:lint - ~:test - ~:test-rust + - ~:test-dart inputs: [] options: runInCI: false @@ -82,3 +83,34 @@ tasks: command: cargo test --locked --manifest-path testdata/rust-client/Cargo.toml options: cache: true + generate-dart-fixtures: + deps: [~:mod-download] + inputs: + - "@group(sources)" + - testdata/fixtures/*.yaml + script: |- + set -euo pipefail + go run ./cmd/oasmith --openapi testdata/fixtures/public-client.yaml --mode client --lang dart --out testdata/dart-client/lib/public + go run ./cmd/oasmith --openapi testdata/fixtures/rust-models.yaml --mode types --lang dart --out testdata/dart-client/lib/models + outputs: + - testdata/dart-client/lib/public/api.dart + - testdata/dart-client/lib/public/models.dart + - testdata/dart-client/lib/models/models.dart + options: + cache: true + test-dart: + deps: [~:generate-dart-fixtures] + inputs: + - "@group(sources)" + - testdata/fixtures/*.yaml + - testdata/dart-client/pubspec.yaml + - testdata/dart-client/lib/**/*.dart + - testdata/dart-client/bin/**/*.dart + script: |- + set -euo pipefail + cd testdata/dart-client + dart pub get + dart analyze + dart run bin/check.dart + options: + cache: true diff --git a/oasmith_test.go b/oasmith_test.go index 06ddf15..781e968 100644 --- a/oasmith_test.go +++ b/oasmith_test.go @@ -7,6 +7,7 @@ import ( "strings" "testing" + "github.com/responsibleapi/oasmith/internal/dartemit" "github.com/responsibleapi/oasmith/internal/goemit" "github.com/responsibleapi/oasmith/internal/openapi" "github.com/responsibleapi/oasmith/internal/tsemit" @@ -69,6 +70,14 @@ func TestGoldenFixtures(t *testing.T) { return tsemit.Emit(doc, tsemit.Options{OutDir: outDir}) }, }, + { + name: "public-client-dart", + fixture: "public-client.yaml", + golden: "public-client-dart", + emit: func(doc *openapi.Document, outDir string, _ string) error { + return dartemit.Emit(doc, dartemit.Options{OutDir: outDir}, true) + }, + }, } { t.Run(testCase.name, func(t *testing.T) { t.Parallel() diff --git a/testdata/dart-client/bin/check.dart b/testdata/dart-client/bin/check.dart new file mode 100644 index 0000000..c3a650e --- /dev/null +++ b/testdata/dart-client/bin/check.dart @@ -0,0 +1,58 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; +import 'package:oasmith_dart_fixture/public/api.dart'; + +void expect(bool condition, String message) { + if (!condition) throw StateError(message); +} + +Future main() async { + final requests = []; + var reads = 0; + final client = PublicApiClient( + baseUri: Uri.parse('https://api.example.test/'), + send: (baseRequest) async { + final request = baseRequest as http.Request; + requests.add(request); + return http.StreamedResponse( + Stream.value(utf8.encode('{"id":"thing-1","name":"example"}')), + 201, + ); + }, + readBody: (response, maxBytes) async { + expect(maxBytes == 4 << 20, 'body limit was not passed to transport'); + reads++; + return response.stream.toBytes(); + }, + ); + final thing = await client.createThing( + thingId: 'space/and?query', + tag: 'one two', + notify: true, + label: ['first', 'second'], + xRequestId: 'request-1', + body: const CreateThing(name: 'example'), + ); + expect(thing.id == 'thing-1', 'typed response did not decode'); + expect(reads == 1, 'transport body reader was not used'); + final request = requests.single; + expect(request.url.path == '/things/space%2Fand%3Fquery', 'path parameter was not encoded'); + expect(request.url.toString().contains('space%2Fand%3Fquery'), 'path parameter was not encoded'); + expect(request.url.queryParametersAll['label']?.join(',') == 'first,second', 'array query was not repeated'); + expect(request.url.queryParameters['notify'] == 'true', 'boolean query was not encoded'); + expect(request.url.queryParameters['tag'] == 'one two', 'optional query was not encoded'); + expect(request.headers['x-request-id'] == 'request-1', 'header parameter was not passed'); + expect(jsonDecode(request.body)['name'] == 'example', 'typed body was not encoded'); + final terminal = TerminalResult.fromJson({ + 'status': 'completed', + 'thing': {'id': 'thing-1', 'name': 'example'}, + }); + expect(terminal is CompletedResult && terminal.thing.id == 'thing-1', 'discriminated union did not decode'); + try { + TerminalResult.fromJson({'status': 'unknown'}); + throw StateError('unknown discriminator was accepted'); + } on FormatException { + // Expected: unknown variants must not silently decode. + } +} diff --git a/testdata/dart-client/pubspec.lock b/testdata/dart-client/pubspec.lock new file mode 100644 index 0000000..5234804 --- /dev/null +++ b/testdata/dart-client/pubspec.lock @@ -0,0 +1,93 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + http: + dependency: "direct main" + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + meta: + dependency: transitive + description: + name: meta + sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" + url: "https://pub.dev" + source: hosted + version: "1.19.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" +sdks: + dart: ">=3.13.0 <4.0.0" diff --git a/testdata/dart-client/pubspec.yaml b/testdata/dart-client/pubspec.yaml new file mode 100644 index 0000000..78ac5a3 --- /dev/null +++ b/testdata/dart-client/pubspec.yaml @@ -0,0 +1,6 @@ +name: oasmith_dart_fixture +publish_to: none +environment: + sdk: ^3.13.0 +dependencies: + http: ^1.5.0 diff --git a/testdata/golden/public-client-dart/api.dart b/testdata/golden/public-client-dart/api.dart new file mode 100644 index 0000000..7a20100 --- /dev/null +++ b/testdata/golden/public-client-dart/api.dart @@ -0,0 +1,148 @@ +// Generated by OASmith. Do not edit. +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +import 'models.dart'; +export 'models.dart'; + +class ApiStatusException implements Exception { + const ApiStatusException(this.statusCode); + final int statusCode; + @override + String toString() => 'API returned HTTP $statusCode'; +} + +class PublicApiClient { + PublicApiClient({ + required this.baseUri, + required this.send, + this.responseError, + this.readBody, + }); + final Uri baseUri; + final Future Function(http.BaseRequest) send; + final Future Function(http.StreamedResponse)? responseError; + final Future> Function(http.StreamedResponse, int)? readBody; + + Uri _uri(String path, Map query) { + if (!path.startsWith('/') || path.startsWith('//')) + throw const FormatException('API path must be absolute'); + final uri = baseUri + .resolve(path) + .replace(queryParameters: query.isEmpty ? null : query); + if (uri.origin != baseUri.origin) + throw const FormatException('API path escapes configured origin'); + return uri; + } + + String _wire(Object? value) { + final encoded = encodePublicJson(value); + if (encoded is String) return encoded; + if (encoded is num || encoded is bool) return encoded.toString(); + throw const FormatException('API parameter must be scalar'); + } + + Future _error(http.StreamedResponse response) async => + responseError == null + ? ApiStatusException(response.statusCode) + : responseError!(response); + Future> _readBounded(http.StreamedResponse response) async { + if (readBody != null) return readBody!(response, 4 << 20); + final bytes = []; + await for (final chunk in response.stream) { + if (bytes.length + chunk.length > (4 << 20)) + throw const FormatException('API response exceeds 4 MiB'); + bytes.addAll(chunk); + } + return bytes; + } + + Future watchEvents() async { + final query = {}; + final request = http.Request("GET", _uri('/events', query)); + request.headers['Accept'] = "text/event-stream"; + final response = await send(request); + if (!const {200}.contains(response.statusCode)) + throw await _error(response); + return response; + } + + Future patchThing({CreateThing? body}) async { + final query = {}; + final request = http.Request("POST", _uri('/optional-json', query)); + request.headers['Accept'] = "*/*"; + if (body != null) { + request.headers['Content-Type'] = 'application/json'; + request.body = jsonEncode(encodePublicJson(body)); + } + final response = await send(request); + if (!const {204}.contains(response.statusCode)) + throw await _error(response); + await _readBounded(response); + return; + } + + Future uploadOptionalMedia({List? body}) async { + final query = {}; + final request = http.Request("POST", _uri('/optional-raw', query)); + request.headers['Accept'] = "*/*"; + if (body != null) { + request.headers['Content-Type'] = "application/octet-stream"; + request.bodyBytes = body; + } + final response = await send(request); + if (!const {204}.contains(response.statusCode)) + throw await _error(response); + await _readBounded(response); + return; + } + + Future createThing({ + required String thingId, + String? tag, + required bool notify, + List? label, + required String xRequestId, + required CreateThing body, + }) async { + final query = {}; + if (tag != null) query["tag"] = _wire(tag); + query["notify"] = _wire(notify); + if (label != null) query["label"] = label.map(_wire).toList(); + final request = http.Request( + "POST", + _uri('/things/${Uri.encodeComponent(_wire(thingId))}', query), + ); + request.headers['Accept'] = "application/json"; + request.headers["x-request-id"] = _wire(xRequestId); + request.headers['Content-Type'] = 'application/json'; + request.body = jsonEncode(encodePublicJson(body)); + final response = await send(request); + if (!const {201}.contains(response.statusCode)) + throw await _error(response); + final bytes = await _readBounded(response); + return Thing.fromJson(jsonDecode(utf8.decode(bytes))); + } + + Future uploadMedia({ + required String owner, + required String uploadType, + required List body, + }) async { + final query = {}; + query["uploadType"] = _wire(uploadType); + final request = http.Request( + "POST", + _uri('/uploads/${Uri.encodeComponent(_wire(owner))}', query), + ); + request.headers['Accept'] = "application/json"; + request.headers['Content-Type'] = "application/octet-stream"; + request.bodyBytes = body; + final response = await send(request); + if (!const {201}.contains(response.statusCode)) + throw await _error(response); + final bytes = await _readBounded(response); + return Thing.fromJson(jsonDecode(utf8.decode(bytes))); + } +} diff --git a/testdata/golden/public-client-dart/models.dart b/testdata/golden/public-client-dart/models.dart new file mode 100644 index 0000000..79cdc1b --- /dev/null +++ b/testdata/golden/public-client-dart/models.dart @@ -0,0 +1,244 @@ +// Generated by OASmith. Do not edit. + +abstract interface class PublicJsonValue { + Object? toJson(); +} + +Map expectPublicMap(Object? value) { + if (value is! Map) throw const FormatException('expected JSON object'); + final result = {}; + for (final entry in value.entries) { + if (entry.key is! String) + throw const FormatException('expected JSON string key'); + result[entry.key as String] = entry.value; + } + return result; +} + +List expectPublicList(Object? value) { + if (value is! List) throw const FormatException('expected JSON array'); + return value; +} + +String expectPublicString(Object? value) { + if (value is! String) throw const FormatException('expected JSON string'); + return value; +} + +int expectPublicInt(Object? value) { + if (value is! int) throw const FormatException('expected JSON integer'); + return value; +} + +double expectPublicDouble(Object? value) { + if (value is! num) throw const FormatException('expected JSON number'); + return value.toDouble(); +} + +bool expectPublicBool(Object? value) { + if (value is! bool) throw const FormatException('expected JSON boolean'); + return value; +} + +Object? requirePublicField(Map value, String key) { + if (!value.containsKey(key)) + throw FormatException('missing required field: $key'); + return value[key]; +} + +Object? encodePublicJson(Object? value) { + if (value is PublicJsonValue) return value.toJson(); + if (value is List) return value.map(encodePublicJson).toList(); + if (value is Map) + return value.map( + (key, item) => MapEntry(key.toString(), encodePublicJson(item)), + ); + return value; +} + +sealed class Event implements PublicJsonValue { + const Event(); + factory Event.fromJson(Object? value) { + final map = expectPublicMap(value); + switch (map["event"]) { + case "progress": + return EventVariant1.fromJson(value); + case "terminal": + return EventVariant2.fromJson(value); + default: + throw FormatException('unknown Event discriminator: ${map["event"]}'); + } + } +} + +sealed class TerminalResult implements PublicJsonValue { + const TerminalResult(); + factory TerminalResult.fromJson(Object? value) { + final map = expectPublicMap(value); + switch (map["status"]) { + case "completed": + return CompletedResult.fromJson(value); + case "failed": + return FailedResult.fromJson(value); + default: + throw FormatException( + 'unknown TerminalResult discriminator: ${map["status"]}', + ); + } + } +} + +class CompletedResult implements PublicJsonValue, TerminalResult { + final String status; + final Thing thing; + const CompletedResult({required this.status, required this.thing}); + factory CompletedResult.fromJson(Object? raw) { + final map = expectPublicMap(raw); + if (requirePublicField(map, "status") != "completed") + throw const FormatException('invalid status discriminator'); + final status = expectPublicString(requirePublicField(map, "status")); + final thing = Thing.fromJson(requirePublicField(map, "thing")); + return CompletedResult(status: status, thing: thing); + } + @override + Map toJson() => { + "status": encodePublicJson(status), + "thing": encodePublicJson(thing), + }; +} + +class CreateThing implements PublicJsonValue { + final String name; + const CreateThing({required this.name}); + factory CreateThing.fromJson(Object? raw) { + final map = expectPublicMap(raw); + final name = expectPublicString(requirePublicField(map, "name")); + return CreateThing(name: name); + } + @override + Map toJson() => {"name": encodePublicJson(name)}; +} + +class EventVariant1 implements Event, PublicJsonValue { + final String data; + final String event; + const EventVariant1({required this.data, required this.event}); + factory EventVariant1.fromJson(Object? raw) { + final map = expectPublicMap(raw); + final data = expectPublicString(requirePublicField(map, "data")); + if (requirePublicField(map, "event") != "progress") + throw const FormatException('invalid event discriminator'); + final event = expectPublicString(requirePublicField(map, "event")); + return EventVariant1(data: data, event: event); + } + @override + Map toJson() => { + "data": encodePublicJson(data), + "event": encodePublicJson(event), + }; +} + +class EventVariant2 implements Event, PublicJsonValue { + final String data; + final String event; + const EventVariant2({required this.data, required this.event}); + factory EventVariant2.fromJson(Object? raw) { + final map = expectPublicMap(raw); + final data = expectPublicString(requirePublicField(map, "data")); + if (requirePublicField(map, "event") != "terminal") + throw const FormatException('invalid event discriminator'); + final event = expectPublicString(requirePublicField(map, "event")); + return EventVariant2(data: data, event: event); + } + @override + Map toJson() => { + "data": encodePublicJson(data), + "event": encodePublicJson(event), + }; +} + +class FailedResult implements PublicJsonValue, TerminalResult { + final String message; + final String status; + const FailedResult({required this.message, required this.status}); + factory FailedResult.fromJson(Object? raw) { + final map = expectPublicMap(raw); + final message = expectPublicString(requirePublicField(map, "message")); + if (requirePublicField(map, "status") != "failed") + throw const FormatException('invalid status discriminator'); + final status = expectPublicString(requirePublicField(map, "status")); + return FailedResult(message: message, status: status); + } + @override + Map toJson() => { + "message": encodePublicJson(message), + "status": encodePublicJson(status), + }; +} + +class Problem implements PublicJsonValue { + final String message; + const Problem({required this.message}); + factory Problem.fromJson(Object? raw) { + final map = expectPublicMap(raw); + final message = expectPublicString(requirePublicField(map, "message")); + return Problem(message: message); + } + @override + Map toJson() => {"message": encodePublicJson(message)}; +} + +class ProgressEvent implements PublicJsonValue { + final String kind; + final int percent; + const ProgressEvent({required this.kind, required this.percent}); + factory ProgressEvent.fromJson(Object? raw) { + final map = expectPublicMap(raw); + if (requirePublicField(map, "kind") != "progress") + throw const FormatException('invalid kind discriminator'); + final kind = expectPublicString(requirePublicField(map, "kind")); + final percent = expectPublicInt(requirePublicField(map, "percent")); + return ProgressEvent(kind: kind, percent: percent); + } + @override + Map toJson() => { + "kind": encodePublicJson(kind), + "percent": encodePublicJson(percent), + }; +} + +class TerminalEvent implements PublicJsonValue { + final String kind; + final TerminalResult result; + const TerminalEvent({required this.kind, required this.result}); + factory TerminalEvent.fromJson(Object? raw) { + final map = expectPublicMap(raw); + if (requirePublicField(map, "kind") != "terminal") + throw const FormatException('invalid kind discriminator'); + final kind = expectPublicString(requirePublicField(map, "kind")); + final result = TerminalResult.fromJson(requirePublicField(map, "result")); + return TerminalEvent(kind: kind, result: result); + } + @override + Map toJson() => { + "kind": encodePublicJson(kind), + "result": encodePublicJson(result), + }; +} + +class Thing implements PublicJsonValue { + final String id; + final String name; + const Thing({required this.id, required this.name}); + factory Thing.fromJson(Object? raw) { + final map = expectPublicMap(raw); + final id = expectPublicString(requirePublicField(map, "id")); + final name = expectPublicString(requirePublicField(map, "name")); + return Thing(id: id, name: name); + } + @override + Map toJson() => { + "id": encodePublicJson(id), + "name": encodePublicJson(name), + }; +}