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
14 changes: 14 additions & 0 deletions docs/api-reference/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ Entry point for creating schemas. See [Schema Types](../core-concepts/schemas.md
- `Ack.anyOf(List<AckSchema> schemas)`: Creates an `AnyOfSchema` for union types.
- `Ack.any()`: Creates an `AnySchema` that accepts any non-null JSON-safe
value. Chain `.nullable()` to allow `null`.
- `Ack.lazy(String name, AckSchema Function() builder)`: Creates a memoized
deferred schema reference for recursive schema graphs. JSON Schema export
renders Draft-7 `definitions` / `$ref` entries using `name`.
- `Ack.discriminated<T extends Object>(...)`: Creates a discriminated union
schema. Branches may be plain `ObjectSchema` or transformed schemas whose
base is an `ObjectSchema`. The union owns the discriminator: branches normally
Expand Down Expand Up @@ -224,6 +227,17 @@ Schema for polymorphic validation based on a string discriminator property.
- Generated subtype `parse()` and `safeParse()` methods validate through the
union's effective branch.

### `Ack.lazy(...)`

Schema reference for recursive object graphs.

- Created using `Ack.lazy<Boundary, Runtime>(name, builder)`.
- The builder is resolved once and memoized.
- `toJsonSchema()` and `toSchemaModel()` export Draft-7 `definitions` / `$ref`
entries using the lazy `name`.
- Bare or wrapped lazy schemas cannot be used as discriminated-union branches
because the branch discriminator must be analyzable at construction time.

## Code Generation Annotations

Use the [`ack_generator`](https://pub.dev/packages/ack_generator) builder to
Expand Down
19 changes: 19 additions & 0 deletions docs/core-concepts/schemas.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,25 @@ final shapeSchema = Ack.discriminated(
The union owns the discriminator and injects the exact branch literal at
parse/export boundaries. Branch schemas usually omit the discriminator field.

### Recursive Schemas

Use `Ack.lazy(...)` when a schema needs to refer to itself:

```dart
late final ObjectSchema categorySchema;

categorySchema = Ack.object({
'name': Ack.string(),
'children': Ack.list(
Ack.lazy<JsonMap, JsonMap>('Category', () => categorySchema),
),
});
```

The lazy builder is resolved once and memoized. JSON Schema export renders the
reference through Draft-7 `definitions` / `$ref`, so recursive children are
referenced instead of inlined forever.

### Any

Accept any non-null JSON-safe value without validation (use sparingly):
Expand Down
10 changes: 10 additions & 0 deletions docs/guides/json-schema-integration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ Ack attempts to map its [built-in constraints](../core-concepts/validation.mdx)
| [`Ack.boolean()`](../core-concepts/schemas.mdx#boolean) | `type: boolean` | Type |
| [`Ack.list(...)`](../core-concepts/schemas.mdx#list) | `type: array`, `items: {...}` | Type |
| [`Ack.object(...)`](../core-concepts/schemas.mdx#object) | `type: object`, `properties: {...}`, `required: [...]` | Type |
| [`Ack.lazy(...)`](../core-concepts/schemas.mdx#recursive-schemas) | `definitions`, `$ref` | Recursive type |

## Shape Stability Notes

Expand All @@ -186,6 +187,11 @@ nullability rules:
branches. Each branch contains the exact required discriminator `const`.
- `Ack.discriminated(...).nullable()` wraps that `anyOf` union with a second
`{ "type": "null" }` branch.
- `Ack.lazy(name, ...)` is emitted as a Draft-7 `definitions` entry and local
`$ref` values such as `{ "$ref": "#/definitions/Category" }`.
- Non-null lazy refs that carry metadata such as `description` use `allOf`
around the `$ref` so Draft-7 validators do not ignore that metadata as a
`$ref` sibling. Nullable lazy refs keep metadata beside the top-level `anyOf`.

This means nullable enums are represented as:

Expand Down Expand Up @@ -229,6 +235,10 @@ shape should implement that as explicit adapter rendering.
- **`Ack.any()`:** Runtime validation accepts non-null JSON-safe values.
JSON-like adapter exports represent those JSON-compatible values and attach
an `ack_any_json_boundary` warning to the `AckSchemaModel`.
- **`Ack.lazy()` runtime checks:** Recursive structure is exported with
Draft-7 `definitions` / `$ref`. Constraints and refinements added directly
to the lazy reference are runtime-only and are reported as schema-model
warnings rather than emitted beside `$ref`.
- **Date/time range constraints:** Draft-7 has no standard `formatMinimum` or
`formatMaximum` keywords. ACK validates `.min()` and `.max()` at runtime and
records schema-model warnings instead of rendering non-standard keywords.
Expand Down
23 changes: 19 additions & 4 deletions packages/ack/lib/src/ack.dart
Original file line number Diff line number Diff line change
Expand Up @@ -67,10 +67,9 @@ final class Ack {
/// (e.g. adding constraints, copying with new flags). Prefer [enumCodec]
/// when downstream code expects every value-shape to be a `CodecSchema`.
static CodecSchema<String, T> enumCodec<T extends Enum>(List<T> values) =>
enumValues(values).codec<T>(
decode: (value) => value,
encode: (value) => value,
);
enumValues(
values,
).codec<T>(decode: (value) => value, encode: (value) => value);

/// Creates a string schema that only accepts one of the given [values].
static StringSchema enumString(List<String> values) =>
Expand All @@ -86,6 +85,22 @@ final class Ack {
/// to accept `null`.
static AnySchema any() => const AnySchema();

/// Creates a schema reference that is resolved lazily on first use.
///
/// The [builder] is called once and memoized. Two `Ack.lazy` instances are
/// equal only when their `builder` closure is the same reference -- pulling
/// the closure into a `final` variable lets two calls share equality.
///
/// `toJsonSchema()` and `toSchemaModel()` export lazy references through
/// recursive `definitions`/`$ref` JSON Schema definitions. Bare or wrapped
/// `Ack.lazy` schemas cannot be used as discriminated union branches.
static LazySchema<Boundary, Runtime> lazy<
Boundary extends Object,
Runtime extends Object
>(String name, AckSchema<Boundary, Runtime> Function() builder) {
return LazySchema<Boundary, Runtime>(name, builder);
}

/// Creates a schema for a specific Dart instance type [T], with [T] as
/// both boundary and runtime type.
static InstanceSchema<T> instance<T extends Object>() => InstanceSchema<T>();
Expand Down
60 changes: 59 additions & 1 deletion packages/ack/lib/src/schema_model/ack_schema_model.dart
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,13 @@ final class _AckSchemaModelCommon {

/// Returns the non-hoistable portion (extensions plus type-specific
/// keywords flow through here) to embed inside the inner branch.
Map<String, Object?> toEmbeddedJson() => {...extensions};
Map<String, Object?> toEmbeddedJson() {
final embedded = {...extensions};
embedded.remove('definitions');
return embedded;
}

Object? get definitions => extensions['definitions'];
}

@immutable
Expand Down Expand Up @@ -239,6 +245,10 @@ sealed class AckSchemaModel {
keywords,
commonHandled,
),
AckRefSchemaModel schema => schema._withJsonSchemaKeywords(
keywords,
commonHandled,
),
};
}

Expand All @@ -253,6 +263,7 @@ sealed class AckSchemaModel {
// branch so consumers see them next to the `type` they constrain.
return {
..._common.toHoistedJson(),
if (_common.definitions != null) 'definitions': _common.definitions,
'anyOf': [
{...typeJson, ..._common.toEmbeddedJson()},
_nullSchemaJson,
Expand All @@ -272,6 +283,7 @@ sealed class AckSchemaModel {
// composed union.
return {
..._common.toHoistedJson(),
if (_common.definitions != null) 'definitions': _common.definitions,
'anyOf': [
{..._common.toEmbeddedJson(), keyword: branches},
_nullSchemaJson,
Expand Down Expand Up @@ -316,6 +328,48 @@ sealed class AckSchemaModel {
}
}

final class AckRefSchemaModel extends AckSchemaModel {
const AckRefSchemaModel({
required this.refName,
super.title,
super.description,
super.nullable,
super.defaultValue,
super.warnings,
super.extensions,
});

AckRefSchemaModel._(_AckSchemaModelCommon common, {required this.refName})
: super._(common);

final String refName;

@override
Map<String, Object?> toJsonSchema() {
final refJson = {r'$ref': '#/definitions/${_jsonPointerToken(refName)}'};
if (nullable) return finishTypeJson(refJson);

final commonJson = _common.toJson();
if (commonJson.isEmpty) return refJson;

return {
...commonJson,
'allOf': [refJson],
};
}

@override
AckRefSchemaModel _rebuildWithCommon(_AckSchemaModelCommon common) =>
AckRefSchemaModel._(common, refName: refName);

AckSchemaModel _withJsonSchemaKeywords(
Map<String, Object?> keywords,
Set<String> commonHandled,
) {
return _withUnhandledKeywords(keywords, commonHandled);
}
}

final class AckStringSchemaModel extends AckSchemaModel {
const AckStringSchemaModel({
this.format,
Expand Down Expand Up @@ -1115,3 +1169,7 @@ num? _readNumKeyword(Map<String, Object?> keywords, String key) {
final value = keywords[key];
return value is num ? value : null;
}

String _jsonPointerToken(String value) {
return value.replaceAll('~', '~0').replaceAll('/', '~1');
}
Loading
Loading