From 15a493a4fe4d17cb8483d99de40e25a6fab4b1c5 Mon Sep 17 00:00:00 2001 From: adelnizamutdinov Date: Fri, 2 Oct 2026 10:19:19 +0000 Subject: [PATCH 1/2] Remove Dart generator and SDK dependency --- .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 ----------------- 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 -------------- 15 files changed, 8 insertions(+), 1504 deletions(-) delete mode 100644 internal/dartemit/api.go delete mode 100644 internal/dartemit/dartemit.go delete mode 100644 internal/dartemit/models.go delete mode 100644 testdata/dart-client/bin/check.dart delete mode 100644 testdata/dart-client/pubspec.lock delete mode 100644 testdata/dart-client/pubspec.yaml delete mode 100644 testdata/golden/public-client-dart/api.dart delete mode 100644 testdata/golden/public-client-dart/models.dart diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 75df67e..25683e6 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -22,9 +22,6 @@ 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 70eff33..ce478b2 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,3 @@ dump-*.json /testdata/rust-client/src/public/ /testdata/rust-client/src/models/ /testdata/rust-client/src/responses/ -/testdata/dart-client/.dart_tool/ -/testdata/dart-client/lib/public/ -/testdata/dart-client/lib/models/ diff --git a/README.md b/README.md index 0be59d9..f39c2e3 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # OASmith -OASmith generates focused Go, TypeScript, Rust, and Dart code from OpenAPI YAML or JSON +OASmith generates focused Go, TypeScript, and Rust 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,8 +14,6 @@ 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, @@ -43,7 +41,7 @@ Every invocation requires: - `--openapi`: input OpenAPI YAML or JSON document; - `--mode`: `types` or `client`; -- `--lang`: `go`, `typescript`, `rust`, or `dart`, subject to the supported pairs above; +- `--lang`: `go`, `typescript`, or `rust`, subject to the supported pairs above; - `--out`: generated output directory. JSON input is supported alongside YAML. The document syntax is accepted @@ -52,16 +50,6 @@ 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 93d898e..ec07319 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -5,7 +5,6 @@ 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" @@ -31,8 +30,6 @@ 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": @@ -42,7 +39,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, client/rust, types/dart, and client/dart", opts.Mode, opts.Lang) + 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) } } @@ -55,15 +52,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" || opts.Lang == "dart")) || + if ((opts.Mode == "types" || opts.Mode == "client") && opts.Lang == "rust") || (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, client/rust, types/dart, and client/dart", 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, and client/rust", opts.Mode, opts.Lang) } diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 02d570c..91fd6e0 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, client/rust, types/dart, and client/dart") { + if !strings.Contains(err.Error(), "valid pairs are types/go, client/go, client/typescript, types/rust, and client/rust") { t.Fatalf("Parse error = %q, want valid pair message", err.Error()) } } diff --git a/internal/dartemit/api.go b/internal/dartemit/api.go deleted file mode 100644 index df4e0c4..0000000 --- a/internal/dartemit/api.go +++ /dev/null @@ -1,271 +0,0 @@ -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 deleted file mode 100644 index a76a5e4..0000000 --- a/internal/dartemit/dartemit.go +++ /dev/null @@ -1,312 +0,0 @@ -// 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 deleted file mode 100644 index 5997df2..0000000 --- a/internal/dartemit/models.go +++ /dev/null @@ -1,302 +0,0 @@ -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/moon.yml b/moon.yml index cdbc937..8c2a524 100644 --- a/moon.yml +++ b/moon.yml @@ -55,7 +55,6 @@ tasks: - ~:lint - ~:test - ~:test-rust - - ~:test-dart inputs: [] options: runInCI: false @@ -85,34 +84,3 @@ 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 781e968..06ddf15 100644 --- a/oasmith_test.go +++ b/oasmith_test.go @@ -7,7 +7,6 @@ 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" @@ -70,14 +69,6 @@ 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 deleted file mode 100644 index c3a650e..0000000 --- a/testdata/dart-client/bin/check.dart +++ /dev/null @@ -1,58 +0,0 @@ -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 deleted file mode 100644 index 5234804..0000000 --- a/testdata/dart-client/pubspec.lock +++ /dev/null @@ -1,93 +0,0 @@ -# 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 deleted file mode 100644 index 78ac5a3..0000000 --- a/testdata/dart-client/pubspec.yaml +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index 7a20100..0000000 --- a/testdata/golden/public-client-dart/api.dart +++ /dev/null @@ -1,148 +0,0 @@ -// 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 deleted file mode 100644 index 79cdc1b..0000000 --- a/testdata/golden/public-client-dart/models.dart +++ /dev/null @@ -1,244 +0,0 @@ -// 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), - }; -} From 284a6740977c694bcbe5c38f71ed8c51a00ea295 Mon Sep 17 00:00:00 2001 From: adelnizamutdinov Date: Fri, 2 Oct 2026 10:28:14 +0000 Subject: [PATCH 2/2] Keep Dart generation without SDK formatting --- README.md | 15 +- internal/cli/cli.go | 13 +- internal/cli/cli_test.go | 2 +- internal/dartemit/api.go | 271 ++++++++++++++++++++++++++++++ internal/dartemit/dartemit.go | 300 +++++++++++++++++++++++++++++++++ internal/dartemit/models.go | 302 ++++++++++++++++++++++++++++++++++ 6 files changed, 895 insertions(+), 8 deletions(-) create mode 100644 internal/dartemit/api.go create mode 100644 internal/dartemit/dartemit.go create mode 100644 internal/dartemit/models.go diff --git a/README.md b/README.md index f39c2e3..1c65daf 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,15 @@ 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. +Dart generation writes files directly without a Dart or Flutter SDK. + 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..2b5b0a4 --- /dev/null +++ b/internal/dartemit/dartemit.go @@ -0,0 +1,300 @@ +// Package dartemit emits typed Dart models and an injectable HTTP client. +package dartemit + +import ( + "fmt" + "os" + "path/filepath" + "slices" + "sort" + "strconv" + "strings" + "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) + } + 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) + } + } + 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 +}