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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"lululu",
"Maxie",
"medama",
"Millis",
"Newrelic",
"oneline",
"petstore",
Expand All @@ -35,6 +36,7 @@
"typdef",
"unauthorised",
"uncallable",
"unconfigured",
"użytkownik",
"مرحبا"
]
Expand Down
33 changes: 33 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<int>
"text/plain": text # String

filter:
# Only generate code for specific parts of the spec
includeTags:
Expand Down Expand Up @@ -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<int>
"image/jpeg": binary # → List<int>
"text/plain": text # → String
"application/pdf": binary
```

Supported targets:
- `binary` - Generates `List<int>` (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<int>` (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<int>`. Without config, it also defaults to `List<int>`.

## 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.
Expand Down
24 changes: 24 additions & 0 deletions docs/data_types.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<int>` | `dart:core` | See [Binary Data](#binary-data) |
| `string` | `byte` | `String` | `dart:core` | Base64 encoded data (kept as string) |
| `string` | (with `contentEncoding`) | `List<int>` 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 |
Expand Down Expand Up @@ -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<int>` | Decoded binary data |
| `"text/plain": text` | `String` | Keeps encoded string as-is |
| (no match) | `List<int>` | 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<int>`, 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.
Expand Down
1 change: 0 additions & 1 deletion docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading