From 9e686c0afa01c1d6c2aced419462bc9f6c1d6234 Mon Sep 17 00:00:00 2001 From: Tobias Ottenweller Date: Thu, 15 Jan 2026 20:44:35 +0100 Subject: [PATCH 1/3] feat: better handle contentEncoding --- .vscode/settings.json | 2 + docs/configuration.md | 33 ++ docs/data_types.md | 24 ++ docs/roadmap.md | 1 - .../imposter/response.groovy | 109 +++++++ .../test/content_media_type_test.dart | 301 ++++++++++++++++++ integration_test/binary_models/openapi.yaml | 191 ++++++++++- integration_test/binary_models/tonik.yaml | 10 + packages/tonik/bin/tonik.dart | 1 + .../tonik/lib/src/config/config_loader.dart | 39 +++ .../tonik/lib/src/config/tonik_config.dart | 21 +- .../test/src/config/config_loader_test.dart | 65 ++++ .../lib/src/config/schema_content_type.dart | 1 + .../lib/src/config/tonik_config.dart | 22 +- packages/tonik_core/lib/tonik_core.dart | 1 + .../test/config/tonik_config_test.dart | 42 +++ packages/tonik_parse/lib/src/importer.dart | 14 +- .../tonik_parse/lib/src/model/schema.dart | 8 +- .../tonik_parse/lib/src/model/schema.g.dart | 1 + .../tonik_parse/lib/src/model_importer.dart | 27 +- .../test/model/model_binary_test.dart | 209 +++++++++++- .../test/model/single_schema_import_test.dart | 6 + scripts/setup_integration_tests.sh | 2 +- 23 files changed, 1100 insertions(+), 30 deletions(-) create mode 100644 integration_test/binary_models/binary_models_test/test/content_media_type_test.dart create mode 100644 integration_test/binary_models/tonik.yaml create mode 100644 packages/tonik_core/lib/src/config/schema_content_type.dart diff --git a/.vscode/settings.json b/.vscode/settings.json index 95277f7..f625ded 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -22,6 +22,7 @@ "lululu", "Maxie", "medama", + "Millis", "Newrelic", "oneline", "petstore", @@ -35,6 +36,7 @@ "typdef", "unauthorised", "uncallable", + "unconfigured", "użytkownik", "مرحبا" ] diff --git a/docs/configuration.md b/docs/configuration.md index 65aa5df..15217fd 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -57,6 +57,11 @@ contentTypes: "application/vnd.api+json": json "application/hal+json": json +contentMediaTypes: + # Map schema contentMediaType to Dart types for content-encoded strings + "image/png": binary # List + "text/plain": text # String + filter: # Only generate code for specific parts of the spec includeTags: @@ -246,6 +251,34 @@ contentTypes: Supported targets: `json`, `form`, `text`, `binary`. +## Schema Content Media Type Mapping + +OpenAPI 3.1 allows schemas with `contentEncoding` (e.g., `base64`) and `contentMediaType` to represent encoded binary content within JSON structures. Use `contentMediaTypes` to control how these schemas map to Dart types: + +```yaml +contentMediaTypes: + "image/png": binary # → List + "image/jpeg": binary # → List + "text/plain": text # → String + "application/pdf": binary +``` + +Supported targets: +- `binary` - Generates `List` (raw bytes) +- `text` - Generates `String` (keeps encoded string as-is) + +**Fallback behavior:** When a schema has `contentEncoding` but its `contentMediaType` is not in the config map, Tonik defaults to `List` (binary). + +**Example OpenAPI schema:** +```yaml +ProfileImage: + type: string + contentEncoding: base64 + contentMediaType: image/png +``` + +With `"image/png": binary` in config, this generates a property typed as `List`. Without config, it also defaults to `List`. + ## Filtering Filter which parts of the OpenAPI spec to generate code for. This is useful for large specs where you only need a subset of the API. diff --git a/docs/data_types.md b/docs/data_types.md index 5342019..32cd092 100644 --- a/docs/data_types.md +++ b/docs/data_types.md @@ -13,6 +13,7 @@ This document provides information about how Tonik is mapping data types in Open | `string` | `uri`, `url` | `Uri` | `dart:core` | URI/URL parsing and validation | | `string` | `binary` | `List` | `dart:core` | See [Binary Data](#binary-data) | | `string` | `byte` | `String` | `dart:core` | Base64 encoded data (kept as string) | +| `string` | (with `contentEncoding`) | `List` or `String` | `dart:core` | See [Content-Encoded Strings](#content-encoded-strings) | | `string` | `enum` | `enum` | Generated | Custom enum type | | `string` | (default) | `String` | `dart:core` | Standard string type | | `number` | `float`, `double` | `double` | `dart:core` | 64-bit floating point | @@ -99,6 +100,29 @@ final result = await api.getMessage(); final text = (result as TonikSuccess).value.body; // String ``` +### Content-Encoded Strings + +OpenAPI 3.1 supports `contentEncoding` and `contentMediaType` for string schemas that contain encoded binary data (e.g., base64-encoded images embedded in JSON). + +```yaml +ProfileImage: + type: string + contentEncoding: base64 + contentMediaType: image/png +``` + +Tonik maps these schemas based on configuration: + +| Config `contentMediaTypes` | Dart Type | Description | +|----------------------------|-----------|-------------| +| `"image/png": binary` | `List` | Decoded binary data | +| `"text/plain": text` | `String` | Keeps encoded string as-is | +| (no match) | `List` | Default fallback | + +Configure via [contentMediaTypes](configuration.md#schema-content-media-type-mapping) in `tonik.yaml`. + +> **Note:** Tonik does not perform base64 encoding/decoding automatically. When mapped to `List`, you receive the raw bytes after decoding happens at the transport layer. When mapped to `String`, you receive the base64-encoded string directly. + ### Form URL-Encoded Bodies Tonik supports `application/x-www-form-urlencoded` for flat object schemas. Arrays use exploded syntax (repeated keys). Nested objects throw runtime errors. diff --git a/docs/roadmap.md b/docs/roadmap.md index 7ac46e4..41bf26d 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -8,7 +8,6 @@ - Advanced OpenAPI 3.1 features: - Support for `if/then/else` schemas (via custom encoding/decoding checks) - Support for `const` schemas - - `contentEncoding`, `contentMediaType`, `contentSchema` for binary content - `prefixItems` for tuple validation - `dependentRequired` / `dependentSchemas` - Default values diff --git a/integration_test/binary_models/binary_models_test/imposter/response.groovy b/integration_test/binary_models/binary_models_test/imposter/response.groovy index 786aec6..5caaeff 100644 --- a/integration_test/binary_models/binary_models_test/imposter/response.groovy +++ b/integration_test/binary_models/binary_models_test/imposter/response.groovy @@ -186,6 +186,115 @@ if (context.request.path.matches('.*/files/[^/]+') && context.request.method == response.usingDefaultBehaviour() } +} else if (context.request.path == '/api/v1/content-media-type/image' && context.request.method == 'POST') { + // uploadContentMediaTypeImage endpoint (contentMediaType: image/png -> binary) + if (responseStatus == '201') { + def dataName = 'unknown' + try { + def bodyStr = context.request.body + def jsonSlurper = new groovy.json.JsonSlurper() + def jsonBody = jsonSlurper.parseText(bodyStr) + dataName = jsonBody?.name ?: 'unknown' + } catch (Exception e) { + // If parsing fails, use default + } + response.withHeader('Content-Type', 'application/json') + .withContent("""{"id":"img-${System.currentTimeMillis()}","size":1024,"message":"Image data '${dataName}' uploaded"}""") + } else if (responseStatus == '400') { + response.withHeader('Content-Type', 'application/json') + .withContent('{"code":400,"message":"Bad request"}') + } else { + response.usingDefaultBehaviour() + } + +} else if (context.request.path == '/api/v1/content-media-type/image' && context.request.method == 'GET') { + // getContentMediaTypeImage endpoint (contentMediaType: image/png -> binary) + if (responseStatus == '200') { + // Create base64 encoded image data + byte[] imageData = new byte[256] + new Random().nextBytes(imageData) + def base64Encoded = Base64.getEncoder().encodeToString(imageData) + response.withHeader('Content-Type', 'application/json') + .withContent("""{"name":"test-image","imageData":"${base64Encoded}","description":"Sample image data"}""") + } else if (responseStatus == '404') { + response.withHeader('Content-Type', 'application/json') + .withContent('{"code":404,"message":"Image not found"}') + } else { + response.usingDefaultBehaviour() + } + +} else if (context.request.path == '/api/v1/content-media-type/text' && context.request.method == 'POST') { + // uploadContentMediaTypeText endpoint (contentMediaType: text/plain -> text) + if (responseStatus == '201') { + def dataName = 'unknown' + try { + def bodyStr = context.request.body + def jsonSlurper = new groovy.json.JsonSlurper() + def jsonBody = jsonSlurper.parseText(bodyStr) + dataName = jsonBody?.name ?: 'unknown' + } catch (Exception e) { + // If parsing fails, use default + } + response.withHeader('Content-Type', 'application/json') + .withContent("""{"id":"txt-${System.currentTimeMillis()}","size":512,"message":"Text data '${dataName}' uploaded"}""") + } else if (responseStatus == '400') { + response.withHeader('Content-Type', 'application/json') + .withContent('{"code":400,"message":"Bad request"}') + } else { + response.usingDefaultBehaviour() + } + +} else if (context.request.path == '/api/v1/content-media-type/text' && context.request.method == 'GET') { + // getContentMediaTypeText endpoint (contentMediaType: text/plain -> text) + if (responseStatus == '200') { + // Return base64 encoded text as a string + def textBase64 = Base64.getEncoder().encodeToString("Hello World from contentMediaType test!".getBytes()) + response.withHeader('Content-Type', 'application/json') + .withContent("""{"name":"test-text","textData":"${textBase64}","description":"Sample text data"}""") + } else if (responseStatus == '404') { + response.withHeader('Content-Type', 'application/json') + .withContent('{"code":404,"message":"Text not found"}') + } else { + response.usingDefaultBehaviour() + } + +} else if (context.request.path == '/api/v1/content-media-type/unconfigured' && context.request.method == 'POST') { + // uploadContentMediaTypeUnconfigured endpoint (fallback -> binary) + if (responseStatus == '201') { + def dataName = 'unknown' + try { + def bodyStr = context.request.body + def jsonSlurper = new groovy.json.JsonSlurper() + def jsonBody = jsonSlurper.parseText(bodyStr) + dataName = jsonBody?.name ?: 'unknown' + } catch (Exception e) { + // If parsing fails, use default + } + response.withHeader('Content-Type', 'application/json') + .withContent("""{"id":"unc-${System.currentTimeMillis()}","size":128,"message":"Unconfigured data '${dataName}' uploaded"}""") + } else if (responseStatus == '400') { + response.withHeader('Content-Type', 'application/json') + .withContent('{"code":400,"message":"Bad request"}') + } else { + response.usingDefaultBehaviour() + } + +} else if (context.request.path == '/api/v1/content-media-type/unconfigured' && context.request.method == 'GET') { + // getContentMediaTypeUnconfigured endpoint (fallback -> binary) + if (responseStatus == '200') { + // Create base64 encoded data + byte[] randomData = new byte[64] + new Random().nextBytes(randomData) + def base64Encoded = Base64.getEncoder().encodeToString(randomData) + response.withHeader('Content-Type', 'application/json') + .withContent("""{"name":"test-unconfigured","data":"${base64Encoded}"}""") + } else if (responseStatus == '404') { + response.withHeader('Content-Type', 'application/json') + .withContent('{"code":404,"message":"Data not found"}') + } else { + response.usingDefaultBehaviour() + } + } else { // Use default OpenAPI behavior for unhandled paths response.usingDefaultBehaviour() diff --git a/integration_test/binary_models/binary_models_test/test/content_media_type_test.dart b/integration_test/binary_models/binary_models_test/test/content_media_type_test.dart new file mode 100644 index 0000000..aa6a97c --- /dev/null +++ b/integration_test/binary_models/binary_models_test/test/content_media_type_test.dart @@ -0,0 +1,301 @@ +import 'dart:typed_data'; + +import 'package:binary_models_api/binary_models_api.dart'; +import 'package:dio/dio.dart'; +import 'package:test/test.dart'; +import 'package:tonik_util/tonik_util.dart'; + +import 'test_helper.dart'; + +void main() { + const port = 8083; + const baseUrl = 'http://localhost:$port/api/v1'; + + late ImposterServer imposterServer; + + setUpAll(() async { + imposterServer = ImposterServer(port: port); + await setupImposterServer(imposterServer); + }); + + tearDownAll(() async { + await teardownImposterServer(imposterServer); + }); + + ContentMediaTypeApi buildApi({required String responseStatus}) { + return ContentMediaTypeApi( + CustomServer( + baseUrl: baseUrl, + serverConfig: ServerConfig( + baseOptions: BaseOptions( + headers: {'X-Response-Status': responseStatus}, + ), + ), + ), + ); + } + + group('contentMediaType configured as binary (image/png -> List)', () { + test('ImageEncodedData.imageData is List', () { + // Create test image data + final imageBytes = Uint8List.fromList([ + 0x89, + 0x50, + 0x4E, + 0x47, + 0x0D, + 0x0A, + 0x1A, + 0x0A, + ]); // PNG header bytes + + // ImageEncodedData.imageData should be List due to config + final imageEncodedData = ImageEncodedData( + name: 'test-image', + imageData: imageBytes, + description: 'Test PNG image', + ); + + expect(imageEncodedData.imageData, isA>()); + expect(imageEncodedData.imageData, equals(imageBytes)); + }); + + test('ImageEncodedData serializes imageData to string in JSON', () { + final imageBytes = Uint8List.fromList([ + 72, + 101, + 108, + 108, + 111, + ]); // "Hello" + + final imageEncodedData = ImageEncodedData( + name: 'test-image', + imageData: imageBytes, + ); + + // Serialize to JSON + final json = imageEncodedData.toJson()! as Map; + + // In JSON, the List is UTF-8 decoded to string (not base64) + expect(json['imageData'], isA()); + expect(json['imageData'], equals('Hello')); + }); + + test('ImageEncodedData deserializes string from JSON to List', () { + // Server sends data in JSON as a string + const jsonString = 'Hello'; + + final json = { + 'name': 'test-image', + 'imageData': jsonString, + }; + + final imageEncodedData = ImageEncodedData.fromJson(json); + + // fromJson UTF-8 encodes the string to List + expect(imageEncodedData.imageData, isA>()); + expect( + imageEncodedData.imageData, + equals([72, 101, 108, 108, 111]), + ); // UTF-8 bytes of "Hello" + }); + + test('201 - uploads image data', () async { + final api = buildApi(responseStatus: '201'); + + final imageBytes = Uint8List.fromList(List.generate(100, (i) => i)); + + final imageEncodedData = ImageEncodedData( + name: 'test-upload-image', + imageData: imageBytes, + description: 'Test image upload', + ); + + final result = await api.uploadContentMediaTypeImage( + body: imageEncodedData, + ); + final success = result as TonikSuccess; + + expect(success.response.statusCode, 201); + expect(success.value.id, isNotEmpty); + }); + + test('200 - retrieves image data as List', () async { + final api = buildApi(responseStatus: '200'); + + final result = await api.getContentMediaTypeImage(id: 'img-123'); + final success = result as TonikSuccess; + + expect(success.response.statusCode, 200); + expect(success.value, isA()); + + final responseBody = + (success.value as GetContentMediaTypeImageResponse200).body; + expect(responseBody.imageData, isA>()); + }); + }); + + group('contentMediaType configured as text (text/plain -> String)', () { + test('TextEncodedData.textData is String', () { + // TextEncodedData.textData should be String due to config + const textEncodedData = TextEncodedData( + name: 'test-text', + textData: 'SGVsbG8gV29ybGQh', // base64 of "Hello World!" + description: 'Test text data', + ); + + expect(textEncodedData.textData, isA()); + expect(textEncodedData.textData, equals('SGVsbG8gV29ybGQh')); + }); + + test('TextEncodedData serializes textData as string in JSON', () { + const base64String = 'SGVsbG8gV29ybGQh'; + + const textEncodedData = TextEncodedData( + name: 'test-text', + textData: base64String, + ); + + // Serialize to JSON + final json = textEncodedData.toJson()! as Map; + + // In JSON, the String remains a string (no transformation) + expect(json['textData'], isA()); + expect(json['textData'], equals(base64String)); + }); + + test('TextEncodedData deserializes string as String', () { + const base64String = 'SGVsbG8gV29ybGQh'; + + final json = { + 'name': 'test-text', + 'textData': base64String, + }; + + final textEncodedData = TextEncodedData.fromJson(json); + + expect(textEncodedData.textData, isA()); + expect(textEncodedData.textData, equals(base64String)); + }); + + test('201 - uploads text data', () async { + final api = buildApi(responseStatus: '201'); + + const textEncodedData = TextEncodedData( + name: 'test-upload-text', + textData: 'SGVsbG8gV29ybGQh', + description: 'Test text upload', + ); + + final result = await api.uploadContentMediaTypeText( + body: textEncodedData, + ); + final success = result as TonikSuccess; + + expect(success.response.statusCode, 201); + expect(success.value.id, isNotEmpty); + }); + + test('200 - retrieves text data as String', () async { + final api = buildApi(responseStatus: '200'); + + final result = await api.getContentMediaTypeText(id: 'txt-123'); + final success = result as TonikSuccess; + + expect(success.response.statusCode, 200); + expect(success.value, isA()); + + final responseBody = + (success.value as GetContentMediaTypeTextResponse200).body; + expect(responseBody.textData, isA()); + }); + }); + + group('contentMediaType unconfigured (fallback to binary -> List)', () { + test('UnconfiguredEncodedData.data is List (fallback)', () { + // UnconfiguredEncodedData.data should be List due to fallback + final dataBytes = Uint8List.fromList([100, 200, 255]); + + final unconfiguredData = UnconfiguredEncodedData( + name: 'test-unconfigured', + data: dataBytes, + ); + + expect(unconfiguredData.data, isA>()); + expect(unconfiguredData.data, equals(dataBytes)); + }); + + test('UnconfiguredEncodedData serializes data to string in JSON', () { + final dataBytes = Uint8List.fromList([87, 111, 114, 108, 100]); // "World" + + final unconfiguredData = UnconfiguredEncodedData( + name: 'test-unconfigured', + data: dataBytes, + ); + + // Serialize to JSON + final json = unconfiguredData.toJson()! as Map; + + // In JSON, the List is UTF-8 decoded to string (not base64) + expect(json['data'], isA()); + expect(json['data'], equals('World')); + }); + + test( + 'UnconfiguredEncodedData deserializes string from JSON to List', + () { + // Server sends data in JSON as a string + const jsonString = 'Test'; + + final json = { + 'name': 'test-unconfigured', + 'data': jsonString, + }; + + final unconfiguredData = UnconfiguredEncodedData.fromJson(json); + + // fromJson UTF-8 encodes the string to List + expect(unconfiguredData.data, isA>()); + expect( + unconfiguredData.data, + equals([84, 101, 115, 116]), + ); // UTF-8 bytes of "Test" + }, + ); + + test('201 - uploads unconfigured data', () async { + final api = buildApi(responseStatus: '201'); + + final dataBytes = Uint8List.fromList(List.generate(50, (i) => i * 2)); + + final unconfiguredData = UnconfiguredEncodedData( + name: 'test-upload-unconfigured', + data: dataBytes, + ); + + final result = await api.uploadContentMediaTypeUnconfigured( + body: unconfiguredData, + ); + final success = result as TonikSuccess; + + expect(success.response.statusCode, 201); + expect(success.value.id, isNotEmpty); + }); + + test('200 - retrieves unconfigured data as List', () async { + final api = buildApi(responseStatus: '200'); + + final result = await api.getContentMediaTypeUnconfigured(id: 'unc-123'); + final success = + result as TonikSuccess; + + expect(success.response.statusCode, 200); + expect(success.value, isA()); + + final responseBody = + (success.value as GetContentMediaTypeUnconfiguredResponse200).body; + expect(responseBody.data, isA>()); + }); + }); +} diff --git a/integration_test/binary_models/openapi.yaml b/integration_test/binary_models/openapi.yaml index 8da072a..c7d0b82 100644 --- a/integration_test/binary_models/openapi.yaml +++ b/integration_test/binary_models/openapi.yaml @@ -18,6 +18,8 @@ tags: description: Image operations - name: mixed description: Mixed content operations + - name: contentMediaType + description: contentMediaType with config mapping operations paths: /files/{id}: get: @@ -306,6 +308,141 @@ paths: description: Image downloaded successfully (no explicit schema) content: image/png: {} + /content-media-type/image: + post: + tags: + - contentMediaType + summary: Upload data with contentMediaType image/png (configured as binary) + description: Tests contentMediaType with config mapping to binary + operationId: uploadContentMediaTypeImage + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ImageEncodedData' + responses: + '201': + description: Data uploaded successfully + content: + application/json: + schema: + $ref: '#/components/schemas/UploadResponse' + get: + tags: + - contentMediaType + summary: Get data with contentMediaType image/png (configured as binary) + description: Returns data with contentMediaType mapped to binary via config + operationId: getContentMediaTypeImage + parameters: + - name: id + in: query + required: true + schema: + type: string + responses: + '200': + description: Data retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/ImageEncodedData' + '404': + description: Data not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /content-media-type/text: + post: + tags: + - contentMediaType + summary: Upload data with contentMediaType text/plain (configured as text) + description: Tests contentMediaType with config mapping to text (String) + operationId: uploadContentMediaTypeText + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TextEncodedData' + responses: + '201': + description: Data uploaded successfully + content: + application/json: + schema: + $ref: '#/components/schemas/UploadResponse' + get: + tags: + - contentMediaType + summary: Get data with contentMediaType text/plain (configured as text) + description: Returns data with contentMediaType mapped to text via config + operationId: getContentMediaTypeText + parameters: + - name: id + in: query + required: true + schema: + type: string + responses: + '200': + description: Data retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/TextEncodedData' + '404': + description: Data not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /content-media-type/unconfigured: + post: + tags: + - contentMediaType + summary: Upload data with unconfigured contentMediaType (fallback to binary) + description: Tests contentMediaType fallback when not in config + operationId: uploadContentMediaTypeUnconfigured + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UnconfiguredEncodedData' + responses: + '201': + description: Data uploaded successfully + content: + application/json: + schema: + $ref: '#/components/schemas/UploadResponse' + get: + tags: + - contentMediaType + summary: Get data with unconfigured contentMediaType (fallback to binary) + description: Returns data with unconfigured contentMediaType (fallback) + operationId: getContentMediaTypeUnconfigured + parameters: + - name: id + in: query + required: true + schema: + type: string + responses: + '200': + description: Data retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/UnconfiguredEncodedData' + '404': + description: Data not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' components: schemas: Error: @@ -418,7 +555,8 @@ components: encodedData: type: string contentEncoding: base64 - description: Base64 encoded binary data (OAS 3.1 contentEncoding) + contentMediaType: text/plain + description: Base64 encoded binary data (OAS 3.1 contentEncoding) - configured as text description: type: string description: Optional description @@ -432,3 +570,54 @@ components: type: string message: type: string + ImageEncodedData: + type: object + description: Data with contentMediaType image/png (configured as binary) + required: + - name + - imageData + properties: + name: + type: string + description: Name of the image + imageData: + type: string + contentEncoding: base64 + contentMediaType: image/png + description: Base64 encoded image data (maps to binary via config) + description: + type: string + description: Optional description + TextEncodedData: + type: object + description: Data with contentMediaType text/plain (configured as text -> String) + required: + - name + - textData + properties: + name: + type: string + description: Name of the text + textData: + type: string + contentEncoding: base64 + contentMediaType: text/plain + description: Base64 encoded text data (maps to String via config) + description: + type: string + description: Optional description + UnconfiguredEncodedData: + type: object + description: Data with unconfigured contentMediaType (fallback to binary) + required: + - name + - data + properties: + name: + type: string + description: Name of the data + data: + type: string + contentEncoding: base64 + contentMediaType: application/x-custom-unknown + description: Base64 encoded data with unknown media type (fallback to binary) diff --git a/integration_test/binary_models/tonik.yaml b/integration_test/binary_models/tonik.yaml new file mode 100644 index 0000000..9188a6a --- /dev/null +++ b/integration_test/binary_models/tonik.yaml @@ -0,0 +1,10 @@ +# Configuration for binary_models integration test +# Tests contentMediaType mapping to Dart types + +contentMediaTypes: + # Map image/png to binary (List) + "image/png": binary + # Map text/plain to text (String) + "text/plain": text + # Note: application/x-custom-unknown is NOT configured + # to test fallback behavior (should default to binary/List) diff --git a/packages/tonik/bin/tonik.dart b/packages/tonik/bin/tonik.dart index 03e9056..3b39a16 100644 --- a/packages/tonik/bin/tonik.dart +++ b/packages/tonik/bin/tonik.dart @@ -187,6 +187,7 @@ void main(List arguments) { try { apiDocument = Importer( contentTypes: mergedConfig.contentTypes, + contentMediaTypes: mergedConfig.contentMediaTypes, ).import(apiSpec); logger.info('Successfully parsed OpenAPI document'); } on Object catch (e, s) { diff --git a/packages/tonik/lib/src/config/config_loader.dart b/packages/tonik/lib/src/config/config_loader.dart index 5d4c948..5bb15cb 100644 --- a/packages/tonik/lib/src/config/config_loader.dart +++ b/packages/tonik/lib/src/config/config_loader.dart @@ -49,6 +49,7 @@ extension ConfigLoader on CliConfig { logLevel: _parseLogLevel(yaml['logLevel']), nameOverrides: _parseNameOverrides(yaml['nameOverrides']), contentTypes: _parseContentTypes(yaml['contentTypes']), + contentMediaTypes: _parseContentMediaTypes(yaml['contentMediaTypes']), filter: _parseFilter(yaml['filter']), deprecated: _parseDeprecated(yaml['deprecated']), enums: _parseEnums(yaml['enums']), @@ -138,6 +139,43 @@ extension ConfigLoader on CliConfig { }; } + static Map _parseContentMediaTypes(dynamic value) { + if (value == null) { + return const {}; + } + if (value is! YamlMap) { + throw ConfigLoaderException( + 'Invalid config: "contentMediaTypes" must be a map', + ); + } + + return Map.fromEntries( + value.entries.map((e) { + final key = e.key.toString(); + final schemaContentType = _parseSchemaContentType(e.value, key); + return MapEntry(key, schemaContentType); + }), + ); + } + + static SchemaContentType _parseSchemaContentType(dynamic value, String key) { + if (value == null) { + throw ConfigLoaderException( + 'Invalid config: contentMediaTypes["$key"] cannot be null', + ); + } + + final stringValue = value.toString(); + return switch (stringValue) { + 'binary' => SchemaContentType.binary, + 'text' => SchemaContentType.text, + _ => throw ConfigLoaderException( + 'Invalid schema content type for "$key": $stringValue. ' + 'Must be one of: binary, text', + ), + }; + } + static FilterConfig _parseFilter(dynamic value) { if (value == null) { return const FilterConfig(); @@ -267,6 +305,7 @@ extension ConfigLoader on CliConfig { logLevel: logLevel ?? this.logLevel, nameOverrides: nameOverrides, contentTypes: contentTypes, + contentMediaTypes: contentMediaTypes, filter: filter, deprecated: this.deprecated, enums: enums, diff --git a/packages/tonik/lib/src/config/tonik_config.dart b/packages/tonik/lib/src/config/tonik_config.dart index 8e06c1c..78b33a6 100644 --- a/packages/tonik/lib/src/config/tonik_config.dart +++ b/packages/tonik/lib/src/config/tonik_config.dart @@ -16,6 +16,7 @@ class CliConfig { this.logLevel, this.nameOverrides = const NameOverridesConfig(), this.contentTypes = const {}, + this.contentMediaTypes = const {}, this.filter = const FilterConfig(), this.deprecated = const DeprecatedConfig(), this.enums = const EnumConfig(), @@ -37,6 +38,9 @@ class CliConfig { /// Custom content type mappings: `contentType -> serializationFormat`. final Map contentTypes; + /// Schema-level contentMediaType mappings for encoded content. + final Map contentMediaTypes; + final FilterConfig filter; final DeprecatedConfig deprecated; @@ -46,19 +50,23 @@ class CliConfig { TonikConfig toTonikConfig() => TonikConfig( nameOverrides: nameOverrides, contentTypes: contentTypes, + contentMediaTypes: contentMediaTypes, filter: filter, deprecated: deprecated, enums: enums, ); - static const _mapEquality = MapEquality(); + static const _contentTypeEquality = MapEquality(); + static const _schemaContentTypeEquality = + MapEquality(); @override String toString() => 'CliConfig{spec: $spec, outputDir: $outputDir, ' 'packageName: $packageName, logLevel: $logLevel, ' 'nameOverrides: $nameOverrides, contentTypes: $contentTypes, ' - 'filter: $filter, deprecated: $deprecated, enums: $enums}'; + 'contentMediaTypes: $contentMediaTypes, filter: $filter, ' + 'deprecated: $deprecated, enums: $enums}'; @override bool operator ==(Object other) => @@ -70,7 +78,11 @@ class CliConfig { packageName == other.packageName && logLevel == other.logLevel && nameOverrides == other.nameOverrides && - _mapEquality.equals(contentTypes, other.contentTypes) && + _contentTypeEquality.equals(contentTypes, other.contentTypes) && + _schemaContentTypeEquality.equals( + contentMediaTypes, + other.contentMediaTypes, + ) && filter == other.filter && deprecated == other.deprecated && enums == other.enums; @@ -82,7 +94,8 @@ class CliConfig { packageName, logLevel, nameOverrides, - _mapEquality.hash(contentTypes), + _contentTypeEquality.hash(contentTypes), + _schemaContentTypeEquality.hash(contentMediaTypes), filter, deprecated, enums, diff --git a/packages/tonik/test/src/config/config_loader_test.dart b/packages/tonik/test/src/config/config_loader_test.dart index 3d25e4c..b2ae648 100644 --- a/packages/tonik/test/src/config/config_loader_test.dart +++ b/packages/tonik/test/src/config/config_loader_test.dart @@ -423,6 +423,71 @@ contentTypes: } }); + test('loads contentMediaTypes configuration', () { + File('${tempDir.path}/tonik.yaml').writeAsStringSync(''' +contentMediaTypes: + "image/png": binary + "image/jpeg": binary + "text/csv": text + "application/octet-stream": binary +'''); + + final config = ConfigLoader.load('${tempDir.path}/tonik.yaml'); + + expect(config.contentMediaTypes, hasLength(4)); + expect( + config.contentMediaTypes['image/png'], + SchemaContentType.binary, + ); + expect( + config.contentMediaTypes['image/jpeg'], + SchemaContentType.binary, + ); + expect( + config.contentMediaTypes['text/csv'], + SchemaContentType.text, + ); + expect( + config.contentMediaTypes['application/octet-stream'], + SchemaContentType.binary, + ); + }); + + test('throws meaningful error when contentMediaTypes is not a map', () { + File('${tempDir.path}/tonik.yaml').writeAsStringSync(''' +contentMediaTypes: not_a_map +'''); + + expect( + () => ConfigLoader.load('${tempDir.path}/tonik.yaml'), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('"contentMediaTypes" must be a map'), + ), + ), + ); + }); + + test('throws meaningful error for invalid contentMediaTypes value', () { + File('${tempDir.path}/tonik.yaml').writeAsStringSync(''' +contentMediaTypes: + "image/png": invalid +'''); + + expect( + () => ConfigLoader.load('${tempDir.path}/tonik.yaml'), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('Invalid schema content type'), + ), + ), + ); + }); + test('throws meaningful error when schemas override is not a map', () { File('${tempDir.path}/tonik.yaml').writeAsStringSync(''' nameOverrides: diff --git a/packages/tonik_core/lib/src/config/schema_content_type.dart b/packages/tonik_core/lib/src/config/schema_content_type.dart new file mode 100644 index 0000000..98920ca --- /dev/null +++ b/packages/tonik_core/lib/src/config/schema_content_type.dart @@ -0,0 +1 @@ +enum SchemaContentType { binary, text } diff --git a/packages/tonik_core/lib/src/config/tonik_config.dart b/packages/tonik_core/lib/src/config/tonik_config.dart index 1cb5b65..300ffde 100644 --- a/packages/tonik_core/lib/src/config/tonik_config.dart +++ b/packages/tonik_core/lib/src/config/tonik_config.dart @@ -4,6 +4,7 @@ import 'package:tonik_core/src/config/deprecated_config.dart'; import 'package:tonik_core/src/config/enum_config.dart'; import 'package:tonik_core/src/config/filter_config.dart'; import 'package:tonik_core/src/config/name_overrides_config.dart'; +import 'package:tonik_core/src/config/schema_content_type.dart'; import 'package:tonik_core/src/model/content_type.dart'; /// Main configuration for Tonik code generation. @@ -12,14 +13,15 @@ class TonikConfig { const TonikConfig({ this.nameOverrides = const NameOverridesConfig(), this.contentTypes = const {}, + this.contentMediaTypes = const {}, this.filter = const FilterConfig(), this.deprecated = const DeprecatedConfig(), this.enums = const EnumConfig(), }); final NameOverridesConfig nameOverrides; - final Map contentTypes; + final Map contentMediaTypes; final FilterConfig filter; @@ -30,16 +32,22 @@ class TonikConfig { @override String toString() => 'TonikConfig{nameOverrides: $nameOverrides, contentTypes: $contentTypes, ' - 'filter: $filter, deprecated: $deprecated, enums: $enums}'; + 'contentMediaTypes: $contentMediaTypes, filter: $filter, ' + 'deprecated: $deprecated, enums: $enums}'; @override bool operator ==(Object other) { - const mapEquality = MapEquality(); + const contentTypeEquality = MapEquality(); + const schemaContentTypeEquality = MapEquality(); return identical(this, other) || other is TonikConfig && runtimeType == other.runtimeType && nameOverrides == other.nameOverrides && - mapEquality.equals(contentTypes, other.contentTypes) && + contentTypeEquality.equals(contentTypes, other.contentTypes) && + schemaContentTypeEquality.equals( + contentMediaTypes, + other.contentMediaTypes, + ) && filter == other.filter && deprecated == other.deprecated && enums == other.enums; @@ -47,10 +55,12 @@ class TonikConfig { @override int get hashCode { - const mapEquality = MapEquality(); + const contentTypeEquality = MapEquality(); + const schemaContentTypeEquality = MapEquality(); return Object.hash( nameOverrides, - mapEquality.hash(contentTypes), + contentTypeEquality.hash(contentTypes), + schemaContentTypeEquality.hash(contentMediaTypes), filter, deprecated, enums, diff --git a/packages/tonik_core/lib/tonik_core.dart b/packages/tonik_core/lib/tonik_core.dart index 3e5124c..598ff2b 100644 --- a/packages/tonik_core/lib/tonik_core.dart +++ b/packages/tonik_core/lib/tonik_core.dart @@ -5,6 +5,7 @@ export 'src/config/deprecated_config.dart'; export 'src/config/enum_config.dart'; export 'src/config/filter_config.dart'; export 'src/config/name_overrides_config.dart'; +export 'src/config/schema_content_type.dart'; export 'src/config/tonik_config.dart'; export 'src/model/api_document.dart'; export 'src/model/contact.dart'; diff --git a/packages/tonik_core/test/config/tonik_config_test.dart b/packages/tonik_core/test/config/tonik_config_test.dart index ea71194..25c1424 100644 --- a/packages/tonik_core/test/config/tonik_config_test.dart +++ b/packages/tonik_core/test/config/tonik_config_test.dart @@ -8,9 +8,51 @@ void main() { expect(config.nameOverrides, const NameOverridesConfig()); expect(config.contentTypes, isEmpty); + expect(config.contentMediaTypes, isEmpty); expect(config.filter, const FilterConfig()); expect(config.deprecated, const DeprecatedConfig()); expect(config.enums, const EnumConfig()); }); + + test('stores contentMediaTypes configuration', () { + const config = TonikConfig( + contentMediaTypes: { + 'image/png': SchemaContentType.binary, + 'text/csv': SchemaContentType.text, + }, + ); + + expect(config.contentMediaTypes, hasLength(2)); + expect( + config.contentMediaTypes['image/png'], + SchemaContentType.binary, + ); + expect( + config.contentMediaTypes['text/csv'], + SchemaContentType.text, + ); + }); + + test('equality includes contentMediaTypes', () { + const config1 = TonikConfig( + contentMediaTypes: {'image/png': SchemaContentType.binary}, + ); + const config2 = TonikConfig( + contentMediaTypes: {'image/png': SchemaContentType.binary}, + ); + const config3 = TonikConfig( + contentMediaTypes: {'image/png': SchemaContentType.text}, + ); + + expect(config1, equals(config2)); + expect(config1, isNot(equals(config3))); + }); + }); + + group('SchemaContentType', () { + test('has binary and text values', () { + expect(SchemaContentType.values, contains(SchemaContentType.binary)); + expect(SchemaContentType.values, contains(SchemaContentType.text)); + }); }); } diff --git a/packages/tonik_parse/lib/src/importer.dart b/packages/tonik_parse/lib/src/importer.dart index 06c5cdf..54466ce 100644 --- a/packages/tonik_parse/lib/src/importer.dart +++ b/packages/tonik_parse/lib/src/importer.dart @@ -14,13 +14,20 @@ import 'package:tonik_parse/src/security_scheme_importer.dart'; import 'package:tonik_parse/src/server_importer.dart'; class Importer { - Importer({this.contentTypes = const {}}); + Importer({this.contentTypes = const {}, this.contentMediaTypes = const {}}); /// Maps media type strings to ContentType for parsing request/response bodies. /// Default includes 'application/json' only. Add custom JSON-like media types /// (e.g., 'application/hal+json': ContentType.json) via configuration. final Map contentTypes; + /// Maps contentMediaType values to SchemaContentType for content-encoded + /// string schemas. When a schema has contentEncoding set, this config + /// determines whether it generates StringModel (text) or BinaryModel + /// (binary). + /// If no match found, defaults to BinaryModel. + final Map contentMediaTypes; + static final _log = Logger('Importer'); core.ApiDocument import(Map fileContent) { @@ -29,7 +36,10 @@ class Importer { // Detect and log OpenAPI version (permissive, no validation) _detectAndLogVersion(openApiObject.openapi); - final modelImporter = ModelImporter(openApiObject); + final modelImporter = ModelImporter( + openApiObject, + contentMediaTypes: contentMediaTypes, + ); final securitySchemeImporter = SecuritySchemeImporter(openApiObject); final responseHeaderImporter = ResponseHeaderImporter( openApiObject: openApiObject, diff --git a/packages/tonik_parse/lib/src/model/schema.dart b/packages/tonik_parse/lib/src/model/schema.dart index 993311a..25d5c3e 100644 --- a/packages/tonik_parse/lib/src/model/schema.dart +++ b/packages/tonik_parse/lib/src/model/schema.dart @@ -27,6 +27,7 @@ class Schema { required this.defs, required this.contentEncoding, required this.contentMediaType, + required this.contentSchema, this.isBooleanSchema, }); @@ -54,6 +55,7 @@ class Schema { defs: null, contentEncoding: null, contentMediaType: null, + contentSchema: null, isBooleanSchema: json, ); } @@ -82,6 +84,7 @@ class Schema { defs: null, contentEncoding: null, contentMediaType: null, + contentSchema: null, ); } @@ -126,6 +129,9 @@ class Schema { final String? contentEncoding; @JsonKey(name: 'contentMediaType') final String? contentMediaType; + @SchemaConverter() + @JsonKey(name: 'contentSchema') + final Schema? contentSchema; /// Indicates if this schema is a boolean schema (true/false). /// @@ -149,7 +155,7 @@ class Schema { 'isDeprecated: $isDeprecated, uniqueItems: $uniqueItems, ' 'xDartName: $xDartName, xDartEnum: $xDartEnum, ' 'contentEncoding: $contentEncoding, contentMediaType: $contentMediaType, ' - 'isBooleanSchema: $isBooleanSchema}'; + 'contentSchema: $contentSchema, isBooleanSchema: $isBooleanSchema}'; } class _SchemaTypeConverter implements JsonConverter, dynamic> { diff --git a/packages/tonik_parse/lib/src/model/schema.g.dart b/packages/tonik_parse/lib/src/model/schema.g.dart index 2fd771f..43e774b 100644 --- a/packages/tonik_parse/lib/src/model/schema.g.dart +++ b/packages/tonik_parse/lib/src/model/schema.g.dart @@ -38,4 +38,5 @@ Schema _$SchemaFromJson(Map json) => Schema( ), contentEncoding: json['contentEncoding'] as String?, contentMediaType: json['contentMediaType'] as String?, + contentSchema: const SchemaConverter().fromJson(json['contentSchema']), ); diff --git a/packages/tonik_parse/lib/src/model_importer.dart b/packages/tonik_parse/lib/src/model_importer.dart index f195dda..a33cb10 100644 --- a/packages/tonik_parse/lib/src/model_importer.dart +++ b/packages/tonik_parse/lib/src/model_importer.dart @@ -5,10 +5,14 @@ import 'package:tonik_parse/src/model/open_api_object.dart'; import 'package:tonik_parse/src/model/schema.dart'; class ModelImporter { - ModelImporter(OpenApiObject openApiObject) - : _schemas = openApiObject.components?.schemas ?? {}; + ModelImporter( + OpenApiObject openApiObject, { + Map contentMediaTypes = const {}, + }) : _schemas = openApiObject.components?.schemas ?? {}, + _contentMediaTypes = contentMediaTypes; final Map _schemas; + final Map _contentMediaTypes; final Map _defs = {}; late Set models; @@ -390,9 +394,9 @@ class ModelImporter { 'string' when schema.format == 'uri' || schema.format == 'url' => UriModel(context: context), 'string' when schema.format == 'binary' => BinaryModel(context: context), - 'string' - when schema.format == 'byte' || schema.contentEncoding == 'base64' => - StringModel(context: context), + 'string' when schema.contentEncoding != null => + _resolveContentEncodedModel(schema, context), + 'string' when schema.format == 'byte' => StringModel(context: context), 'string' when schema.enumerated != null => _parseEnum( name, schema.enumerated!, @@ -435,6 +439,18 @@ class ModelImporter { return model; } + Model _resolveContentEncodedModel(Schema schema, Context context) { + final mediaType = schema.contentMediaType; + if (mediaType != null && _contentMediaTypes.containsKey(mediaType)) { + return switch (_contentMediaTypes[mediaType]!) { + .text => StringModel(context: context), + .binary => BinaryModel(context: context), + }; + } + + return BinaryModel(context: context); + } + OneOfModel _parseMultiType( List types, Schema schema, @@ -465,6 +481,7 @@ class ModelImporter { defs: schema.defs, contentEncoding: schema.contentEncoding, contentMediaType: schema.contentMediaType, + contentSchema: schema.contentSchema, ); return ( discriminatorValue: null, diff --git a/packages/tonik_parse/test/model/model_binary_test.dart b/packages/tonik_parse/test/model/model_binary_test.dart index 40dad52..8502b68 100644 --- a/packages/tonik_parse/test/model/model_binary_test.dart +++ b/packages/tonik_parse/test/model/model_binary_test.dart @@ -55,6 +55,7 @@ void main() { defs: null, contentEncoding: null, contentMediaType: null, + contentSchema: null, ); final inlineByte = Schema( @@ -79,6 +80,7 @@ void main() { defs: null, contentEncoding: null, contentMediaType: null, + contentSchema: null, ); final inlineContentEncodingBase64 = Schema( @@ -103,6 +105,7 @@ void main() { defs: null, contentEncoding: 'base64', contentMediaType: null, + contentSchema: null, ); late ModelImporter importer; @@ -129,17 +132,22 @@ void main() { expect(result.context.path, ['components', 'schemas']); }); - test('returns StringModel for contentEncoding: base64', () { - final context = Context.initial().pushAll(['components', 'schemas']); + test( + 'returns BinaryModel for contentEncoding: base64 without config', + () { + final context = Context.initial().pushAll(['components', 'schemas']); - final result = importer.importSchema( - inlineContentEncodingBase64, - context, - ); + final result = importer.importSchema( + inlineContentEncodingBase64, + context, + ); - expect(result, isA()); - expect(result.context.path, ['components', 'schemas']); - }); + // Without contentMediaTypes config, contentEncoding falls back to + // BinaryModel + expect(result, isA()); + expect(result.context.path, ['components', 'schemas']); + }, + ); test('does not add inline binary schema to models', () { final context = Context.initial().pushAll(['components', 'schemas']); @@ -204,6 +212,7 @@ void main() { defs: null, contentEncoding: null, contentMediaType: null, + contentSchema: null, ), 'Base64Data': Schema( ref: null, @@ -227,6 +236,7 @@ void main() { defs: null, contentEncoding: null, contentMediaType: null, + contentSchema: null, ), }, responses: {}, @@ -263,4 +273,185 @@ void main() { expect((base64Data as AliasModel).model, isA()); }); }); + + group('contentMediaType parsing', () { + final openApiObject = OpenApiObject( + openapi: '3.1.0', + info: Info( + title: 'Test API', + description: 'Test API Description', + version: '1.0.0', + contact: null, + license: null, + termsOfService: null, + summary: null, + ), + servers: [], + paths: {}, + components: Components( + schemas: {}, + responses: {}, + parameters: {}, + requestBodies: {}, + headers: {}, + securitySchemes: {}, + pathItems: {}, + ), + tags: [], + ); + + // contentEncoding without config should fallback to BinaryModel. + final stringWithEncodingAndMediaType = Schema( + ref: null, + type: ['string'], + format: null, + required: [], + enumerated: null, + allOf: null, + anyOf: null, + oneOf: null, + not: null, + items: null, + properties: {}, + description: '', + isNullable: false, + discriminator: null, + isDeprecated: false, + uniqueItems: false, + xDartName: null, + xDartEnum: null, + defs: null, + contentEncoding: 'base64', + contentMediaType: 'image/png', + contentSchema: null, + ); + + // contentEncoding without contentMediaType should also fallback + // to BinaryModel. + final stringWithEncodingNoMediaType = Schema( + ref: null, + type: ['string'], + format: null, + required: [], + enumerated: null, + allOf: null, + anyOf: null, + oneOf: null, + not: null, + items: null, + properties: {}, + description: '', + isNullable: false, + discriminator: null, + isDeprecated: false, + uniqueItems: false, + xDartName: null, + xDartEnum: null, + defs: null, + contentEncoding: 'base64', + contentMediaType: null, + contentSchema: null, + ); + + test( + 'returns BinaryModel when contentEncoding present (fallback, no config)', + () { + final importer = ModelImporter(openApiObject)..import(); + final context = Context.initial().pushAll(['components', 'schemas']); + + final result = importer.importSchema( + stringWithEncodingAndMediaType, + context, + ); + + expect(result, isA()); + }, + ); + + test('returns BinaryModel when contentEncoding present but no ' + 'contentMediaType (fallback)', () { + final importer = ModelImporter(openApiObject)..import(); + final context = Context.initial().pushAll(['components', 'schemas']); + + final result = importer.importSchema( + stringWithEncodingNoMediaType, + context, + ); + + expect(result, isA()); + }); + + test('returns BinaryModel when config maps contentMediaType to binary', () { + final importer = ModelImporter( + openApiObject, + contentMediaTypes: { + 'image/png': SchemaContentType.binary, + }, + )..import(); + + final context = Context.initial().pushAll(['components', 'schemas']); + final result = importer.importSchema( + stringWithEncodingAndMediaType, + context, + ); + + expect(result, isA()); + }); + + test('returns StringModel when config maps contentMediaType to text', () { + final stringWithTextMediaType = Schema( + ref: null, + type: ['string'], + format: null, + required: [], + enumerated: null, + allOf: null, + anyOf: null, + oneOf: null, + not: null, + items: null, + properties: {}, + description: '', + isNullable: false, + discriminator: null, + isDeprecated: false, + uniqueItems: false, + xDartName: null, + xDartEnum: null, + defs: null, + contentEncoding: 'base64', + contentMediaType: 'text/plain', + contentSchema: null, + ); + + final importer = ModelImporter( + openApiObject, + contentMediaTypes: { + 'text/plain': SchemaContentType.text, + }, + )..import(); + + final context = Context.initial().pushAll(['components', 'schemas']); + final result = importer.importSchema(stringWithTextMediaType, context); + + expect(result, isA()); + }); + + test('config can override any media type to text', () { + final importer = ModelImporter( + openApiObject, + contentMediaTypes: { + 'image/png': SchemaContentType.text, + }, + )..import(); + + final context = Context.initial().pushAll(['components', 'schemas']); + final result = importer.importSchema( + stringWithEncodingAndMediaType, + context, + ); + + expect(result, isA()); + }); + }); } diff --git a/packages/tonik_parse/test/model/single_schema_import_test.dart b/packages/tonik_parse/test/model/single_schema_import_test.dart index 01c38c1..ee4adc6 100644 --- a/packages/tonik_parse/test/model/single_schema_import_test.dart +++ b/packages/tonik_parse/test/model/single_schema_import_test.dart @@ -44,6 +44,7 @@ void main() { defs: null, contentEncoding: null, contentMediaType: null, + contentSchema: null, ), }, responses: {}, @@ -78,6 +79,7 @@ void main() { defs: null, contentEncoding: null, contentMediaType: null, + contentSchema: null, ); final inlineClass = Schema( @@ -102,6 +104,7 @@ void main() { defs: null, contentEncoding: null, contentMediaType: null, + contentSchema: null, ); final inlineUri = Schema( @@ -126,6 +129,7 @@ void main() { defs: null, contentEncoding: null, contentMediaType: null, + contentSchema: null, ); final inlineUrl = Schema( @@ -150,6 +154,7 @@ void main() { defs: null, contentEncoding: null, contentMediaType: null, + contentSchema: null, ); final reference = Schema( @@ -174,6 +179,7 @@ void main() { defs: null, contentEncoding: null, contentMediaType: null, + contentSchema: null, ); late ModelImporter importer; diff --git a/scripts/setup_integration_tests.sh b/scripts/setup_integration_tests.sh index 7f45173..1b8303f 100755 --- a/scripts/setup_integration_tests.sh +++ b/scripts/setup_integration_tests.sh @@ -172,7 +172,7 @@ $TONIK_BINARY -p path_encoding_api -s path_encoding/openapi.yaml -o path_encodin add_dependency_overrides_recursive "path_encoding/path_encoding_api" cd path_encoding/path_encoding_api && dart pub get && cd ../.. -$TONIK_BINARY -p binary_models_api -s binary_models/openapi.yaml -o binary_models +$TONIK_BINARY --config binary_models/tonik.yaml -p binary_models_api -s binary_models/openapi.yaml -o binary_models add_dependency_overrides_recursive "binary_models/binary_models_api" cd binary_models/binary_models_api && dart pub get && cd ../.. From 9c2603a4328db63b7f3b1dbae4ff38102d5f09c1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 15 Jan 2026 19:51:03 +0000 Subject: [PATCH 2/3] Initial plan From 4bf862493db8555122d9c1bc60c03cf5819a53a7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 15 Jan 2026 19:53:47 +0000 Subject: [PATCH 3/3] Clarify misleading comment about contentEncoding in test Co-authored-by: t-unit <366172+t-unit@users.noreply.github.com> --- .../binary_models_test/test/content_media_type_test.dart | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/integration_test/binary_models/binary_models_test/test/content_media_type_test.dart b/integration_test/binary_models/binary_models_test/test/content_media_type_test.dart index aa6a97c..a683e06 100644 --- a/integration_test/binary_models/binary_models_test/test/content_media_type_test.dart +++ b/integration_test/binary_models/binary_models_test/test/content_media_type_test.dart @@ -77,7 +77,9 @@ void main() { // Serialize to JSON final json = imageEncodedData.toJson()! as Map; - // In JSON, the List is UTF-8 decoded to string (not base64) + // The schema has contentEncoding: base64, but this test uses ASCII-compatible + // bytes that are valid UTF-8 to demonstrate the bidirectional conversion. + // In this test, List is decoded as UTF-8 string in JSON for simplicity. expect(json['imageData'], isA()); expect(json['imageData'], equals('Hello')); });