Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
16 changes: 14 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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.

Expand Down
13 changes: 8 additions & 5 deletions internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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":
Expand All @@ -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)
}
}

Expand All @@ -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 <openapidoc> --mode <types|client> --lang <go|typescript|rust> --out <dir>")
return Options{}, fmt.Errorf("usage: oasmith --openapi <openapidoc> --mode <types|client> --lang <go|typescript|rust|dart> --out <dir>")
}
if opts.OpenAPI == "" || opts.Mode == "" || opts.Lang == "" || opts.Out == "" {
return Options{}, fmt.Errorf("usage: oasmith --openapi <openapidoc> --mode <types|client> --lang <go|typescript|rust> --out <dir>")
return Options{}, fmt.Errorf("usage: oasmith --openapi <openapidoc> --mode <types|client> --lang <go|typescript|rust|dart> --out <dir>")
}
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)
}
2 changes: 1 addition & 1 deletion internal/cli/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
}
Expand Down
271 changes: 271 additions & 0 deletions internal/dartemit/api.go
Original file line number Diff line number Diff line change
@@ -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<http.StreamedResponse> Function(http.BaseRequest) send;
final Future<Exception> Function(http.StreamedResponse)? responseError;
final Future<List<int>> Function(http.StreamedResponse, int)? readBody;

Uri _uri(String path, Map<String, dynamic> 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<Exception> _error(http.StreamedResponse response) async =>
responseError == null ? ApiStatusException(response.statusCode) : responseError!(response);
Future<List<int>> _readBounded(http.StreamedResponse response) async {
if (readBody != null) return readBody!(response, 4 << 20);
final bytes = <int>[];
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<int>", 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<int>", 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 <int>{")
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 = <String, dynamic>{};\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")
}
Loading