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
39 changes: 39 additions & 0 deletions docs/guides/json-schema-integration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,45 @@ Ack attempts to map its [built-in constraints](../core-concepts/validation.mdx)
| [`Ack.list(...)`](../core-concepts/schemas.mdx#list) | `type: array`, `items: {...}` | Type |
| [`Ack.object(...)`](../core-concepts/schemas.mdx#object) | `type: object`, `properties: {...}`, `required: [...]` | Type |

## Shape Stability Notes

`toJsonSchema()` uses stable nullability wrapping rules:

- Primitive/object/list/enum schemas marked with `.nullable()` are emitted as:
- `anyOf: [<base-schema>, { "type": "null" }]`
- `Ack.anyOf([...]).nullable()` is emitted as nested `anyOf`:
- outer `anyOf` for nullability
- inner `anyOf` for the union branches
- `Ack.discriminated(...).nullable()` follows the same nested pattern:
- outer `anyOf` for nullability
- inner `anyOf` for discriminated branches

This means nullable enums are represented as:

```json
{
"anyOf": [
{ "type": "string", "enum": ["admin", "user", "guest"] },
{ "type": "null" }
]
}
```

And nullable discriminated unions are represented as:

```json
{
"anyOf": [
{ "anyOf": [/* discriminated object branches */] },
{ "type": "null" }
]
}
```

If you build consumers that inspect generated schemas, treat nullability and
union composition as separate concerns and avoid assuming enum values always
live at the top level.

**Limitations:**

- **Custom Constraints:** [`Constraint<T>` + `Validator<T>`](./custom-validation.mdx) instances added via `.constrain()` are **not** translated to JSON Schema as there's no standard way to represent arbitrary logic.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import 'dart:convert';
import 'package:ack/ack.dart';
import 'package:test/test.dart';

enum UserRole { admin, user, guest }

/// Tests for code snippets in docs/guides/json-schema-integration.mdx.
void main() {
group('Docs /guides/json-schema-integration.mdx', () {
Expand All @@ -14,7 +16,7 @@ void main() {
.maxLength(50)
.describe("User's full name"),
'email': Ack.string().email().describe("User's email address"),
'role': Ack.enumString(['admin', 'user', 'guest']).withDefault('user'),
'role': Ack.enumValues(UserRole.values).withDefault(UserRole.user),
'isActive': Ack.boolean().withDefault(true),
'tags': Ack.list(
Ack.string(),
Expand All @@ -38,6 +40,57 @@ void main() {
expect(required, containsAll(['id', 'name', 'email']));
});

test('nullable enum is emitted as anyOf(enum, null)', () {
final schema = Ack.enumValues(UserRole.values).nullable();
final jsonSchema = schema.toJsonSchema();

final anyOf = jsonSchema['anyOf'] as List<Object?>;
expect(anyOf, hasLength(2));
expect(
anyOf.any((e) => e is Map<String, Object?> && e['type'] == 'null'),
isTrue,
);

final enumBranch =
anyOf.firstWhere(
(e) => e is Map<String, Object?> && e['type'] == 'string',
)
as Map<String, Object?>;
expect(enumBranch['enum'], equals(['admin', 'user', 'guest']));
});

test('nullable discriminated schema is emitted as nested anyOf', () {
final schema = Ack.discriminated(
discriminatorKey: 'kind',
schemas: {
'circle': Ack.object({
'kind': Ack.literal('circle'),
'radius': Ack.double().positive(),
}),
'square': Ack.object({
'kind': Ack.literal('square'),
'size': Ack.double().positive(),
}),
},
).nullable();

final jsonSchema = schema.toJsonSchema();
final outerAnyOf = jsonSchema['anyOf'] as List<Object?>;
expect(outerAnyOf, hasLength(2));
expect(
outerAnyOf.any((e) => e is Map<String, Object?> && e['type'] == 'null'),
isTrue,
);

final unionBranch =
outerAnyOf.firstWhere(
(e) => e is Map<String, Object?> && e['anyOf'] is List,
)
as Map<String, Object?>;
final innerAnyOf = unionBranch['anyOf'] as List<Object?>;
expect(innerAnyOf, hasLength(2));
});

test('API specification example includes referenced schema', () {
Map<String, Object?> buildApiSpecification() {
final userJsonSchema = buildUserSchema().toJsonSchema();
Expand Down
Loading