From 78f63a50ffc51536ef76df1f0457781ea9093a5b Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Wed, 1 Jul 2026 11:22:28 +0200 Subject: [PATCH 01/11] spike(codegen): Smithy/OpenAPI to idiomatic Dart HTTP client emitter Explores generating the supabase-dart HTTP layer (Storage + Functions) from the shared Smithy models in supabase/sdk#51. Adds a small custom Dart emitter that consumes the committed OpenAPI artifacts and produces http-based clients with streaming upload, streaming response, multipart and per-request header injection. Off-the -shelf generators (dart-dio, dart) were rejected for lacking streaming and requiring build_runner. Part of SDK-1106. --- codegen/.gitignore | 2 + codegen/README.md | 113 ++ codegen/bin/generate.dart | 536 +++++++ .../lib/src/generated/functions_api.g.dart | 143 ++ codegen/lib/src/generated/storage_api.g.dart | 945 +++++++++++ codegen/lib/src/runtime.dart | 138 ++ codegen/lib/supabase_codegen_spike.dart | 3 + codegen/openapi/FunctionsService.openapi.json | 305 ++++ codegen/openapi/StorageService.openapi.json | 1388 +++++++++++++++++ codegen/pubspec.yaml | 16 + codegen/test/generated_client_test.dart | 217 +++ 11 files changed, 3806 insertions(+) create mode 100644 codegen/.gitignore create mode 100644 codegen/README.md create mode 100644 codegen/bin/generate.dart create mode 100644 codegen/lib/src/generated/functions_api.g.dart create mode 100644 codegen/lib/src/generated/storage_api.g.dart create mode 100644 codegen/lib/src/runtime.dart create mode 100644 codegen/lib/supabase_codegen_spike.dart create mode 100644 codegen/openapi/FunctionsService.openapi.json create mode 100644 codegen/openapi/StorageService.openapi.json create mode 100644 codegen/pubspec.yaml create mode 100644 codegen/test/generated_client_test.dart diff --git a/codegen/.gitignore b/codegen/.gitignore new file mode 100644 index 000000000..05d029502 --- /dev/null +++ b/codegen/.gitignore @@ -0,0 +1,2 @@ +.dart_tool/ +pubspec.lock diff --git a/codegen/README.md b/codegen/README.md new file mode 100644 index 000000000..30ac15833 --- /dev/null +++ b/codegen/README.md @@ -0,0 +1,113 @@ +# Dart/Flutter codegen spike (SDK-1106) + +> **DO NOT MERGE — Spike / Proof of Concept.** + +Investigation into generating the mechanical HTTP layer of `supabase-dart` +(Storage + Functions) from the shared Smithy models in +[supabase/sdk#51](https://github.com/supabase/sdk/pull/51), producing idiomatic +Dart a maintainer would own long-term. + +Companion to the Swift spike +([supabase/supabase-swift#1047](https://github.com/supabase/supabase-swift/pull/1047)). + +## TL;DR — recommendation + +**Adopt a small custom Dart emitter.** It is the only option that meets all +three hard constraints at once: + +1. streaming request bodies (`Stream>`, mandatory for TUS + large + mobile uploads), +2. streaming responses (mandatory for Functions SSE/streaming), +3. no `build_runner` and no `dart:io` (so Web/WASM works). + +Off-the-shelf generators were **rejected**: + +| Toolchain | Verdict | Reason | +|-----------|---------|--------| +| OpenAPI Generator `dart-dio` | Reject | Requires `build_runner` + `built_value` + `dio`; no streaming request/response body support | +| OpenAPI Generator `dart` | Reject | Uses `http` but buffers all bodies; no streaming; less idiomatic output | +| TypeSpec / Smithy Dart emitter | N/A | No official Dart emitter exists | +| Speakeasy | N/A | No Dart target | + +The custom emitter is `bin/generate.dart` (~500 lines, zero deps beyond the +Dart SDK). It consumes the committed OpenAPI artifacts and writes `http`-based +clients into `lib/src/generated/`. Building it took roughly the length of this +spike, and extending it to Auth is incremental. + +## What's here + +``` +codegen/ + openapi/ # artifacts copied verbatim from supabase/sdk#51 + StorageService.openapi.json + FunctionsService.openapi.json + bin/generate.dart # the emitter (dart run bin/generate.dart) + lib/ + src/runtime.dart # hand-written transport: ApiClient, streaming, errors + src/generated/ + storage_api.g.dart # GENERATED — 18 operations + models + functions_api.g.dart # GENERATED — 5 invoke operations + test/generated_client_test.dart # proves the spike questions against a mock client +``` + +Regenerate with: + +```bash +dart pub get +dart run bin/generate.dart +dart test +``` + +## Design + +The split mirrors the Swift PR: a thin hand-written **runtime** +(`ApiClient`) owns transport and headers; the **generated** clients are pure +request-building + response-decoding. The public `supabase-dart` API +(`StorageFileApi`, `FunctionsClient`, …) would sit on top as an idiomatic +facade, exactly as it does today, calling the generated methods instead of the +hand-rolled `fetch` layer. + +- Built on the `http` package the SDK already depends on. No `dio`, no + `build_runner`, no `dart:io` — the same code runs on iOS, Android, macOS, + Windows, Linux, Web and WASM. +- Models are immutable (`final` fields, named constructor, `fromJson`/`toJson`). + snake_case wire keys are mapped to camelCase Dart fields. +- The auth token hook is a per-request `HeaderProvider` callback, so a token + refreshed by the auth loop is always picked up. + +## Spike questions + +| # | Question | Answer | +|---|----------|--------| +| 1 | Streaming uploads (`Stream` body, no buffering)? | **Yes.** Generated via `http.StreamedRequest`; bytes flow source→socket. Proven in `test` (TUS `uploadChunk`). Off-the-shelf generators cannot do this. | +| 2 | Streaming responses? | **Yes.** Octet-stream responses return `StreamedApiResponse` wrapping `response.stream`; caller receives events incrementally. Proven (Functions `invokeFunctionGet`). | +| 3 | Multipart with a streaming file part? | **Yes.** Generated `http.MultipartFile(field, stream, length)`. Proven (`uploadObject`). | +| 4 | Middleware/interceptor for runtime auth headers? | **Yes.** `HeaderProvider` runs before every request; proven with a token that changes between calls. | +| 5 | Auth flows generatable? | **Partly.** Auth isn't in the shared models yet. The HTTP operations (sign-in/up, token refresh, OTP, admin) are plain JSON and would generate cleanly once added. The session loop (refresh timer, storage, `onAuthStateChange`) stays hand-written. | +| 6 | PostgREST query builder? | **No.** The dynamic query string (`.select()`, `.eq()`, `.order()`) can't be modelled in Smithy/OpenAPI. Codegen could at most supply a generic request executor; the builder and row types stay hand-written. Recommend keeping PostgREST out of codegen. | +| 7 | Web compatibility (no `dart:io`)? | **Yes.** Only `http` is used, no conditional imports. Note: streaming *uploads* on Web depend on the HTTP client — `BrowserClient` buffers, `fetch_client` streams — same caveat that already applies to the hand-written SDK. | +| 8 | `build_runner` required? | **No.** `dart run bin/generate.dart`; output is committed and reviewable. This is the decisive advantage over `dart-dio`. | +| 9 | Effort to build an emitter? | **Low.** One ~500-line file generated idiomatic clients for both services. | +| 10 | Model gaps found | See below. | + +## Model gaps found (for supabase/sdk#51) + +- **Functions output should be `@streaming`.** It's currently a plain `Blob` + (OpenAPI `format: byte`). This emitter already treats `application/octet-stream` + responses as streams, but marking the output `@streaming` makes the intent + explicit and matches the TUS `@streaming` input. +- **Functions dynamic query params** (limitation #5 in the model README) still + need per-SDK middleware URL-rewriting; unchanged by this spike. +- **Single-member outputs** (`ListBucketsResponseContent { items }`) generate a + wrapper object. The facade layer unwraps it (`listBuckets().items`); acceptable, + but a codegen convention to unwrap single-member structures would be nicer. +- **`Long`/`Integer` both collapse to OpenAPI `number` → Dart `num`.** Works for + JSON, but `int` would read better for offsets/sizes. Not a blocker. + +## Not covered (out of scope, matching Swift spike) + +- Auth and PostgREST models don't exist in `sdk#51` yet. +- Realtime (WebSocket) is incompatible with REST codegen — stays hand-written. +- Wiring the generated clients into the real `storage_client` / + `functions_client` packages (the Swift PR did this incrementally; deferred + here to keep the spike self-contained). diff --git a/codegen/bin/generate.dart b/codegen/bin/generate.dart new file mode 100644 index 000000000..b9a2a0291 --- /dev/null +++ b/codegen/bin/generate.dart @@ -0,0 +1,536 @@ +import 'dart:convert'; +import 'dart:io'; + +/// A minimal, dependency-free OpenAPI 3.0 -> idiomatic Dart emitter for the +/// Supabase HTTP layer. It consumes the committed artifacts in `openapi/` +/// (produced from the shared Smithy models in supabase/sdk#51) and writes +/// `http`-based clients into `lib/src/generated/`. +/// +/// Run with: `dart run bin/generate.dart` +void main() { + _generate( + specPath: 'openapi/StorageService.openapi.json', + className: 'StorageApi', + outputPath: 'lib/src/generated/storage_api.g.dart', + ); + _generate( + specPath: 'openapi/FunctionsService.openapi.json', + className: 'FunctionsApi', + outputPath: 'lib/src/generated/functions_api.g.dart', + ); + stdout.writeln('Done. Formatting output...'); + Process.runSync('dart', ['format', 'lib/src/generated']); +} + +void _generate({ + required String specPath, + required String className, + required String outputPath, +}) { + final spec = jsonDecode(File(specPath).readAsStringSync()) as Map; + final schemas = + (spec['components']?['schemas'] as Map?)?.cast() ?? {}; + final paths = (spec['paths'] as Map).cast(); + + final buffer = StringBuffer(); + + // Model classes. + for (final entry in schemas.entries) { + final schema = (entry.value as Map).cast(); + if (_isModel(schema)) { + buffer.writeln(_generateModel(entry.key, schema)); + } + } + + // Client class. + buffer + ..writeln('/// Generated HTTP client. Every operation goes through the') + ..writeln('/// hand-written [ApiClient] runtime for headers and transport.') + ..writeln('class $className {') + ..writeln(' $className(this._client);') + ..writeln() + ..writeln(' final ApiClient _client;') + ..writeln(); + + for (final pathEntry in paths.entries) { + final path = pathEntry.key; + final operations = (pathEntry.value as Map).cast(); + for (final opEntry in operations.entries) { + final method = opEntry.key; + if (!_httpMethods.contains(method)) continue; + buffer.writeln( + _generateOperation(path, method, (opEntry.value as Map).cast()), + ); + } + } + + buffer.writeln('}'); + + final body = buffer.toString(); + final needsConvert = + body.contains('jsonEncode') || body.contains('jsonDecode'); + + final header = StringBuffer() + ..writeln('// GENERATED CODE - DO NOT MODIFY BY HAND.') + ..writeln('// Generated from $specPath by bin/generate.dart.') + ..writeln('// ignore_for_file: prefer_final_locals') + ..writeln(); + if (needsConvert) { + header.writeln("import 'dart:convert';"); + header.writeln(); + } + header + ..writeln("import 'package:http/http.dart' as http;") + ..writeln() + ..writeln("import '../runtime.dart';") + ..writeln(); + + File(outputPath).writeAsStringSync('$header$body'); + stdout.writeln('Generated $outputPath'); +} + +const _httpMethods = {'get', 'post', 'put', 'patch', 'delete', 'head'}; + +// ─── Models ────────────────────────────────────────────────────────────── + +bool _isModel(Map schema) => + schema['type'] == 'object' && schema['properties'] != null; + +String _generateModel(String name, Map schema) { + final properties = (schema['properties'] as Map).cast(); + final required = ((schema['required'] as List?) ?? []).cast().toSet(); + + final fields = <_Field>[]; + properties.forEach((jsonKey, raw) { + final propSchema = (raw as Map).cast(); + fields.add( + _Field( + jsonKey: jsonKey, + dartName: _camelCase(jsonKey), + schema: propSchema, + isRequired: required.contains(jsonKey), + ), + ); + }); + + final buffer = StringBuffer() + ..writeln('class $name {') + ..writeln(' $name({'); + for (final field in fields) { + final prefix = field.isRequired ? 'required ' : ''; + buffer.writeln(' ${prefix}this.${field.dartName},'); + } + buffer + ..writeln(' });') + ..writeln(); + + for (final field in fields) { + final type = field.isRequired ? field.dartType : '${field.dartType}?'; + buffer.writeln(' final $type ${field.dartName};'); + } + + // fromJson + buffer + ..writeln() + ..writeln(' factory $name.fromJson(Map json) => $name('); + for (final field in fields) { + buffer.writeln( + " ${field.dartName}: ${_fromJson(field.schema, "json['${field.jsonKey}']", field.isRequired)},", + ); + } + buffer + ..writeln(' );') + ..writeln(); + + // toJson + buffer.writeln(' Map toJson() => {'); + for (final field in fields) { + final valueExpr = _toJson(field.schema, field.dartName, field.isRequired); + if (field.isRequired) { + buffer.writeln(" '${field.jsonKey}': $valueExpr,"); + } else { + buffer.writeln( + " if (${field.dartName} != null) '${field.jsonKey}': $valueExpr,", + ); + } + } + buffer + ..writeln(' };') + ..writeln('}') + ..writeln(); + + return buffer.toString(); +} + +// ─── Operations ──────────────────────────────────────────────────────────── + +String _generateOperation( + String path, + String method, + Map op, +) { + final operationId = op['operationId'] as String; + final methodName = _lowerFirst(operationId); + final parameters = ((op['parameters'] as List?) ?? []) + .cast() + .map((p) => p.cast()) + .toList(); + + final pathParams = parameters.where((p) => p['in'] == 'path').toList(); + final headerParams = parameters.where((p) => p['in'] == 'header').toList(); + final queryParams = parameters.where((p) => p['in'] == 'query').toList(); + + final body = _resolveBody(op); + final response = _resolveResponse(op); + + // Build the parameter list. + final params = []; + for (final param in pathParams) { + params.add( + 'required String ${_camelCase(_stripWildcard(param['name'] as String))}'); + } + for (final param in headerParams) { + final name = _camelCase(param['name'] as String); + final type = _headerDartType(param['schema'] as Map?); + final isRequired = param['required'] == true; + params.add(isRequired ? 'required $type $name' : '$type? $name'); + } + for (final param in queryParams) { + final name = _camelCase(param['name'] as String); + params.add('String? $name'); + } + params.addAll(body.parameters); + + final signature = params.isEmpty ? '' : '{${params.join(', ')}}'; + + final buffer = StringBuffer() + ..writeln( + ' Future<${response.returnType}> $methodName($signature) async {'); + + // URI. + var dartPath = path; + for (final param in pathParams) { + final wire = param['name'] as String; + dartPath = dartPath.replaceAll( + '{$wire}', + '\${${_camelCase(_stripWildcard(wire))}}', + ); + } + if (queryParams.isEmpty) { + buffer.writeln(" final uri = _client.uri('$dartPath');"); + } else { + buffer.writeln(" final uri = _client.uri('$dartPath', {"); + for (final param in queryParams) { + buffer.writeln( + " '${param['name']}': ${_camelCase(param['name'] as String)},"); + } + buffer.writeln(' });'); + } + + // Headers. + buffer.writeln(' final headers = await _client.headers({'); + for (final param in headerParams) { + final wire = param['name'] as String; + final name = _camelCase(wire); + final type = _headerDartType(param['schema'] as Map?); + final valueExpr = type == 'String' ? name : '\'\$$name\''; + if (param['required'] == true) { + buffer.writeln(" '$wire': $valueExpr,"); + } else { + buffer.writeln(" if ($name != null) '$wire': $valueExpr,"); + } + } + buffer.writeln(' });'); + + // Request construction + send. + buffer.write(body.buildRequest(method.toUpperCase())); + buffer.writeln(' request.headers.addAll(headers);'); + buffer.writeln(' final streamed = await _client.send(request);'); + + // Response handling. + buffer.write(response.handle); + + buffer.writeln(' }'); + return buffer.toString(); +} + +// ─── Request body resolution ───────────────────────────────────────────────── + +class _Body { + _Body({ + required this.parameters, + required this.buildRequest, + }); + + final List parameters; + final String Function(String method) buildRequest; +} + +_Body _resolveBody(Map op) { + final content = + (op['requestBody']?['content'] as Map?)?.cast(); + if (content == null) { + return _Body( + parameters: const [], + buildRequest: (method) => + " final request = http.Request('$method', uri);\n", + ); + } + + if (content.containsKey('multipart/form-data')) { + return _multipartBody(content['multipart/form-data'] as Map); + } + + final jsonContent = content['application/json']; + if (jsonContent != null) { + final schema = (jsonContent['schema'] as Map).cast(); + final type = _refName(schema[r'$ref'] as String); + return _Body( + parameters: ['required $type body'], + buildRequest: (method) => + " final request = http.Request('$method', uri)\n" + " ..headers['content-type'] = 'application/json'\n" + ' ..body = jsonEncode(body.toJson());\n', + ); + } + + // Binary / streaming payload (e.g. TUS UploadChunk). + return _Body( + parameters: [ + 'required Stream> body', + 'int? contentLength', + ], + buildRequest: (method) => + " final request = streamingRequest('$method', uri, " + 'body: body, contentLength: contentLength);\n', + ); +} + +_Body _multipartBody(Map content) { + final schema = (content['schema'] as Map).cast(); + final properties = (schema['properties'] as Map).cast(); + + final params = []; + final fieldWrites = []; + String? fileField; + properties.forEach((key, raw) { + final propSchema = (raw as Map).cast(); + if (propSchema['format'] == 'binary') { + fileField = key; + } else if (propSchema['type'] == 'object') { + params.add('Map? ${_camelCase(key)}'); + fieldWrites.add( + " if (${_camelCase(key)} != null) request.fields['$key'] = jsonEncode(${_camelCase(key)});", + ); + } else { + params.add('String? ${_camelCase(key)}'); + fieldWrites.add( + " if (${_camelCase(key)} != null) request.fields['$key'] = ${_camelCase(key)};", + ); + } + }); + + params.insert(0, 'required Stream> file'); + params.insert(1, 'required int fileLength'); + params.add('String? fileName'); + + return _Body( + parameters: params, + buildRequest: (method) { + final buffer = StringBuffer() + ..writeln(" final request = http.MultipartRequest('$method', uri);") + ..writeln(' request.files.add(http.MultipartFile(') + ..writeln(" '${fileField ?? 'file'}',") + ..writeln(' file,') + ..writeln(' fileLength,') + ..writeln(' filename: fileName,') + ..writeln(' ));'); + for (final write in fieldWrites) { + buffer.writeln(write); + } + return buffer.toString(); + }, + ); +} + +// ─── Response resolution ───────────────────────────────────────────────────── + +class _Response { + _Response({required this.returnType, required this.handle}); + + final String returnType; + final String handle; +} + +_Response _resolveResponse(Map op) { + final responses = (op['responses'] as Map).cast(); + final successKey = ['200', '201', '204', '202'] + .firstWhere(responses.containsKey, orElse: () => ''); + final success = + (responses[successKey] as Map?)?.cast() ?? {}; + + final content = (success['content'] as Map?)?.cast(); + if (content != null) { + final jsonContent = content['application/json']; + if (jsonContent != null) { + final schema = (jsonContent['schema'] as Map).cast(); + final type = _refName(schema[r'$ref'] as String); + return _Response( + returnType: type, + handle: ' final response = await readOrThrow(streamed);\n' + ' return $type.fromJson(jsonDecode(response.body) as Map);\n', + ); + } + // Binary response streamed straight to the caller (spike question 2). + return _Response( + returnType: 'StreamedApiResponse', + handle: + ' if (streamed.statusCode < 200 || streamed.statusCode >= 300) {\n' + ' await readOrThrow(streamed);\n' + ' }\n' + ' return StreamedApiResponse(\n' + ' statusCode: streamed.statusCode,\n' + ' headers: streamed.headers,\n' + ' stream: streamed.stream,\n' + ' );\n', + ); + } + + // Header-only success (e.g. TUS Upload-Offset). Expose the typed headers. + final headers = (success['headers'] as Map?)?.cast(); + if (headers != null && headers.isNotEmpty) { + final buffer = StringBuffer() + ..writeln(' final response = await readOrThrow(streamed);') + ..writeln(' return {'); + for (final entry in headers.entries) { + final wire = entry.key; + final schema = + ((entry.value as Map)['schema'] as Map?)?.cast(); + final isNumber = + schema?['type'] == 'number' || schema?['type'] == 'integer'; + final read = isNumber + ? "int.parse(response.headers['${wire.toLowerCase()}']!)" + : "response.headers['${wire.toLowerCase()}']"; + buffer.writeln(" '${_camelCase(wire)}': $read,"); + } + buffer.writeln(' };'); + return _Response( + returnType: 'Map', + handle: buffer.toString(), + ); + } + + // No content. + return _Response( + returnType: 'void', + handle: ' await readOrThrow(streamed);\n', + ); +} + +// ─── Type + serialization helpers ──────────────────────────────────────────── + +class _Field { + _Field({ + required this.jsonKey, + required this.dartName, + required this.schema, + required this.isRequired, + }); + + final String jsonKey; + final String dartName; + final Map schema; + final bool isRequired; + + String get dartType => _dartType(schema); +} + +String _dartType(Map schema) { + if (schema.containsKey(r'$ref')) return _refName(schema[r'$ref'] as String); + switch (schema['type']) { + case 'string': + final format = schema['format']; + if (format == 'binary' || format == 'byte') return 'Stream>'; + return 'String'; + case 'boolean': + return 'bool'; + case 'integer': + return 'int'; + case 'number': + return 'num'; + case 'array': + return 'List<${_dartType((schema['items'] as Map))}>'; + case 'object': + return 'Map'; + default: + return 'dynamic'; + } +} + +String _fromJson(Map schema, String expr, bool isRequired) { + final suffix = isRequired ? '' : '?'; + if (schema.containsKey(r'$ref')) { + final type = _refName(schema[r'$ref'] as String); + if (isRequired) { + return '$type.fromJson($expr as Map)'; + } + return '$expr == null ? null : $type.fromJson($expr as Map)'; + } + switch (schema['type']) { + case 'array': + final items = (schema['items'] as Map); + if (items.containsKey(r'$ref')) { + final type = _refName(items[r'$ref'] as String); + final map = + '($expr as List).map((e) => $type.fromJson(e as Map)).toList()'; + return isRequired ? map : '$expr == null ? null : $map'; + } + final inner = _dartType(items); + final cast = '($expr as List).cast<$inner>()'; + return isRequired ? cast : '$expr == null ? null : $cast'; + case 'object': + return '$expr as Map$suffix'; + default: + return '$expr as ${_dartType(schema)}$suffix'; + } +} + +String _toJson(Map schema, String name, bool isRequired) { + final access = isRequired ? name : '$name!'; + if (schema.containsKey(r'$ref')) { + return '$access.toJson()'; + } + if (schema['type'] == 'array') { + final items = (schema['items'] as Map); + if (items.containsKey(r'$ref')) { + return '$access.map((e) => e.toJson()).toList()'; + } + } + return name; +} + +String _headerDartType(Map? schema) { + final type = schema?['type']; + if (type == 'number' || type == 'integer') return 'int'; + if (type == 'boolean') return 'bool'; + return 'String'; +} + +String _refName(String ref) => ref.split('/').last; + +String _stripWildcard(String name) => + name.endsWith('+') ? name.substring(0, name.length - 1) : name; + +String _camelCase(String input) { + final parts = input.split(RegExp('[_-]')); + if (parts.isEmpty) return input; + final buffer = StringBuffer(_lowerFirst(parts.first)); + for (final part in parts.skip(1)) { + if (part.isEmpty) continue; + buffer.write(part[0].toUpperCase() + part.substring(1)); + } + return buffer.toString(); +} + +String _lowerFirst(String input) => + input.isEmpty ? input : input[0].toLowerCase() + input.substring(1); diff --git a/codegen/lib/src/generated/functions_api.g.dart b/codegen/lib/src/generated/functions_api.g.dart new file mode 100644 index 000000000..f4d4339f9 --- /dev/null +++ b/codegen/lib/src/generated/functions_api.g.dart @@ -0,0 +1,143 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND. +// Generated from openapi/FunctionsService.openapi.json by bin/generate.dart. +// ignore_for_file: prefer_final_locals + +import 'package:http/http.dart' as http; + +import '../runtime.dart'; + +class FunctionsErrorResponseContent { + FunctionsErrorResponseContent({ + this.message, + }); + + final String? message; + + factory FunctionsErrorResponseContent.fromJson(Map json) => + FunctionsErrorResponseContent( + message: json['message'] as String?, + ); + + Map toJson() => { + if (message != null) 'message': message, + }; +} + +/// Generated HTTP client. Every operation goes through the +/// hand-written [ApiClient] runtime for headers and transport. +class FunctionsApi { + FunctionsApi(this._client); + + final ApiClient _client; + + Future invokeFunctionDelete( + {required String functionName, + String? xRegion, + required Stream> body, + int? contentLength}) async { + final uri = _client.uri('/functions/v1/${functionName}'); + final headers = await _client.headers({ + if (xRegion != null) 'x-region': xRegion, + }); + final request = streamingRequest('DELETE', uri, + body: body, contentLength: contentLength); + request.headers.addAll(headers); + final streamed = await _client.send(request); + if (streamed.statusCode < 200 || streamed.statusCode >= 300) { + await readOrThrow(streamed); + } + return StreamedApiResponse( + statusCode: streamed.statusCode, + headers: streamed.headers, + stream: streamed.stream, + ); + } + + Future invokeFunctionGet( + {required String functionName, String? xRegion}) async { + final uri = _client.uri('/functions/v1/${functionName}'); + final headers = await _client.headers({ + if (xRegion != null) 'x-region': xRegion, + }); + final request = http.Request('GET', uri); + request.headers.addAll(headers); + final streamed = await _client.send(request); + if (streamed.statusCode < 200 || streamed.statusCode >= 300) { + await readOrThrow(streamed); + } + return StreamedApiResponse( + statusCode: streamed.statusCode, + headers: streamed.headers, + stream: streamed.stream, + ); + } + + Future invokeFunctionPatch( + {required String functionName, + String? xRegion, + required Stream> body, + int? contentLength}) async { + final uri = _client.uri('/functions/v1/${functionName}'); + final headers = await _client.headers({ + if (xRegion != null) 'x-region': xRegion, + }); + final request = streamingRequest('PATCH', uri, + body: body, contentLength: contentLength); + request.headers.addAll(headers); + final streamed = await _client.send(request); + if (streamed.statusCode < 200 || streamed.statusCode >= 300) { + await readOrThrow(streamed); + } + return StreamedApiResponse( + statusCode: streamed.statusCode, + headers: streamed.headers, + stream: streamed.stream, + ); + } + + Future invokeFunctionPost( + {required String functionName, + String? xRegion, + required Stream> body, + int? contentLength}) async { + final uri = _client.uri('/functions/v1/${functionName}'); + final headers = await _client.headers({ + if (xRegion != null) 'x-region': xRegion, + }); + final request = + streamingRequest('POST', uri, body: body, contentLength: contentLength); + request.headers.addAll(headers); + final streamed = await _client.send(request); + if (streamed.statusCode < 200 || streamed.statusCode >= 300) { + await readOrThrow(streamed); + } + return StreamedApiResponse( + statusCode: streamed.statusCode, + headers: streamed.headers, + stream: streamed.stream, + ); + } + + Future invokeFunctionPut( + {required String functionName, + String? xRegion, + required Stream> body, + int? contentLength}) async { + final uri = _client.uri('/functions/v1/${functionName}'); + final headers = await _client.headers({ + if (xRegion != null) 'x-region': xRegion, + }); + final request = + streamingRequest('PUT', uri, body: body, contentLength: contentLength); + request.headers.addAll(headers); + final streamed = await _client.send(request); + if (streamed.statusCode < 200 || streamed.statusCode >= 300) { + await readOrThrow(streamed); + } + return StreamedApiResponse( + statusCode: streamed.statusCode, + headers: streamed.headers, + stream: streamed.stream, + ); + } +} diff --git a/codegen/lib/src/generated/storage_api.g.dart b/codegen/lib/src/generated/storage_api.g.dart new file mode 100644 index 000000000..8656952ca --- /dev/null +++ b/codegen/lib/src/generated/storage_api.g.dart @@ -0,0 +1,945 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND. +// Generated from openapi/StorageService.openapi.json by bin/generate.dart. +// ignore_for_file: prefer_final_locals + +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +import '../runtime.dart'; + +class Bucket { + Bucket({ + required this.id, + required this.name, + required this.public, + this.fileSizeLimit, + this.allowedMimeTypes, + this.createdAt, + this.updatedAt, + }); + + final String id; + final String name; + final bool public; + final num? fileSizeLimit; + final List? allowedMimeTypes; + final String? createdAt; + final String? updatedAt; + + factory Bucket.fromJson(Map json) => Bucket( + id: json['id'] as String, + name: json['name'] as String, + public: json['public'] as bool, + fileSizeLimit: json['file_size_limit'] as num?, + allowedMimeTypes: json['allowed_mime_types'] == null + ? null + : (json['allowed_mime_types'] as List).cast(), + createdAt: json['created_at'] as String?, + updatedAt: json['updated_at'] as String?, + ); + + Map toJson() => { + 'id': id, + 'name': name, + 'public': public, + if (fileSizeLimit != null) 'file_size_limit': fileSizeLimit, + if (allowedMimeTypes != null) 'allowed_mime_types': allowedMimeTypes, + if (createdAt != null) 'created_at': createdAt, + if (updatedAt != null) 'updated_at': updatedAt, + }; +} + +class CopyObjectRequestContent { + CopyObjectRequestContent({ + required this.bucketId, + required this.sourceKey, + required this.destinationKey, + this.destinationBucket, + }); + + final String bucketId; + final String sourceKey; + final String destinationKey; + final String? destinationBucket; + + factory CopyObjectRequestContent.fromJson(Map json) => + CopyObjectRequestContent( + bucketId: json['bucketId'] as String, + sourceKey: json['sourceKey'] as String, + destinationKey: json['destinationKey'] as String, + destinationBucket: json['destinationBucket'] as String?, + ); + + Map toJson() => { + 'bucketId': bucketId, + 'sourceKey': sourceKey, + 'destinationKey': destinationKey, + if (destinationBucket != null) 'destinationBucket': destinationBucket, + }; +} + +class CopyObjectResponseContent { + CopyObjectResponseContent({ + required this.key, + }); + + final String key; + + factory CopyObjectResponseContent.fromJson(Map json) => + CopyObjectResponseContent( + key: json['Key'] as String, + ); + + Map toJson() => { + 'Key': key, + }; +} + +class CreateBucketRequestContent { + CreateBucketRequestContent({ + required this.id, + required this.name, + required this.public, + this.fileSizeLimit, + this.allowedMimeTypes, + }); + + final String id; + final String name; + final bool public; + final num? fileSizeLimit; + final List? allowedMimeTypes; + + factory CreateBucketRequestContent.fromJson(Map json) => + CreateBucketRequestContent( + id: json['id'] as String, + name: json['name'] as String, + public: json['public'] as bool, + fileSizeLimit: json['file_size_limit'] as num?, + allowedMimeTypes: json['allowed_mime_types'] == null + ? null + : (json['allowed_mime_types'] as List).cast(), + ); + + Map toJson() => { + 'id': id, + 'name': name, + 'public': public, + if (fileSizeLimit != null) 'file_size_limit': fileSizeLimit, + if (allowedMimeTypes != null) 'allowed_mime_types': allowedMimeTypes, + }; +} + +class CreateSignedUploadUrlResponseContent { + CreateSignedUploadUrlResponseContent({ + required this.url, + }); + + final String url; + + factory CreateSignedUploadUrlResponseContent.fromJson( + Map json) => + CreateSignedUploadUrlResponseContent( + url: json['url'] as String, + ); + + Map toJson() => { + 'url': url, + }; +} + +class CreateSignedUrlRequestContent { + CreateSignedUrlRequestContent({ + required this.expiresIn, + }); + + final num expiresIn; + + factory CreateSignedUrlRequestContent.fromJson(Map json) => + CreateSignedUrlRequestContent( + expiresIn: json['expiresIn'] as num, + ); + + Map toJson() => { + 'expiresIn': expiresIn, + }; +} + +class CreateSignedUrlResponseContent { + CreateSignedUrlResponseContent({ + required this.signedURL, + }); + + final String signedURL; + + factory CreateSignedUrlResponseContent.fromJson(Map json) => + CreateSignedUrlResponseContent( + signedURL: json['signedURL'] as String, + ); + + Map toJson() => { + 'signedURL': signedURL, + }; +} + +class CreateSignedUrlsRequestContent { + CreateSignedUrlsRequestContent({ + required this.expiresIn, + required this.paths, + }); + + final num expiresIn; + final List paths; + + factory CreateSignedUrlsRequestContent.fromJson(Map json) => + CreateSignedUrlsRequestContent( + expiresIn: json['expiresIn'] as num, + paths: (json['paths'] as List).cast(), + ); + + Map toJson() => { + 'expiresIn': expiresIn, + 'paths': paths, + }; +} + +class CreateSignedUrlsResponseContent { + CreateSignedUrlsResponseContent({ + required this.items, + }); + + final List items; + + factory CreateSignedUrlsResponseContent.fromJson(Map json) => + CreateSignedUrlsResponseContent( + items: (json['items'] as List) + .map((e) => SignedUrlResult.fromJson(e as Map)) + .toList(), + ); + + Map toJson() => { + 'items': items.map((e) => e.toJson()).toList(), + }; +} + +class DeleteObjectsRequestContent { + DeleteObjectsRequestContent({ + required this.prefixes, + }); + + final List prefixes; + + factory DeleteObjectsRequestContent.fromJson(Map json) => + DeleteObjectsRequestContent( + prefixes: (json['prefixes'] as List).cast(), + ); + + Map toJson() => { + 'prefixes': prefixes, + }; +} + +class DeleteObjectsResponseContent { + DeleteObjectsResponseContent({ + required this.items, + }); + + final List items; + + factory DeleteObjectsResponseContent.fromJson(Map json) => + DeleteObjectsResponseContent( + items: (json['items'] as List) + .map((e) => FileObject.fromJson(e as Map)) + .toList(), + ); + + Map toJson() => { + 'items': items.map((e) => e.toJson()).toList(), + }; +} + +class FileMetadata { + FileMetadata({ + this.eTag, + this.size, + this.mimetype, + this.cacheControl, + this.lastModified, + this.contentLength, + this.httpStatusCode, + }); + + final String? eTag; + final num? size; + final String? mimetype; + final String? cacheControl; + final String? lastModified; + final num? contentLength; + final num? httpStatusCode; + + factory FileMetadata.fromJson(Map json) => FileMetadata( + eTag: json['eTag'] as String?, + size: json['size'] as num?, + mimetype: json['mimetype'] as String?, + cacheControl: json['cacheControl'] as String?, + lastModified: json['lastModified'] as String?, + contentLength: json['contentLength'] as num?, + httpStatusCode: json['httpStatusCode'] as num?, + ); + + Map toJson() => { + if (eTag != null) 'eTag': eTag, + if (size != null) 'size': size, + if (mimetype != null) 'mimetype': mimetype, + if (cacheControl != null) 'cacheControl': cacheControl, + if (lastModified != null) 'lastModified': lastModified, + if (contentLength != null) 'contentLength': contentLength, + if (httpStatusCode != null) 'httpStatusCode': httpStatusCode, + }; +} + +class FileObject { + FileObject({ + required this.name, + this.id, + this.updatedAt, + this.createdAt, + this.lastAccessedAt, + this.metadata, + }); + + final String name; + final String? id; + final String? updatedAt; + final String? createdAt; + final String? lastAccessedAt; + final FileMetadata? metadata; + + factory FileObject.fromJson(Map json) => FileObject( + name: json['name'] as String, + id: json['id'] as String?, + updatedAt: json['updated_at'] as String?, + createdAt: json['created_at'] as String?, + lastAccessedAt: json['last_accessed_at'] as String?, + metadata: json['metadata'] == null + ? null + : FileMetadata.fromJson(json['metadata'] as Map), + ); + + Map toJson() => { + 'name': name, + if (id != null) 'id': id, + if (updatedAt != null) 'updated_at': updatedAt, + if (createdAt != null) 'created_at': createdAt, + if (lastAccessedAt != null) 'last_accessed_at': lastAccessedAt, + if (metadata != null) 'metadata': metadata!.toJson(), + }; +} + +class GetBucketResponseContent { + GetBucketResponseContent({ + required this.id, + required this.name, + required this.public, + this.fileSizeLimit, + this.allowedMimeTypes, + this.createdAt, + this.updatedAt, + }); + + final String id; + final String name; + final bool public; + final num? fileSizeLimit; + final List? allowedMimeTypes; + final String? createdAt; + final String? updatedAt; + + factory GetBucketResponseContent.fromJson(Map json) => + GetBucketResponseContent( + id: json['id'] as String, + name: json['name'] as String, + public: json['public'] as bool, + fileSizeLimit: json['file_size_limit'] as num?, + allowedMimeTypes: json['allowed_mime_types'] == null + ? null + : (json['allowed_mime_types'] as List).cast(), + createdAt: json['created_at'] as String?, + updatedAt: json['updated_at'] as String?, + ); + + Map toJson() => { + 'id': id, + 'name': name, + 'public': public, + if (fileSizeLimit != null) 'file_size_limit': fileSizeLimit, + if (allowedMimeTypes != null) 'allowed_mime_types': allowedMimeTypes, + if (createdAt != null) 'created_at': createdAt, + if (updatedAt != null) 'updated_at': updatedAt, + }; +} + +class GetObjectInfoResponseContent { + GetObjectInfoResponseContent({ + this.eTag, + this.size, + this.mimetype, + this.cacheControl, + this.lastModified, + this.contentLength, + this.httpStatusCode, + }); + + final String? eTag; + final num? size; + final String? mimetype; + final String? cacheControl; + final String? lastModified; + final num? contentLength; + final num? httpStatusCode; + + factory GetObjectInfoResponseContent.fromJson(Map json) => + GetObjectInfoResponseContent( + eTag: json['eTag'] as String?, + size: json['size'] as num?, + mimetype: json['mimetype'] as String?, + cacheControl: json['cacheControl'] as String?, + lastModified: json['lastModified'] as String?, + contentLength: json['contentLength'] as num?, + httpStatusCode: json['httpStatusCode'] as num?, + ); + + Map toJson() => { + if (eTag != null) 'eTag': eTag, + if (size != null) 'size': size, + if (mimetype != null) 'mimetype': mimetype, + if (cacheControl != null) 'cacheControl': cacheControl, + if (lastModified != null) 'lastModified': lastModified, + if (contentLength != null) 'contentLength': contentLength, + if (httpStatusCode != null) 'httpStatusCode': httpStatusCode, + }; +} + +class ListBucketsResponseContent { + ListBucketsResponseContent({ + required this.items, + }); + + final List items; + + factory ListBucketsResponseContent.fromJson(Map json) => + ListBucketsResponseContent( + items: (json['items'] as List) + .map((e) => Bucket.fromJson(e as Map)) + .toList(), + ); + + Map toJson() => { + 'items': items.map((e) => e.toJson()).toList(), + }; +} + +class ListObjectsRequestContent { + ListObjectsRequestContent({ + required this.prefix, + this.limit, + this.offset, + this.sortBy, + }); + + final String prefix; + final num? limit; + final num? offset; + final SortBy? sortBy; + + factory ListObjectsRequestContent.fromJson(Map json) => + ListObjectsRequestContent( + prefix: json['prefix'] as String, + limit: json['limit'] as num?, + offset: json['offset'] as num?, + sortBy: json['sortBy'] == null + ? null + : SortBy.fromJson(json['sortBy'] as Map), + ); + + Map toJson() => { + 'prefix': prefix, + if (limit != null) 'limit': limit, + if (offset != null) 'offset': offset, + if (sortBy != null) 'sortBy': sortBy!.toJson(), + }; +} + +class ListObjectsResponseContent { + ListObjectsResponseContent({ + required this.items, + }); + + final List items; + + factory ListObjectsResponseContent.fromJson(Map json) => + ListObjectsResponseContent( + items: (json['items'] as List) + .map((e) => FileObject.fromJson(e as Map)) + .toList(), + ); + + Map toJson() => { + 'items': items.map((e) => e.toJson()).toList(), + }; +} + +class MoveObjectRequestContent { + MoveObjectRequestContent({ + required this.bucketId, + required this.sourceKey, + required this.destinationKey, + this.destinationBucket, + }); + + final String bucketId; + final String sourceKey; + final String destinationKey; + final String? destinationBucket; + + factory MoveObjectRequestContent.fromJson(Map json) => + MoveObjectRequestContent( + bucketId: json['bucketId'] as String, + sourceKey: json['sourceKey'] as String, + destinationKey: json['destinationKey'] as String, + destinationBucket: json['destinationBucket'] as String?, + ); + + Map toJson() => { + 'bucketId': bucketId, + 'sourceKey': sourceKey, + 'destinationKey': destinationKey, + if (destinationBucket != null) 'destinationBucket': destinationBucket, + }; +} + +class SignedUrlResult { + SignedUrlResult({ + this.signedURL, + required this.path, + this.error, + }); + + final String? signedURL; + final String path; + final String? error; + + factory SignedUrlResult.fromJson(Map json) => + SignedUrlResult( + signedURL: json['signedURL'] as String?, + path: json['path'] as String, + error: json['error'] as String?, + ); + + Map toJson() => { + if (signedURL != null) 'signedURL': signedURL, + 'path': path, + if (error != null) 'error': error, + }; +} + +class SortBy { + SortBy({ + this.column, + this.order, + }); + + final String? column; + final String? order; + + factory SortBy.fromJson(Map json) => SortBy( + column: json['column'] as String?, + order: json['order'] as String?, + ); + + Map toJson() => { + if (column != null) 'column': column, + if (order != null) 'order': order, + }; +} + +class StorageErrorResponseContent { + StorageErrorResponseContent({ + this.message, + this.error, + this.statusCode, + }); + + final String? message; + final String? error; + final String? statusCode; + + factory StorageErrorResponseContent.fromJson(Map json) => + StorageErrorResponseContent( + message: json['message'] as String?, + error: json['error'] as String?, + statusCode: json['statusCode'] as String?, + ); + + Map toJson() => { + if (message != null) 'message': message, + if (error != null) 'error': error, + if (statusCode != null) 'statusCode': statusCode, + }; +} + +class UpdateBucketRequestContent { + UpdateBucketRequestContent({ + required this.public, + this.fileSizeLimit, + this.allowedMimeTypes, + }); + + final bool public; + final num? fileSizeLimit; + final List? allowedMimeTypes; + + factory UpdateBucketRequestContent.fromJson(Map json) => + UpdateBucketRequestContent( + public: json['public'] as bool, + fileSizeLimit: json['file_size_limit'] as num?, + allowedMimeTypes: json['allowed_mime_types'] == null + ? null + : (json['allowed_mime_types'] as List).cast(), + ); + + Map toJson() => { + 'public': public, + if (fileSizeLimit != null) 'file_size_limit': fileSizeLimit, + if (allowedMimeTypes != null) 'allowed_mime_types': allowedMimeTypes, + }; +} + +class FileUploadedResponse { + FileUploadedResponse({ + required this.key, + required this.id, + }); + + final String key; + final String id; + + factory FileUploadedResponse.fromJson(Map json) => + FileUploadedResponse( + key: json['Key'] as String, + id: json['Id'] as String, + ); + + Map toJson() => { + 'Key': key, + 'Id': id, + }; +} + +/// Generated HTTP client. Every operation goes through the +/// hand-written [ApiClient] runtime for headers and transport. +class StorageApi { + StorageApi(this._client); + + final ApiClient _client; + + Future listBuckets() async { + final uri = _client.uri('/bucket'); + final headers = await _client.headers({}); + final request = http.Request('GET', uri); + request.headers.addAll(headers); + final streamed = await _client.send(request); + final response = await readOrThrow(streamed); + return ListBucketsResponseContent.fromJson( + jsonDecode(response.body) as Map); + } + + Future createBucket({required CreateBucketRequestContent body}) async { + final uri = _client.uri('/bucket'); + final headers = await _client.headers({}); + final request = http.Request('POST', uri) + ..headers['content-type'] = 'application/json' + ..body = jsonEncode(body.toJson()); + request.headers.addAll(headers); + final streamed = await _client.send(request); + await readOrThrow(streamed); + } + + Future deleteBucket({required String id}) async { + final uri = _client.uri('/bucket/${id}'); + final headers = await _client.headers({}); + final request = http.Request('DELETE', uri); + request.headers.addAll(headers); + final streamed = await _client.send(request); + await readOrThrow(streamed); + } + + Future getBucket({required String id}) async { + final uri = _client.uri('/bucket/${id}'); + final headers = await _client.headers({}); + final request = http.Request('GET', uri); + request.headers.addAll(headers); + final streamed = await _client.send(request); + final response = await readOrThrow(streamed); + return GetBucketResponseContent.fromJson( + jsonDecode(response.body) as Map); + } + + Future updateBucket( + {required String id, required UpdateBucketRequestContent body}) async { + final uri = _client.uri('/bucket/${id}'); + final headers = await _client.headers({}); + final request = http.Request('PUT', uri) + ..headers['content-type'] = 'application/json' + ..body = jsonEncode(body.toJson()); + request.headers.addAll(headers); + final streamed = await _client.send(request); + await readOrThrow(streamed); + } + + Future emptyBucket({required String id}) async { + final uri = _client.uri('/bucket/${id}/empty'); + final headers = await _client.headers({}); + final request = http.Request('POST', uri); + request.headers.addAll(headers); + final streamed = await _client.send(request); + await readOrThrow(streamed); + } + + Future copyObject( + {required CopyObjectRequestContent body}) async { + final uri = _client.uri('/object/copy'); + final headers = await _client.headers({}); + final request = http.Request('POST', uri) + ..headers['content-type'] = 'application/json' + ..body = jsonEncode(body.toJson()); + request.headers.addAll(headers); + final streamed = await _client.send(request); + final response = await readOrThrow(streamed); + return CopyObjectResponseContent.fromJson( + jsonDecode(response.body) as Map); + } + + Future getObjectInfo( + {required String bucketId, required String wildcardPath}) async { + final uri = _client.uri('/object/info/${bucketId}/${wildcardPath}'); + final headers = await _client.headers({}); + final request = http.Request('GET', uri); + request.headers.addAll(headers); + final streamed = await _client.send(request); + final response = await readOrThrow(streamed); + return GetObjectInfoResponseContent.fromJson( + jsonDecode(response.body) as Map); + } + + Future listObjects( + {required String bucketId, + required ListObjectsRequestContent body}) async { + final uri = _client.uri('/object/list/${bucketId}'); + final headers = await _client.headers({}); + final request = http.Request('POST', uri) + ..headers['content-type'] = 'application/json' + ..body = jsonEncode(body.toJson()); + request.headers.addAll(headers); + final streamed = await _client.send(request); + final response = await readOrThrow(streamed); + return ListObjectsResponseContent.fromJson( + jsonDecode(response.body) as Map); + } + + Future moveObject({required MoveObjectRequestContent body}) async { + final uri = _client.uri('/object/move'); + final headers = await _client.headers({}); + final request = http.Request('POST', uri) + ..headers['content-type'] = 'application/json' + ..body = jsonEncode(body.toJson()); + request.headers.addAll(headers); + final streamed = await _client.send(request); + await readOrThrow(streamed); + } + + Future createSignedUrls( + {required String bucketId, + required CreateSignedUrlsRequestContent body}) async { + final uri = _client.uri('/object/sign/${bucketId}'); + final headers = await _client.headers({}); + final request = http.Request('POST', uri) + ..headers['content-type'] = 'application/json' + ..body = jsonEncode(body.toJson()); + request.headers.addAll(headers); + final streamed = await _client.send(request); + final response = await readOrThrow(streamed); + return CreateSignedUrlsResponseContent.fromJson( + jsonDecode(response.body) as Map); + } + + Future createSignedUrl( + {required String bucketId, + required String wildcardPath, + required CreateSignedUrlRequestContent body}) async { + final uri = _client.uri('/object/sign/${bucketId}/${wildcardPath}'); + final headers = await _client.headers({}); + final request = http.Request('POST', uri) + ..headers['content-type'] = 'application/json' + ..body = jsonEncode(body.toJson()); + request.headers.addAll(headers); + final streamed = await _client.send(request); + final response = await readOrThrow(streamed); + return CreateSignedUrlResponseContent.fromJson( + jsonDecode(response.body) as Map); + } + + Future createSignedUploadUrl( + {required String bucketId, + required String wildcardPath, + String? xUpsert}) async { + final uri = _client.uri('/object/upload/sign/${bucketId}/${wildcardPath}'); + final headers = await _client.headers({ + if (xUpsert != null) 'x-upsert': xUpsert, + }); + final request = http.Request('POST', uri); + request.headers.addAll(headers); + final streamed = await _client.send(request); + final response = await readOrThrow(streamed); + return CreateSignedUploadUrlResponseContent.fromJson( + jsonDecode(response.body) as Map); + } + + Future deleteObjects( + {required String bucketId, + required DeleteObjectsRequestContent body}) async { + final uri = _client.uri('/object/${bucketId}'); + final headers = await _client.headers({}); + final request = http.Request('DELETE', uri) + ..headers['content-type'] = 'application/json' + ..body = jsonEncode(body.toJson()); + request.headers.addAll(headers); + final streamed = await _client.send(request); + final response = await readOrThrow(streamed); + return DeleteObjectsResponseContent.fromJson( + jsonDecode(response.body) as Map); + } + + Future headObject( + {required String bucketId, required String wildcardPath}) async { + final uri = _client.uri('/object/${bucketId}/${wildcardPath}'); + final headers = await _client.headers({}); + final request = http.Request('HEAD', uri); + request.headers.addAll(headers); + final streamed = await _client.send(request); + await readOrThrow(streamed); + } + + Future uploadObject( + {required String bucketId, + required String wildcardPath, + String? xUpsert, + required Stream> file, + required int fileLength, + String? cacheControl, + Map? metadata, + String? fileName}) async { + final uri = _client.uri('/object/${bucketId}/${wildcardPath}'); + final headers = await _client.headers({ + if (xUpsert != null) 'x-upsert': xUpsert, + }); + final request = http.MultipartRequest('POST', uri); + request.files.add(http.MultipartFile( + 'file', + file, + fileLength, + filename: fileName, + )); + if (cacheControl != null) request.fields['cacheControl'] = cacheControl; + if (metadata != null) request.fields['metadata'] = jsonEncode(metadata); + request.headers.addAll(headers); + final streamed = await _client.send(request); + final response = await readOrThrow(streamed); + return FileUploadedResponse.fromJson( + jsonDecode(response.body) as Map); + } + + Future updateObject( + {required String bucketId, + required String wildcardPath, + required Stream> file, + required int fileLength, + String? cacheControl, + Map? metadata, + String? fileName}) async { + final uri = _client.uri('/object/${bucketId}/${wildcardPath}'); + final headers = await _client.headers({}); + final request = http.MultipartRequest('PUT', uri); + request.files.add(http.MultipartFile( + 'file', + file, + fileLength, + filename: fileName, + )); + if (cacheControl != null) request.fields['cacheControl'] = cacheControl; + if (metadata != null) request.fields['metadata'] = jsonEncode(metadata); + request.headers.addAll(headers); + final streamed = await _client.send(request); + final response = await readOrThrow(streamed); + return FileUploadedResponse.fromJson( + jsonDecode(response.body) as Map); + } + + Future> createTusUpload( + {required String tusResumable, + required int uploadLength, + required String uploadMetadata, + String? xUpsert}) async { + final uri = _client.uri('/upload/resumable'); + final headers = await _client.headers({ + 'Tus-Resumable': tusResumable, + 'Upload-Length': '$uploadLength', + 'Upload-Metadata': uploadMetadata, + if (xUpsert != null) 'x-upsert': xUpsert, + }); + final request = http.Request('POST', uri); + request.headers.addAll(headers); + final streamed = await _client.send(request); + final response = await readOrThrow(streamed); + return { + 'location': response.headers['location'], + }; + } + + Future> getUploadOffset( + {required String uploadId, required String tusResumable}) async { + final uri = _client.uri('/upload/resumable/${uploadId}'); + final headers = await _client.headers({ + 'Tus-Resumable': tusResumable, + }); + final request = http.Request('HEAD', uri); + request.headers.addAll(headers); + final streamed = await _client.send(request); + final response = await readOrThrow(streamed); + return { + 'uploadOffset': int.parse(response.headers['upload-offset']!), + }; + } + + Future> uploadChunk( + {required String uploadId, + required String tusResumable, + required int uploadOffset, + required Stream> body, + int? contentLength}) async { + final uri = _client.uri('/upload/resumable/${uploadId}'); + final headers = await _client.headers({ + 'Tus-Resumable': tusResumable, + 'Upload-Offset': '$uploadOffset', + }); + final request = streamingRequest('PATCH', uri, + body: body, contentLength: contentLength); + request.headers.addAll(headers); + final streamed = await _client.send(request); + final response = await readOrThrow(streamed); + return { + 'uploadOffset': int.parse(response.headers['upload-offset']!), + }; + } +} diff --git a/codegen/lib/src/runtime.dart b/codegen/lib/src/runtime.dart new file mode 100644 index 000000000..484eb6239 --- /dev/null +++ b/codegen/lib/src/runtime.dart @@ -0,0 +1,138 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +/// A callback invoked before every request to supply headers whose values can +/// change at runtime, most importantly the `Authorization` bearer token. +/// +/// This is the interceptor/middleware hook (spike question 4). Because it runs +/// per request, a token refreshed by the auth loop is always picked up without +/// rebuilding the client. +typedef HeaderProvider = FutureOr> Function(); + +/// Thrown for any non-2xx response. [body] is the decoded error payload when +/// the server returned JSON, otherwise the raw string. +class ApiException implements Exception { + ApiException({ + required this.statusCode, + required this.body, + this.reasonPhrase, + }); + + final int statusCode; + final Object? body; + final String? reasonPhrase; + + @override + String toString() => 'ApiException($statusCode $reasonPhrase): $body'; +} + +/// A response whose body is handed to the caller as a live byte stream instead +/// of being buffered (spike question 2). Used for operations that return +/// `application/octet-stream`, e.g. Functions streaming/SSE responses. +class StreamedApiResponse { + StreamedApiResponse({ + required this.statusCode, + required this.headers, + required this.stream, + }); + + final int statusCode; + final Map headers; + final Stream> stream; +} + +/// Minimal transport shared by the generated clients. Deliberately built on the +/// `http` package that supabase-dart already depends on, with no `build_runner` +/// and no platform-specific imports so the same code runs on mobile, desktop +/// and web. +class ApiClient { + ApiClient({ + required this.baseUrl, + http.Client? httpClient, + this.headerProvider, + Map defaultHeaders = const {}, + }) : _httpClient = httpClient ?? http.Client(), + _defaultHeaders = defaultHeaders; + + final String baseUrl; + final http.Client _httpClient; + final Map _defaultHeaders; + final HeaderProvider? headerProvider; + + /// Merges default headers, the runtime [headerProvider] result and the + /// per-operation [extra] headers. Later entries win. + Future> headers([ + Map extra = const {}, + ]) async { + final provided = await headerProvider?.call() ?? const {}; + return {..._defaultHeaders, ...provided, ...extra}; + } + + /// Builds a request URI, dropping query entries whose value is null. + Uri uri(String path, [Map query = const {}]) { + final filtered = { + for (final entry in query.entries) + if (entry.value != null) entry.key: entry.value!, + }; + return Uri.parse('$baseUrl$path').replace( + queryParameters: filtered.isEmpty ? null : filtered, + ); + } + + Future send(http.BaseRequest request) { + return _httpClient.send(request); + } + + void close() => _httpClient.close(); +} + +/// Reads a streamed response into memory and raises [ApiException] on a non-2xx +/// status. Shared by every generated JSON operation. +Future readOrThrow(http.StreamedResponse streamed) async { + final response = await http.Response.fromStream(streamed); + if (response.statusCode < 200 || response.statusCode >= 300) { + throw _errorFrom(response); + } + return response; +} + +ApiException _errorFrom(http.Response response) { + Object? body = response.body; + final contentType = response.headers['content-type'] ?? ''; + if (contentType.contains('application/json') && response.body.isNotEmpty) { + try { + body = jsonDecode(response.body); + } catch (_) { + // Keep the raw string body. + } + } + return ApiException( + statusCode: response.statusCode, + body: body, + reasonPhrase: response.reasonPhrase, + ); +} + +/// Feeds [body] into a [http.StreamedRequest] without collecting it into memory +/// first (spike question 1). The bytes flow straight from the source stream to +/// the socket. +http.StreamedRequest streamingRequest( + String method, + Uri uri, { + required Stream> body, + int? contentLength, +}) { + final request = http.StreamedRequest(method, uri); + if (contentLength != null) { + request.contentLength = contentLength; + } + body.listen( + request.sink.add, + onError: request.sink.addError, + onDone: request.sink.close, + cancelOnError: true, + ); + return request; +} diff --git a/codegen/lib/supabase_codegen_spike.dart b/codegen/lib/supabase_codegen_spike.dart new file mode 100644 index 000000000..006440842 --- /dev/null +++ b/codegen/lib/supabase_codegen_spike.dart @@ -0,0 +1,3 @@ +export 'src/generated/functions_api.g.dart'; +export 'src/generated/storage_api.g.dart'; +export 'src/runtime.dart'; diff --git a/codegen/openapi/FunctionsService.openapi.json b/codegen/openapi/FunctionsService.openapi.json new file mode 100644 index 000000000..9f4f9db18 --- /dev/null +++ b/codegen/openapi/FunctionsService.openapi.json @@ -0,0 +1,305 @@ +{ + "openapi": "3.0.2", + "info": { + "title": "Supabase Functions API", + "version": "1.0" + }, + "paths": { + "/functions/v1/{functionName}": { + "delete": { + "operationId": "InvokeFunctionDelete", + "requestBody": { + "content": { + "application/octet-stream": { + "schema": { + "$ref": "#/components/schemas/InvokeFunctionDeleteInputPayload" + } + } + } + }, + "parameters": [ + { + "name": "functionName", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "x-region", + "in": "header", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "InvokeFunctionDelete 200 response", + "content": { + "application/octet-stream": { + "schema": { + "$ref": "#/components/schemas/InvokeFunctionDeleteOutputPayload" + } + } + } + }, + "400": { + "description": "FunctionsError 400 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FunctionsErrorResponseContent" + } + } + } + } + } + }, + "get": { + "operationId": "InvokeFunctionGet", + "parameters": [ + { + "name": "functionName", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "x-region", + "in": "header", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "InvokeFunctionGet 200 response", + "content": { + "application/octet-stream": { + "schema": { + "$ref": "#/components/schemas/InvokeFunctionGetOutputPayload" + } + } + } + }, + "400": { + "description": "FunctionsError 400 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FunctionsErrorResponseContent" + } + } + } + } + } + }, + "patch": { + "operationId": "InvokeFunctionPatch", + "requestBody": { + "content": { + "application/octet-stream": { + "schema": { + "$ref": "#/components/schemas/InvokeFunctionPatchInputPayload" + } + } + } + }, + "parameters": [ + { + "name": "functionName", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "x-region", + "in": "header", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "InvokeFunctionPatch 200 response", + "content": { + "application/octet-stream": { + "schema": { + "$ref": "#/components/schemas/InvokeFunctionPatchOutputPayload" + } + } + } + }, + "400": { + "description": "FunctionsError 400 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FunctionsErrorResponseContent" + } + } + } + } + } + }, + "post": { + "operationId": "InvokeFunctionPost", + "requestBody": { + "content": { + "application/octet-stream": { + "schema": { + "$ref": "#/components/schemas/InvokeFunctionPostInputPayload" + } + } + } + }, + "parameters": [ + { + "name": "functionName", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "x-region", + "in": "header", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "InvokeFunctionPost 200 response", + "content": { + "application/octet-stream": { + "schema": { + "$ref": "#/components/schemas/InvokeFunctionPostOutputPayload" + } + } + } + }, + "400": { + "description": "FunctionsError 400 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FunctionsErrorResponseContent" + } + } + } + } + } + }, + "put": { + "operationId": "InvokeFunctionPut", + "requestBody": { + "content": { + "application/octet-stream": { + "schema": { + "$ref": "#/components/schemas/InvokeFunctionPutInputPayload" + } + } + } + }, + "parameters": [ + { + "name": "functionName", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "x-region", + "in": "header", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "InvokeFunctionPut 200 response", + "content": { + "application/octet-stream": { + "schema": { + "$ref": "#/components/schemas/InvokeFunctionPutOutputPayload" + } + } + } + }, + "400": { + "description": "FunctionsError 400 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FunctionsErrorResponseContent" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "FunctionsErrorResponseContent": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + } + }, + "InvokeFunctionDeleteInputPayload": { + "type": "string", + "format": "byte" + }, + "InvokeFunctionDeleteOutputPayload": { + "type": "string", + "format": "byte" + }, + "InvokeFunctionGetOutputPayload": { + "type": "string", + "format": "byte" + }, + "InvokeFunctionPatchInputPayload": { + "type": "string", + "format": "byte" + }, + "InvokeFunctionPatchOutputPayload": { + "type": "string", + "format": "byte" + }, + "InvokeFunctionPostInputPayload": { + "type": "string", + "format": "byte" + }, + "InvokeFunctionPostOutputPayload": { + "type": "string", + "format": "byte" + }, + "InvokeFunctionPutInputPayload": { + "type": "string", + "format": "byte" + }, + "InvokeFunctionPutOutputPayload": { + "type": "string", + "format": "byte" + } + } + } +} diff --git a/codegen/openapi/StorageService.openapi.json b/codegen/openapi/StorageService.openapi.json new file mode 100644 index 000000000..0e40c0007 --- /dev/null +++ b/codegen/openapi/StorageService.openapi.json @@ -0,0 +1,1388 @@ +{ + "openapi": "3.0.2", + "info": { + "title": "Supabase Storage API", + "version": "1.0" + }, + "paths": { + "/bucket": { + "get": { + "operationId": "ListBuckets", + "responses": { + "200": { + "description": "ListBuckets 200 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListBucketsResponseContent" + } + } + } + }, + "400": { + "description": "StorageError 400 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StorageErrorResponseContent" + } + } + } + } + } + }, + "post": { + "operationId": "CreateBucket", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateBucketRequestContent" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "CreateBucket 200 response" + }, + "400": { + "description": "StorageError 400 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StorageErrorResponseContent" + } + } + } + } + } + } + }, + "/bucket/{id}": { + "delete": { + "operationId": "DeleteBucket", + "parameters": [ + { + "name": "id", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "DeleteBucket 200 response" + }, + "400": { + "description": "StorageError 400 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StorageErrorResponseContent" + } + } + } + } + } + }, + "get": { + "operationId": "GetBucket", + "parameters": [ + { + "name": "id", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "GetBucket 200 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetBucketResponseContent" + } + } + } + }, + "400": { + "description": "StorageError 400 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StorageErrorResponseContent" + } + } + } + } + } + }, + "put": { + "operationId": "UpdateBucket", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateBucketRequestContent" + } + } + }, + "required": true + }, + "parameters": [ + { + "name": "id", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "UpdateBucket 200 response" + }, + "400": { + "description": "StorageError 400 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StorageErrorResponseContent" + } + } + } + } + } + } + }, + "/bucket/{id}/empty": { + "post": { + "operationId": "EmptyBucket", + "parameters": [ + { + "name": "id", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "EmptyBucket 200 response" + }, + "400": { + "description": "StorageError 400 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StorageErrorResponseContent" + } + } + } + } + } + } + }, + "/object/copy": { + "post": { + "operationId": "CopyObject", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CopyObjectRequestContent" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "CopyObject 200 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CopyObjectResponseContent" + } + } + } + }, + "400": { + "description": "StorageError 400 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StorageErrorResponseContent" + } + } + } + } + } + } + }, + "/object/info/{bucketId}/{wildcardPath+}": { + "get": { + "operationId": "GetObjectInfo", + "parameters": [ + { + "name": "bucketId", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "wildcardPath+", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "GetObjectInfo 200 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetObjectInfoResponseContent" + } + } + } + }, + "400": { + "description": "StorageError 400 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StorageErrorResponseContent" + } + } + } + } + } + } + }, + "/object/list/{bucketId}": { + "post": { + "operationId": "ListObjects", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListObjectsRequestContent" + } + } + }, + "required": true + }, + "parameters": [ + { + "name": "bucketId", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "ListObjects 200 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListObjectsResponseContent" + } + } + } + }, + "400": { + "description": "StorageError 400 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StorageErrorResponseContent" + } + } + } + } + } + } + }, + "/object/move": { + "post": { + "operationId": "MoveObject", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MoveObjectRequestContent" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "MoveObject 200 response" + }, + "400": { + "description": "StorageError 400 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StorageErrorResponseContent" + } + } + } + } + } + } + }, + "/object/sign/{bucketId}": { + "post": { + "operationId": "CreateSignedUrls", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateSignedUrlsRequestContent" + } + } + }, + "required": true + }, + "parameters": [ + { + "name": "bucketId", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "CreateSignedUrls 200 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateSignedUrlsResponseContent" + } + } + } + }, + "400": { + "description": "StorageError 400 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StorageErrorResponseContent" + } + } + } + } + } + } + }, + "/object/sign/{bucketId}/{wildcardPath+}": { + "post": { + "operationId": "CreateSignedUrl", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateSignedUrlRequestContent" + } + } + }, + "required": true + }, + "parameters": [ + { + "name": "bucketId", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "wildcardPath+", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "CreateSignedUrl 200 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateSignedUrlResponseContent" + } + } + } + }, + "400": { + "description": "StorageError 400 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StorageErrorResponseContent" + } + } + } + } + } + } + }, + "/object/upload/sign/{bucketId}/{wildcardPath+}": { + "post": { + "operationId": "CreateSignedUploadUrl", + "parameters": [ + { + "name": "bucketId", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "wildcardPath+", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "x-upsert", + "in": "header", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "CreateSignedUploadUrl 200 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateSignedUploadUrlResponseContent" + } + } + } + }, + "400": { + "description": "StorageError 400 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StorageErrorResponseContent" + } + } + } + } + } + } + }, + "/object/{bucketId}": { + "delete": { + "operationId": "DeleteObjects", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteObjectsRequestContent" + } + } + }, + "required": true + }, + "parameters": [ + { + "name": "bucketId", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "DeleteObjects 200 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteObjectsResponseContent" + } + } + } + }, + "400": { + "description": "StorageError 400 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StorageErrorResponseContent" + } + } + } + } + } + } + }, + "/object/{bucketId}/{wildcardPath+}": { + "head": { + "operationId": "HeadObject", + "parameters": [ + { + "name": "bucketId", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "wildcardPath+", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "HeadObject 200 response" + }, + "400": { + "description": "StorageError 400 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StorageErrorResponseContent" + } + } + } + } + } + }, + "post": { + "operationId": "UploadObject", + "parameters": [ + { + "name": "bucketId", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "wildcardPath+", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "x-upsert", + "in": "header", + "schema": { + "type": "string" + }, + "required": false + } + ], + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "cacheControl": { + "type": "string" + }, + "metadata": { + "type": "object", + "additionalProperties": true + }, + "file": { + "type": "string", + "format": "binary" + } + }, + "required": [ + "file" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Upload successful", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FileUploadedResponse" + } + } + } + }, + "400": { + "description": "StorageError 400 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StorageErrorResponseContent" + } + } + } + } + } + }, + "put": { + "operationId": "UpdateObject", + "parameters": [ + { + "name": "bucketId", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "wildcardPath+", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "cacheControl": { + "type": "string" + }, + "metadata": { + "type": "object", + "additionalProperties": true + }, + "file": { + "type": "string", + "format": "binary" + } + }, + "required": [ + "file" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Upload successful", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FileUploadedResponse" + } + } + } + }, + "400": { + "description": "StorageError 400 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StorageErrorResponseContent" + } + } + } + } + } + } + }, + "/upload/resumable": { + "post": { + "description": "Step 1: Create a new TUS upload session.\nThe server responds with a Location header containing the upload URL.", + "operationId": "CreateTusUpload", + "parameters": [ + { + "name": "Tus-Resumable", + "in": "header", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "Upload-Length", + "in": "header", + "description": "Total size of the file in bytes.", + "schema": { + "type": "number", + "description": "Total size of the file in bytes." + }, + "required": true + }, + { + "name": "Upload-Metadata", + "in": "header", + "description": "Base64-encoded TUS metadata (bucketName, objectName, contentType, cacheControl).", + "schema": { + "type": "string", + "description": "Base64-encoded TUS metadata (bucketName, objectName, contentType, cacheControl)." + }, + "required": true + }, + { + "name": "x-upsert", + "in": "header", + "description": "Set to \"true\" to overwrite an existing object at the same path.", + "schema": { + "type": "string", + "description": "Set to \"true\" to overwrite an existing object at the same path." + } + } + ], + "responses": { + "201": { + "description": "CreateTusUpload 201 response", + "headers": { + "Location": { + "description": "Full URL of the created upload session. Used in subsequent PATCH/HEAD requests.", + "schema": { + "type": "string", + "description": "Full URL of the created upload session. Used in subsequent PATCH/HEAD requests." + }, + "required": true + } + } + }, + "400": { + "description": "StorageError 400 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StorageErrorResponseContent" + } + } + } + } + } + } + }, + "/upload/resumable/{uploadId}": { + "head": { + "description": "Step 3: Query the server-side offset of a TUS session (used when resuming).", + "operationId": "GetUploadOffset", + "parameters": [ + { + "name": "uploadId", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "Tus-Resumable", + "in": "header", + "schema": { + "type": "string" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "GetUploadOffset 200 response", + "headers": { + "Upload-Offset": { + "schema": { + "type": "number" + }, + "required": true + } + } + }, + "400": { + "description": "StorageError 400 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StorageErrorResponseContent" + } + } + } + } + } + }, + "patch": { + "description": "Step 2: Upload a chunk of data to an existing TUS session.\nRepeat with increasing Upload-Offset until all bytes are sent.", + "operationId": "UploadChunk", + "requestBody": { + "content": { + "application/octet-stream": { + "schema": { + "$ref": "#/components/schemas/UploadChunkInputPayload" + } + } + }, + "required": true + }, + "parameters": [ + { + "name": "uploadId", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "Tus-Resumable", + "in": "header", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "Upload-Offset", + "in": "header", + "description": "Byte offset at which this chunk begins.", + "schema": { + "type": "number", + "description": "Byte offset at which this chunk begins." + }, + "required": true + } + ], + "responses": { + "204": { + "description": "UploadChunk 204 response", + "headers": { + "Upload-Offset": { + "description": "New server-side offset after the chunk was accepted.", + "schema": { + "type": "number", + "description": "New server-side offset after the chunk was accepted." + }, + "required": true + } + } + }, + "400": { + "description": "StorageError 400 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StorageErrorResponseContent" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "Bucket": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "public": { + "type": "boolean" + }, + "file_size_limit": { + "type": "number" + }, + "allowed_mime_types": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Common string list shape reused across services." + }, + "created_at": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "public" + ] + }, + "CopyObjectRequestContent": { + "type": "object", + "properties": { + "bucketId": { + "type": "string" + }, + "sourceKey": { + "type": "string" + }, + "destinationKey": { + "type": "string" + }, + "destinationBucket": { + "type": "string" + } + }, + "required": [ + "bucketId", + "destinationKey", + "sourceKey" + ] + }, + "CopyObjectResponseContent": { + "type": "object", + "properties": { + "Key": { + "type": "string" + } + }, + "required": [ + "Key" + ] + }, + "CreateBucketRequestContent": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "public": { + "type": "boolean" + }, + "file_size_limit": { + "type": "number" + }, + "allowed_mime_types": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Common string list shape reused across services." + } + }, + "required": [ + "id", + "name", + "public" + ] + }, + "CreateSignedUploadUrlResponseContent": { + "type": "object", + "properties": { + "url": { + "type": "string" + } + }, + "required": [ + "url" + ] + }, + "CreateSignedUrlRequestContent": { + "type": "object", + "properties": { + "expiresIn": { + "type": "number" + } + }, + "required": [ + "expiresIn" + ] + }, + "CreateSignedUrlResponseContent": { + "type": "object", + "properties": { + "signedURL": { + "type": "string" + } + }, + "required": [ + "signedURL" + ] + }, + "CreateSignedUrlsRequestContent": { + "type": "object", + "properties": { + "expiresIn": { + "type": "number" + }, + "paths": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Common string list shape reused across services." + } + }, + "required": [ + "expiresIn", + "paths" + ] + }, + "CreateSignedUrlsResponseContent": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SignedUrlResult" + } + } + }, + "required": [ + "items" + ] + }, + "DeleteObjectsRequestContent": { + "type": "object", + "properties": { + "prefixes": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Common string list shape reused across services." + } + }, + "required": [ + "prefixes" + ] + }, + "DeleteObjectsResponseContent": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FileObject" + } + } + }, + "required": [ + "items" + ] + }, + "FileMetadata": { + "type": "object", + "properties": { + "eTag": { + "type": "string" + }, + "size": { + "type": "number" + }, + "mimetype": { + "type": "string" + }, + "cacheControl": { + "type": "string" + }, + "lastModified": { + "type": "string" + }, + "contentLength": { + "type": "number" + }, + "httpStatusCode": { + "type": "number" + } + } + }, + "FileObject": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "id": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "last_accessed_at": { + "type": "string" + }, + "metadata": { + "$ref": "#/components/schemas/FileMetadata" + } + }, + "required": [ + "name" + ] + }, + "GetBucketResponseContent": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "public": { + "type": "boolean" + }, + "file_size_limit": { + "type": "number" + }, + "allowed_mime_types": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Common string list shape reused across services." + }, + "created_at": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "public" + ] + }, + "GetObjectInfoResponseContent": { + "type": "object", + "properties": { + "eTag": { + "type": "string" + }, + "size": { + "type": "number" + }, + "mimetype": { + "type": "string" + }, + "cacheControl": { + "type": "string" + }, + "lastModified": { + "type": "string" + }, + "contentLength": { + "type": "number" + }, + "httpStatusCode": { + "type": "number" + } + } + }, + "ListBucketsResponseContent": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Bucket" + } + } + }, + "required": [ + "items" + ] + }, + "ListObjectsRequestContent": { + "type": "object", + "properties": { + "prefix": { + "type": "string" + }, + "limit": { + "type": "number" + }, + "offset": { + "type": "number" + }, + "sortBy": { + "$ref": "#/components/schemas/SortBy" + } + }, + "required": [ + "prefix" + ] + }, + "ListObjectsResponseContent": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FileObject" + } + } + }, + "required": [ + "items" + ] + }, + "MoveObjectRequestContent": { + "type": "object", + "properties": { + "bucketId": { + "type": "string" + }, + "sourceKey": { + "type": "string" + }, + "destinationKey": { + "type": "string" + }, + "destinationBucket": { + "type": "string" + } + }, + "required": [ + "bucketId", + "destinationKey", + "sourceKey" + ] + }, + "SignedUrlResult": { + "type": "object", + "properties": { + "signedURL": { + "type": "string" + }, + "path": { + "type": "string" + }, + "error": { + "type": "string" + } + }, + "required": [ + "path" + ] + }, + "SortBy": { + "type": "object", + "properties": { + "column": { + "type": "string" + }, + "order": { + "type": "string" + } + } + }, + "StorageErrorResponseContent": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "error": { + "type": "string" + }, + "statusCode": { + "type": "string" + } + } + }, + "UpdateBucketRequestContent": { + "type": "object", + "properties": { + "public": { + "type": "boolean" + }, + "file_size_limit": { + "type": "number" + }, + "allowed_mime_types": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Common string list shape reused across services." + } + }, + "required": [ + "public" + ] + }, + "UploadChunkInputPayload": { + "type": "string", + "description": "Raw chunk bytes, streamed directly \u2014 never buffered.", + "format": "binary" + }, + "FileUploadedResponse": { + "type": "object", + "properties": { + "Key": { + "type": "string" + }, + "Id": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "Key", + "Id" + ] + } + } + } +} \ No newline at end of file diff --git a/codegen/pubspec.yaml b/codegen/pubspec.yaml new file mode 100644 index 000000000..6ad53740e --- /dev/null +++ b/codegen/pubspec.yaml @@ -0,0 +1,16 @@ +name: supabase_codegen_spike +description: >- + Spike (SDK-1106): OpenAPI 3.0 -> idiomatic Dart HTTP client emitter for the + Supabase Storage and Functions APIs. Not published. +publish_to: none +version: 0.0.1 + +environment: + sdk: ^3.5.0 + +dependencies: + http: ^1.2.2 + +dev_dependencies: + supabase_lints: ^0.1.0 + test: ^1.24.0 diff --git a/codegen/test/generated_client_test.dart b/codegen/test/generated_client_test.dart new file mode 100644 index 000000000..73bec4c08 --- /dev/null +++ b/codegen/test/generated_client_test.dart @@ -0,0 +1,217 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:http/http.dart' as http; +import 'package:supabase_codegen_spike/supabase_codegen_spike.dart'; +import 'package:test/test.dart'; + +/// A test double that records the outgoing request and returns a canned +/// response. It never touches the network. +class _RecordingClient extends http.BaseClient { + _RecordingClient(this._handler); + + final Future Function(http.BaseRequest request) + _handler; + + http.BaseRequest? lastRequest; + + @override + Future send(http.BaseRequest request) { + lastRequest = request; + return _handler(request); + } +} + +http.StreamedResponse _json(Object body, {int status = 200}) { + final bytes = utf8.encode(jsonEncode(body)); + return http.StreamedResponse( + Stream.value(bytes), + status, + headers: {'content-type': 'application/json'}, + ); +} + +void main() { + group('JSON operations', () { + test('listBuckets decodes into models', () async { + final http = _RecordingClient( + (_) async => _json({ + 'items': [ + {'id': 'avatars', 'name': 'avatars', 'public': true}, + ], + }), + ); + final api = StorageApi(ApiClient(baseUrl: 'https://x', httpClient: http)); + + final result = await api.listBuckets(); + + expect(result.items, hasLength(1)); + expect(result.items.first.id, 'avatars'); + expect(result.items.first.public, isTrue); + expect(http.lastRequest!.method, 'GET'); + expect(http.lastRequest!.url.path, '/bucket'); + }); + + test('createBucket serializes the request model', () async { + Map? sentBody; + final client = _RecordingClient((request) async { + sentBody = jsonDecode( + await (request as http.Request).finalize().bytesToString(), + ) as Map; + return _json({'name': 'photos'}); + }); + final api = + StorageApi(ApiClient(baseUrl: 'https://x', httpClient: client)); + + await api.createBucket( + body: CreateBucketRequestContent( + id: 'photos', + name: 'photos', + public: false, + ), + ); + + expect(sentBody, {'id': 'photos', 'name': 'photos', 'public': false}); + }); + + test('non-2xx maps to ApiException with decoded body', () async { + final api = StorageApi( + ApiClient( + baseUrl: 'https://x', + httpClient: _RecordingClient( + (_) async => _json({'message': 'not found'}, status: 404), + ), + ), + ); + + await expectLater( + api.getBucket(id: 'missing'), + throwsA( + isA() + .having((e) => e.statusCode, 'statusCode', 404) + .having((e) => (e.body as Map)['message'], 'body', 'not found'), + ), + ); + }); + }); + + group('Streaming', () { + test('upload streams the request body without buffering (question 1)', + () async { + final received = []; + final client = _RecordingClient((request) async { + // Pull the finalized body chunk by chunk; nothing was collected into a + // Uint8List by the generated client before this point. + await for (final chunk in request.finalize()) { + received.addAll(chunk); + } + return http.StreamedResponse( + const Stream.empty(), + 204, + headers: {'upload-offset': '9'}, + ); + }); + final api = + StorageApi(ApiClient(baseUrl: 'https://x', httpClient: client)); + + final source = Stream>.fromIterable([ + [1, 2, 3], + [4, 5, 6], + [7, 8, 9], + ]); + + final result = await api.uploadChunk( + uploadId: 'abc', + tusResumable: '1.0.0', + uploadOffset: 0, + body: source, + contentLength: 9, + ); + + expect(client.lastRequest, isA()); + expect(received, [1, 2, 3, 4, 5, 6, 7, 8, 9]); + expect(result['uploadOffset'], 9); + expect(client.lastRequest!.headers['Upload-Offset'], '0'); + expect(client.lastRequest!.headers['Tus-Resumable'], '1.0.0'); + }); + + test('response is handed back as a live stream (question 2)', () async { + final controller = StreamController>(); + final api = FunctionsApi( + ApiClient( + baseUrl: 'https://x', + httpClient: _RecordingClient( + (_) async => http.StreamedResponse(controller.stream, 200), + ), + ), + ); + + final response = await api.invokeFunctionGet(functionName: 'sse'); + expect(response, isA()); + + final received = []; + final done = response.stream + .transform(utf8.decoder) + .listen(received.add) + .asFuture(); + + // Emit events over time; the caller receives them incrementally. + controller.add(utf8.encode('event-1')); + await Future.delayed(Duration.zero); + expect(received, ['event-1']); + controller.add(utf8.encode('event-2')); + await controller.close(); + await done; + + expect(received, ['event-1', 'event-2']); + }); + + test('multipart upload streams the file part (question 3)', () async { + http.BaseRequest? captured; + final client = _RecordingClient((request) async { + captured = request; + await request.finalize().drain(); + return _json({'Key': 'avatars/a.png', 'Id': 'uuid'}); + }); + final api = + StorageApi(ApiClient(baseUrl: 'https://x', httpClient: client)); + + final result = await api.uploadObject( + bucketId: 'avatars', + wildcardPath: 'a.png', + file: Stream.value(Uint8List.fromList([1, 2, 3])), + fileLength: 3, + fileName: 'a.png', + cacheControl: '3600', + ); + + expect(captured, isA()); + expect(result.key, 'avatars/a.png'); + }); + }); + + group('Middleware / header injection (question 4)', () { + test('headerProvider is invoked per request for fresh auth tokens', + () async { + var token = 'first'; + final client = _RecordingClient((_) async => _json({'items': []})); + final api = StorageApi( + ApiClient( + baseUrl: 'https://x', + httpClient: client, + defaultHeaders: {'apikey': 'anon'}, + headerProvider: () => {'Authorization': 'Bearer $token'}, + ), + ); + + await api.listBuckets(); + expect(client.lastRequest!.headers['Authorization'], 'Bearer first'); + expect(client.lastRequest!.headers['apikey'], 'anon'); + + token = 'refreshed'; + await api.listBuckets(); + expect(client.lastRequest!.headers['Authorization'], 'Bearer refreshed'); + }); + }); +} From 52e6c0a3bc7d1b8ae6d6ab69778e754cc7dfddb9 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Wed, 1 Jul 2026 11:33:37 +0200 Subject: [PATCH 02/11] refactor(codegen): move spike into packages/supabase_openapi_generator Rename the package from supabase_codegen_spike, move it under packages/, add analysis_options, align README tables and drop em dashes. --- codegen/README.md | 113 ------------------ codegen/pubspec.yaml | 16 --- .../supabase_openapi_generator}/.gitignore | 0 packages/supabase_openapi_generator/README.md | 113 ++++++++++++++++++ .../analysis_options.yaml | 1 + .../bin/generate.dart | 5 +- .../lib/src/generated/functions_api.g.dart | 2 +- .../lib/src/generated/storage_api.g.dart | 2 +- .../lib/src/runtime.dart | 0 .../lib/supabase_openapi_generator.dart | 0 .../openapi/FunctionsService.openapi.json | 0 .../openapi/StorageService.openapi.json | 0 .../supabase_openapi_generator/pubspec.yaml | 17 +++ .../test/generated_client_test.dart | 2 +- 14 files changed, 138 insertions(+), 133 deletions(-) delete mode 100644 codegen/README.md delete mode 100644 codegen/pubspec.yaml rename {codegen => packages/supabase_openapi_generator}/.gitignore (100%) create mode 100644 packages/supabase_openapi_generator/README.md create mode 100644 packages/supabase_openapi_generator/analysis_options.yaml rename {codegen => packages/supabase_openapi_generator}/bin/generate.dart (99%) rename {codegen => packages/supabase_openapi_generator}/lib/src/generated/functions_api.g.dart (98%) rename {codegen => packages/supabase_openapi_generator}/lib/src/generated/storage_api.g.dart (99%) rename {codegen => packages/supabase_openapi_generator}/lib/src/runtime.dart (100%) rename codegen/lib/supabase_codegen_spike.dart => packages/supabase_openapi_generator/lib/supabase_openapi_generator.dart (100%) rename {codegen => packages/supabase_openapi_generator}/openapi/FunctionsService.openapi.json (100%) rename {codegen => packages/supabase_openapi_generator}/openapi/StorageService.openapi.json (100%) create mode 100644 packages/supabase_openapi_generator/pubspec.yaml rename {codegen => packages/supabase_openapi_generator}/test/generated_client_test.dart (98%) diff --git a/codegen/README.md b/codegen/README.md deleted file mode 100644 index 30ac15833..000000000 --- a/codegen/README.md +++ /dev/null @@ -1,113 +0,0 @@ -# Dart/Flutter codegen spike (SDK-1106) - -> **DO NOT MERGE — Spike / Proof of Concept.** - -Investigation into generating the mechanical HTTP layer of `supabase-dart` -(Storage + Functions) from the shared Smithy models in -[supabase/sdk#51](https://github.com/supabase/sdk/pull/51), producing idiomatic -Dart a maintainer would own long-term. - -Companion to the Swift spike -([supabase/supabase-swift#1047](https://github.com/supabase/supabase-swift/pull/1047)). - -## TL;DR — recommendation - -**Adopt a small custom Dart emitter.** It is the only option that meets all -three hard constraints at once: - -1. streaming request bodies (`Stream>`, mandatory for TUS + large - mobile uploads), -2. streaming responses (mandatory for Functions SSE/streaming), -3. no `build_runner` and no `dart:io` (so Web/WASM works). - -Off-the-shelf generators were **rejected**: - -| Toolchain | Verdict | Reason | -|-----------|---------|--------| -| OpenAPI Generator `dart-dio` | Reject | Requires `build_runner` + `built_value` + `dio`; no streaming request/response body support | -| OpenAPI Generator `dart` | Reject | Uses `http` but buffers all bodies; no streaming; less idiomatic output | -| TypeSpec / Smithy Dart emitter | N/A | No official Dart emitter exists | -| Speakeasy | N/A | No Dart target | - -The custom emitter is `bin/generate.dart` (~500 lines, zero deps beyond the -Dart SDK). It consumes the committed OpenAPI artifacts and writes `http`-based -clients into `lib/src/generated/`. Building it took roughly the length of this -spike, and extending it to Auth is incremental. - -## What's here - -``` -codegen/ - openapi/ # artifacts copied verbatim from supabase/sdk#51 - StorageService.openapi.json - FunctionsService.openapi.json - bin/generate.dart # the emitter (dart run bin/generate.dart) - lib/ - src/runtime.dart # hand-written transport: ApiClient, streaming, errors - src/generated/ - storage_api.g.dart # GENERATED — 18 operations + models - functions_api.g.dart # GENERATED — 5 invoke operations - test/generated_client_test.dart # proves the spike questions against a mock client -``` - -Regenerate with: - -```bash -dart pub get -dart run bin/generate.dart -dart test -``` - -## Design - -The split mirrors the Swift PR: a thin hand-written **runtime** -(`ApiClient`) owns transport and headers; the **generated** clients are pure -request-building + response-decoding. The public `supabase-dart` API -(`StorageFileApi`, `FunctionsClient`, …) would sit on top as an idiomatic -facade, exactly as it does today, calling the generated methods instead of the -hand-rolled `fetch` layer. - -- Built on the `http` package the SDK already depends on. No `dio`, no - `build_runner`, no `dart:io` — the same code runs on iOS, Android, macOS, - Windows, Linux, Web and WASM. -- Models are immutable (`final` fields, named constructor, `fromJson`/`toJson`). - snake_case wire keys are mapped to camelCase Dart fields. -- The auth token hook is a per-request `HeaderProvider` callback, so a token - refreshed by the auth loop is always picked up. - -## Spike questions - -| # | Question | Answer | -|---|----------|--------| -| 1 | Streaming uploads (`Stream` body, no buffering)? | **Yes.** Generated via `http.StreamedRequest`; bytes flow source→socket. Proven in `test` (TUS `uploadChunk`). Off-the-shelf generators cannot do this. | -| 2 | Streaming responses? | **Yes.** Octet-stream responses return `StreamedApiResponse` wrapping `response.stream`; caller receives events incrementally. Proven (Functions `invokeFunctionGet`). | -| 3 | Multipart with a streaming file part? | **Yes.** Generated `http.MultipartFile(field, stream, length)`. Proven (`uploadObject`). | -| 4 | Middleware/interceptor for runtime auth headers? | **Yes.** `HeaderProvider` runs before every request; proven with a token that changes between calls. | -| 5 | Auth flows generatable? | **Partly.** Auth isn't in the shared models yet. The HTTP operations (sign-in/up, token refresh, OTP, admin) are plain JSON and would generate cleanly once added. The session loop (refresh timer, storage, `onAuthStateChange`) stays hand-written. | -| 6 | PostgREST query builder? | **No.** The dynamic query string (`.select()`, `.eq()`, `.order()`) can't be modelled in Smithy/OpenAPI. Codegen could at most supply a generic request executor; the builder and row types stay hand-written. Recommend keeping PostgREST out of codegen. | -| 7 | Web compatibility (no `dart:io`)? | **Yes.** Only `http` is used, no conditional imports. Note: streaming *uploads* on Web depend on the HTTP client — `BrowserClient` buffers, `fetch_client` streams — same caveat that already applies to the hand-written SDK. | -| 8 | `build_runner` required? | **No.** `dart run bin/generate.dart`; output is committed and reviewable. This is the decisive advantage over `dart-dio`. | -| 9 | Effort to build an emitter? | **Low.** One ~500-line file generated idiomatic clients for both services. | -| 10 | Model gaps found | See below. | - -## Model gaps found (for supabase/sdk#51) - -- **Functions output should be `@streaming`.** It's currently a plain `Blob` - (OpenAPI `format: byte`). This emitter already treats `application/octet-stream` - responses as streams, but marking the output `@streaming` makes the intent - explicit and matches the TUS `@streaming` input. -- **Functions dynamic query params** (limitation #5 in the model README) still - need per-SDK middleware URL-rewriting; unchanged by this spike. -- **Single-member outputs** (`ListBucketsResponseContent { items }`) generate a - wrapper object. The facade layer unwraps it (`listBuckets().items`); acceptable, - but a codegen convention to unwrap single-member structures would be nicer. -- **`Long`/`Integer` both collapse to OpenAPI `number` → Dart `num`.** Works for - JSON, but `int` would read better for offsets/sizes. Not a blocker. - -## Not covered (out of scope, matching Swift spike) - -- Auth and PostgREST models don't exist in `sdk#51` yet. -- Realtime (WebSocket) is incompatible with REST codegen — stays hand-written. -- Wiring the generated clients into the real `storage_client` / - `functions_client` packages (the Swift PR did this incrementally; deferred - here to keep the spike self-contained). diff --git a/codegen/pubspec.yaml b/codegen/pubspec.yaml deleted file mode 100644 index 6ad53740e..000000000 --- a/codegen/pubspec.yaml +++ /dev/null @@ -1,16 +0,0 @@ -name: supabase_codegen_spike -description: >- - Spike (SDK-1106): OpenAPI 3.0 -> idiomatic Dart HTTP client emitter for the - Supabase Storage and Functions APIs. Not published. -publish_to: none -version: 0.0.1 - -environment: - sdk: ^3.5.0 - -dependencies: - http: ^1.2.2 - -dev_dependencies: - supabase_lints: ^0.1.0 - test: ^1.24.0 diff --git a/codegen/.gitignore b/packages/supabase_openapi_generator/.gitignore similarity index 100% rename from codegen/.gitignore rename to packages/supabase_openapi_generator/.gitignore diff --git a/packages/supabase_openapi_generator/README.md b/packages/supabase_openapi_generator/README.md new file mode 100644 index 000000000..e0e6d3611 --- /dev/null +++ b/packages/supabase_openapi_generator/README.md @@ -0,0 +1,113 @@ +# Dart/Flutter codegen spike (SDK-1106) + +> **DO NOT MERGE. Spike / proof of concept.** + +Investigation into generating the mechanical HTTP layer of `supabase-dart` +(Storage + Functions) from the shared Smithy models in +[supabase/sdk#51](https://github.com/supabase/sdk/pull/51), producing idiomatic +Dart a maintainer would own long-term. + +Companion to the Swift spike +([supabase/supabase-swift#1047](https://github.com/supabase/supabase-swift/pull/1047)). + +## TL;DR: recommendation + +**Adopt a small custom Dart emitter.** It is the only option that meets all +three hard constraints at once: + +1. streaming request bodies (`Stream>`, mandatory for TUS + large + mobile uploads), +2. streaming responses (mandatory for Functions SSE/streaming), +3. no `build_runner` and no `dart:io` (so Web/WASM works). + +Off-the-shelf generators were **rejected**: + +| Toolchain | Verdict | Reason | +| ------------------------------ | ------- | ------------------------------------------------------------------------------------------- | +| OpenAPI Generator `dart-dio` | Reject | Requires `build_runner` + `built_value` + `dio`; no streaming request/response body support | +| OpenAPI Generator `dart` | Reject | Uses `http` but buffers all bodies; no streaming; less idiomatic output | +| TypeSpec / Smithy Dart emitter | N/A | No official Dart emitter exists | +| Speakeasy | N/A | No Dart target | + +The custom emitter is `bin/generate.dart` (~500 lines, zero deps beyond the +Dart SDK). It consumes the committed OpenAPI artifacts and writes `http`-based +clients into `lib/src/generated/`. Building it took roughly the length of this +spike, and extending it to Auth is incremental. + +## What's here + +``` +packages/supabase_openapi_generator/ + openapi/ # artifacts copied verbatim from supabase/sdk#51 + StorageService.openapi.json + FunctionsService.openapi.json + bin/generate.dart # the emitter (dart run bin/generate.dart) + lib/ + src/runtime.dart # hand-written transport: ApiClient, streaming, errors + src/generated/ + storage_api.g.dart # GENERATED, 18 operations + models + functions_api.g.dart # GENERATED, 5 invoke operations + test/generated_client_test.dart # proves the spike questions against a mock client +``` + +Regenerate with: + +```bash +dart pub get +dart run bin/generate.dart +dart test +``` + +## Design + +The split mirrors the Swift PR: a thin hand-written **runtime** +(`ApiClient`) owns transport and headers; the **generated** clients are pure +request-building + response-decoding. The public `supabase-dart` API +(`StorageFileApi`, `FunctionsClient`, …) would sit on top as an idiomatic +facade, exactly as it does today, calling the generated methods instead of the +hand-rolled `fetch` layer. + +- Built on the `http` package the SDK already depends on. No `dio`, no + `build_runner`, no `dart:io`. The same code runs on iOS, Android, macOS, + Windows, Linux, Web and WASM. +- Models are immutable (`final` fields, named constructor, `fromJson`/`toJson`). + snake_case wire keys are mapped to camelCase Dart fields. +- The auth token hook is a per-request `HeaderProvider` callback, so a token + refreshed by the auth loop is always picked up. + +## Spike questions + +| # | Question | Answer | +| --- | ----------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1 | Streaming uploads (`Stream` body, no buffering)? | **Yes.** Generated via `http.StreamedRequest`; bytes flow source→socket. Proven in `test` (TUS `uploadChunk`). Off-the-shelf generators cannot do this. | +| 2 | Streaming responses? | **Yes.** Octet-stream responses return `StreamedApiResponse` wrapping `response.stream`; caller receives events incrementally. Proven (Functions `invokeFunctionGet`). | +| 3 | Multipart with a streaming file part? | **Yes.** Generated `http.MultipartFile(field, stream, length)`. Proven (`uploadObject`). | +| 4 | Middleware/interceptor for runtime auth headers? | **Yes.** `HeaderProvider` runs before every request; proven with a token that changes between calls. | +| 5 | Auth flows generatable? | **Partly.** Auth isn't in the shared models yet. The HTTP operations (sign-in/up, token refresh, OTP, admin) are plain JSON and would generate cleanly once added. The session loop (refresh timer, storage, `onAuthStateChange`) stays hand-written. | +| 6 | PostgREST query builder? | **No.** The dynamic query string (`.select()`, `.eq()`, `.order()`) can't be modelled in Smithy/OpenAPI. Codegen could at most supply a generic request executor; the builder and row types stay hand-written. Recommend keeping PostgREST out of codegen. | +| 7 | Web compatibility (no `dart:io`)? | **Yes.** Only `http` is used, no conditional imports. Note: streaming *uploads* on Web depend on the HTTP client (`BrowserClient` buffers, `fetch_client` streams), the same caveat that already applies to the hand-written SDK. | +| 8 | `build_runner` required? | **No.** `dart run bin/generate.dart`; output is committed and reviewable. This is the decisive advantage over `dart-dio`. | +| 9 | Effort to build an emitter? | **Low.** One ~500-line file generated idiomatic clients for both services. | +| 10 | Model gaps found | See below. | + +## Model gaps found (for supabase/sdk#51) + +- **Functions output should be `@streaming`.** It's currently a plain `Blob` + (OpenAPI `format: byte`). This emitter already treats `application/octet-stream` + responses as streams, but marking the output `@streaming` makes the intent + explicit and matches the TUS `@streaming` input. +- **Functions dynamic query params** (limitation #5 in the model README) still + need per-SDK middleware URL-rewriting; unchanged by this spike. +- **Single-member outputs** (`ListBucketsResponseContent { items }`) generate a + wrapper object. The facade layer unwraps it (`listBuckets().items`); acceptable, + but a codegen convention to unwrap single-member structures would be nicer. +- **`Long`/`Integer` both collapse to OpenAPI `number` → Dart `num`.** Works for + JSON, but `int` would read better for offsets/sizes. Not a blocker. + +## Not covered (out of scope, matching Swift spike) + +- Auth and PostgREST models don't exist in `sdk#51` yet. +- Realtime (WebSocket) is incompatible with REST codegen, stays hand-written. +- Wiring the generated clients into the real `storage_client` / + `functions_client` packages (the Swift PR did this incrementally; deferred + here to keep the spike self-contained). diff --git a/packages/supabase_openapi_generator/analysis_options.yaml b/packages/supabase_openapi_generator/analysis_options.yaml new file mode 100644 index 000000000..a8ab68344 --- /dev/null +++ b/packages/supabase_openapi_generator/analysis_options.yaml @@ -0,0 +1 @@ +include: package:supabase_lints/analysis_options.yaml diff --git a/codegen/bin/generate.dart b/packages/supabase_openapi_generator/bin/generate.dart similarity index 99% rename from codegen/bin/generate.dart rename to packages/supabase_openapi_generator/bin/generate.dart index b9a2a0291..ca36e3a52 100644 --- a/codegen/bin/generate.dart +++ b/packages/supabase_openapi_generator/bin/generate.dart @@ -73,7 +73,10 @@ void _generate({ final header = StringBuffer() ..writeln('// GENERATED CODE - DO NOT MODIFY BY HAND.') ..writeln('// Generated from $specPath by bin/generate.dart.') - ..writeln('// ignore_for_file: prefer_final_locals') + ..writeln( + '// ignore_for_file: prefer_final_locals, ' + 'unnecessary_brace_in_string_interps', + ) ..writeln(); if (needsConvert) { header.writeln("import 'dart:convert';"); diff --git a/codegen/lib/src/generated/functions_api.g.dart b/packages/supabase_openapi_generator/lib/src/generated/functions_api.g.dart similarity index 98% rename from codegen/lib/src/generated/functions_api.g.dart rename to packages/supabase_openapi_generator/lib/src/generated/functions_api.g.dart index f4d4339f9..01792ad56 100644 --- a/codegen/lib/src/generated/functions_api.g.dart +++ b/packages/supabase_openapi_generator/lib/src/generated/functions_api.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND. // Generated from openapi/FunctionsService.openapi.json by bin/generate.dart. -// ignore_for_file: prefer_final_locals +// ignore_for_file: prefer_final_locals, unnecessary_brace_in_string_interps import 'package:http/http.dart' as http; diff --git a/codegen/lib/src/generated/storage_api.g.dart b/packages/supabase_openapi_generator/lib/src/generated/storage_api.g.dart similarity index 99% rename from codegen/lib/src/generated/storage_api.g.dart rename to packages/supabase_openapi_generator/lib/src/generated/storage_api.g.dart index 8656952ca..1034ff109 100644 --- a/codegen/lib/src/generated/storage_api.g.dart +++ b/packages/supabase_openapi_generator/lib/src/generated/storage_api.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND. // Generated from openapi/StorageService.openapi.json by bin/generate.dart. -// ignore_for_file: prefer_final_locals +// ignore_for_file: prefer_final_locals, unnecessary_brace_in_string_interps import 'dart:convert'; diff --git a/codegen/lib/src/runtime.dart b/packages/supabase_openapi_generator/lib/src/runtime.dart similarity index 100% rename from codegen/lib/src/runtime.dart rename to packages/supabase_openapi_generator/lib/src/runtime.dart diff --git a/codegen/lib/supabase_codegen_spike.dart b/packages/supabase_openapi_generator/lib/supabase_openapi_generator.dart similarity index 100% rename from codegen/lib/supabase_codegen_spike.dart rename to packages/supabase_openapi_generator/lib/supabase_openapi_generator.dart diff --git a/codegen/openapi/FunctionsService.openapi.json b/packages/supabase_openapi_generator/openapi/FunctionsService.openapi.json similarity index 100% rename from codegen/openapi/FunctionsService.openapi.json rename to packages/supabase_openapi_generator/openapi/FunctionsService.openapi.json diff --git a/codegen/openapi/StorageService.openapi.json b/packages/supabase_openapi_generator/openapi/StorageService.openapi.json similarity index 100% rename from codegen/openapi/StorageService.openapi.json rename to packages/supabase_openapi_generator/openapi/StorageService.openapi.json diff --git a/packages/supabase_openapi_generator/pubspec.yaml b/packages/supabase_openapi_generator/pubspec.yaml new file mode 100644 index 000000000..932d6f30b --- /dev/null +++ b/packages/supabase_openapi_generator/pubspec.yaml @@ -0,0 +1,17 @@ +name: supabase_openapi_generator +description: >- + Generates idiomatic Dart HTTP clients for Supabase services from OpenAPI 3.0 + specifications, with streaming upload, streaming response and multipart + support. +publish_to: none +version: 0.0.1 + +environment: + sdk: ^3.5.0 + +dependencies: + http: ^1.2.2 + +dev_dependencies: + supabase_lints: ^0.1.0 + test: ^1.24.0 diff --git a/codegen/test/generated_client_test.dart b/packages/supabase_openapi_generator/test/generated_client_test.dart similarity index 98% rename from codegen/test/generated_client_test.dart rename to packages/supabase_openapi_generator/test/generated_client_test.dart index 73bec4c08..ab21bcf44 100644 --- a/codegen/test/generated_client_test.dart +++ b/packages/supabase_openapi_generator/test/generated_client_test.dart @@ -3,7 +3,7 @@ import 'dart:convert'; import 'dart:typed_data'; import 'package:http/http.dart' as http; -import 'package:supabase_codegen_spike/supabase_codegen_spike.dart'; +import 'package:supabase_openapi_generator/supabase_openapi_generator.dart'; import 'package:test/test.dart'; /// A test double that records the outgoing request and returns a canned From 3a4025fbec69d30aa0e826d458da13a1fdc9e22b Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Wed, 1 Jul 2026 11:40:08 +0200 Subject: [PATCH 03/11] fix(codegen): address code-review findings and refocus README - percent-encode path parameters (wildcards keep slashes), mirroring the storage client fix in #1479 - parse numeric response headers tolerantly instead of force-unwrapping - apply operation content-type after caller/default headers so it wins - wire streaming bodies via addStream so source errors are catchable - add tests for each fix - rewrite README to describe the package; drop spike references from code --- packages/supabase_openapi_generator/README.md | 161 +++++++----------- .../bin/generate.dart | 35 ++-- .../lib/src/generated/functions_api.g.dart | 15 +- .../lib/src/generated/storage_api.g.dart | 79 ++++----- .../lib/src/runtime.dart | 30 ++-- .../test/generated_client_test.dart | 82 +++++++++ 6 files changed, 237 insertions(+), 165 deletions(-) diff --git a/packages/supabase_openapi_generator/README.md b/packages/supabase_openapi_generator/README.md index e0e6d3611..aa2766edc 100644 --- a/packages/supabase_openapi_generator/README.md +++ b/packages/supabase_openapi_generator/README.md @@ -1,113 +1,82 @@ -# Dart/Flutter codegen spike (SDK-1106) +# supabase_openapi_generator -> **DO NOT MERGE. Spike / proof of concept.** +Generates idiomatic Dart HTTP clients for Supabase services from OpenAPI 3.0 +specifications. The generated clients are built on the `http` package, need no +`build_runner`, and run unchanged on mobile, desktop, web and WASM. -Investigation into generating the mechanical HTTP layer of `supabase-dart` -(Storage + Functions) from the shared Smithy models in -[supabase/sdk#51](https://github.com/supabase/sdk/pull/51), producing idiomatic -Dart a maintainer would own long-term. +The emitter turns an OpenAPI document into: -Companion to the Swift spike -([supabase/supabase-swift#1047](https://github.com/supabase/supabase-swift/pull/1047)). +- immutable model classes (`final` fields, named constructor, `fromJson`/`toJson`, + snake_case wire keys mapped to camelCase Dart fields), and +- a client class with one typed method per operation. -## TL;DR: recommendation +Supported transport features: -**Adopt a small custom Dart emitter.** It is the only option that meets all -three hard constraints at once: +- **Streaming uploads** via `Stream>` request bodies, never buffered + into memory. +- **Streaming responses** handed back as a live byte stream. +- **Multipart** uploads with a streaming file part. +- **Per-request header injection** for runtime values such as auth tokens. +- **Percent-encoded path parameters** so object keys with reserved characters + address the correct resource. -1. streaming request bodies (`Stream>`, mandatory for TUS + large - mobile uploads), -2. streaming responses (mandatory for Functions SSE/streaming), -3. no `build_runner` and no `dart:io` (so Web/WASM works). - -Off-the-shelf generators were **rejected**: - -| Toolchain | Verdict | Reason | -| ------------------------------ | ------- | ------------------------------------------------------------------------------------------- | -| OpenAPI Generator `dart-dio` | Reject | Requires `build_runner` + `built_value` + `dio`; no streaming request/response body support | -| OpenAPI Generator `dart` | Reject | Uses `http` but buffers all bodies; no streaming; less idiomatic output | -| TypeSpec / Smithy Dart emitter | N/A | No official Dart emitter exists | -| Speakeasy | N/A | No Dart target | - -The custom emitter is `bin/generate.dart` (~500 lines, zero deps beyond the -Dart SDK). It consumes the committed OpenAPI artifacts and writes `http`-based -clients into `lib/src/generated/`. Building it took roughly the length of this -spike, and extending it to Auth is incremental. - -## What's here +## Layout ``` -packages/supabase_openapi_generator/ - openapi/ # artifacts copied verbatim from supabase/sdk#51 - StorageService.openapi.json - FunctionsService.openapi.json - bin/generate.dart # the emitter (dart run bin/generate.dart) +supabase_openapi_generator/ + openapi/ # OpenAPI 3.0 input documents + bin/generate.dart # the emitter lib/ - src/runtime.dart # hand-written transport: ApiClient, streaming, errors - src/generated/ - storage_api.g.dart # GENERATED, 18 operations + models - functions_api.g.dart # GENERATED, 5 invoke operations - test/generated_client_test.dart # proves the spike questions against a mock client + src/runtime.dart # transport: ApiClient, streaming, errors + src/generated/*.g.dart # generated clients (checked in) ``` -Regenerate with: +## Generating + +Point the emitter at the OpenAPI documents in `openapi/` and run it. Output is +written to `lib/src/generated/` and formatted automatically. ```bash dart pub get dart run bin/generate.dart -dart test ``` -## Design - -The split mirrors the Swift PR: a thin hand-written **runtime** -(`ApiClient`) owns transport and headers; the **generated** clients are pure -request-building + response-decoding. The public `supabase-dart` API -(`StorageFileApi`, `FunctionsClient`, …) would sit on top as an idiomatic -facade, exactly as it does today, calling the generated methods instead of the -hand-rolled `fetch` layer. - -- Built on the `http` package the SDK already depends on. No `dio`, no - `build_runner`, no `dart:io`. The same code runs on iOS, Android, macOS, - Windows, Linux, Web and WASM. -- Models are immutable (`final` fields, named constructor, `fromJson`/`toJson`). - snake_case wire keys are mapped to camelCase Dart fields. -- The auth token hook is a per-request `HeaderProvider` callback, so a token - refreshed by the auth loop is always picked up. - -## Spike questions - -| # | Question | Answer | -| --- | ----------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 1 | Streaming uploads (`Stream` body, no buffering)? | **Yes.** Generated via `http.StreamedRequest`; bytes flow source→socket. Proven in `test` (TUS `uploadChunk`). Off-the-shelf generators cannot do this. | -| 2 | Streaming responses? | **Yes.** Octet-stream responses return `StreamedApiResponse` wrapping `response.stream`; caller receives events incrementally. Proven (Functions `invokeFunctionGet`). | -| 3 | Multipart with a streaming file part? | **Yes.** Generated `http.MultipartFile(field, stream, length)`. Proven (`uploadObject`). | -| 4 | Middleware/interceptor for runtime auth headers? | **Yes.** `HeaderProvider` runs before every request; proven with a token that changes between calls. | -| 5 | Auth flows generatable? | **Partly.** Auth isn't in the shared models yet. The HTTP operations (sign-in/up, token refresh, OTP, admin) are plain JSON and would generate cleanly once added. The session loop (refresh timer, storage, `onAuthStateChange`) stays hand-written. | -| 6 | PostgREST query builder? | **No.** The dynamic query string (`.select()`, `.eq()`, `.order()`) can't be modelled in Smithy/OpenAPI. Codegen could at most supply a generic request executor; the builder and row types stay hand-written. Recommend keeping PostgREST out of codegen. | -| 7 | Web compatibility (no `dart:io`)? | **Yes.** Only `http` is used, no conditional imports. Note: streaming *uploads* on Web depend on the HTTP client (`BrowserClient` buffers, `fetch_client` streams), the same caveat that already applies to the hand-written SDK. | -| 8 | `build_runner` required? | **No.** `dart run bin/generate.dart`; output is committed and reviewable. This is the decisive advantage over `dart-dio`. | -| 9 | Effort to build an emitter? | **Low.** One ~500-line file generated idiomatic clients for both services. | -| 10 | Model gaps found | See below. | - -## Model gaps found (for supabase/sdk#51) - -- **Functions output should be `@streaming`.** It's currently a plain `Blob` - (OpenAPI `format: byte`). This emitter already treats `application/octet-stream` - responses as streams, but marking the output `@streaming` makes the intent - explicit and matches the TUS `@streaming` input. -- **Functions dynamic query params** (limitation #5 in the model README) still - need per-SDK middleware URL-rewriting; unchanged by this spike. -- **Single-member outputs** (`ListBucketsResponseContent { items }`) generate a - wrapper object. The facade layer unwraps it (`listBuckets().items`); acceptable, - but a codegen convention to unwrap single-member structures would be nicer. -- **`Long`/`Integer` both collapse to OpenAPI `number` → Dart `num`.** Works for - JSON, but `int` would read better for offsets/sizes. Not a blocker. - -## Not covered (out of scope, matching Swift spike) - -- Auth and PostgREST models don't exist in `sdk#51` yet. -- Realtime (WebSocket) is incompatible with REST codegen, stays hand-written. -- Wiring the generated clients into the real `storage_client` / - `functions_client` packages (the Swift PR did this incrementally; deferred - here to keep the spike self-contained). +To generate from different documents or to different targets, edit the +`_generate(...)` calls in `bin/generate.dart`. + +## Using a generated client + +Construct an `ApiClient` with the base URL and, optionally, an `httpClient`, +default headers, and a `headerProvider` that supplies runtime headers before +every request: + +```dart +final client = ApiClient( + baseUrl: 'https://.supabase.co/storage/v1', + defaultHeaders: {'apikey': anonKey}, + headerProvider: () => {'Authorization': 'Bearer ${session.accessToken}'}, +); + +final storage = StorageApi(client); + +// JSON operation. +final buckets = await storage.listBuckets(); + +// Streaming upload (bytes flow straight from the source to the socket). +await storage.uploadChunk( + uploadId: uploadId, + tusResumable: '1.0.0', + uploadOffset: 0, + body: file.openRead(), + contentLength: length, +); +``` + +Non-2xx responses throw `ApiException` with the decoded error body. +Octet-stream responses return a `StreamedApiResponse` exposing the live stream. + +## Testing + +```bash +dart test +``` diff --git a/packages/supabase_openapi_generator/bin/generate.dart b/packages/supabase_openapi_generator/bin/generate.dart index ca36e3a52..718c62af8 100644 --- a/packages/supabase_openapi_generator/bin/generate.dart +++ b/packages/supabase_openapi_generator/bin/generate.dart @@ -1,10 +1,9 @@ import 'dart:convert'; import 'dart:io'; -/// A minimal, dependency-free OpenAPI 3.0 -> idiomatic Dart emitter for the -/// Supabase HTTP layer. It consumes the committed artifacts in `openapi/` -/// (produced from the shared Smithy models in supabase/sdk#51) and writes -/// `http`-based clients into `lib/src/generated/`. +/// A minimal, dependency-free OpenAPI 3.0 -> idiomatic Dart emitter. It +/// consumes the OpenAPI documents in `openapi/` and writes `http`-based clients +/// into `lib/src/generated/`. /// /// Run with: `dart run bin/generate.dart` void main() { @@ -210,14 +209,15 @@ String _generateOperation( ..writeln( ' Future<${response.returnType}> $methodName($signature) async {'); - // URI. + // URI. Path values are percent-encoded so keys with reserved characters + // (spaces, `?`, `#`, …) don't corrupt the URL. Wildcard segments keep `/`. var dartPath = path; for (final param in pathParams) { final wire = param['name'] as String; - dartPath = dartPath.replaceAll( - '{$wire}', - '\${${_camelCase(_stripWildcard(wire))}}', - ); + final name = _camelCase(_stripWildcard(wire)); + final encoded = + wire.endsWith('+') ? 'encodePath($name)' : 'Uri.encodeComponent($name)'; + dartPath = dartPath.replaceAll('{$wire}', '\${$encoded}'); } if (queryParams.isEmpty) { buffer.writeln(" final uri = _client.uri('$dartPath');"); @@ -245,9 +245,12 @@ String _generateOperation( } buffer.writeln(' });'); - // Request construction + send. + // Request construction + send. Operation-owned headers (e.g. the JSON + // content-type) are applied after addAll so caller/default headers can't + // clobber them. buffer.write(body.buildRequest(method.toUpperCase())); buffer.writeln(' request.headers.addAll(headers);'); + buffer.write(body.afterHeaders); buffer.writeln(' final streamed = await _client.send(request);'); // Response handling. @@ -263,10 +266,15 @@ class _Body { _Body({ required this.parameters, required this.buildRequest, + this.afterHeaders = '', }); final List parameters; final String Function(String method) buildRequest; + + /// Emitted after `request.headers.addAll(headers)` so operation-owned headers + /// win over caller/default headers. + final String afterHeaders; } _Body _resolveBody(Map op) { @@ -292,8 +300,9 @@ _Body _resolveBody(Map op) { parameters: ['required $type body'], buildRequest: (method) => " final request = http.Request('$method', uri)\n" - " ..headers['content-type'] = 'application/json'\n" ' ..body = jsonEncode(body.toJson());\n', + afterHeaders: + " request.headers['content-type'] = 'application/json';\n", ); } @@ -384,7 +393,7 @@ _Response _resolveResponse(Map op) { ' return $type.fromJson(jsonDecode(response.body) as Map);\n', ); } - // Binary response streamed straight to the caller (spike question 2). + // Binary response streamed straight to the caller. return _Response( returnType: 'StreamedApiResponse', handle: @@ -412,7 +421,7 @@ _Response _resolveResponse(Map op) { final isNumber = schema?['type'] == 'number' || schema?['type'] == 'integer'; final read = isNumber - ? "int.parse(response.headers['${wire.toLowerCase()}']!)" + ? "parseIntHeader(response.headers['${wire.toLowerCase()}'])" : "response.headers['${wire.toLowerCase()}']"; buffer.writeln(" '${_camelCase(wire)}': $read,"); } diff --git a/packages/supabase_openapi_generator/lib/src/generated/functions_api.g.dart b/packages/supabase_openapi_generator/lib/src/generated/functions_api.g.dart index 01792ad56..3d2fa13a8 100644 --- a/packages/supabase_openapi_generator/lib/src/generated/functions_api.g.dart +++ b/packages/supabase_openapi_generator/lib/src/generated/functions_api.g.dart @@ -35,7 +35,8 @@ class FunctionsApi { String? xRegion, required Stream> body, int? contentLength}) async { - final uri = _client.uri('/functions/v1/${functionName}'); + final uri = + _client.uri('/functions/v1/${Uri.encodeComponent(functionName)}'); final headers = await _client.headers({ if (xRegion != null) 'x-region': xRegion, }); @@ -55,7 +56,8 @@ class FunctionsApi { Future invokeFunctionGet( {required String functionName, String? xRegion}) async { - final uri = _client.uri('/functions/v1/${functionName}'); + final uri = + _client.uri('/functions/v1/${Uri.encodeComponent(functionName)}'); final headers = await _client.headers({ if (xRegion != null) 'x-region': xRegion, }); @@ -77,7 +79,8 @@ class FunctionsApi { String? xRegion, required Stream> body, int? contentLength}) async { - final uri = _client.uri('/functions/v1/${functionName}'); + final uri = + _client.uri('/functions/v1/${Uri.encodeComponent(functionName)}'); final headers = await _client.headers({ if (xRegion != null) 'x-region': xRegion, }); @@ -100,7 +103,8 @@ class FunctionsApi { String? xRegion, required Stream> body, int? contentLength}) async { - final uri = _client.uri('/functions/v1/${functionName}'); + final uri = + _client.uri('/functions/v1/${Uri.encodeComponent(functionName)}'); final headers = await _client.headers({ if (xRegion != null) 'x-region': xRegion, }); @@ -123,7 +127,8 @@ class FunctionsApi { String? xRegion, required Stream> body, int? contentLength}) async { - final uri = _client.uri('/functions/v1/${functionName}'); + final uri = + _client.uri('/functions/v1/${Uri.encodeComponent(functionName)}'); final headers = await _client.headers({ if (xRegion != null) 'x-region': xRegion, }); diff --git a/packages/supabase_openapi_generator/lib/src/generated/storage_api.g.dart b/packages/supabase_openapi_generator/lib/src/generated/storage_api.g.dart index 1034ff109..8a83518fc 100644 --- a/packages/supabase_openapi_generator/lib/src/generated/storage_api.g.dart +++ b/packages/supabase_openapi_generator/lib/src/generated/storage_api.g.dart @@ -658,16 +658,15 @@ class StorageApi { Future createBucket({required CreateBucketRequestContent body}) async { final uri = _client.uri('/bucket'); final headers = await _client.headers({}); - final request = http.Request('POST', uri) - ..headers['content-type'] = 'application/json' - ..body = jsonEncode(body.toJson()); + final request = http.Request('POST', uri)..body = jsonEncode(body.toJson()); request.headers.addAll(headers); + request.headers['content-type'] = 'application/json'; final streamed = await _client.send(request); await readOrThrow(streamed); } Future deleteBucket({required String id}) async { - final uri = _client.uri('/bucket/${id}'); + final uri = _client.uri('/bucket/${Uri.encodeComponent(id)}'); final headers = await _client.headers({}); final request = http.Request('DELETE', uri); request.headers.addAll(headers); @@ -676,7 +675,7 @@ class StorageApi { } Future getBucket({required String id}) async { - final uri = _client.uri('/bucket/${id}'); + final uri = _client.uri('/bucket/${Uri.encodeComponent(id)}'); final headers = await _client.headers({}); final request = http.Request('GET', uri); request.headers.addAll(headers); @@ -688,18 +687,17 @@ class StorageApi { Future updateBucket( {required String id, required UpdateBucketRequestContent body}) async { - final uri = _client.uri('/bucket/${id}'); + final uri = _client.uri('/bucket/${Uri.encodeComponent(id)}'); final headers = await _client.headers({}); - final request = http.Request('PUT', uri) - ..headers['content-type'] = 'application/json' - ..body = jsonEncode(body.toJson()); + final request = http.Request('PUT', uri)..body = jsonEncode(body.toJson()); request.headers.addAll(headers); + request.headers['content-type'] = 'application/json'; final streamed = await _client.send(request); await readOrThrow(streamed); } Future emptyBucket({required String id}) async { - final uri = _client.uri('/bucket/${id}/empty'); + final uri = _client.uri('/bucket/${Uri.encodeComponent(id)}/empty'); final headers = await _client.headers({}); final request = http.Request('POST', uri); request.headers.addAll(headers); @@ -711,10 +709,9 @@ class StorageApi { {required CopyObjectRequestContent body}) async { final uri = _client.uri('/object/copy'); final headers = await _client.headers({}); - final request = http.Request('POST', uri) - ..headers['content-type'] = 'application/json' - ..body = jsonEncode(body.toJson()); + final request = http.Request('POST', uri)..body = jsonEncode(body.toJson()); request.headers.addAll(headers); + request.headers['content-type'] = 'application/json'; final streamed = await _client.send(request); final response = await readOrThrow(streamed); return CopyObjectResponseContent.fromJson( @@ -723,7 +720,8 @@ class StorageApi { Future getObjectInfo( {required String bucketId, required String wildcardPath}) async { - final uri = _client.uri('/object/info/${bucketId}/${wildcardPath}'); + final uri = _client.uri( + '/object/info/${Uri.encodeComponent(bucketId)}/${encodePath(wildcardPath)}'); final headers = await _client.headers({}); final request = http.Request('GET', uri); request.headers.addAll(headers); @@ -736,12 +734,11 @@ class StorageApi { Future listObjects( {required String bucketId, required ListObjectsRequestContent body}) async { - final uri = _client.uri('/object/list/${bucketId}'); + final uri = _client.uri('/object/list/${Uri.encodeComponent(bucketId)}'); final headers = await _client.headers({}); - final request = http.Request('POST', uri) - ..headers['content-type'] = 'application/json' - ..body = jsonEncode(body.toJson()); + final request = http.Request('POST', uri)..body = jsonEncode(body.toJson()); request.headers.addAll(headers); + request.headers['content-type'] = 'application/json'; final streamed = await _client.send(request); final response = await readOrThrow(streamed); return ListObjectsResponseContent.fromJson( @@ -751,10 +748,9 @@ class StorageApi { Future moveObject({required MoveObjectRequestContent body}) async { final uri = _client.uri('/object/move'); final headers = await _client.headers({}); - final request = http.Request('POST', uri) - ..headers['content-type'] = 'application/json' - ..body = jsonEncode(body.toJson()); + final request = http.Request('POST', uri)..body = jsonEncode(body.toJson()); request.headers.addAll(headers); + request.headers['content-type'] = 'application/json'; final streamed = await _client.send(request); await readOrThrow(streamed); } @@ -762,12 +758,11 @@ class StorageApi { Future createSignedUrls( {required String bucketId, required CreateSignedUrlsRequestContent body}) async { - final uri = _client.uri('/object/sign/${bucketId}'); + final uri = _client.uri('/object/sign/${Uri.encodeComponent(bucketId)}'); final headers = await _client.headers({}); - final request = http.Request('POST', uri) - ..headers['content-type'] = 'application/json' - ..body = jsonEncode(body.toJson()); + final request = http.Request('POST', uri)..body = jsonEncode(body.toJson()); request.headers.addAll(headers); + request.headers['content-type'] = 'application/json'; final streamed = await _client.send(request); final response = await readOrThrow(streamed); return CreateSignedUrlsResponseContent.fromJson( @@ -778,12 +773,12 @@ class StorageApi { {required String bucketId, required String wildcardPath, required CreateSignedUrlRequestContent body}) async { - final uri = _client.uri('/object/sign/${bucketId}/${wildcardPath}'); + final uri = _client.uri( + '/object/sign/${Uri.encodeComponent(bucketId)}/${encodePath(wildcardPath)}'); final headers = await _client.headers({}); - final request = http.Request('POST', uri) - ..headers['content-type'] = 'application/json' - ..body = jsonEncode(body.toJson()); + final request = http.Request('POST', uri)..body = jsonEncode(body.toJson()); request.headers.addAll(headers); + request.headers['content-type'] = 'application/json'; final streamed = await _client.send(request); final response = await readOrThrow(streamed); return CreateSignedUrlResponseContent.fromJson( @@ -794,7 +789,8 @@ class StorageApi { {required String bucketId, required String wildcardPath, String? xUpsert}) async { - final uri = _client.uri('/object/upload/sign/${bucketId}/${wildcardPath}'); + final uri = _client.uri( + '/object/upload/sign/${Uri.encodeComponent(bucketId)}/${encodePath(wildcardPath)}'); final headers = await _client.headers({ if (xUpsert != null) 'x-upsert': xUpsert, }); @@ -809,12 +805,12 @@ class StorageApi { Future deleteObjects( {required String bucketId, required DeleteObjectsRequestContent body}) async { - final uri = _client.uri('/object/${bucketId}'); + final uri = _client.uri('/object/${Uri.encodeComponent(bucketId)}'); final headers = await _client.headers({}); final request = http.Request('DELETE', uri) - ..headers['content-type'] = 'application/json' ..body = jsonEncode(body.toJson()); request.headers.addAll(headers); + request.headers['content-type'] = 'application/json'; final streamed = await _client.send(request); final response = await readOrThrow(streamed); return DeleteObjectsResponseContent.fromJson( @@ -823,7 +819,8 @@ class StorageApi { Future headObject( {required String bucketId, required String wildcardPath}) async { - final uri = _client.uri('/object/${bucketId}/${wildcardPath}'); + final uri = _client.uri( + '/object/${Uri.encodeComponent(bucketId)}/${encodePath(wildcardPath)}'); final headers = await _client.headers({}); final request = http.Request('HEAD', uri); request.headers.addAll(headers); @@ -840,7 +837,8 @@ class StorageApi { String? cacheControl, Map? metadata, String? fileName}) async { - final uri = _client.uri('/object/${bucketId}/${wildcardPath}'); + final uri = _client.uri( + '/object/${Uri.encodeComponent(bucketId)}/${encodePath(wildcardPath)}'); final headers = await _client.headers({ if (xUpsert != null) 'x-upsert': xUpsert, }); @@ -868,7 +866,8 @@ class StorageApi { String? cacheControl, Map? metadata, String? fileName}) async { - final uri = _client.uri('/object/${bucketId}/${wildcardPath}'); + final uri = _client.uri( + '/object/${Uri.encodeComponent(bucketId)}/${encodePath(wildcardPath)}'); final headers = await _client.headers({}); final request = http.MultipartRequest('PUT', uri); request.files.add(http.MultipartFile( @@ -909,7 +908,8 @@ class StorageApi { Future> getUploadOffset( {required String uploadId, required String tusResumable}) async { - final uri = _client.uri('/upload/resumable/${uploadId}'); + final uri = + _client.uri('/upload/resumable/${Uri.encodeComponent(uploadId)}'); final headers = await _client.headers({ 'Tus-Resumable': tusResumable, }); @@ -918,7 +918,7 @@ class StorageApi { final streamed = await _client.send(request); final response = await readOrThrow(streamed); return { - 'uploadOffset': int.parse(response.headers['upload-offset']!), + 'uploadOffset': parseIntHeader(response.headers['upload-offset']), }; } @@ -928,7 +928,8 @@ class StorageApi { required int uploadOffset, required Stream> body, int? contentLength}) async { - final uri = _client.uri('/upload/resumable/${uploadId}'); + final uri = + _client.uri('/upload/resumable/${Uri.encodeComponent(uploadId)}'); final headers = await _client.headers({ 'Tus-Resumable': tusResumable, 'Upload-Offset': '$uploadOffset', @@ -939,7 +940,7 @@ class StorageApi { final streamed = await _client.send(request); final response = await readOrThrow(streamed); return { - 'uploadOffset': int.parse(response.headers['upload-offset']!), + 'uploadOffset': parseIntHeader(response.headers['upload-offset']), }; } } diff --git a/packages/supabase_openapi_generator/lib/src/runtime.dart b/packages/supabase_openapi_generator/lib/src/runtime.dart index 484eb6239..c69537a65 100644 --- a/packages/supabase_openapi_generator/lib/src/runtime.dart +++ b/packages/supabase_openapi_generator/lib/src/runtime.dart @@ -6,9 +6,9 @@ import 'package:http/http.dart' as http; /// A callback invoked before every request to supply headers whose values can /// change at runtime, most importantly the `Authorization` bearer token. /// -/// This is the interceptor/middleware hook (spike question 4). Because it runs -/// per request, a token refreshed by the auth loop is always picked up without -/// rebuilding the client. +/// This is the interceptor/middleware hook. Because it runs per request, a +/// token refreshed by the auth loop is always picked up without rebuilding the +/// client. typedef HeaderProvider = FutureOr> Function(); /// Thrown for any non-2xx response. [body] is the decoded error payload when @@ -29,7 +29,7 @@ class ApiException implements Exception { } /// A response whose body is handed to the caller as a live byte stream instead -/// of being buffered (spike question 2). Used for operations that return +/// of being buffered. Used for operations that return /// `application/octet-stream`, e.g. Functions streaming/SSE responses. class StreamedApiResponse { StreamedApiResponse({ @@ -115,9 +115,20 @@ ApiException _errorFrom(http.Response response) { ); } +/// Percent-encodes a path value per RFC 3986 path-segment rules, preserving +/// `/` separators (so wildcard object keys with sub-paths stay intact). Mirrors +/// the encoding the hand-written storage client applies to object keys. +String encodePath(String value) => Uri(pathSegments: value.split('/')).path; + +/// Reads an integer-valued response header without crashing when it is absent +/// or non-integer. Returns null instead of throwing. +int? parseIntHeader(String? value) => + value == null ? null : num.tryParse(value)?.toInt(); + /// Feeds [body] into a [http.StreamedRequest] without collecting it into memory -/// first (spike question 1). The bytes flow straight from the source stream to -/// the socket. +/// first. The bytes flow straight from the source stream to +/// the socket. Uses [StreamSink.addStream] so a source error propagates to the +/// send future instead of becoming an unhandled error after the sink closes. http.StreamedRequest streamingRequest( String method, Uri uri, { @@ -128,11 +139,6 @@ http.StreamedRequest streamingRequest( if (contentLength != null) { request.contentLength = contentLength; } - body.listen( - request.sink.add, - onError: request.sink.addError, - onDone: request.sink.close, - cancelOnError: true, - ); + request.sink.addStream(body).whenComplete(request.sink.close); return request; } diff --git a/packages/supabase_openapi_generator/test/generated_client_test.dart b/packages/supabase_openapi_generator/test/generated_client_test.dart index ab21bcf44..7d848c822 100644 --- a/packages/supabase_openapi_generator/test/generated_client_test.dart +++ b/packages/supabase_openapi_generator/test/generated_client_test.dart @@ -191,6 +191,88 @@ void main() { }); }); + group('Correctness fixes', () { + test('path parameters are percent-encoded, wildcard keeps slashes', + () async { + final client = _RecordingClient((_) async => _json({})); + final api = + StorageApi(ApiClient(baseUrl: 'https://x', httpClient: client)); + + await api.getObjectInfo( + bucketId: 'my bucket', + wildcardPath: 'sub dir/a?b.png', + ); + + final url = client.lastRequest!.url; + // Decoded segments round-trip to the original values, and the `?` did not + // leak into the query string. + expect(url.pathSegments, [ + 'object', + 'info', + 'my bucket', + 'sub dir', + 'a?b.png', + ]); + expect(url.query, isEmpty); + }); + + test('operation content-type wins over provider headers', () async { + final client = _RecordingClient((_) async => _json({'name': 'photos'})); + final api = StorageApi( + ApiClient( + baseUrl: 'https://x', + httpClient: client, + headerProvider: () => {'content-type': 'text/plain'}, + ), + ); + + await api.createBucket( + body: CreateBucketRequestContent( + id: 'photos', + name: 'photos', + public: false, + ), + ); + + expect(client.lastRequest!.headers['content-type'], 'application/json'); + }); + + test('missing numeric response header yields null instead of crashing', + () async { + final client = _RecordingClient( + (_) async => http.StreamedResponse(const Stream.empty(), 200), + ); + final api = + StorageApi(ApiClient(baseUrl: 'https://x', httpClient: client)); + + final result = + await api.getUploadOffset(uploadId: 'abc', tusResumable: '1.0.0'); + + expect(result['uploadOffset'], isNull); + }); + + test('a body-stream error surfaces as a catchable exception', () async { + final client = _RecordingClient((request) async { + await request.finalize().drain(); + return http.StreamedResponse(const Stream.empty(), 204); + }); + final api = + StorageApi(ApiClient(baseUrl: 'https://x', httpClient: client)); + + final failing = Stream>.error(StateError('read failed')); + + await expectLater( + api.uploadChunk( + uploadId: 'abc', + tusResumable: '1.0.0', + uploadOffset: 0, + body: failing, + ), + throwsA(isA()), + ); + }); + }); + group('Middleware / header injection (question 4)', () { test('headerProvider is invoked per request for fresh auth tokens', () async { From 626e68eb3de9a55c238566eb6c72480f4c851c6d Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Wed, 1 Jul 2026 11:45:50 +0200 Subject: [PATCH 04/11] feat(codegen): generate PostgREST (DatabaseService) client from SDK branch The SDK branch (supabase/sdk#51) now includes a DatabaseService model. Generate a DatabaseApi from it to verify the emitter works against every model in the branch. Type query parameters by their schema instead of always String?, and add PostgREST transport coverage. --- .../bin/generate.dart | 14 +- .../lib/src/generated/database_api.g.dart | 255 ++++++ .../lib/supabase_openapi_generator.dart | 1 + .../openapi/DatabaseService.openapi.json | 741 ++++++++++++++++++ .../test/generated_client_test.dart | 32 + 5 files changed, 1040 insertions(+), 3 deletions(-) create mode 100644 packages/supabase_openapi_generator/lib/src/generated/database_api.g.dart create mode 100644 packages/supabase_openapi_generator/openapi/DatabaseService.openapi.json diff --git a/packages/supabase_openapi_generator/bin/generate.dart b/packages/supabase_openapi_generator/bin/generate.dart index 718c62af8..e06ee9084 100644 --- a/packages/supabase_openapi_generator/bin/generate.dart +++ b/packages/supabase_openapi_generator/bin/generate.dart @@ -17,6 +17,11 @@ void main() { className: 'FunctionsApi', outputPath: 'lib/src/generated/functions_api.g.dart', ); + _generate( + specPath: 'openapi/DatabaseService.openapi.json', + className: 'DatabaseApi', + outputPath: 'lib/src/generated/database_api.g.dart', + ); stdout.writeln('Done. Formatting output...'); Process.runSync('dart', ['format', 'lib/src/generated']); } @@ -199,7 +204,8 @@ String _generateOperation( } for (final param in queryParams) { final name = _camelCase(param['name'] as String); - params.add('String? $name'); + final type = _headerDartType(param['schema'] as Map?); + params.add('$type? $name'); } params.addAll(body.parameters); @@ -224,8 +230,10 @@ String _generateOperation( } else { buffer.writeln(" final uri = _client.uri('$dartPath', {"); for (final param in queryParams) { - buffer.writeln( - " '${param['name']}': ${_camelCase(param['name'] as String)},"); + final name = _camelCase(param['name'] as String); + final type = _headerDartType(param['schema'] as Map?); + final valueExpr = type == 'String' ? name : '$name?.toString()'; + buffer.writeln(" '${param['name']}': $valueExpr,"); } buffer.writeln(' });'); } diff --git a/packages/supabase_openapi_generator/lib/src/generated/database_api.g.dart b/packages/supabase_openapi_generator/lib/src/generated/database_api.g.dart new file mode 100644 index 000000000..30a7fdbe3 --- /dev/null +++ b/packages/supabase_openapi_generator/lib/src/generated/database_api.g.dart @@ -0,0 +1,255 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND. +// Generated from openapi/DatabaseService.openapi.json by bin/generate.dart. +// ignore_for_file: prefer_final_locals, unnecessary_brace_in_string_interps + +import 'package:http/http.dart' as http; + +import '../runtime.dart'; + +class DatabaseErrorResponseContent { + DatabaseErrorResponseContent({ + this.code, + this.message, + this.details, + this.hint, + }); + + final String? code; + final String? message; + final String? details; + final String? hint; + + factory DatabaseErrorResponseContent.fromJson(Map json) => + DatabaseErrorResponseContent( + code: json['code'] as String?, + message: json['message'] as String?, + details: json['details'] as String?, + hint: json['hint'] as String?, + ); + + Map toJson() => { + if (code != null) 'code': code, + if (message != null) 'message': message, + if (details != null) 'details': details, + if (hint != null) 'hint': hint, + }; +} + +/// Generated HTTP client. Every operation goes through the +/// hand-written [ApiClient] runtime for headers and transport. +class DatabaseApi { + DatabaseApi(this._client); + + final ApiClient _client; + + Future callRpcGet( + {required String functionName, + String? acceptProfile, + String? select, + String? args}) async { + final uri = _client.uri('/rpc/${Uri.encodeComponent(functionName)}', { + 'select': select, + 'args': args, + }); + final headers = await _client.headers({ + if (acceptProfile != null) 'Accept-Profile': acceptProfile, + }); + final request = http.Request('GET', uri); + request.headers.addAll(headers); + final streamed = await _client.send(request); + if (streamed.statusCode < 200 || streamed.statusCode >= 300) { + await readOrThrow(streamed); + } + return StreamedApiResponse( + statusCode: streamed.statusCode, + headers: streamed.headers, + stream: streamed.stream, + ); + } + + Future callRpcPost( + {required String functionName, + String? contentProfile, + String? prefer, + String? select, + required Stream> body, + int? contentLength}) async { + final uri = _client.uri('/rpc/${Uri.encodeComponent(functionName)}', { + 'select': select, + }); + final headers = await _client.headers({ + if (contentProfile != null) 'Content-Profile': contentProfile, + if (prefer != null) 'Prefer': prefer, + }); + final request = + streamingRequest('POST', uri, body: body, contentLength: contentLength); + request.headers.addAll(headers); + final streamed = await _client.send(request); + if (streamed.statusCode < 200 || streamed.statusCode >= 300) { + await readOrThrow(streamed); + } + return StreamedApiResponse( + statusCode: streamed.statusCode, + headers: streamed.headers, + stream: streamed.stream, + ); + } + + Future deleteRows( + {required String table, + String? contentProfile, + String? prefer, + String? select, + String? filters}) async { + final uri = _client.uri('/${Uri.encodeComponent(table)}', { + 'select': select, + 'filters': filters, + }); + final headers = await _client.headers({ + if (contentProfile != null) 'Content-Profile': contentProfile, + if (prefer != null) 'Prefer': prefer, + }); + final request = http.Request('DELETE', uri); + request.headers.addAll(headers); + final streamed = await _client.send(request); + if (streamed.statusCode < 200 || streamed.statusCode >= 300) { + await readOrThrow(streamed); + } + return StreamedApiResponse( + statusCode: streamed.statusCode, + headers: streamed.headers, + stream: streamed.stream, + ); + } + + Future selectRows( + {required String table, + String? accept, + String? acceptProfile, + String? prefer, + String? range, + String? rangeUnit, + String? select, + String? order, + int? limit, + int? offset, + String? filters}) async { + final uri = _client.uri('/${Uri.encodeComponent(table)}', { + 'select': select, + 'order': order, + 'limit': limit?.toString(), + 'offset': offset?.toString(), + 'filters': filters, + }); + final headers = await _client.headers({ + if (accept != null) 'Accept': accept, + if (acceptProfile != null) 'Accept-Profile': acceptProfile, + if (prefer != null) 'Prefer': prefer, + if (range != null) 'Range': range, + if (rangeUnit != null) 'Range-Unit': rangeUnit, + }); + final request = http.Request('GET', uri); + request.headers.addAll(headers); + final streamed = await _client.send(request); + if (streamed.statusCode < 200 || streamed.statusCode >= 300) { + await readOrThrow(streamed); + } + return StreamedApiResponse( + statusCode: streamed.statusCode, + headers: streamed.headers, + stream: streamed.stream, + ); + } + + Future updateRows( + {required String table, + String? contentProfile, + String? prefer, + String? select, + String? filters, + required Stream> body, + int? contentLength}) async { + final uri = _client.uri('/${Uri.encodeComponent(table)}', { + 'select': select, + 'filters': filters, + }); + final headers = await _client.headers({ + if (contentProfile != null) 'Content-Profile': contentProfile, + if (prefer != null) 'Prefer': prefer, + }); + final request = streamingRequest('PATCH', uri, + body: body, contentLength: contentLength); + request.headers.addAll(headers); + final streamed = await _client.send(request); + if (streamed.statusCode < 200 || streamed.statusCode >= 300) { + await readOrThrow(streamed); + } + return StreamedApiResponse( + statusCode: streamed.statusCode, + headers: streamed.headers, + stream: streamed.stream, + ); + } + + Future insertRows( + {required String table, + String? contentProfile, + String? prefer, + String? select, + String? columns, + required Stream> body, + int? contentLength}) async { + final uri = _client.uri('/${Uri.encodeComponent(table)}', { + 'select': select, + 'columns': columns, + }); + final headers = await _client.headers({ + if (contentProfile != null) 'Content-Profile': contentProfile, + if (prefer != null) 'Prefer': prefer, + }); + final request = + streamingRequest('POST', uri, body: body, contentLength: contentLength); + request.headers.addAll(headers); + final streamed = await _client.send(request); + if (streamed.statusCode < 200 || streamed.statusCode >= 300) { + await readOrThrow(streamed); + } + return StreamedApiResponse( + statusCode: streamed.statusCode, + headers: streamed.headers, + stream: streamed.stream, + ); + } + + Future upsertRows( + {required String table, + String? contentProfile, + String? prefer, + String? select, + String? onConflict, + String? filters, + required Stream> body, + int? contentLength}) async { + final uri = _client.uri('/${Uri.encodeComponent(table)}', { + 'select': select, + 'on_conflict': onConflict, + 'filters': filters, + }); + final headers = await _client.headers({ + if (contentProfile != null) 'Content-Profile': contentProfile, + if (prefer != null) 'Prefer': prefer, + }); + final request = + streamingRequest('PUT', uri, body: body, contentLength: contentLength); + request.headers.addAll(headers); + final streamed = await _client.send(request); + if (streamed.statusCode < 200 || streamed.statusCode >= 300) { + await readOrThrow(streamed); + } + return StreamedApiResponse( + statusCode: streamed.statusCode, + headers: streamed.headers, + stream: streamed.stream, + ); + } +} diff --git a/packages/supabase_openapi_generator/lib/supabase_openapi_generator.dart b/packages/supabase_openapi_generator/lib/supabase_openapi_generator.dart index 006440842..46115aa4c 100644 --- a/packages/supabase_openapi_generator/lib/supabase_openapi_generator.dart +++ b/packages/supabase_openapi_generator/lib/supabase_openapi_generator.dart @@ -1,3 +1,4 @@ +export 'src/generated/database_api.g.dart'; export 'src/generated/functions_api.g.dart'; export 'src/generated/storage_api.g.dart'; export 'src/runtime.dart'; diff --git a/packages/supabase_openapi_generator/openapi/DatabaseService.openapi.json b/packages/supabase_openapi_generator/openapi/DatabaseService.openapi.json new file mode 100644 index 000000000..51263cdd7 --- /dev/null +++ b/packages/supabase_openapi_generator/openapi/DatabaseService.openapi.json @@ -0,0 +1,741 @@ +{ + "openapi": "3.0.2", + "info": { + "title": "Supabase Database API", + "version": "1.0", + "description": "PostgREST-backed database API.\n\nBase URL: https://{project-ref}.supabase.co/rest/v1\n\nKnown limitations:\n 1. Write operations return 204 (no body) by default and 200 with a body when\n Prefer: return=representation \u2014 the model uses 200 throughout so generators\n always produce body-parsing code; clients must tolerate empty bodies.\n 2. RPC GET arguments are function-specific; they are expressed via the same\n @httpQueryParams map as row filters, with function-defined keys." + }, + "paths": { + "/rpc/{functionName}": { + "get": { + "operationId": "CallRpcGet", + "parameters": [ + { + "name": "functionName", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "select", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "args", + "in": "query", + "description": "Function arguments \u2014 each entry becomes a query parameter.\nKeys and value formats are defined by the PostgreSQL function signature.", + "style": "form", + "schema": { + "$ref": "#/components/schemas/StringMap" + } + }, + { + "name": "Accept-Profile", + "in": "header", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "CallRpcGet 200 response", + "headers": { + "Content-Range": { + "schema": { + "type": "string" + } + } + }, + "content": { + "application/octet-stream": { + "schema": { + "$ref": "#/components/schemas/CallRpcGetOutputPayload" + } + } + } + }, + "400": { + "description": "DatabaseError 400 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DatabaseErrorResponseContent" + } + } + } + } + } + }, + "post": { + "operationId": "CallRpcPost", + "requestBody": { + "content": { + "application/octet-stream": { + "schema": { + "$ref": "#/components/schemas/CallRpcPostInputPayload" + } + } + } + }, + "parameters": [ + { + "name": "functionName", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "select", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "Content-Profile", + "in": "header", + "schema": { + "type": "string" + } + }, + { + "name": "Prefer", + "in": "header", + "description": "e.g. \"params=single-object\" \u2014 treat the entire body as a single parameter.", + "schema": { + "type": "string", + "description": "e.g. \"params=single-object\" \u2014 treat the entire body as a single parameter." + } + } + ], + "responses": { + "200": { + "description": "CallRpcPost 200 response", + "headers": { + "Content-Range": { + "schema": { + "type": "string" + } + } + }, + "content": { + "application/octet-stream": { + "schema": { + "$ref": "#/components/schemas/CallRpcPostOutputPayload" + } + } + } + }, + "400": { + "description": "DatabaseError 400 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DatabaseErrorResponseContent" + } + } + } + } + } + } + }, + "/{table}": { + "delete": { + "operationId": "DeleteRows", + "parameters": [ + { + "name": "table", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "select", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "filters", + "in": "query", + "description": "Horizontal filters \u2014 rows matching these filters will be deleted.", + "style": "form", + "schema": { + "$ref": "#/components/schemas/StringMap" + } + }, + { + "name": "Content-Profile", + "in": "header", + "schema": { + "type": "string" + } + }, + { + "name": "Prefer", + "in": "header", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "DeleteRows 200 response", + "headers": { + "Content-Range": { + "description": "Pagination info \u2014 e.g. \"0-9/200\" (range/total) or \"0-9/*\" (unknown count).", + "schema": { + "type": "string", + "description": "Pagination info \u2014 e.g. \"0-9/200\" (range/total) or \"0-9/*\" (unknown count)." + } + } + }, + "content": { + "application/octet-stream": { + "schema": { + "$ref": "#/components/schemas/DeleteRowsOutputPayload" + } + } + } + }, + "400": { + "description": "DatabaseError 400 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DatabaseErrorResponseContent" + } + } + } + } + } + }, + "get": { + "operationId": "SelectRows", + "parameters": [ + { + "name": "table", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "select", + "in": "query", + "description": "Column selection \u2014 comma-separated, supports aliasing, casting, embedded\nresources, and JSON operators. e.g. \"id,name,orders(total,status)\".", + "schema": { + "type": "string", + "description": "Column selection \u2014 comma-separated, supports aliasing, casting, embedded\nresources, and JSON operators. e.g. \"id,name,orders(total,status)\"." + } + }, + { + "name": "order", + "in": "query", + "description": "Ordering \u2014 e.g. \"name.asc,age.desc.nullslast\"", + "schema": { + "type": "string", + "description": "Ordering \u2014 e.g. \"name.asc,age.desc.nullslast\"" + } + }, + { + "name": "limit", + "in": "query", + "description": "Maximum number of rows to return.", + "schema": { + "type": "number", + "description": "Maximum number of rows to return." + } + }, + { + "name": "offset", + "in": "query", + "description": "Row offset for pagination.", + "schema": { + "type": "number", + "description": "Row offset for pagination." + } + }, + { + "name": "filters", + "in": "query", + "description": "Horizontal filters \u2014 each entry becomes a query parameter.\nKey: column name (or \"or\"/\"and\" for logical groups).\nValue: \"{operator}.{value}\" e.g. {\"id\": \"eq.5\", \"name\": \"like.foo*\"}.\nSee FilterOperator for the full list of operators.", + "style": "form", + "schema": { + "$ref": "#/components/schemas/StringMap" + } + }, + { + "name": "Accept", + "in": "header", + "description": "Response format. e.g. \"application/json\" (default), \"text/csv\",\n\"application/vnd.pgrst.object+json\" (singular-row mode).", + "schema": { + "type": "string", + "description": "Response format. e.g. \"application/json\" (default), \"text/csv\",\n\"application/vnd.pgrst.object+json\" (singular-row mode)." + } + }, + { + "name": "Accept-Profile", + "in": "header", + "description": "Target a non-default schema exposed by PostgREST.", + "schema": { + "type": "string", + "description": "Target a non-default schema exposed by PostgREST." + } + }, + { + "name": "Prefer", + "in": "header", + "description": "Counting mode. e.g. \"count=exact\", \"count=planned\", \"count=estimated\".", + "schema": { + "type": "string", + "description": "Counting mode. e.g. \"count=exact\", \"count=planned\", \"count=estimated\"." + } + }, + { + "name": "Range", + "in": "header", + "description": "Range-based pagination \u2014 e.g. \"0-9\" (ten rows starting at 0).", + "schema": { + "type": "string", + "description": "Range-based pagination \u2014 e.g. \"0-9\" (ten rows starting at 0)." + } + }, + { + "name": "Range-Unit", + "in": "header", + "description": "Unit for the Range header. Defaults to \"items\".", + "schema": { + "type": "string", + "description": "Unit for the Range header. Defaults to \"items\"." + } + } + ], + "responses": { + "200": { + "description": "SelectRows 200 response", + "headers": { + "Content-Range": { + "description": "Pagination info \u2014 e.g. \"0-9/200\" (range/total) or \"0-9/*\" (unknown count).", + "schema": { + "type": "string", + "description": "Pagination info \u2014 e.g. \"0-9/200\" (range/total) or \"0-9/*\" (unknown count)." + } + } + }, + "content": { + "application/octet-stream": { + "schema": { + "$ref": "#/components/schemas/SelectRowsOutputPayload" + } + } + } + }, + "400": { + "description": "DatabaseError 400 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DatabaseErrorResponseContent" + } + } + } + } + } + }, + "patch": { + "operationId": "UpdateRows", + "requestBody": { + "content": { + "application/octet-stream": { + "schema": { + "$ref": "#/components/schemas/UpdateRowsInputPayload" + } + } + }, + "required": true + }, + "parameters": [ + { + "name": "table", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "select", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "filters", + "in": "query", + "description": "Horizontal filters \u2014 rows matching these filters will be updated.\nKey: column name. Value: \"{operator}.{value}\" e.g. {\"id\": \"eq.5\"}.", + "style": "form", + "schema": { + "$ref": "#/components/schemas/StringMap" + } + }, + { + "name": "Content-Profile", + "in": "header", + "schema": { + "type": "string" + } + }, + { + "name": "Prefer", + "in": "header", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "UpdateRows 200 response", + "headers": { + "Content-Range": { + "description": "Pagination info \u2014 e.g. \"0-9/200\" (range/total) or \"0-9/*\" (unknown count).", + "schema": { + "type": "string", + "description": "Pagination info \u2014 e.g. \"0-9/200\" (range/total) or \"0-9/*\" (unknown count)." + } + } + }, + "content": { + "application/octet-stream": { + "schema": { + "$ref": "#/components/schemas/UpdateRowsOutputPayload" + } + } + } + }, + "400": { + "description": "DatabaseError 400 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DatabaseErrorResponseContent" + } + } + } + } + } + }, + "post": { + "operationId": "InsertRows", + "requestBody": { + "content": { + "application/octet-stream": { + "schema": { + "$ref": "#/components/schemas/InsertRowsInputPayload" + } + } + }, + "required": true + }, + "parameters": [ + { + "name": "table", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "select", + "in": "query", + "description": "Columns to select in the returned representation (requires return=representation).", + "schema": { + "type": "string", + "description": "Columns to select in the returned representation (requires return=representation)." + } + }, + { + "name": "columns", + "in": "query", + "description": "Restrict which columns may be populated (useful with CSV uploads).", + "schema": { + "type": "string", + "description": "Restrict which columns may be populated (useful with CSV uploads)." + } + }, + { + "name": "Content-Profile", + "in": "header", + "description": "Target a non-default schema for the write.", + "schema": { + "type": "string", + "description": "Target a non-default schema for the write." + } + }, + { + "name": "Prefer", + "in": "header", + "description": "Return behavior and conflict handling.\ne.g. \"return=representation\", \"return=minimal\" (default),\n \"return=headers-only\", \"resolution=merge-duplicates\".", + "schema": { + "type": "string", + "description": "Return behavior and conflict handling.\ne.g. \"return=representation\", \"return=minimal\" (default),\n \"return=headers-only\", \"resolution=merge-duplicates\"." + } + } + ], + "responses": { + "201": { + "description": "InsertRows 201 response", + "headers": { + "Content-Range": { + "description": "Pagination info \u2014 e.g. \"0-9/200\" (range/total) or \"0-9/*\" (unknown count).", + "schema": { + "type": "string", + "description": "Pagination info \u2014 e.g. \"0-9/200\" (range/total) or \"0-9/*\" (unknown count)." + } + } + }, + "content": { + "application/octet-stream": { + "schema": { + "$ref": "#/components/schemas/InsertRowsOutputPayload" + } + } + } + }, + "400": { + "description": "DatabaseError 400 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DatabaseErrorResponseContent" + } + } + } + } + } + }, + "put": { + "operationId": "UpsertRows", + "requestBody": { + "content": { + "application/octet-stream": { + "schema": { + "$ref": "#/components/schemas/UpsertRowsInputPayload" + } + } + }, + "required": true + }, + "parameters": [ + { + "name": "table", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "select", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "on_conflict", + "in": "query", + "description": "Columns to match for conflict detection (if not the primary key).", + "schema": { + "type": "string", + "description": "Columns to match for conflict detection (if not the primary key)." + } + }, + { + "name": "filters", + "in": "query", + "description": "Horizontal filters \u2014 rows matching these filters will be upserted.", + "style": "form", + "schema": { + "$ref": "#/components/schemas/StringMap" + } + }, + { + "name": "Content-Profile", + "in": "header", + "schema": { + "type": "string" + } + }, + { + "name": "Prefer", + "in": "header", + "description": "e.g. \"return=representation\", \"resolution=merge-duplicates\",\n \"resolution=ignore-duplicates\".", + "schema": { + "type": "string", + "description": "e.g. \"return=representation\", \"resolution=merge-duplicates\",\n \"resolution=ignore-duplicates\"." + } + } + ], + "responses": { + "200": { + "description": "UpsertRows 200 response", + "headers": { + "Content-Range": { + "description": "Pagination info \u2014 e.g. \"0-9/200\" (range/total) or \"0-9/*\" (unknown count).", + "schema": { + "type": "string", + "description": "Pagination info \u2014 e.g. \"0-9/200\" (range/total) or \"0-9/*\" (unknown count)." + } + } + }, + "content": { + "application/octet-stream": { + "schema": { + "$ref": "#/components/schemas/UpsertRowsOutputPayload" + } + } + } + }, + "400": { + "description": "DatabaseError 400 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DatabaseErrorResponseContent" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "CallRpcGetOutputPayload": { + "type": "string", + "format": "byte" + }, + "CallRpcPostInputPayload": { + "type": "string", + "description": "Named parameters as a JSON object, or a single argument when combined\nwith Prefer: params=single-object.", + "format": "byte" + }, + "CallRpcPostOutputPayload": { + "type": "string", + "format": "byte" + }, + "DatabaseErrorResponseContent": { + "type": "object", + "properties": { + "code": { + "type": "string", + "description": "PostgreSQL error code (e.g. \"23505\") or PostgREST error code (e.g. \"PGRST301\")." + }, + "message": { + "type": "string", + "description": "Human-readable error message." + }, + "details": { + "type": "string", + "description": "Extra context \u2014 constraint name, offending column, etc." + }, + "hint": { + "type": "string", + "description": "Hint from PostgreSQL." + } + } + }, + "DeleteRowsOutputPayload": { + "type": "string", + "format": "byte" + }, + "InsertRowsInputPayload": { + "type": "string", + "description": "JSON object or array of objects to insert.", + "format": "byte" + }, + "InsertRowsOutputPayload": { + "type": "string", + "format": "byte" + }, + "SelectRowsOutputPayload": { + "type": "string", + "format": "byte" + }, + "StringMap": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "Generic string-to-string map \u2014 used for arbitrary query parameter collections\n(e.g. PostgREST filter params, RPC GET arguments)." + }, + "UpdateRowsInputPayload": { + "type": "string", + "description": "Partial JSON object with fields to update.", + "format": "byte" + }, + "UpdateRowsOutputPayload": { + "type": "string", + "format": "byte" + }, + "UpsertRowsInputPayload": { + "type": "string", + "description": "JSON object or array of objects to upsert.", + "format": "byte" + }, + "UpsertRowsOutputPayload": { + "type": "string", + "format": "byte" + }, + "FilterOperator": { + "type": "string", + "description": "PostgREST column filter operators. Format a filter value as \"{operator}.{value}\", e.g. \"eq.5\". Prefix with \"not.\" to negate: \"not.eq.5\". For logical grouping use keys \"or\" / \"and\" in the filters map.", + "enum": [ + "eq", + "neq", + "lt", + "lte", + "gt", + "gte", + "like", + "ilike", + "match", + "imatch", + "is", + "isdistinct", + "in", + "cs", + "cd", + "ov", + "sl", + "sr", + "nxl", + "nxr", + "adj", + "fts", + "plfts", + "phfts", + "wfts" + ] + } + } + } +} \ No newline at end of file diff --git a/packages/supabase_openapi_generator/test/generated_client_test.dart b/packages/supabase_openapi_generator/test/generated_client_test.dart index 7d848c822..5e0e88093 100644 --- a/packages/supabase_openapi_generator/test/generated_client_test.dart +++ b/packages/supabase_openapi_generator/test/generated_client_test.dart @@ -273,6 +273,38 @@ void main() { }); }); + group('PostgREST (DatabaseService) transport', () { + test('selectRows serializes typed query params and streams the response', + () async { + final client = _RecordingClient( + (_) async => http.StreamedResponse( + Stream.value(utf8.encode('[{"id":1}]')), + 200, + ), + ); + final api = + DatabaseApi(ApiClient(baseUrl: 'https://x', httpClient: client)); + + final response = await api.selectRows( + table: 'my table', + select: 'id,name', + limit: 10, + offset: 20, + ); + + final url = client.lastRequest!.url; + expect(url.pathSegments, ['my table']); + expect(url.queryParameters['select'], 'id,name'); + expect(url.queryParameters['limit'], '10'); + expect(url.queryParameters['offset'], '20'); + expect(response, isA()); + expect( + await response.stream.transform(utf8.decoder).join(), + '[{"id":1}]', + ); + }); + }); + group('Middleware / header injection (question 4)', () { test('headerProvider is invoked per request for fresh auth tokens', () async { From a2efa2ff860220231f4a2e8942d88d154fb4a6ae Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Wed, 1 Jul 2026 12:01:03 +0200 Subject: [PATCH 05/11] test(codegen): satisfy DCM avoid-unused-assignment in header test --- .../test/generated_client_test.dart | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/supabase_openapi_generator/test/generated_client_test.dart b/packages/supabase_openapi_generator/test/generated_client_test.dart index 5e0e88093..6868bf978 100644 --- a/packages/supabase_openapi_generator/test/generated_client_test.dart +++ b/packages/supabase_openapi_generator/test/generated_client_test.dart @@ -308,14 +308,16 @@ void main() { group('Middleware / header injection (question 4)', () { test('headerProvider is invoked per request for fresh auth tokens', () async { - var token = 'first'; + // The token lives in a mutable holder so the provider closure reads the + // latest value on every request. + final token = ['first']; final client = _RecordingClient((_) async => _json({'items': []})); final api = StorageApi( ApiClient( baseUrl: 'https://x', httpClient: client, defaultHeaders: {'apikey': 'anon'}, - headerProvider: () => {'Authorization': 'Bearer $token'}, + headerProvider: () => {'Authorization': 'Bearer ${token.first}'}, ), ); @@ -323,7 +325,7 @@ void main() { expect(client.lastRequest!.headers['Authorization'], 'Bearer first'); expect(client.lastRequest!.headers['apikey'], 'anon'); - token = 'refreshed'; + token[0] = 'refreshed'; await api.listBuckets(); expect(client.lastRequest!.headers['Authorization'], 'Bearer refreshed'); }); From 4e7462dce67811abc5514b5b483be37495a997fa Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Wed, 1 Jul 2026 15:20:13 +0200 Subject: [PATCH 06/11] refactor(codegen): build emitter output with code_builder and dart_style Replace hand-rolled StringBuffer scaffolding with package:code_builder for class/field/constructor/method structure and format in-process with package:dart_style, dropping the Process.runSync('dart format') call and the manual dart:convert import heuristic. Generated output is equivalent and all tests pass. --- .../bin/generate.dart | 458 ++++---- .../lib/src/generated/database_api.g.dart | 183 +-- .../lib/src/generated/functions_api.g.dart | 126 +- .../lib/src/generated/storage_api.g.dart | 1009 +++++++++-------- .../supabase_openapi_generator/pubspec.yaml | 4 + 5 files changed, 986 insertions(+), 794 deletions(-) diff --git a/packages/supabase_openapi_generator/bin/generate.dart b/packages/supabase_openapi_generator/bin/generate.dart index e06ee9084..112978109 100644 --- a/packages/supabase_openapi_generator/bin/generate.dart +++ b/packages/supabase_openapi_generator/bin/generate.dart @@ -1,9 +1,17 @@ import 'dart:convert'; import 'dart:io'; -/// A minimal, dependency-free OpenAPI 3.0 -> idiomatic Dart emitter. It -/// consumes the OpenAPI documents in `openapi/` and writes `http`-based clients -/// into `lib/src/generated/`. +import 'package:code_builder/code_builder.dart'; +import 'package:dart_style/dart_style.dart'; + +/// A minimal OpenAPI 3.0 -> idiomatic Dart emitter. It consumes the OpenAPI +/// documents in `openapi/` and writes `http`-based clients into +/// `lib/src/generated/`. +/// +/// The Dart source is built with `package:code_builder` (class/field/method +/// structure and imports) and formatted in-process with `package:dart_style`, +/// so there is no hand-rolled brace/comma bookkeeping and no shelling out to +/// `dart format`. Procedural method bodies are supplied as code blocks. /// /// Run with: `dart run bin/generate.dart` void main() { @@ -22,8 +30,6 @@ void main() { className: 'DatabaseApi', outputPath: 'lib/src/generated/database_api.g.dart', ); - stdout.writeln('Done. Formatting output...'); - Process.runSync('dart', ['format', 'lib/src/generated']); } void _generate({ @@ -36,148 +42,186 @@ void _generate({ (spec['components']?['schemas'] as Map?)?.cast() ?? {}; final paths = (spec['paths'] as Map).cast(); - final buffer = StringBuffer(); - - // Model classes. - for (final entry in schemas.entries) { - final schema = (entry.value as Map).cast(); - if (_isModel(schema)) { - buffer.writeln(_generateModel(entry.key, schema)); - } - } - - // Client class. - buffer - ..writeln('/// Generated HTTP client. Every operation goes through the') - ..writeln('/// hand-written [ApiClient] runtime for headers and transport.') - ..writeln('class $className {') - ..writeln(' $className(this._client);') - ..writeln() - ..writeln(' final ApiClient _client;') - ..writeln(); - - for (final pathEntry in paths.entries) { - final path = pathEntry.key; - final operations = (pathEntry.value as Map).cast(); - for (final opEntry in operations.entries) { - final method = opEntry.key; - if (!_httpMethods.contains(method)) continue; - buffer.writeln( - _generateOperation(path, method, (opEntry.value as Map).cast()), - ); - } - } - - buffer.writeln('}'); + final classes = [ + for (final entry in schemas.entries) + if (_isModel((entry.value as Map).cast())) + _buildModel(entry.key, (entry.value as Map).cast()), + _buildClient(className, paths), + ]; + + final library = Library( + (b) => b + ..directives.addAll([ + if (_needsConvert(paths)) Directive.import('dart:convert'), + Directive.import('package:http/http.dart', as: 'http'), + Directive.import('../runtime.dart'), + ]) + ..body.addAll(classes), + ); - final body = buffer.toString(); - final needsConvert = - body.contains('jsonEncode') || body.contains('jsonDecode'); + final rendered = library + .accept(DartEmitter(orderDirectives: true, useNullSafetySyntax: true)) + .toString(); - final header = StringBuffer() - ..writeln('// GENERATED CODE - DO NOT MODIFY BY HAND.') - ..writeln('// Generated from $specPath by bin/generate.dart.') - ..writeln( + final header = '// GENERATED CODE - DO NOT MODIFY BY HAND.\n' + '// Generated from $specPath by bin/generate.dart.\n' '// ignore_for_file: prefer_final_locals, ' - 'unnecessary_brace_in_string_interps', - ) - ..writeln(); - if (needsConvert) { - header.writeln("import 'dart:convert';"); - header.writeln(); - } - header - ..writeln("import 'package:http/http.dart' as http;") - ..writeln() - ..writeln("import '../runtime.dart';") - ..writeln(); + 'unnecessary_brace_in_string_interps\n\n'; - File(outputPath).writeAsStringSync('$header$body'); + final formatted = DartFormatter( + languageVersion: DartFormatter.latestLanguageVersion, + ).format('$header$rendered'); + + File(outputPath).writeAsStringSync(formatted); stdout.writeln('Generated $outputPath'); } const _httpMethods = {'get', 'post', 'put', 'patch', 'delete', 'head'}; +/// True when any operation carries or returns `application/json` (and therefore +/// the generated file needs `dart:convert`). Error responses are ignored: those +/// are decoded by the runtime, not the generated code. +bool _needsConvert(Map paths) { + for (final operations in paths.values) { + for (final op in (operations as Map).values) { + if (op is! Map) continue; + final requestBody = (op['requestBody']?['content'] as Map?); + if (requestBody?.containsKey('application/json') ?? false) return true; + final responses = (op['responses'] as Map?) ?? {}; + final successKey = ['200', '201', '204', '202'] + .firstWhere(responses.containsKey, orElse: () => ''); + final content = (responses[successKey] as Map?)?['content'] as Map?; + if (content?.containsKey('application/json') ?? false) return true; + } + } + return false; +} + // ─── Models ────────────────────────────────────────────────────────────── bool _isModel(Map schema) => schema['type'] == 'object' && schema['properties'] != null; -String _generateModel(String name, Map schema) { +Class _buildModel(String name, Map schema) { final properties = (schema['properties'] as Map).cast(); final required = ((schema['required'] as List?) ?? []).cast().toSet(); - final fields = <_Field>[]; - properties.forEach((jsonKey, raw) { - final propSchema = (raw as Map).cast(); - fields.add( + final fields = [ + for (final entry in properties.entries) _Field( - jsonKey: jsonKey, - dartName: _camelCase(jsonKey), - schema: propSchema, - isRequired: required.contains(jsonKey), + jsonKey: entry.key, + dartName: _camelCase(entry.key), + schema: (entry.value as Map).cast(), + isRequired: required.contains(entry.key), ), - ); - }); - - final buffer = StringBuffer() - ..writeln('class $name {') - ..writeln(' $name({'); - for (final field in fields) { - final prefix = field.isRequired ? 'required ' : ''; - buffer.writeln(' ${prefix}this.${field.dartName},'); - } - buffer - ..writeln(' });') - ..writeln(); + ]; + + final fromJsonArgs = fields + .map((f) => + "${f.dartName}: ${_fromJson(f.schema, "json['${f.jsonKey}']", f.isRequired)},") + .join('\n'); + + final toJsonEntries = fields.map((f) { + final value = _toJson(f.schema, f.dartName, f.isRequired); + return f.isRequired + ? "'${f.jsonKey}': $value," + : "if (${f.dartName} != null) '${f.jsonKey}': $value,"; + }).join('\n'); + + return Class( + (b) => b + ..name = name + ..constructors.add( + Constructor( + (c) => c + ..optionalParameters.addAll([ + for (final f in fields) + Parameter((p) => p + ..named = true + ..toThis = true + ..required = f.isRequired + ..name = f.dartName), + ]), + ), + ) + ..constructors.add( + Constructor( + (c) => c + ..factory = true + ..name = 'fromJson' + ..requiredParameters.add( + Parameter((p) => p + ..type = refer('Map') + ..name = 'json'), + ) + ..body = Code('return $name($fromJsonArgs);'), + ), + ) + ..fields.addAll([ + for (final f in fields) + Field((fb) => fb + ..modifier = FieldModifier.final$ + ..type = refer(f.isRequired ? f.dartType : '${f.dartType}?') + ..name = f.dartName), + ]) + ..methods.add( + Method( + (m) => m + ..name = 'toJson' + ..returns = refer('Map') + ..body = Code('return {$toJsonEntries};'), + ), + ), + ); +} - for (final field in fields) { - final type = field.isRequired ? field.dartType : '${field.dartType}?'; - buffer.writeln(' final $type ${field.dartName};'); - } +// ─── Client ────────────────────────────────────────────────────────────── - // fromJson - buffer - ..writeln() - ..writeln(' factory $name.fromJson(Map json) => $name('); - for (final field in fields) { - buffer.writeln( - " ${field.dartName}: ${_fromJson(field.schema, "json['${field.jsonKey}']", field.isRequired)},", - ); - } - buffer - ..writeln(' );') - ..writeln(); - - // toJson - buffer.writeln(' Map toJson() => {'); - for (final field in fields) { - final valueExpr = _toJson(field.schema, field.dartName, field.isRequired); - if (field.isRequired) { - buffer.writeln(" '${field.jsonKey}': $valueExpr,"); - } else { - buffer.writeln( - " if (${field.dartName} != null) '${field.jsonKey}': $valueExpr,", +Class _buildClient(String className, Map paths) { + final methods = []; + for (final pathEntry in paths.entries) { + final operations = (pathEntry.value as Map).cast(); + for (final opEntry in operations.entries) { + if (!_httpMethods.contains(opEntry.key)) continue; + methods.add( + _buildOperation( + pathEntry.key, + opEntry.key, + (opEntry.value as Map).cast(), + ), ); } } - buffer - ..writeln(' };') - ..writeln('}') - ..writeln(); - return buffer.toString(); + return Class( + (b) => b + ..name = className + ..docs.addAll([ + '/// Generated HTTP client. Every operation goes through the', + '/// hand-written [ApiClient] runtime for headers and transport.', + ]) + ..constructors.add( + Constructor( + (c) => c + ..requiredParameters.add( + Parameter((p) => p + ..toThis = true + ..name = '_client'), + ), + ), + ) + ..fields.add( + Field((f) => f + ..modifier = FieldModifier.final$ + ..type = refer('ApiClient') + ..name = '_client'), + ) + ..methods.addAll(methods), + ); } -// ─── Operations ──────────────────────────────────────────────────────────── - -String _generateOperation( - String path, - String method, - Map op, -) { +Method _buildOperation(String path, String method, Map op) { final operationId = op['operationId'] as String; - final methodName = _lowerFirst(operationId); final parameters = ((op['parameters'] as List?) ?? []) .cast() .map((p) => p.cast()) @@ -190,30 +234,22 @@ String _generateOperation( final body = _resolveBody(op); final response = _resolveResponse(op); - // Build the parameter list. - final params = []; - for (final param in pathParams) { - params.add( - 'required String ${_camelCase(_stripWildcard(param['name'] as String))}'); - } - for (final param in headerParams) { - final name = _camelCase(param['name'] as String); - final type = _headerDartType(param['schema'] as Map?); - final isRequired = param['required'] == true; - params.add(isRequired ? 'required $type $name' : '$type? $name'); - } - for (final param in queryParams) { - final name = _camelCase(param['name'] as String); - final type = _headerDartType(param['schema'] as Map?); - params.add('$type? $name'); - } - params.addAll(body.parameters); - - final signature = params.isEmpty ? '' : '{${params.join(', ')}}'; + final params = [ + for (final param in pathParams) + _namedParam('String', _camelCase(_stripWildcard(param['name'] as String)), + required: true), + for (final param in headerParams) + _namedParam(_headerDartType(param['schema'] as Map?), + _camelCase(param['name'] as String), + required: param['required'] == true), + for (final param in queryParams) + _namedParam(_headerDartType(param['schema'] as Map?), + _camelCase(param['name'] as String), + required: false), + ...body.parameters, + ]; - final buffer = StringBuffer() - ..writeln( - ' Future<${response.returnType}> $methodName($signature) async {'); + final buffer = StringBuffer(); // URI. Path values are percent-encoded so keys with reserved characters // (spaces, `?`, `#`, …) don't corrupt the URL. Wildcard segments keep `/`. @@ -226,48 +262,57 @@ String _generateOperation( dartPath = dartPath.replaceAll('{$wire}', '\${$encoded}'); } if (queryParams.isEmpty) { - buffer.writeln(" final uri = _client.uri('$dartPath');"); + buffer.writeln("final uri = _client.uri('$dartPath');"); } else { - buffer.writeln(" final uri = _client.uri('$dartPath', {"); + buffer.writeln("final uri = _client.uri('$dartPath', {"); for (final param in queryParams) { final name = _camelCase(param['name'] as String); final type = _headerDartType(param['schema'] as Map?); final valueExpr = type == 'String' ? name : '$name?.toString()'; - buffer.writeln(" '${param['name']}': $valueExpr,"); + buffer.writeln("'${param['name']}': $valueExpr,"); } - buffer.writeln(' });'); + buffer.writeln('});'); } - // Headers. - buffer.writeln(' final headers = await _client.headers({'); + buffer.writeln('final headers = await _client.headers({'); for (final param in headerParams) { final wire = param['name'] as String; final name = _camelCase(wire); final type = _headerDartType(param['schema'] as Map?); final valueExpr = type == 'String' ? name : '\'\$$name\''; if (param['required'] == true) { - buffer.writeln(" '$wire': $valueExpr,"); + buffer.writeln("'$wire': $valueExpr,"); } else { - buffer.writeln(" if ($name != null) '$wire': $valueExpr,"); + buffer.writeln("if ($name != null) '$wire': $valueExpr,"); } } - buffer.writeln(' });'); + buffer.writeln('});'); - // Request construction + send. Operation-owned headers (e.g. the JSON - // content-type) are applied after addAll so caller/default headers can't - // clobber them. + // Operation-owned headers (e.g. the JSON content-type) are applied after + // addAll so caller/default headers can't clobber them. buffer.write(body.buildRequest(method.toUpperCase())); - buffer.writeln(' request.headers.addAll(headers);'); + buffer.writeln('request.headers.addAll(headers);'); buffer.write(body.afterHeaders); - buffer.writeln(' final streamed = await _client.send(request);'); - - // Response handling. + buffer.writeln('final streamed = await _client.send(request);'); buffer.write(response.handle); - buffer.writeln(' }'); - return buffer.toString(); + return Method( + (m) => m + ..name = _lowerFirst(operationId) + ..modifier = MethodModifier.async + ..returns = refer('Future<${response.returnType}>') + ..optionalParameters.addAll(params) + ..body = Code(buffer.toString()), + ); } +Parameter _namedParam(String type, String name, {required bool required}) => + Parameter((p) => p + ..named = true + ..required = required + ..type = refer(required ? type : '$type?') + ..name = name); + // ─── Request body resolution ───────────────────────────────────────────────── class _Body { @@ -277,7 +322,7 @@ class _Body { this.afterHeaders = '', }); - final List parameters; + final List parameters; final String Function(String method) buildRequest; /// Emitted after `request.headers.addAll(headers)` so operation-owned headers @@ -292,7 +337,7 @@ _Body _resolveBody(Map op) { return _Body( parameters: const [], buildRequest: (method) => - " final request = http.Request('$method', uri);\n", + "final request = http.Request('$method', uri);\n", ); } @@ -305,23 +350,21 @@ _Body _resolveBody(Map op) { final schema = (jsonContent['schema'] as Map).cast(); final type = _refName(schema[r'$ref'] as String); return _Body( - parameters: ['required $type body'], - buildRequest: (method) => - " final request = http.Request('$method', uri)\n" - ' ..body = jsonEncode(body.toJson());\n', - afterHeaders: - " request.headers['content-type'] = 'application/json';\n", + parameters: [_namedParam(type, 'body', required: true)], + buildRequest: (method) => "final request = http.Request('$method', uri)\n" + '..body = jsonEncode(body.toJson());\n', + afterHeaders: "request.headers['content-type'] = 'application/json';\n", ); } // Binary / streaming payload (e.g. TUS UploadChunk). return _Body( parameters: [ - 'required Stream> body', - 'int? contentLength', + _namedParam('Stream>', 'body', required: true), + _namedParam('int', 'contentLength', required: false), ], buildRequest: (method) => - " final request = streamingRequest('$method', uri, " + "final request = streamingRequest('$method', uri, " 'body: body, contentLength: contentLength);\n', ); } @@ -330,41 +373,43 @@ _Body _multipartBody(Map content) { final schema = (content['schema'] as Map).cast(); final properties = (schema['properties'] as Map).cast(); - final params = []; + final fieldParams = []; final fieldWrites = []; String? fileField; properties.forEach((key, raw) { final propSchema = (raw as Map).cast(); + final name = _camelCase(key); if (propSchema['format'] == 'binary') { fileField = key; } else if (propSchema['type'] == 'object') { - params.add('Map? ${_camelCase(key)}'); + fieldParams + .add(_namedParam('Map', name, required: false)); fieldWrites.add( - " if (${_camelCase(key)} != null) request.fields['$key'] = jsonEncode(${_camelCase(key)});", - ); + "if ($name != null) request.fields['$key'] = jsonEncode($name);"); } else { - params.add('String? ${_camelCase(key)}'); - fieldWrites.add( - " if (${_camelCase(key)} != null) request.fields['$key'] = ${_camelCase(key)};", - ); + fieldParams.add(_namedParam('String', name, required: false)); + fieldWrites.add("if ($name != null) request.fields['$key'] = $name;"); } }); - params.insert(0, 'required Stream> file'); - params.insert(1, 'required int fileLength'); - params.add('String? fileName'); + final params = [ + _namedParam('Stream>', 'file', required: true), + _namedParam('int', 'fileLength', required: true), + ...fieldParams, + _namedParam('String', 'fileName', required: false), + ]; return _Body( parameters: params, buildRequest: (method) { final buffer = StringBuffer() - ..writeln(" final request = http.MultipartRequest('$method', uri);") - ..writeln(' request.files.add(http.MultipartFile(') - ..writeln(" '${fileField ?? 'file'}',") - ..writeln(' file,') - ..writeln(' fileLength,') - ..writeln(' filename: fileName,') - ..writeln(' ));'); + ..writeln("final request = http.MultipartRequest('$method', uri);") + ..writeln('request.files.add(http.MultipartFile(') + ..writeln("'${fileField ?? 'file'}',") + ..writeln('file,') + ..writeln('fileLength,') + ..writeln('filename: fileName,') + ..writeln('));'); for (final write in fieldWrites) { buffer.writeln(write); } @@ -397,22 +442,21 @@ _Response _resolveResponse(Map op) { final type = _refName(schema[r'$ref'] as String); return _Response( returnType: type, - handle: ' final response = await readOrThrow(streamed);\n' - ' return $type.fromJson(jsonDecode(response.body) as Map);\n', + handle: 'final response = await readOrThrow(streamed);\n' + 'return $type.fromJson(jsonDecode(response.body) as Map);\n', ); } // Binary response streamed straight to the caller. return _Response( returnType: 'StreamedApiResponse', - handle: - ' if (streamed.statusCode < 200 || streamed.statusCode >= 300) {\n' - ' await readOrThrow(streamed);\n' - ' }\n' - ' return StreamedApiResponse(\n' - ' statusCode: streamed.statusCode,\n' - ' headers: streamed.headers,\n' - ' stream: streamed.stream,\n' - ' );\n', + handle: 'if (streamed.statusCode < 200 || streamed.statusCode >= 300) {\n' + 'await readOrThrow(streamed);\n' + '}\n' + 'return StreamedApiResponse(\n' + 'statusCode: streamed.statusCode,\n' + 'headers: streamed.headers,\n' + 'stream: streamed.stream,\n' + ');\n', ); } @@ -420,8 +464,8 @@ _Response _resolveResponse(Map op) { final headers = (success['headers'] as Map?)?.cast(); if (headers != null && headers.isNotEmpty) { final buffer = StringBuffer() - ..writeln(' final response = await readOrThrow(streamed);') - ..writeln(' return {'); + ..writeln('final response = await readOrThrow(streamed);') + ..writeln('return {'); for (final entry in headers.entries) { final wire = entry.key; final schema = @@ -431,19 +475,17 @@ _Response _resolveResponse(Map op) { final read = isNumber ? "parseIntHeader(response.headers['${wire.toLowerCase()}'])" : "response.headers['${wire.toLowerCase()}']"; - buffer.writeln(" '${_camelCase(wire)}': $read,"); + buffer.writeln("'${_camelCase(wire)}': $read,"); } - buffer.writeln(' };'); + buffer.writeln('};'); return _Response( - returnType: 'Map', - handle: buffer.toString(), - ); + returnType: 'Map', handle: buffer.toString()); } // No content. return _Response( returnType: 'void', - handle: ' await readOrThrow(streamed);\n', + handle: 'await readOrThrow(streamed);\n', ); } diff --git a/packages/supabase_openapi_generator/lib/src/generated/database_api.g.dart b/packages/supabase_openapi_generator/lib/src/generated/database_api.g.dart index 30a7fdbe3..b79aa6ff2 100644 --- a/packages/supabase_openapi_generator/lib/src/generated/database_api.g.dart +++ b/packages/supabase_openapi_generator/lib/src/generated/database_api.g.dart @@ -14,25 +14,31 @@ class DatabaseErrorResponseContent { this.hint, }); + factory DatabaseErrorResponseContent.fromJson(Map json) { + return DatabaseErrorResponseContent( + code: json['code'] as String?, + message: json['message'] as String?, + details: json['details'] as String?, + hint: json['hint'] as String?, + ); + } + final String? code; + final String? message; + final String? details; + final String? hint; - factory DatabaseErrorResponseContent.fromJson(Map json) => - DatabaseErrorResponseContent( - code: json['code'] as String?, - message: json['message'] as String?, - details: json['details'] as String?, - hint: json['hint'] as String?, - ); - - Map toJson() => { - if (code != null) 'code': code, - if (message != null) 'message': message, - if (details != null) 'details': details, - if (hint != null) 'hint': hint, - }; + Map toJson() { + return { + if (code != null) 'code': code, + if (message != null) 'message': message, + if (details != null) 'details': details, + if (hint != null) 'hint': hint, + }; + } } /// Generated HTTP client. Every operation goes through the @@ -42,11 +48,12 @@ class DatabaseApi { final ApiClient _client; - Future callRpcGet( - {required String functionName, - String? acceptProfile, - String? select, - String? args}) async { + Future callRpcGet({ + required String functionName, + String? acceptProfile, + String? select, + String? args, + }) async { final uri = _client.uri('/rpc/${Uri.encodeComponent(functionName)}', { 'select': select, 'args': args, @@ -67,13 +74,14 @@ class DatabaseApi { ); } - Future callRpcPost( - {required String functionName, - String? contentProfile, - String? prefer, - String? select, - required Stream> body, - int? contentLength}) async { + Future callRpcPost({ + required String functionName, + String? contentProfile, + String? prefer, + String? select, + required Stream> body, + int? contentLength, + }) async { final uri = _client.uri('/rpc/${Uri.encodeComponent(functionName)}', { 'select': select, }); @@ -81,8 +89,12 @@ class DatabaseApi { if (contentProfile != null) 'Content-Profile': contentProfile, if (prefer != null) 'Prefer': prefer, }); - final request = - streamingRequest('POST', uri, body: body, contentLength: contentLength); + final request = streamingRequest( + 'POST', + uri, + body: body, + contentLength: contentLength, + ); request.headers.addAll(headers); final streamed = await _client.send(request); if (streamed.statusCode < 200 || streamed.statusCode >= 300) { @@ -95,12 +107,13 @@ class DatabaseApi { ); } - Future deleteRows( - {required String table, - String? contentProfile, - String? prefer, - String? select, - String? filters}) async { + Future deleteRows({ + required String table, + String? contentProfile, + String? prefer, + String? select, + String? filters, + }) async { final uri = _client.uri('/${Uri.encodeComponent(table)}', { 'select': select, 'filters': filters, @@ -122,18 +135,19 @@ class DatabaseApi { ); } - Future selectRows( - {required String table, - String? accept, - String? acceptProfile, - String? prefer, - String? range, - String? rangeUnit, - String? select, - String? order, - int? limit, - int? offset, - String? filters}) async { + Future selectRows({ + required String table, + String? accept, + String? acceptProfile, + String? prefer, + String? range, + String? rangeUnit, + String? select, + String? order, + int? limit, + int? offset, + String? filters, + }) async { final uri = _client.uri('/${Uri.encodeComponent(table)}', { 'select': select, 'order': order, @@ -161,14 +175,15 @@ class DatabaseApi { ); } - Future updateRows( - {required String table, - String? contentProfile, - String? prefer, - String? select, - String? filters, - required Stream> body, - int? contentLength}) async { + Future updateRows({ + required String table, + String? contentProfile, + String? prefer, + String? select, + String? filters, + required Stream> body, + int? contentLength, + }) async { final uri = _client.uri('/${Uri.encodeComponent(table)}', { 'select': select, 'filters': filters, @@ -177,8 +192,12 @@ class DatabaseApi { if (contentProfile != null) 'Content-Profile': contentProfile, if (prefer != null) 'Prefer': prefer, }); - final request = streamingRequest('PATCH', uri, - body: body, contentLength: contentLength); + final request = streamingRequest( + 'PATCH', + uri, + body: body, + contentLength: contentLength, + ); request.headers.addAll(headers); final streamed = await _client.send(request); if (streamed.statusCode < 200 || streamed.statusCode >= 300) { @@ -191,14 +210,15 @@ class DatabaseApi { ); } - Future insertRows( - {required String table, - String? contentProfile, - String? prefer, - String? select, - String? columns, - required Stream> body, - int? contentLength}) async { + Future insertRows({ + required String table, + String? contentProfile, + String? prefer, + String? select, + String? columns, + required Stream> body, + int? contentLength, + }) async { final uri = _client.uri('/${Uri.encodeComponent(table)}', { 'select': select, 'columns': columns, @@ -207,8 +227,12 @@ class DatabaseApi { if (contentProfile != null) 'Content-Profile': contentProfile, if (prefer != null) 'Prefer': prefer, }); - final request = - streamingRequest('POST', uri, body: body, contentLength: contentLength); + final request = streamingRequest( + 'POST', + uri, + body: body, + contentLength: contentLength, + ); request.headers.addAll(headers); final streamed = await _client.send(request); if (streamed.statusCode < 200 || streamed.statusCode >= 300) { @@ -221,15 +245,16 @@ class DatabaseApi { ); } - Future upsertRows( - {required String table, - String? contentProfile, - String? prefer, - String? select, - String? onConflict, - String? filters, - required Stream> body, - int? contentLength}) async { + Future upsertRows({ + required String table, + String? contentProfile, + String? prefer, + String? select, + String? onConflict, + String? filters, + required Stream> body, + int? contentLength, + }) async { final uri = _client.uri('/${Uri.encodeComponent(table)}', { 'select': select, 'on_conflict': onConflict, @@ -239,8 +264,12 @@ class DatabaseApi { if (contentProfile != null) 'Content-Profile': contentProfile, if (prefer != null) 'Prefer': prefer, }); - final request = - streamingRequest('PUT', uri, body: body, contentLength: contentLength); + final request = streamingRequest( + 'PUT', + uri, + body: body, + contentLength: contentLength, + ); request.headers.addAll(headers); final streamed = await _client.send(request); if (streamed.statusCode < 200 || streamed.statusCode >= 300) { diff --git a/packages/supabase_openapi_generator/lib/src/generated/functions_api.g.dart b/packages/supabase_openapi_generator/lib/src/generated/functions_api.g.dart index 3d2fa13a8..ddfc38af9 100644 --- a/packages/supabase_openapi_generator/lib/src/generated/functions_api.g.dart +++ b/packages/supabase_openapi_generator/lib/src/generated/functions_api.g.dart @@ -7,20 +7,17 @@ import 'package:http/http.dart' as http; import '../runtime.dart'; class FunctionsErrorResponseContent { - FunctionsErrorResponseContent({ - this.message, - }); + FunctionsErrorResponseContent({this.message}); - final String? message; + factory FunctionsErrorResponseContent.fromJson(Map json) { + return FunctionsErrorResponseContent(message: json['message'] as String?); + } - factory FunctionsErrorResponseContent.fromJson(Map json) => - FunctionsErrorResponseContent( - message: json['message'] as String?, - ); + final String? message; - Map toJson() => { - if (message != null) 'message': message, - }; + Map toJson() { + return {if (message != null) 'message': message}; + } } /// Generated HTTP client. Every operation goes through the @@ -30,18 +27,24 @@ class FunctionsApi { final ApiClient _client; - Future invokeFunctionDelete( - {required String functionName, - String? xRegion, - required Stream> body, - int? contentLength}) async { - final uri = - _client.uri('/functions/v1/${Uri.encodeComponent(functionName)}'); + Future invokeFunctionDelete({ + required String functionName, + String? xRegion, + required Stream> body, + int? contentLength, + }) async { + final uri = _client.uri( + '/functions/v1/${Uri.encodeComponent(functionName)}', + ); final headers = await _client.headers({ if (xRegion != null) 'x-region': xRegion, }); - final request = streamingRequest('DELETE', uri, - body: body, contentLength: contentLength); + final request = streamingRequest( + 'DELETE', + uri, + body: body, + contentLength: contentLength, + ); request.headers.addAll(headers); final streamed = await _client.send(request); if (streamed.statusCode < 200 || streamed.statusCode >= 300) { @@ -54,10 +57,13 @@ class FunctionsApi { ); } - Future invokeFunctionGet( - {required String functionName, String? xRegion}) async { - final uri = - _client.uri('/functions/v1/${Uri.encodeComponent(functionName)}'); + Future invokeFunctionGet({ + required String functionName, + String? xRegion, + }) async { + final uri = _client.uri( + '/functions/v1/${Uri.encodeComponent(functionName)}', + ); final headers = await _client.headers({ if (xRegion != null) 'x-region': xRegion, }); @@ -74,18 +80,24 @@ class FunctionsApi { ); } - Future invokeFunctionPatch( - {required String functionName, - String? xRegion, - required Stream> body, - int? contentLength}) async { - final uri = - _client.uri('/functions/v1/${Uri.encodeComponent(functionName)}'); + Future invokeFunctionPatch({ + required String functionName, + String? xRegion, + required Stream> body, + int? contentLength, + }) async { + final uri = _client.uri( + '/functions/v1/${Uri.encodeComponent(functionName)}', + ); final headers = await _client.headers({ if (xRegion != null) 'x-region': xRegion, }); - final request = streamingRequest('PATCH', uri, - body: body, contentLength: contentLength); + final request = streamingRequest( + 'PATCH', + uri, + body: body, + contentLength: contentLength, + ); request.headers.addAll(headers); final streamed = await _client.send(request); if (streamed.statusCode < 200 || streamed.statusCode >= 300) { @@ -98,18 +110,24 @@ class FunctionsApi { ); } - Future invokeFunctionPost( - {required String functionName, - String? xRegion, - required Stream> body, - int? contentLength}) async { - final uri = - _client.uri('/functions/v1/${Uri.encodeComponent(functionName)}'); + Future invokeFunctionPost({ + required String functionName, + String? xRegion, + required Stream> body, + int? contentLength, + }) async { + final uri = _client.uri( + '/functions/v1/${Uri.encodeComponent(functionName)}', + ); final headers = await _client.headers({ if (xRegion != null) 'x-region': xRegion, }); - final request = - streamingRequest('POST', uri, body: body, contentLength: contentLength); + final request = streamingRequest( + 'POST', + uri, + body: body, + contentLength: contentLength, + ); request.headers.addAll(headers); final streamed = await _client.send(request); if (streamed.statusCode < 200 || streamed.statusCode >= 300) { @@ -122,18 +140,24 @@ class FunctionsApi { ); } - Future invokeFunctionPut( - {required String functionName, - String? xRegion, - required Stream> body, - int? contentLength}) async { - final uri = - _client.uri('/functions/v1/${Uri.encodeComponent(functionName)}'); + Future invokeFunctionPut({ + required String functionName, + String? xRegion, + required Stream> body, + int? contentLength, + }) async { + final uri = _client.uri( + '/functions/v1/${Uri.encodeComponent(functionName)}', + ); final headers = await _client.headers({ if (xRegion != null) 'x-region': xRegion, }); - final request = - streamingRequest('PUT', uri, body: body, contentLength: contentLength); + final request = streamingRequest( + 'PUT', + uri, + body: body, + contentLength: contentLength, + ); request.headers.addAll(headers); final streamed = await _client.send(request); if (streamed.statusCode < 200 || streamed.statusCode >= 300) { diff --git a/packages/supabase_openapi_generator/lib/src/generated/storage_api.g.dart b/packages/supabase_openapi_generator/lib/src/generated/storage_api.g.dart index 8a83518fc..b9d107cf9 100644 --- a/packages/supabase_openapi_generator/lib/src/generated/storage_api.g.dart +++ b/packages/supabase_openapi_generator/lib/src/generated/storage_api.g.dart @@ -19,35 +19,45 @@ class Bucket { this.updatedAt, }); + factory Bucket.fromJson(Map json) { + return Bucket( + id: json['id'] as String, + name: json['name'] as String, + public: json['public'] as bool, + fileSizeLimit: json['file_size_limit'] as num?, + allowedMimeTypes: json['allowed_mime_types'] == null + ? null + : (json['allowed_mime_types'] as List).cast(), + createdAt: json['created_at'] as String?, + updatedAt: json['updated_at'] as String?, + ); + } + final String id; + final String name; + final bool public; + final num? fileSizeLimit; + final List? allowedMimeTypes; + final String? createdAt; + final String? updatedAt; - factory Bucket.fromJson(Map json) => Bucket( - id: json['id'] as String, - name: json['name'] as String, - public: json['public'] as bool, - fileSizeLimit: json['file_size_limit'] as num?, - allowedMimeTypes: json['allowed_mime_types'] == null - ? null - : (json['allowed_mime_types'] as List).cast(), - createdAt: json['created_at'] as String?, - updatedAt: json['updated_at'] as String?, - ); - - Map toJson() => { - 'id': id, - 'name': name, - 'public': public, - if (fileSizeLimit != null) 'file_size_limit': fileSizeLimit, - if (allowedMimeTypes != null) 'allowed_mime_types': allowedMimeTypes, - if (createdAt != null) 'created_at': createdAt, - if (updatedAt != null) 'updated_at': updatedAt, - }; + Map toJson() { + return { + 'id': id, + 'name': name, + 'public': public, + if (fileSizeLimit != null) 'file_size_limit': fileSizeLimit, + if (allowedMimeTypes != null) 'allowed_mime_types': allowedMimeTypes, + if (createdAt != null) 'created_at': createdAt, + if (updatedAt != null) 'updated_at': updatedAt, + }; + } } class CopyObjectRequestContent { @@ -58,42 +68,45 @@ class CopyObjectRequestContent { this.destinationBucket, }); + factory CopyObjectRequestContent.fromJson(Map json) { + return CopyObjectRequestContent( + bucketId: json['bucketId'] as String, + sourceKey: json['sourceKey'] as String, + destinationKey: json['destinationKey'] as String, + destinationBucket: json['destinationBucket'] as String?, + ); + } + final String bucketId; + final String sourceKey; + final String destinationKey; + final String? destinationBucket; - factory CopyObjectRequestContent.fromJson(Map json) => - CopyObjectRequestContent( - bucketId: json['bucketId'] as String, - sourceKey: json['sourceKey'] as String, - destinationKey: json['destinationKey'] as String, - destinationBucket: json['destinationBucket'] as String?, - ); - - Map toJson() => { - 'bucketId': bucketId, - 'sourceKey': sourceKey, - 'destinationKey': destinationKey, - if (destinationBucket != null) 'destinationBucket': destinationBucket, - }; + Map toJson() { + return { + 'bucketId': bucketId, + 'sourceKey': sourceKey, + 'destinationKey': destinationKey, + if (destinationBucket != null) 'destinationBucket': destinationBucket, + }; + } } class CopyObjectResponseContent { - CopyObjectResponseContent({ - required this.key, - }); + CopyObjectResponseContent({required this.key}); - final String key; + factory CopyObjectResponseContent.fromJson(Map json) { + return CopyObjectResponseContent(key: json['Key'] as String); + } - factory CopyObjectResponseContent.fromJson(Map json) => - CopyObjectResponseContent( - key: json['Key'] as String, - ); + final String key; - Map toJson() => { - 'Key': key, - }; + Map toJson() { + return {'Key': key}; + } } class CreateBucketRequestContent { @@ -105,82 +118,83 @@ class CreateBucketRequestContent { this.allowedMimeTypes, }); + factory CreateBucketRequestContent.fromJson(Map json) { + return CreateBucketRequestContent( + id: json['id'] as String, + name: json['name'] as String, + public: json['public'] as bool, + fileSizeLimit: json['file_size_limit'] as num?, + allowedMimeTypes: json['allowed_mime_types'] == null + ? null + : (json['allowed_mime_types'] as List).cast(), + ); + } + final String id; + final String name; + final bool public; + final num? fileSizeLimit; + final List? allowedMimeTypes; - factory CreateBucketRequestContent.fromJson(Map json) => - CreateBucketRequestContent( - id: json['id'] as String, - name: json['name'] as String, - public: json['public'] as bool, - fileSizeLimit: json['file_size_limit'] as num?, - allowedMimeTypes: json['allowed_mime_types'] == null - ? null - : (json['allowed_mime_types'] as List).cast(), - ); - - Map toJson() => { - 'id': id, - 'name': name, - 'public': public, - if (fileSizeLimit != null) 'file_size_limit': fileSizeLimit, - if (allowedMimeTypes != null) 'allowed_mime_types': allowedMimeTypes, - }; + Map toJson() { + return { + 'id': id, + 'name': name, + 'public': public, + if (fileSizeLimit != null) 'file_size_limit': fileSizeLimit, + if (allowedMimeTypes != null) 'allowed_mime_types': allowedMimeTypes, + }; + } } class CreateSignedUploadUrlResponseContent { - CreateSignedUploadUrlResponseContent({ - required this.url, - }); + CreateSignedUploadUrlResponseContent({required this.url}); + + factory CreateSignedUploadUrlResponseContent.fromJson( + Map json, + ) { + return CreateSignedUploadUrlResponseContent(url: json['url'] as String); + } final String url; - factory CreateSignedUploadUrlResponseContent.fromJson( - Map json) => - CreateSignedUploadUrlResponseContent( - url: json['url'] as String, - ); - - Map toJson() => { - 'url': url, - }; + Map toJson() { + return {'url': url}; + } } class CreateSignedUrlRequestContent { - CreateSignedUrlRequestContent({ - required this.expiresIn, - }); + CreateSignedUrlRequestContent({required this.expiresIn}); - final num expiresIn; + factory CreateSignedUrlRequestContent.fromJson(Map json) { + return CreateSignedUrlRequestContent(expiresIn: json['expiresIn'] as num); + } - factory CreateSignedUrlRequestContent.fromJson(Map json) => - CreateSignedUrlRequestContent( - expiresIn: json['expiresIn'] as num, - ); + final num expiresIn; - Map toJson() => { - 'expiresIn': expiresIn, - }; + Map toJson() { + return {'expiresIn': expiresIn}; + } } class CreateSignedUrlResponseContent { - CreateSignedUrlResponseContent({ - required this.signedURL, - }); + CreateSignedUrlResponseContent({required this.signedURL}); - final String signedURL; + factory CreateSignedUrlResponseContent.fromJson(Map json) { + return CreateSignedUrlResponseContent( + signedURL: json['signedURL'] as String, + ); + } - factory CreateSignedUrlResponseContent.fromJson(Map json) => - CreateSignedUrlResponseContent( - signedURL: json['signedURL'] as String, - ); + final String signedURL; - Map toJson() => { - 'signedURL': signedURL, - }; + Map toJson() { + return {'signedURL': signedURL}; + } } class CreateSignedUrlsRequestContent { @@ -189,74 +203,72 @@ class CreateSignedUrlsRequestContent { required this.paths, }); + factory CreateSignedUrlsRequestContent.fromJson(Map json) { + return CreateSignedUrlsRequestContent( + expiresIn: json['expiresIn'] as num, + paths: (json['paths'] as List).cast(), + ); + } + final num expiresIn; - final List paths; - factory CreateSignedUrlsRequestContent.fromJson(Map json) => - CreateSignedUrlsRequestContent( - expiresIn: json['expiresIn'] as num, - paths: (json['paths'] as List).cast(), - ); + final List paths; - Map toJson() => { - 'expiresIn': expiresIn, - 'paths': paths, - }; + Map toJson() { + return {'expiresIn': expiresIn, 'paths': paths}; + } } class CreateSignedUrlsResponseContent { - CreateSignedUrlsResponseContent({ - required this.items, - }); + CreateSignedUrlsResponseContent({required this.items}); + + factory CreateSignedUrlsResponseContent.fromJson(Map json) { + return CreateSignedUrlsResponseContent( + items: (json['items'] as List) + .map((e) => SignedUrlResult.fromJson(e as Map)) + .toList(), + ); + } final List items; - factory CreateSignedUrlsResponseContent.fromJson(Map json) => - CreateSignedUrlsResponseContent( - items: (json['items'] as List) - .map((e) => SignedUrlResult.fromJson(e as Map)) - .toList(), - ); - - Map toJson() => { - 'items': items.map((e) => e.toJson()).toList(), - }; + Map toJson() { + return {'items': items.map((e) => e.toJson()).toList()}; + } } class DeleteObjectsRequestContent { - DeleteObjectsRequestContent({ - required this.prefixes, - }); + DeleteObjectsRequestContent({required this.prefixes}); - final List prefixes; + factory DeleteObjectsRequestContent.fromJson(Map json) { + return DeleteObjectsRequestContent( + prefixes: (json['prefixes'] as List).cast(), + ); + } - factory DeleteObjectsRequestContent.fromJson(Map json) => - DeleteObjectsRequestContent( - prefixes: (json['prefixes'] as List).cast(), - ); + final List prefixes; - Map toJson() => { - 'prefixes': prefixes, - }; + Map toJson() { + return {'prefixes': prefixes}; + } } class DeleteObjectsResponseContent { - DeleteObjectsResponseContent({ - required this.items, - }); + DeleteObjectsResponseContent({required this.items}); + + factory DeleteObjectsResponseContent.fromJson(Map json) { + return DeleteObjectsResponseContent( + items: (json['items'] as List) + .map((e) => FileObject.fromJson(e as Map)) + .toList(), + ); + } final List items; - factory DeleteObjectsResponseContent.fromJson(Map json) => - DeleteObjectsResponseContent( - items: (json['items'] as List) - .map((e) => FileObject.fromJson(e as Map)) - .toList(), - ); - - Map toJson() => { - 'items': items.map((e) => e.toJson()).toList(), - }; + Map toJson() { + return {'items': items.map((e) => e.toJson()).toList()}; + } } class FileMetadata { @@ -270,33 +282,43 @@ class FileMetadata { this.httpStatusCode, }); + factory FileMetadata.fromJson(Map json) { + return FileMetadata( + eTag: json['eTag'] as String?, + size: json['size'] as num?, + mimetype: json['mimetype'] as String?, + cacheControl: json['cacheControl'] as String?, + lastModified: json['lastModified'] as String?, + contentLength: json['contentLength'] as num?, + httpStatusCode: json['httpStatusCode'] as num?, + ); + } + final String? eTag; + final num? size; + final String? mimetype; + final String? cacheControl; + final String? lastModified; + final num? contentLength; + final num? httpStatusCode; - factory FileMetadata.fromJson(Map json) => FileMetadata( - eTag: json['eTag'] as String?, - size: json['size'] as num?, - mimetype: json['mimetype'] as String?, - cacheControl: json['cacheControl'] as String?, - lastModified: json['lastModified'] as String?, - contentLength: json['contentLength'] as num?, - httpStatusCode: json['httpStatusCode'] as num?, - ); - - Map toJson() => { - if (eTag != null) 'eTag': eTag, - if (size != null) 'size': size, - if (mimetype != null) 'mimetype': mimetype, - if (cacheControl != null) 'cacheControl': cacheControl, - if (lastModified != null) 'lastModified': lastModified, - if (contentLength != null) 'contentLength': contentLength, - if (httpStatusCode != null) 'httpStatusCode': httpStatusCode, - }; + Map toJson() { + return { + if (eTag != null) 'eTag': eTag, + if (size != null) 'size': size, + if (mimetype != null) 'mimetype': mimetype, + if (cacheControl != null) 'cacheControl': cacheControl, + if (lastModified != null) 'lastModified': lastModified, + if (contentLength != null) 'contentLength': contentLength, + if (httpStatusCode != null) 'httpStatusCode': httpStatusCode, + }; + } } class FileObject { @@ -309,32 +331,41 @@ class FileObject { this.metadata, }); + factory FileObject.fromJson(Map json) { + return FileObject( + name: json['name'] as String, + id: json['id'] as String?, + updatedAt: json['updated_at'] as String?, + createdAt: json['created_at'] as String?, + lastAccessedAt: json['last_accessed_at'] as String?, + metadata: json['metadata'] == null + ? null + : FileMetadata.fromJson(json['metadata'] as Map), + ); + } + final String name; + final String? id; + final String? updatedAt; + final String? createdAt; + final String? lastAccessedAt; + final FileMetadata? metadata; - factory FileObject.fromJson(Map json) => FileObject( - name: json['name'] as String, - id: json['id'] as String?, - updatedAt: json['updated_at'] as String?, - createdAt: json['created_at'] as String?, - lastAccessedAt: json['last_accessed_at'] as String?, - metadata: json['metadata'] == null - ? null - : FileMetadata.fromJson(json['metadata'] as Map), - ); - - Map toJson() => { - 'name': name, - if (id != null) 'id': id, - if (updatedAt != null) 'updated_at': updatedAt, - if (createdAt != null) 'created_at': createdAt, - if (lastAccessedAt != null) 'last_accessed_at': lastAccessedAt, - if (metadata != null) 'metadata': metadata!.toJson(), - }; + Map toJson() { + return { + 'name': name, + if (id != null) 'id': id, + if (updatedAt != null) 'updated_at': updatedAt, + if (createdAt != null) 'created_at': createdAt, + if (lastAccessedAt != null) 'last_accessed_at': lastAccessedAt, + if (metadata != null) 'metadata': metadata!.toJson(), + }; + } } class GetBucketResponseContent { @@ -348,36 +379,45 @@ class GetBucketResponseContent { this.updatedAt, }); + factory GetBucketResponseContent.fromJson(Map json) { + return GetBucketResponseContent( + id: json['id'] as String, + name: json['name'] as String, + public: json['public'] as bool, + fileSizeLimit: json['file_size_limit'] as num?, + allowedMimeTypes: json['allowed_mime_types'] == null + ? null + : (json['allowed_mime_types'] as List).cast(), + createdAt: json['created_at'] as String?, + updatedAt: json['updated_at'] as String?, + ); + } + final String id; + final String name; + final bool public; + final num? fileSizeLimit; + final List? allowedMimeTypes; + final String? createdAt; + final String? updatedAt; - factory GetBucketResponseContent.fromJson(Map json) => - GetBucketResponseContent( - id: json['id'] as String, - name: json['name'] as String, - public: json['public'] as bool, - fileSizeLimit: json['file_size_limit'] as num?, - allowedMimeTypes: json['allowed_mime_types'] == null - ? null - : (json['allowed_mime_types'] as List).cast(), - createdAt: json['created_at'] as String?, - updatedAt: json['updated_at'] as String?, - ); - - Map toJson() => { - 'id': id, - 'name': name, - 'public': public, - if (fileSizeLimit != null) 'file_size_limit': fileSizeLimit, - if (allowedMimeTypes != null) 'allowed_mime_types': allowedMimeTypes, - if (createdAt != null) 'created_at': createdAt, - if (updatedAt != null) 'updated_at': updatedAt, - }; + Map toJson() { + return { + 'id': id, + 'name': name, + 'public': public, + if (fileSizeLimit != null) 'file_size_limit': fileSizeLimit, + if (allowedMimeTypes != null) 'allowed_mime_types': allowedMimeTypes, + if (createdAt != null) 'created_at': createdAt, + if (updatedAt != null) 'updated_at': updatedAt, + }; + } } class GetObjectInfoResponseContent { @@ -391,53 +431,61 @@ class GetObjectInfoResponseContent { this.httpStatusCode, }); + factory GetObjectInfoResponseContent.fromJson(Map json) { + return GetObjectInfoResponseContent( + eTag: json['eTag'] as String?, + size: json['size'] as num?, + mimetype: json['mimetype'] as String?, + cacheControl: json['cacheControl'] as String?, + lastModified: json['lastModified'] as String?, + contentLength: json['contentLength'] as num?, + httpStatusCode: json['httpStatusCode'] as num?, + ); + } + final String? eTag; + final num? size; + final String? mimetype; + final String? cacheControl; + final String? lastModified; + final num? contentLength; + final num? httpStatusCode; - factory GetObjectInfoResponseContent.fromJson(Map json) => - GetObjectInfoResponseContent( - eTag: json['eTag'] as String?, - size: json['size'] as num?, - mimetype: json['mimetype'] as String?, - cacheControl: json['cacheControl'] as String?, - lastModified: json['lastModified'] as String?, - contentLength: json['contentLength'] as num?, - httpStatusCode: json['httpStatusCode'] as num?, - ); - - Map toJson() => { - if (eTag != null) 'eTag': eTag, - if (size != null) 'size': size, - if (mimetype != null) 'mimetype': mimetype, - if (cacheControl != null) 'cacheControl': cacheControl, - if (lastModified != null) 'lastModified': lastModified, - if (contentLength != null) 'contentLength': contentLength, - if (httpStatusCode != null) 'httpStatusCode': httpStatusCode, - }; + Map toJson() { + return { + if (eTag != null) 'eTag': eTag, + if (size != null) 'size': size, + if (mimetype != null) 'mimetype': mimetype, + if (cacheControl != null) 'cacheControl': cacheControl, + if (lastModified != null) 'lastModified': lastModified, + if (contentLength != null) 'contentLength': contentLength, + if (httpStatusCode != null) 'httpStatusCode': httpStatusCode, + }; + } } class ListBucketsResponseContent { - ListBucketsResponseContent({ - required this.items, - }); + ListBucketsResponseContent({required this.items}); + + factory ListBucketsResponseContent.fromJson(Map json) { + return ListBucketsResponseContent( + items: (json['items'] as List) + .map((e) => Bucket.fromJson(e as Map)) + .toList(), + ); + } final List items; - factory ListBucketsResponseContent.fromJson(Map json) => - ListBucketsResponseContent( - items: (json['items'] as List) - .map((e) => Bucket.fromJson(e as Map)) - .toList(), - ); - - Map toJson() => { - 'items': items.map((e) => e.toJson()).toList(), - }; + Map toJson() { + return {'items': items.map((e) => e.toJson()).toList()}; + } } class ListObjectsRequestContent { @@ -448,46 +496,51 @@ class ListObjectsRequestContent { this.sortBy, }); + factory ListObjectsRequestContent.fromJson(Map json) { + return ListObjectsRequestContent( + prefix: json['prefix'] as String, + limit: json['limit'] as num?, + offset: json['offset'] as num?, + sortBy: json['sortBy'] == null + ? null + : SortBy.fromJson(json['sortBy'] as Map), + ); + } + final String prefix; + final num? limit; + final num? offset; + final SortBy? sortBy; - factory ListObjectsRequestContent.fromJson(Map json) => - ListObjectsRequestContent( - prefix: json['prefix'] as String, - limit: json['limit'] as num?, - offset: json['offset'] as num?, - sortBy: json['sortBy'] == null - ? null - : SortBy.fromJson(json['sortBy'] as Map), - ); - - Map toJson() => { - 'prefix': prefix, - if (limit != null) 'limit': limit, - if (offset != null) 'offset': offset, - if (sortBy != null) 'sortBy': sortBy!.toJson(), - }; + Map toJson() { + return { + 'prefix': prefix, + if (limit != null) 'limit': limit, + if (offset != null) 'offset': offset, + if (sortBy != null) 'sortBy': sortBy!.toJson(), + }; + } } class ListObjectsResponseContent { - ListObjectsResponseContent({ - required this.items, - }); + ListObjectsResponseContent({required this.items}); + + factory ListObjectsResponseContent.fromJson(Map json) { + return ListObjectsResponseContent( + items: (json['items'] as List) + .map((e) => FileObject.fromJson(e as Map)) + .toList(), + ); + } final List items; - factory ListObjectsResponseContent.fromJson(Map json) => - ListObjectsResponseContent( - items: (json['items'] as List) - .map((e) => FileObject.fromJson(e as Map)) - .toList(), - ); - - Map toJson() => { - 'items': items.map((e) => e.toJson()).toList(), - }; + Map toJson() { + return {'items': items.map((e) => e.toJson()).toList()}; + } } class MoveObjectRequestContent { @@ -498,95 +551,105 @@ class MoveObjectRequestContent { this.destinationBucket, }); + factory MoveObjectRequestContent.fromJson(Map json) { + return MoveObjectRequestContent( + bucketId: json['bucketId'] as String, + sourceKey: json['sourceKey'] as String, + destinationKey: json['destinationKey'] as String, + destinationBucket: json['destinationBucket'] as String?, + ); + } + final String bucketId; + final String sourceKey; + final String destinationKey; + final String? destinationBucket; - factory MoveObjectRequestContent.fromJson(Map json) => - MoveObjectRequestContent( - bucketId: json['bucketId'] as String, - sourceKey: json['sourceKey'] as String, - destinationKey: json['destinationKey'] as String, - destinationBucket: json['destinationBucket'] as String?, - ); - - Map toJson() => { - 'bucketId': bucketId, - 'sourceKey': sourceKey, - 'destinationKey': destinationKey, - if (destinationBucket != null) 'destinationBucket': destinationBucket, - }; + Map toJson() { + return { + 'bucketId': bucketId, + 'sourceKey': sourceKey, + 'destinationKey': destinationKey, + if (destinationBucket != null) 'destinationBucket': destinationBucket, + }; + } } class SignedUrlResult { - SignedUrlResult({ - this.signedURL, - required this.path, - this.error, - }); + SignedUrlResult({this.signedURL, required this.path, this.error}); + + factory SignedUrlResult.fromJson(Map json) { + return SignedUrlResult( + signedURL: json['signedURL'] as String?, + path: json['path'] as String, + error: json['error'] as String?, + ); + } final String? signedURL; + final String path; + final String? error; - factory SignedUrlResult.fromJson(Map json) => - SignedUrlResult( - signedURL: json['signedURL'] as String?, - path: json['path'] as String, - error: json['error'] as String?, - ); - - Map toJson() => { - if (signedURL != null) 'signedURL': signedURL, - 'path': path, - if (error != null) 'error': error, - }; + Map toJson() { + return { + if (signedURL != null) 'signedURL': signedURL, + 'path': path, + if (error != null) 'error': error, + }; + } } class SortBy { - SortBy({ - this.column, - this.order, - }); + SortBy({this.column, this.order}); + + factory SortBy.fromJson(Map json) { + return SortBy( + column: json['column'] as String?, + order: json['order'] as String?, + ); + } final String? column; - final String? order; - factory SortBy.fromJson(Map json) => SortBy( - column: json['column'] as String?, - order: json['order'] as String?, - ); + final String? order; - Map toJson() => { - if (column != null) 'column': column, - if (order != null) 'order': order, - }; + Map toJson() { + return { + if (column != null) 'column': column, + if (order != null) 'order': order, + }; + } } class StorageErrorResponseContent { - StorageErrorResponseContent({ - this.message, - this.error, - this.statusCode, - }); + StorageErrorResponseContent({this.message, this.error, this.statusCode}); + + factory StorageErrorResponseContent.fromJson(Map json) { + return StorageErrorResponseContent( + message: json['message'] as String?, + error: json['error'] as String?, + statusCode: json['statusCode'] as String?, + ); + } final String? message; + final String? error; + final String? statusCode; - factory StorageErrorResponseContent.fromJson(Map json) => - StorageErrorResponseContent( - message: json['message'] as String?, - error: json['error'] as String?, - statusCode: json['statusCode'] as String?, - ); - - Map toJson() => { - if (message != null) 'message': message, - if (error != null) 'error': error, - if (statusCode != null) 'statusCode': statusCode, - }; + Map toJson() { + return { + if (message != null) 'message': message, + if (error != null) 'error': error, + if (statusCode != null) 'statusCode': statusCode, + }; + } } class UpdateBucketRequestContent { @@ -596,45 +659,48 @@ class UpdateBucketRequestContent { this.allowedMimeTypes, }); + factory UpdateBucketRequestContent.fromJson(Map json) { + return UpdateBucketRequestContent( + public: json['public'] as bool, + fileSizeLimit: json['file_size_limit'] as num?, + allowedMimeTypes: json['allowed_mime_types'] == null + ? null + : (json['allowed_mime_types'] as List).cast(), + ); + } + final bool public; + final num? fileSizeLimit; + final List? allowedMimeTypes; - factory UpdateBucketRequestContent.fromJson(Map json) => - UpdateBucketRequestContent( - public: json['public'] as bool, - fileSizeLimit: json['file_size_limit'] as num?, - allowedMimeTypes: json['allowed_mime_types'] == null - ? null - : (json['allowed_mime_types'] as List).cast(), - ); - - Map toJson() => { - 'public': public, - if (fileSizeLimit != null) 'file_size_limit': fileSizeLimit, - if (allowedMimeTypes != null) 'allowed_mime_types': allowedMimeTypes, - }; + Map toJson() { + return { + 'public': public, + if (fileSizeLimit != null) 'file_size_limit': fileSizeLimit, + if (allowedMimeTypes != null) 'allowed_mime_types': allowedMimeTypes, + }; + } } class FileUploadedResponse { - FileUploadedResponse({ - required this.key, - required this.id, - }); + FileUploadedResponse({required this.key, required this.id}); + + factory FileUploadedResponse.fromJson(Map json) { + return FileUploadedResponse( + key: json['Key'] as String, + id: json['Id'] as String, + ); + } final String key; - final String id; - factory FileUploadedResponse.fromJson(Map json) => - FileUploadedResponse( - key: json['Key'] as String, - id: json['Id'] as String, - ); + final String id; - Map toJson() => { - 'Key': key, - 'Id': id, - }; + Map toJson() { + return {'Key': key, 'Id': id}; + } } /// Generated HTTP client. Every operation goes through the @@ -652,7 +718,8 @@ class StorageApi { final streamed = await _client.send(request); final response = await readOrThrow(streamed); return ListBucketsResponseContent.fromJson( - jsonDecode(response.body) as Map); + jsonDecode(response.body) as Map, + ); } Future createBucket({required CreateBucketRequestContent body}) async { @@ -682,11 +749,14 @@ class StorageApi { final streamed = await _client.send(request); final response = await readOrThrow(streamed); return GetBucketResponseContent.fromJson( - jsonDecode(response.body) as Map); + jsonDecode(response.body) as Map, + ); } - Future updateBucket( - {required String id, required UpdateBucketRequestContent body}) async { + Future updateBucket({ + required String id, + required UpdateBucketRequestContent body, + }) async { final uri = _client.uri('/bucket/${Uri.encodeComponent(id)}'); final headers = await _client.headers({}); final request = http.Request('PUT', uri)..body = jsonEncode(body.toJson()); @@ -705,8 +775,9 @@ class StorageApi { await readOrThrow(streamed); } - Future copyObject( - {required CopyObjectRequestContent body}) async { + Future copyObject({ + required CopyObjectRequestContent body, + }) async { final uri = _client.uri('/object/copy'); final headers = await _client.headers({}); final request = http.Request('POST', uri)..body = jsonEncode(body.toJson()); @@ -715,25 +786,31 @@ class StorageApi { final streamed = await _client.send(request); final response = await readOrThrow(streamed); return CopyObjectResponseContent.fromJson( - jsonDecode(response.body) as Map); + jsonDecode(response.body) as Map, + ); } - Future getObjectInfo( - {required String bucketId, required String wildcardPath}) async { + Future getObjectInfo({ + required String bucketId, + required String wildcardPath, + }) async { final uri = _client.uri( - '/object/info/${Uri.encodeComponent(bucketId)}/${encodePath(wildcardPath)}'); + '/object/info/${Uri.encodeComponent(bucketId)}/${encodePath(wildcardPath)}', + ); final headers = await _client.headers({}); final request = http.Request('GET', uri); request.headers.addAll(headers); final streamed = await _client.send(request); final response = await readOrThrow(streamed); return GetObjectInfoResponseContent.fromJson( - jsonDecode(response.body) as Map); + jsonDecode(response.body) as Map, + ); } - Future listObjects( - {required String bucketId, - required ListObjectsRequestContent body}) async { + Future listObjects({ + required String bucketId, + required ListObjectsRequestContent body, + }) async { final uri = _client.uri('/object/list/${Uri.encodeComponent(bucketId)}'); final headers = await _client.headers({}); final request = http.Request('POST', uri)..body = jsonEncode(body.toJson()); @@ -742,7 +819,8 @@ class StorageApi { final streamed = await _client.send(request); final response = await readOrThrow(streamed); return ListObjectsResponseContent.fromJson( - jsonDecode(response.body) as Map); + jsonDecode(response.body) as Map, + ); } Future moveObject({required MoveObjectRequestContent body}) async { @@ -755,9 +833,10 @@ class StorageApi { await readOrThrow(streamed); } - Future createSignedUrls( - {required String bucketId, - required CreateSignedUrlsRequestContent body}) async { + Future createSignedUrls({ + required String bucketId, + required CreateSignedUrlsRequestContent body, + }) async { final uri = _client.uri('/object/sign/${Uri.encodeComponent(bucketId)}'); final headers = await _client.headers({}); final request = http.Request('POST', uri)..body = jsonEncode(body.toJson()); @@ -766,15 +845,18 @@ class StorageApi { final streamed = await _client.send(request); final response = await readOrThrow(streamed); return CreateSignedUrlsResponseContent.fromJson( - jsonDecode(response.body) as Map); + jsonDecode(response.body) as Map, + ); } - Future createSignedUrl( - {required String bucketId, - required String wildcardPath, - required CreateSignedUrlRequestContent body}) async { + Future createSignedUrl({ + required String bucketId, + required String wildcardPath, + required CreateSignedUrlRequestContent body, + }) async { final uri = _client.uri( - '/object/sign/${Uri.encodeComponent(bucketId)}/${encodePath(wildcardPath)}'); + '/object/sign/${Uri.encodeComponent(bucketId)}/${encodePath(wildcardPath)}', + ); final headers = await _client.headers({}); final request = http.Request('POST', uri)..body = jsonEncode(body.toJson()); request.headers.addAll(headers); @@ -782,15 +864,18 @@ class StorageApi { final streamed = await _client.send(request); final response = await readOrThrow(streamed); return CreateSignedUrlResponseContent.fromJson( - jsonDecode(response.body) as Map); + jsonDecode(response.body) as Map, + ); } - Future createSignedUploadUrl( - {required String bucketId, - required String wildcardPath, - String? xUpsert}) async { + Future createSignedUploadUrl({ + required String bucketId, + required String wildcardPath, + String? xUpsert, + }) async { final uri = _client.uri( - '/object/upload/sign/${Uri.encodeComponent(bucketId)}/${encodePath(wildcardPath)}'); + '/object/upload/sign/${Uri.encodeComponent(bucketId)}/${encodePath(wildcardPath)}', + ); final headers = await _client.headers({ if (xUpsert != null) 'x-upsert': xUpsert, }); @@ -799,12 +884,14 @@ class StorageApi { final streamed = await _client.send(request); final response = await readOrThrow(streamed); return CreateSignedUploadUrlResponseContent.fromJson( - jsonDecode(response.body) as Map); + jsonDecode(response.body) as Map, + ); } - Future deleteObjects( - {required String bucketId, - required DeleteObjectsRequestContent body}) async { + Future deleteObjects({ + required String bucketId, + required DeleteObjectsRequestContent body, + }) async { final uri = _client.uri('/object/${Uri.encodeComponent(bucketId)}'); final headers = await _client.headers({}); final request = http.Request('DELETE', uri) @@ -814,13 +901,17 @@ class StorageApi { final streamed = await _client.send(request); final response = await readOrThrow(streamed); return DeleteObjectsResponseContent.fromJson( - jsonDecode(response.body) as Map); + jsonDecode(response.body) as Map, + ); } - Future headObject( - {required String bucketId, required String wildcardPath}) async { + Future headObject({ + required String bucketId, + required String wildcardPath, + }) async { final uri = _client.uri( - '/object/${Uri.encodeComponent(bucketId)}/${encodePath(wildcardPath)}'); + '/object/${Uri.encodeComponent(bucketId)}/${encodePath(wildcardPath)}', + ); final headers = await _client.headers({}); final request = http.Request('HEAD', uri); request.headers.addAll(headers); @@ -828,68 +919,69 @@ class StorageApi { await readOrThrow(streamed); } - Future uploadObject( - {required String bucketId, - required String wildcardPath, - String? xUpsert, - required Stream> file, - required int fileLength, - String? cacheControl, - Map? metadata, - String? fileName}) async { + Future uploadObject({ + required String bucketId, + required String wildcardPath, + String? xUpsert, + required Stream> file, + required int fileLength, + String? cacheControl, + Map? metadata, + String? fileName, + }) async { final uri = _client.uri( - '/object/${Uri.encodeComponent(bucketId)}/${encodePath(wildcardPath)}'); + '/object/${Uri.encodeComponent(bucketId)}/${encodePath(wildcardPath)}', + ); final headers = await _client.headers({ if (xUpsert != null) 'x-upsert': xUpsert, }); final request = http.MultipartRequest('POST', uri); - request.files.add(http.MultipartFile( - 'file', - file, - fileLength, - filename: fileName, - )); + request.files.add( + http.MultipartFile('file', file, fileLength, filename: fileName), + ); if (cacheControl != null) request.fields['cacheControl'] = cacheControl; if (metadata != null) request.fields['metadata'] = jsonEncode(metadata); request.headers.addAll(headers); final streamed = await _client.send(request); final response = await readOrThrow(streamed); return FileUploadedResponse.fromJson( - jsonDecode(response.body) as Map); + jsonDecode(response.body) as Map, + ); } - Future updateObject( - {required String bucketId, - required String wildcardPath, - required Stream> file, - required int fileLength, - String? cacheControl, - Map? metadata, - String? fileName}) async { + Future updateObject({ + required String bucketId, + required String wildcardPath, + required Stream> file, + required int fileLength, + String? cacheControl, + Map? metadata, + String? fileName, + }) async { final uri = _client.uri( - '/object/${Uri.encodeComponent(bucketId)}/${encodePath(wildcardPath)}'); + '/object/${Uri.encodeComponent(bucketId)}/${encodePath(wildcardPath)}', + ); final headers = await _client.headers({}); final request = http.MultipartRequest('PUT', uri); - request.files.add(http.MultipartFile( - 'file', - file, - fileLength, - filename: fileName, - )); + request.files.add( + http.MultipartFile('file', file, fileLength, filename: fileName), + ); if (cacheControl != null) request.fields['cacheControl'] = cacheControl; if (metadata != null) request.fields['metadata'] = jsonEncode(metadata); request.headers.addAll(headers); final streamed = await _client.send(request); final response = await readOrThrow(streamed); return FileUploadedResponse.fromJson( - jsonDecode(response.body) as Map); + jsonDecode(response.body) as Map, + ); } - Future> createTusUpload( - {required String tusResumable, - required int uploadLength, - required String uploadMetadata, - String? xUpsert}) async { + Future> createTusUpload({ + required String tusResumable, + required int uploadLength, + required String uploadMetadata, + String? xUpsert, + }) async { final uri = _client.uri('/upload/resumable'); final headers = await _client.headers({ 'Tus-Resumable': tusResumable, @@ -901,46 +993,47 @@ class StorageApi { request.headers.addAll(headers); final streamed = await _client.send(request); final response = await readOrThrow(streamed); - return { - 'location': response.headers['location'], - }; + return {'location': response.headers['location']}; } - Future> getUploadOffset( - {required String uploadId, required String tusResumable}) async { - final uri = - _client.uri('/upload/resumable/${Uri.encodeComponent(uploadId)}'); - final headers = await _client.headers({ - 'Tus-Resumable': tusResumable, - }); + Future> getUploadOffset({ + required String uploadId, + required String tusResumable, + }) async { + final uri = _client.uri( + '/upload/resumable/${Uri.encodeComponent(uploadId)}', + ); + final headers = await _client.headers({'Tus-Resumable': tusResumable}); final request = http.Request('HEAD', uri); request.headers.addAll(headers); final streamed = await _client.send(request); final response = await readOrThrow(streamed); - return { - 'uploadOffset': parseIntHeader(response.headers['upload-offset']), - }; + return {'uploadOffset': parseIntHeader(response.headers['upload-offset'])}; } - Future> uploadChunk( - {required String uploadId, - required String tusResumable, - required int uploadOffset, - required Stream> body, - int? contentLength}) async { - final uri = - _client.uri('/upload/resumable/${Uri.encodeComponent(uploadId)}'); + Future> uploadChunk({ + required String uploadId, + required String tusResumable, + required int uploadOffset, + required Stream> body, + int? contentLength, + }) async { + final uri = _client.uri( + '/upload/resumable/${Uri.encodeComponent(uploadId)}', + ); final headers = await _client.headers({ 'Tus-Resumable': tusResumable, 'Upload-Offset': '$uploadOffset', }); - final request = streamingRequest('PATCH', uri, - body: body, contentLength: contentLength); + final request = streamingRequest( + 'PATCH', + uri, + body: body, + contentLength: contentLength, + ); request.headers.addAll(headers); final streamed = await _client.send(request); final response = await readOrThrow(streamed); - return { - 'uploadOffset': parseIntHeader(response.headers['upload-offset']), - }; + return {'uploadOffset': parseIntHeader(response.headers['upload-offset'])}; } } diff --git a/packages/supabase_openapi_generator/pubspec.yaml b/packages/supabase_openapi_generator/pubspec.yaml index 932d6f30b..efa06fd0b 100644 --- a/packages/supabase_openapi_generator/pubspec.yaml +++ b/packages/supabase_openapi_generator/pubspec.yaml @@ -10,6 +10,10 @@ environment: sdk: ^3.5.0 dependencies: + # code_builder + dart_style are used by the code generator in bin/. The + # generated library code (lib/) depends only on http. + code_builder: ^4.11.1 + dart_style: ^3.1.9 http: ^1.2.2 dev_dependencies: From 921c8ca8a1e3165812ab3ae5ee82bcf079bef04a Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Wed, 1 Jul 2026 15:20:32 +0200 Subject: [PATCH 07/11] chore(codegen): drop unnecessary pubspec comment --- packages/supabase_openapi_generator/pubspec.yaml | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/supabase_openapi_generator/pubspec.yaml b/packages/supabase_openapi_generator/pubspec.yaml index efa06fd0b..a6d9d262e 100644 --- a/packages/supabase_openapi_generator/pubspec.yaml +++ b/packages/supabase_openapi_generator/pubspec.yaml @@ -10,8 +10,6 @@ environment: sdk: ^3.5.0 dependencies: - # code_builder + dart_style are used by the code generator in bin/. The - # generated library code (lib/) depends only on http. code_builder: ^4.11.1 dart_style: ^3.1.9 http: ^1.2.2 From 363d0ca0607a02e9b7424110d2a398fbeae6c6ea Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Wed, 1 Jul 2026 15:47:40 +0200 Subject: [PATCH 08/11] fix(codegen): make pipeline pass - exclude the tooling package from the SDK capability-matrix scan via the existing .sdk-parse-ignore mechanism (it is not part of the SDK surface) - model HTTP methods as an HttpMethod enum used via .name - spell out abbreviated identifiers in the generator --- .sdk-parse-ignore | 3 + .../bin/generate.dart | 261 +++++++++--------- .../lib/src/generated/storage_api.g.dart | 23 +- 3 files changed, 156 insertions(+), 131 deletions(-) diff --git a/.sdk-parse-ignore b/.sdk-parse-ignore index 1dd619d7e..a850194a5 100644 --- a/.sdk-parse-ignore +++ b/.sdk-parse-ignore @@ -10,3 +10,6 @@ packages/supabase_flutter/lib/src/clear_auth_url_parameters.dart packages/supabase_flutter/lib/src/clear_auth_url_parameters_web.dart packages/supabase_flutter/lib/src/clear_auth_url_parameters_stub.dart + +# Code generation tooling, not part of the Supabase SDK capability surface. +packages/supabase_openapi_generator/ diff --git a/packages/supabase_openapi_generator/bin/generate.dart b/packages/supabase_openapi_generator/bin/generate.dart index 112978109..cf313a891 100644 --- a/packages/supabase_openapi_generator/bin/generate.dart +++ b/packages/supabase_openapi_generator/bin/generate.dart @@ -32,6 +32,17 @@ void main() { ); } +/// The HTTP methods an operation can use. The [Enum.name] of each value is the +/// lowercase key used in an OpenAPI path item (`get`, `post`, …). +enum HttpMethod { get, post, put, patch, delete, head } + +HttpMethod? _httpMethodFrom(String name) { + for (final method in HttpMethod.values) { + if (method.name == name) return method; + } + return null; +} + void _generate({ required String specPath, required String className, @@ -50,7 +61,7 @@ void _generate({ ]; final library = Library( - (b) => b + (builder) => builder ..directives.addAll([ if (_needsConvert(paths)) Directive.import('dart:convert'), Directive.import('package:http/http.dart', as: 'http'), @@ -76,18 +87,16 @@ void _generate({ stdout.writeln('Generated $outputPath'); } -const _httpMethods = {'get', 'post', 'put', 'patch', 'delete', 'head'}; - /// True when any operation carries or returns `application/json` (and therefore /// the generated file needs `dart:convert`). Error responses are ignored: those /// are decoded by the runtime, not the generated code. bool _needsConvert(Map paths) { for (final operations in paths.values) { - for (final op in (operations as Map).values) { - if (op is! Map) continue; - final requestBody = (op['requestBody']?['content'] as Map?); + for (final operation in (operations as Map).values) { + if (operation is! Map) continue; + final requestBody = operation['requestBody']?['content'] as Map?; if (requestBody?.containsKey('application/json') ?? false) return true; - final responses = (op['responses'] as Map?) ?? {}; + final responses = (operation['responses'] as Map?) ?? {}; final successKey = ['200', '201', '204', '202'] .firstWhere(responses.containsKey, orElse: () => ''); final content = (responses[successKey] as Map?)?['content'] as Map?; @@ -116,57 +125,58 @@ Class _buildModel(String name, Map schema) { ), ]; - final fromJsonArgs = fields - .map((f) => - "${f.dartName}: ${_fromJson(f.schema, "json['${f.jsonKey}']", f.isRequired)},") + final fromJsonArguments = fields + .map((field) => + "${field.dartName}: ${_fromJson(field.schema, "json['${field.jsonKey}']", field.isRequired)},") .join('\n'); - final toJsonEntries = fields.map((f) { - final value = _toJson(f.schema, f.dartName, f.isRequired); - return f.isRequired - ? "'${f.jsonKey}': $value," - : "if (${f.dartName} != null) '${f.jsonKey}': $value,"; + final toJsonEntries = fields.map((field) { + final value = _toJson(field.schema, field.dartName, field.isRequired); + return field.isRequired + ? "'${field.jsonKey}': $value," + : "if (${field.dartName} != null) '${field.jsonKey}': $value,"; }).join('\n'); return Class( - (b) => b + (classBuilder) => classBuilder ..name = name ..constructors.add( Constructor( - (c) => c + (constructorBuilder) => constructorBuilder ..optionalParameters.addAll([ - for (final f in fields) - Parameter((p) => p + for (final field in fields) + Parameter((parameterBuilder) => parameterBuilder ..named = true ..toThis = true - ..required = f.isRequired - ..name = f.dartName), + ..required = field.isRequired + ..name = field.dartName), ]), ), ) ..constructors.add( Constructor( - (c) => c + (constructorBuilder) => constructorBuilder ..factory = true ..name = 'fromJson' ..requiredParameters.add( - Parameter((p) => p + Parameter((parameterBuilder) => parameterBuilder ..type = refer('Map') ..name = 'json'), ) - ..body = Code('return $name($fromJsonArgs);'), + ..body = Code('return $name($fromJsonArguments);'), ), ) ..fields.addAll([ - for (final f in fields) - Field((fb) => fb + for (final field in fields) + Field((fieldBuilder) => fieldBuilder ..modifier = FieldModifier.final$ - ..type = refer(f.isRequired ? f.dartType : '${f.dartType}?') - ..name = f.dartName), + ..type = + refer(field.isRequired ? field.dartType : '${field.dartType}?') + ..name = field.dartName), ]) ..methods.add( Method( - (m) => m + (methodBuilder) => methodBuilder ..name = 'toJson' ..returns = refer('Map') ..body = Code('return {$toJsonEntries};'), @@ -181,20 +191,21 @@ Class _buildClient(String className, Map paths) { final methods = []; for (final pathEntry in paths.entries) { final operations = (pathEntry.value as Map).cast(); - for (final opEntry in operations.entries) { - if (!_httpMethods.contains(opEntry.key)) continue; + for (final operationEntry in operations.entries) { + final method = _httpMethodFrom(operationEntry.key); + if (method == null) continue; methods.add( _buildOperation( pathEntry.key, - opEntry.key, - (opEntry.value as Map).cast(), + method, + (operationEntry.value as Map).cast(), ), ); } } return Class( - (b) => b + (classBuilder) => classBuilder ..name = className ..docs.addAll([ '/// Generated HTTP client. Every operation goes through the', @@ -202,16 +213,16 @@ Class _buildClient(String className, Map paths) { ]) ..constructors.add( Constructor( - (c) => c + (constructorBuilder) => constructorBuilder ..requiredParameters.add( - Parameter((p) => p + Parameter((parameterBuilder) => parameterBuilder ..toThis = true ..name = '_client'), ), ), ) ..fields.add( - Field((f) => f + Field((fieldBuilder) => fieldBuilder ..modifier = FieldModifier.final$ ..type = refer('ApiClient') ..name = '_client'), @@ -220,31 +231,36 @@ Class _buildClient(String className, Map paths) { ); } -Method _buildOperation(String path, String method, Map op) { - final operationId = op['operationId'] as String; - final parameters = ((op['parameters'] as List?) ?? []) +Method _buildOperation( + String path, + HttpMethod method, + Map operation, +) { + final operationId = operation['operationId'] as String; + final parameters = ((operation['parameters'] as List?) ?? []) .cast() - .map((p) => p.cast()) + .map((parameter) => parameter.cast()) .toList(); - final pathParams = parameters.where((p) => p['in'] == 'path').toList(); - final headerParams = parameters.where((p) => p['in'] == 'header').toList(); - final queryParams = parameters.where((p) => p['in'] == 'query').toList(); + final pathParameters = parameters.where((p) => p['in'] == 'path').toList(); + final headerParameters = parameters.where((p) => p['in'] == 'header').toList(); + final queryParameters = parameters.where((p) => p['in'] == 'query').toList(); - final body = _resolveBody(op); - final response = _resolveResponse(op); + final body = _resolveBody(operation); + final response = _resolveResponse(operation); - final params = [ - for (final param in pathParams) - _namedParam('String', _camelCase(_stripWildcard(param['name'] as String)), + final namedParameters = [ + for (final parameter in pathParameters) + _namedParameter( + 'String', _camelCase(_stripWildcard(parameter['name'] as String)), required: true), - for (final param in headerParams) - _namedParam(_headerDartType(param['schema'] as Map?), - _camelCase(param['name'] as String), - required: param['required'] == true), - for (final param in queryParams) - _namedParam(_headerDartType(param['schema'] as Map?), - _camelCase(param['name'] as String), + for (final parameter in headerParameters) + _namedParameter(_headerDartType(parameter['schema'] as Map?), + _camelCase(parameter['name'] as String), + required: parameter['required'] == true), + for (final parameter in queryParameters) + _namedParameter(_headerDartType(parameter['schema'] as Map?), + _camelCase(parameter['name'] as String), required: false), ...body.parameters, ]; @@ -254,60 +270,60 @@ Method _buildOperation(String path, String method, Map op) { // URI. Path values are percent-encoded so keys with reserved characters // (spaces, `?`, `#`, …) don't corrupt the URL. Wildcard segments keep `/`. var dartPath = path; - for (final param in pathParams) { - final wire = param['name'] as String; + for (final parameter in pathParameters) { + final wire = parameter['name'] as String; final name = _camelCase(_stripWildcard(wire)); final encoded = wire.endsWith('+') ? 'encodePath($name)' : 'Uri.encodeComponent($name)'; dartPath = dartPath.replaceAll('{$wire}', '\${$encoded}'); } - if (queryParams.isEmpty) { + if (queryParameters.isEmpty) { buffer.writeln("final uri = _client.uri('$dartPath');"); } else { buffer.writeln("final uri = _client.uri('$dartPath', {"); - for (final param in queryParams) { - final name = _camelCase(param['name'] as String); - final type = _headerDartType(param['schema'] as Map?); - final valueExpr = type == 'String' ? name : '$name?.toString()'; - buffer.writeln("'${param['name']}': $valueExpr,"); + for (final parameter in queryParameters) { + final name = _camelCase(parameter['name'] as String); + final type = _headerDartType(parameter['schema'] as Map?); + final valueExpression = type == 'String' ? name : '$name?.toString()'; + buffer.writeln("'${parameter['name']}': $valueExpression,"); } buffer.writeln('});'); } buffer.writeln('final headers = await _client.headers({'); - for (final param in headerParams) { - final wire = param['name'] as String; + for (final parameter in headerParameters) { + final wire = parameter['name'] as String; final name = _camelCase(wire); - final type = _headerDartType(param['schema'] as Map?); - final valueExpr = type == 'String' ? name : '\'\$$name\''; - if (param['required'] == true) { - buffer.writeln("'$wire': $valueExpr,"); + final type = _headerDartType(parameter['schema'] as Map?); + final valueExpression = type == 'String' ? name : '\'\$$name\''; + if (parameter['required'] == true) { + buffer.writeln("'$wire': $valueExpression,"); } else { - buffer.writeln("if ($name != null) '$wire': $valueExpr,"); + buffer.writeln("if ($name != null) '$wire': $valueExpression,"); } } buffer.writeln('});'); // Operation-owned headers (e.g. the JSON content-type) are applied after // addAll so caller/default headers can't clobber them. - buffer.write(body.buildRequest(method.toUpperCase())); + buffer.write(body.buildRequest(method.name.toUpperCase())); buffer.writeln('request.headers.addAll(headers);'); buffer.write(body.afterHeaders); buffer.writeln('final streamed = await _client.send(request);'); buffer.write(response.handle); return Method( - (m) => m + (methodBuilder) => methodBuilder ..name = _lowerFirst(operationId) ..modifier = MethodModifier.async ..returns = refer('Future<${response.returnType}>') - ..optionalParameters.addAll(params) + ..optionalParameters.addAll(namedParameters) ..body = Code(buffer.toString()), ); } -Parameter _namedParam(String type, String name, {required bool required}) => - Parameter((p) => p +Parameter _namedParameter(String type, String name, {required bool required}) => + Parameter((parameterBuilder) => parameterBuilder ..named = true ..required = required ..type = refer(required ? type : '$type?') @@ -330,14 +346,13 @@ class _Body { final String afterHeaders; } -_Body _resolveBody(Map op) { +_Body _resolveBody(Map operation) { final content = - (op['requestBody']?['content'] as Map?)?.cast(); + (operation['requestBody']?['content'] as Map?)?.cast(); if (content == null) { return _Body( parameters: const [], - buildRequest: (method) => - "final request = http.Request('$method', uri);\n", + buildRequest: (method) => "final request = http.Request('$method', uri);\n", ); } @@ -348,9 +363,9 @@ _Body _resolveBody(Map op) { final jsonContent = content['application/json']; if (jsonContent != null) { final schema = (jsonContent['schema'] as Map).cast(); - final type = _refName(schema[r'$ref'] as String); + final type = _referenceName(schema[r'$ref'] as String); return _Body( - parameters: [_namedParam(type, 'body', required: true)], + parameters: [_namedParameter(type, 'body', required: true)], buildRequest: (method) => "final request = http.Request('$method', uri)\n" '..body = jsonEncode(body.toJson());\n', afterHeaders: "request.headers['content-type'] = 'application/json';\n", @@ -360,11 +375,10 @@ _Body _resolveBody(Map op) { // Binary / streaming payload (e.g. TUS UploadChunk). return _Body( parameters: [ - _namedParam('Stream>', 'body', required: true), - _namedParam('int', 'contentLength', required: false), + _namedParameter('Stream>', 'body', required: true), + _namedParameter('int', 'contentLength', required: false), ], - buildRequest: (method) => - "final request = streamingRequest('$method', uri, " + buildRequest: (method) => "final request = streamingRequest('$method', uri, " 'body: body, contentLength: contentLength);\n', ); } @@ -373,34 +387,34 @@ _Body _multipartBody(Map content) { final schema = (content['schema'] as Map).cast(); final properties = (schema['properties'] as Map).cast(); - final fieldParams = []; + final fieldParameters = []; final fieldWrites = []; String? fileField; properties.forEach((key, raw) { - final propSchema = (raw as Map).cast(); + final propertySchema = (raw as Map).cast(); final name = _camelCase(key); - if (propSchema['format'] == 'binary') { + if (propertySchema['format'] == 'binary') { fileField = key; - } else if (propSchema['type'] == 'object') { - fieldParams - .add(_namedParam('Map', name, required: false)); - fieldWrites.add( - "if ($name != null) request.fields['$key'] = jsonEncode($name);"); + } else if (propertySchema['type'] == 'object') { + fieldParameters + .add(_namedParameter('Map', name, required: false)); + fieldWrites + .add("if ($name != null) request.fields['$key'] = jsonEncode($name);"); } else { - fieldParams.add(_namedParam('String', name, required: false)); + fieldParameters.add(_namedParameter('String', name, required: false)); fieldWrites.add("if ($name != null) request.fields['$key'] = $name;"); } }); - final params = [ - _namedParam('Stream>', 'file', required: true), - _namedParam('int', 'fileLength', required: true), - ...fieldParams, - _namedParam('String', 'fileName', required: false), + final parameters = [ + _namedParameter('Stream>', 'file', required: true), + _namedParameter('int', 'fileLength', required: true), + ...fieldParameters, + _namedParameter('String', 'fileName', required: false), ]; return _Body( - parameters: params, + parameters: parameters, buildRequest: (method) { final buffer = StringBuffer() ..writeln("final request = http.MultipartRequest('$method', uri);") @@ -427,8 +441,8 @@ class _Response { final String handle; } -_Response _resolveResponse(Map op) { - final responses = (op['responses'] as Map).cast(); +_Response _resolveResponse(Map operation) { + final responses = (operation['responses'] as Map).cast(); final successKey = ['200', '201', '204', '202'] .firstWhere(responses.containsKey, orElse: () => ''); final success = @@ -439,7 +453,7 @@ _Response _resolveResponse(Map op) { final jsonContent = content['application/json']; if (jsonContent != null) { final schema = (jsonContent['schema'] as Map).cast(); - final type = _refName(schema[r'$ref'] as String); + final type = _referenceName(schema[r'$ref'] as String); return _Response( returnType: type, handle: 'final response = await readOrThrow(streamed);\n' @@ -478,8 +492,7 @@ _Response _resolveResponse(Map op) { buffer.writeln("'${_camelCase(wire)}': $read,"); } buffer.writeln('};'); - return _Response( - returnType: 'Map', handle: buffer.toString()); + return _Response(returnType: 'Map', handle: buffer.toString()); } // No content. @@ -508,7 +521,9 @@ class _Field { } String _dartType(Map schema) { - if (schema.containsKey(r'$ref')) return _refName(schema[r'$ref'] as String); + if (schema.containsKey(r'$ref')) { + return _referenceName(schema[r'$ref'] as String); + } switch (schema['type']) { case 'string': final format = schema['format']; @@ -529,31 +544,31 @@ String _dartType(Map schema) { } } -String _fromJson(Map schema, String expr, bool isRequired) { +String _fromJson(Map schema, String expression, bool isRequired) { final suffix = isRequired ? '' : '?'; if (schema.containsKey(r'$ref')) { - final type = _refName(schema[r'$ref'] as String); + final type = _referenceName(schema[r'$ref'] as String); if (isRequired) { - return '$type.fromJson($expr as Map)'; + return '$type.fromJson($expression as Map)'; } - return '$expr == null ? null : $type.fromJson($expr as Map)'; + return '$expression == null ? null : $type.fromJson($expression as Map)'; } switch (schema['type']) { case 'array': - final items = (schema['items'] as Map); + final items = schema['items'] as Map; if (items.containsKey(r'$ref')) { - final type = _refName(items[r'$ref'] as String); - final map = - '($expr as List).map((e) => $type.fromJson(e as Map)).toList()'; - return isRequired ? map : '$expr == null ? null : $map'; + final type = _referenceName(items[r'$ref'] as String); + final mapped = + '($expression as List).map((element) => $type.fromJson(element as Map)).toList()'; + return isRequired ? mapped : '$expression == null ? null : $mapped'; } final inner = _dartType(items); - final cast = '($expr as List).cast<$inner>()'; - return isRequired ? cast : '$expr == null ? null : $cast'; + final cast = '($expression as List).cast<$inner>()'; + return isRequired ? cast : '$expression == null ? null : $cast'; case 'object': - return '$expr as Map$suffix'; + return '$expression as Map$suffix'; default: - return '$expr as ${_dartType(schema)}$suffix'; + return '$expression as ${_dartType(schema)}$suffix'; } } @@ -563,9 +578,9 @@ String _toJson(Map schema, String name, bool isRequired) { return '$access.toJson()'; } if (schema['type'] == 'array') { - final items = (schema['items'] as Map); + final items = schema['items'] as Map; if (items.containsKey(r'$ref')) { - return '$access.map((e) => e.toJson()).toList()'; + return '$access.map((element) => element.toJson()).toList()'; } } return name; @@ -578,7 +593,7 @@ String _headerDartType(Map? schema) { return 'String'; } -String _refName(String ref) => ref.split('/').last; +String _referenceName(String reference) => reference.split('/').last; String _stripWildcard(String name) => name.endsWith('+') ? name.substring(0, name.length - 1) : name; diff --git a/packages/supabase_openapi_generator/lib/src/generated/storage_api.g.dart b/packages/supabase_openapi_generator/lib/src/generated/storage_api.g.dart index b9d107cf9..ed96f38b6 100644 --- a/packages/supabase_openapi_generator/lib/src/generated/storage_api.g.dart +++ b/packages/supabase_openapi_generator/lib/src/generated/storage_api.g.dart @@ -225,7 +225,10 @@ class CreateSignedUrlsResponseContent { factory CreateSignedUrlsResponseContent.fromJson(Map json) { return CreateSignedUrlsResponseContent( items: (json['items'] as List) - .map((e) => SignedUrlResult.fromJson(e as Map)) + .map( + (element) => + SignedUrlResult.fromJson(element as Map), + ) .toList(), ); } @@ -233,7 +236,7 @@ class CreateSignedUrlsResponseContent { final List items; Map toJson() { - return {'items': items.map((e) => e.toJson()).toList()}; + return {'items': items.map((element) => element.toJson()).toList()}; } } @@ -259,7 +262,9 @@ class DeleteObjectsResponseContent { factory DeleteObjectsResponseContent.fromJson(Map json) { return DeleteObjectsResponseContent( items: (json['items'] as List) - .map((e) => FileObject.fromJson(e as Map)) + .map( + (element) => FileObject.fromJson(element as Map), + ) .toList(), ); } @@ -267,7 +272,7 @@ class DeleteObjectsResponseContent { final List items; Map toJson() { - return {'items': items.map((e) => e.toJson()).toList()}; + return {'items': items.map((element) => element.toJson()).toList()}; } } @@ -476,7 +481,7 @@ class ListBucketsResponseContent { factory ListBucketsResponseContent.fromJson(Map json) { return ListBucketsResponseContent( items: (json['items'] as List) - .map((e) => Bucket.fromJson(e as Map)) + .map((element) => Bucket.fromJson(element as Map)) .toList(), ); } @@ -484,7 +489,7 @@ class ListBucketsResponseContent { final List items; Map toJson() { - return {'items': items.map((e) => e.toJson()).toList()}; + return {'items': items.map((element) => element.toJson()).toList()}; } } @@ -531,7 +536,9 @@ class ListObjectsResponseContent { factory ListObjectsResponseContent.fromJson(Map json) { return ListObjectsResponseContent( items: (json['items'] as List) - .map((e) => FileObject.fromJson(e as Map)) + .map( + (element) => FileObject.fromJson(element as Map), + ) .toList(), ); } @@ -539,7 +546,7 @@ class ListObjectsResponseContent { final List items; Map toJson() { - return {'items': items.map((e) => e.toJson()).toList()}; + return {'items': items.map((element) => element.toJson()).toList()}; } } From 804ef3d368be16950e288514e6e177e47298da66 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Wed, 1 Jul 2026 15:48:05 +0200 Subject: [PATCH 09/11] docs(codegen): remove testing section from readme --- packages/supabase_openapi_generator/README.md | 6 ------ 1 file changed, 6 deletions(-) diff --git a/packages/supabase_openapi_generator/README.md b/packages/supabase_openapi_generator/README.md index aa2766edc..a14ae7b60 100644 --- a/packages/supabase_openapi_generator/README.md +++ b/packages/supabase_openapi_generator/README.md @@ -74,9 +74,3 @@ await storage.uploadChunk( Non-2xx responses throw `ApiException` with the decoded error body. Octet-stream responses return a `StreamedApiResponse` exposing the live stream. - -## Testing - -```bash -dart test -``` From 3cef78ada0c437aeed4706267cccbd9ca9d927a0 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Wed, 1 Jul 2026 16:02:32 +0200 Subject: [PATCH 10/11] ci(codegen): keep the generator out of the bootstrapped workspace The generator needs a newer Dart than the oldest supported Flutter (its dart_style/code_builder deps), so bootstrapping it on Flutter 3.22 fails. Exclude it from the melos workspace like examples/launcher, and drop its analysis_options so the repo-wide DCM scan skips it (it is not bootstrapped). Also drop a redundant inferrable type argument in a storage_client test that the current DCM ruleset flags (pre-existing, unrelated to this spike). --- melos.yaml | 5 +++-- packages/storage_client/test/client_test.dart | 2 +- packages/supabase_openapi_generator/analysis_options.yaml | 1 - 3 files changed, 4 insertions(+), 4 deletions(-) delete mode 100644 packages/supabase_openapi_generator/analysis_options.yaml diff --git a/melos.yaml b/melos.yaml index 935224ffc..a5a74090e 100644 --- a/melos.yaml +++ b/melos.yaml @@ -6,10 +6,11 @@ packages: - packages/supabase_flutter/example - examples/* -# The launcher is a standalone dev tool that needs a newer Dart than the oldest -# Flutter the library supports, so it stays out of the bootstrapped workspace. +# Standalone dev tools that need a newer Dart than the oldest Flutter the +# library supports, so they stay out of the bootstrapped workspace. ignore: - examples/launcher + - packages/supabase_openapi_generator command: version: diff --git a/packages/storage_client/test/client_test.dart b/packages/storage_client/test/client_test.dart index 231784205..527fbd0f9 100644 --- a/packages/storage_client/test/client_test.dart +++ b/packages/storage_client/test/client_test.dart @@ -684,7 +684,7 @@ void main() { // Valid storage keys that contain characters which are reserved in a URL // and therefore must be percent-encoded to address the correct object. - final keys = [ + final keys = [ 'folder/report?v=2 final.pdf', 'folder/a+b,c@d=e.txt', 'folder/amp∧semi.txt', diff --git a/packages/supabase_openapi_generator/analysis_options.yaml b/packages/supabase_openapi_generator/analysis_options.yaml deleted file mode 100644 index a8ab68344..000000000 --- a/packages/supabase_openapi_generator/analysis_options.yaml +++ /dev/null @@ -1 +0,0 @@ -include: package:supabase_lints/analysis_options.yaml From 9f5037e400301e510f909e4d354f3c1299c67377 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Wed, 1 Jul 2026 16:14:27 +0200 Subject: [PATCH 11/11] chore: add todo to un-ignore workspace tools on a higher sdk --- melos.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/melos.yaml b/melos.yaml index a5a74090e..bc257fcff 100644 --- a/melos.yaml +++ b/melos.yaml @@ -8,6 +8,8 @@ packages: # Standalone dev tools that need a newer Dart than the oldest Flutter the # library supports, so they stay out of the bootstrapped workspace. +# TODO: bring these back into the workspace once the minimum supported SDK is +# high enough to satisfy their constraints. ignore: - examples/launcher - packages/supabase_openapi_generator