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..1c65daf 100644 --- a/README.md +++ b/README.md @@ -59,8 +59,7 @@ 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. +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/dartemit/dartemit.go b/internal/dartemit/dartemit.go index a76a5e4..2b5b0a4 100644 --- a/internal/dartemit/dartemit.go +++ b/internal/dartemit/dartemit.go @@ -2,16 +2,13 @@ package dartemit import ( - "context" "fmt" "os" - "os/exec" "path/filepath" "slices" "sort" "strconv" "strings" - "time" "unicode" "github.com/responsibleapi/oasmith/internal/clientgen" @@ -105,7 +102,6 @@ func (e *emitter) writeFiles(outDir, models string, operations []clientgen.Opera 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 { @@ -115,14 +111,6 @@ func (e *emitter) writeFiles(outDir, models string, operations []clientgen.Opera 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 } 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), - }; -}