From bff77407b087e7bbfe9b4c111cf74e1727642eea Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Fri, 9 Oct 2026 13:33:50 -0400 Subject: [PATCH] feat(generator): generate models from Draft 2020-12 schemas --- .gitignore | 1 + docs/guides/json-schema-integration.mdx | 80 +- docs/json-schema-2020-12-capabilities.md | 55 + llms.txt | 10 +- packages/ack/README.md | 3 +- packages/ack_generator/CHANGELOG.md | 15 + packages/ack_generator/README.md | 81 ++ packages/ack_generator/build.yaml | 11 + .../lib/src/analyzer/schema_first_names.dart | 111 ++ .../analyzer/schema_model_graph_builder.dart | 94 +- packages/ack_generator/lib/src/builder.dart | 10 + .../lib/src/builders/model_emitter.dart | 14 +- .../src/json_schema/json_schema_builder.dart | 72 + .../json_schema_generation_exception.dart | 33 + .../src/json_schema/json_schema_library.dart | 1277 +++++++++++++++++ packages/ack_generator/pubspec.yaml | 2 + .../integration/ack_infer_models_test.dart | 29 + .../json_schema_runtime_build_test.dart | 234 +++ .../test/json_schema/fixture_library.dart | 28 + .../json_schema/fixtures/mapping.schema.dart | 51 + .../json_schema/fixtures/mapping.schema.json | 77 + .../fixtures/mapping_preserve.schema.dart | 56 + .../test/json_schema/instance_corpus.dart | 220 +++ .../json_schema_equivalence_test.dart | 72 + .../json_schema/json_schema_golden_test.dart | 36 + .../json_schema/json_schema_library_test.dart | 543 +++++++ .../json_schema_required_corpus_test.dart | 46 + packages/ack_json_schema_builder/CHANGELOG.md | 11 +- packages/ack_json_schema_builder/README.md | 29 +- .../lib/ack_json_schema_builder.dart | 21 +- .../test/from_json_schema_builder_test.dart | 27 +- 31 files changed, 3193 insertions(+), 156 deletions(-) create mode 100644 docs/json-schema-2020-12-capabilities.md create mode 100644 packages/ack_generator/lib/src/analyzer/schema_first_names.dart create mode 100644 packages/ack_generator/lib/src/json_schema/json_schema_builder.dart create mode 100644 packages/ack_generator/lib/src/json_schema/json_schema_generation_exception.dart create mode 100644 packages/ack_generator/lib/src/json_schema/json_schema_library.dart create mode 100644 packages/ack_generator/test/integration/json_schema_runtime_build_test.dart create mode 100644 packages/ack_generator/test/json_schema/fixture_library.dart create mode 100644 packages/ack_generator/test/json_schema/fixtures/mapping.schema.dart create mode 100644 packages/ack_generator/test/json_schema/fixtures/mapping.schema.json create mode 100644 packages/ack_generator/test/json_schema/fixtures/mapping_preserve.schema.dart create mode 100644 packages/ack_generator/test/json_schema/instance_corpus.dart create mode 100644 packages/ack_generator/test/json_schema/json_schema_equivalence_test.dart create mode 100644 packages/ack_generator/test/json_schema/json_schema_golden_test.dart create mode 100644 packages/ack_generator/test/json_schema/json_schema_library_test.dart create mode 100644 packages/ack_generator/test/json_schema/json_schema_required_corpus_test.dart diff --git a/.gitignore b/.gitignore index f98ee3c0..1b9df00d 100644 --- a/.gitignore +++ b/.gitignore @@ -60,6 +60,7 @@ temp/ # AI Agent workspace .augment/ +/.agents/plans/ # dart_apitool generated files api-model-*.json diff --git a/docs/guides/json-schema-integration.mdx b/docs/guides/json-schema-integration.mdx index 9e278395..36faf645 100644 --- a/docs/guides/json-schema-integration.mdx +++ b/docs/guides/json-schema-integration.mdx @@ -107,34 +107,19 @@ treat `format` as an annotation unless configured to assert it. | Strings | `minLength`, `maxLength` (Unicode code points), `pattern` (unanchored ECMA-262 search in Unicode mode) | | Numbers | `minimum`, `maximum`, `exclusiveMinimum`, `exclusiveMaximum`, `multipleOf` (exact decimal division) | | Composition | `anyOf`, `allOf`, exclusive `oneOf`, `not`, `if`/`then`/`else`; sibling assertions are intersected | -| References | `$ref`, `$id`, `$anchor`, `$defs`, `definitions`; local/cross-document JSON Pointers and structural recursion | -| Annotations | `title`, `description`, `default`, `examples`, `$comment`, `readOnly`, `writeOnly`, `deprecated`, `x-*` vendor extensions | -| Format | `date-time`, `date`, `email`, `uuid`, `uri`, `ipv4`, `ipv6` (strings); `int32`, `int64`, `uint32`, `uint64`, `float`, `double` (numbers) | - -`pattern` compiles with ECMA-262's `u` flag, as the JSON Schema Test Suite -requires and Ajv does by default. `.` matches one code point, so -`pattern: "^.$"` accepts `"😀"`, and `\p{...}` is a Unicode property escape. -A pattern that is invalid in Unicode mode fails import with an -`invalid_schema` diagnostic. For example, an escaped hyphen outside a character -class (`^\d{3}\-\d{4}$`) is invalid; write the hyphen unescaped. - -`Ack.string().matches()` uses Dart's default `RegExp`, without `u`. A native -pattern can match differently after export and import, and a native pattern -that is invalid in Unicode mode exports a schema that this importer and Ajv -reject. - -Both native `.multipleOf()` and imported `multipleOf` divide exactly on the -decimal digits of the number, as the JSON Schema Test Suite requires, so `0.3` -and `0.6` are multiples of `0.1`, `1e308` is a multiple of `0.5`, and an -unrounded binary-float sum such as `0.1 + 0.2` (`0.30000000000000004`) is not. - -This is deliberately not a complete JSON Schema validator. Unsupported -keywords include `patternProperties`, `prefixItems`, -`minContains`, `maxContains`, dependent/unevaluated keywords, dynamic -references, and custom vocabulary declarations. Non-`x-*` unknown extension -keywords and unsupported format labels are also reported. References to the -2020-12 meta-schema fail strict import. -The meta-schema itself is not validated. +| References | `$ref`, `$dynamicRef`, `$id`, `$anchor`, `$dynamicAnchor`, `$defs`, `definitions`; local/cross-document pointers and structural recursion | +| Annotations | `title`, `description`, `default`, `examples`, `$comment`, `readOnly`, `writeOnly`, `deprecated`, `format`, content keywords, unknown extensions | + +`pattern` uses Unicode-mode matching, including Unicode property escapes. +Imported schemas do not coerce instance values or inject `default` values. + +The complete required draft 2020-12 test suite passes for source validation +and export/re-import. Optional vocabulary coverage remains partial; the +repository capability matrix tracks the per-vocabulary limits. +`format` is an annotation by default. Pass `assertFormats: true` to enforce +supported formats, or supply a dialect requiring the format-assertion +vocabulary. Unknown asserted formats fail compilation. Standard 2020-12 +meta-schema resources resolve offline. ### References and bundles @@ -161,15 +146,16 @@ final AckSchema schema = Ack.fromJsonSchema( ); ``` -Exports are self-contained, Draft-7-compatible documents with root-scoped -`definitions` and rewritten references. Original IDs, reference spellings, -and document layout are not preserved. Validation equivalence is the contract, +Exports preserve imported Draft 2020-12 resources by default (`toJsonSchema()` +and `toSchemaModel()`). To lower compatible imported schemas into Draft-7 +`definitions` and rewritten references, call `toJsonSchemaDraft7()` or +`toSchemaModelDraft7()`; imported schemas that require 2020-12-only semantics +reject Draft-7 lowering explicitly. Validation equivalence is the contract, not textual identity. Imported schemas can be nested inside native objects and combined with other imported or lazy schemas without losing reference scope. Explicit JSON Pointers may also target schemas inside extension containers, such as A2UI's `#/components/Text`. Such containers are not scanned as schemas -unless referenced. Empty and duplicate enums are normalized to valid Draft-7 -representations without changing the values they accept. +unless referenced. ### Import failures and diagnostics @@ -240,9 +226,9 @@ final AckSchema ackSchema = final builderSchema = ackSchema.toJsonSchemaBuilder(); ``` -`toAckSchema({baseUri, documents})` is strict and returns the validator directly. -It accepts `Map` bundles and asserts supported `date-time` -formats through Ack's importer. +`toAckSchema({baseUri, documents, assertFormats})` returns the validator +directly. It accepts `Map` bundles. Formats are annotations +unless `assertFormats: true` or a supplied dialect enables assertions. MCP registration compatibility is deferred to separate work; importing a schema does not establish compatibility with a tool-registration API. @@ -266,6 +252,28 @@ date-time fixture adds 43 cases. The supported-subset tests check parse, encode, and re-imported exports without network access. Unsupported cases are not silently skipped within that fixture. +## Generating Dart models from draft 2020-12 + +Enable the opt-in `ack_generator:ack_json_schema` builder for a +`*.schema.json` source. In `model_mode: auto` it retains typed `@AckInfer` +fields and `copyWith` for the representable v1 subset. A valid schema with +other 2020-12 behavior generates validated value models instead; their +`value` holds immutable JSON and their `parse`, `safeParse`, `encode`, +`safeEncode`, and `toJson` methods use the source validator. Set +`model_mode: validated` to force this exact path even for simple shapes, +including the typed `int` and UTF-16 string-length edge cases. + +The builder also accepts `assert_formats: true` and an offline `documents` +mapping from retrieval URI to package-local JSON asset path. It never fetches +URLs. Source `$defs` generate named models, and a validating document root +generates `Root`; arbitrary definition keys get stable Dart class names. +Unknown required vocabularies, unsupported asserted formats, malformed +schemas, and unresolved resources fail the build with JSON-pointer diagnostics. +See the [generator package README](https://pub.dev/packages/ack_generator) +for `build.yaml` syntax. The required 2020-12 corpus generates offline with +its supplied resources; generated runtime parity is also exercised in a real +`build_runner` consumer for representative advanced features. + ## Generating JSON schemas Call `toJsonSchema()` on any `AckSchema` instance. This is the same generic Draft-7 renderer used by `schema.toSchemaModel().toJsonSchema()`. diff --git a/docs/json-schema-2020-12-capabilities.md b/docs/json-schema-2020-12-capabilities.md new file mode 100644 index 00000000..37b19873 --- /dev/null +++ b/docs/json-schema-2020-12-capabilities.md @@ -0,0 +1,55 @@ +# Draft 2020-12 capability matrix + +This matrix tracks four different claims. **Compile** means `Ack.fromJsonSchema` +accepts a schema and resolves its declarations. **Validate** means its instance +semantics are exercised. **Export** means `toJsonSchema()` preserves the +meaning under draft 2020-12. **Generate** means the opt-in +`ack_generator:ack_json_schema` builder emits a usable typed or validated Dart +model. `model_mode: auto` keeps typed v1 output where possible and uses a +validated value-model fallback otherwise; `model_mode: validated` forces the +source validator for exact parse/encode behavior. A keyword is not fully +supported merely because a compiler accepts it. + +Status: **yes** = implemented and covered, **partial** = known gaps, **no** = +unsupported or not yet implemented. `toJsonSchema()` and `toSchemaModel()` +preserve imported Draft 2020-12 resources by default (`toJsonSchemaDraft7()` +and `toSchemaModelDraft7()` lower compatible imports to Draft-7); native Ack +schemas remain Draft-7. + +| Vocabulary / keywords | Compile | Validate | Export | Generate | Remaining work | +|---|---|---|---|---|---| +| Core: boolean schemas, `$schema`, `$id`, `$defs`, `$ref`, pointers, `$anchor`, supplied documents | partial | yes | yes | yes | All 384 required groups generate offline; builder `documents` maps retrieval URIs to local assets. Custom dialects and vocabularies remain bounded by the importer. | +| Core: `$dynamicAnchor`, `$dynamicRef` | yes | yes | yes | yes | Validated value models retain dynamic scope; typed field inference remains unavailable. | +| Core: `$vocabulary` | partial | partial | partial | partial | Supplied custom dialects select supported vocabularies; unknown required vocabularies fail and unknown optional vocabularies remain inert. | +| Core: unknown keywords | yes | n/a | partial | yes | Value models preserve inert annotations; unknown required vocabularies fail. | +| Applicators: `properties`, `additionalProperties`, `propertyNames`, `items`, `allOf`, `anyOf`, `oneOf`, `not` | yes | yes | yes | yes | Typed output covers simple forms; general compositions use validated value models. | +| Applicators: `contains`, `if`, `then`, `else` | yes | yes | yes | yes | Validated value models retain the source assertions; typed fields remain a follow-up. | +| Applicators: `patternProperties`, `dependentSchemas`, `prefixItems` | yes | yes | yes | yes | Validated value models retain the source assertions; typed fields remain a follow-up. | +| Unevaluated: `unevaluatedProperties`, `unevaluatedItems` | yes | yes | yes | yes | Validated value models retain evaluated-location semantics; typed fields remain a follow-up. | +| Validation: `type`, `enum`, `const`, `required`, length/number/array/object bounds, `pattern`, `uniqueItems` | yes | yes | yes | yes | General JSON enum/const use value models. Typed `int` and UTF-16 string-length edges require `model_mode: validated` for exact parity. | +| Validation: `multipleOf`, `minContains`, `maxContains`, `dependentRequired` | yes | yes | yes | yes | Validated value models bind the compiled source schema. | +| Metadata: `title`, `description`, `default`, `examples`, `deprecated`, `readOnly`, `writeOnly`, `$comment` | yes | n/a | yes | yes | Value models retain annotations without injecting defaults or access projections. | +| Format annotation / assertion | yes | yes | yes | yes | `assert_formats: true` enables supported assertions; unknown asserted formats fail compilation by Ack's contract. | +| Content: `contentEncoding`, `contentMediaType`, `contentSchema` | yes | n/a | yes | yes | Preserved as annotations without implicit decoding. | + +## Verification contract + +- Run the **complete pinned upstream** draft 2020-12 suite and remote fixtures + offline, organized by vocabulary. Do not silently skip unsupported groups. +- Run format cases in annotation and assertion modes separately. Include + Unicode property escapes and numeric/collection equality cases. +- Generated parse and encode must agree with the compiled source validator, + including property presence and nullable roots/elements. Wrapper fallback is + explicit, documented and schema-validated. + +The complete **required** suite passes 384 groups in source-validation and +export/re-import modes. The optional format-assertion corpus passes 30 groups +in both modes, with its unknown-format group checked against Ack's explicit +fail-on-unknown-asserted-format contract. All 384 required groups also generate +source offline with their supplied resources. A real `build_runner` consumer +verifies generated parse/encode for +representative recursive, dynamic-reference, composition, unevaluated, numeric, +format-assertion, and external-resource cases. This is not a separate runtime +execution of every generated official case; other optional vocabularies remain +partially covered. The historical selected fixture remains a round-trip +regression corpus. diff --git a/llms.txt b/llms.txt index 1d564890..1d790c14 100644 --- a/llms.txt +++ b/llms.txt @@ -18,7 +18,8 @@ There is no `@AckSchema()` annotation; `AckSchema` is the runtime schema type. 1. `ack`: core runtime validation, codecs, generated-model support, and the code-generation annotations -2. `ack_generator`: generates models from schemas and schemas from models +2. `ack_generator`: generates models from fluent Ack or draft 2020-12 JSON + Schema bundles, and schemas from classes 3. `ack_firebase_ai`: converts Ack schemas to Firebase AI structured-output schemas 4. `ack_json_schema_builder`: converts Ack schemas to `json_schema_builder` schemas 5. `ack_mcp_dart`: advertises MCP tool input schemas and validates typed arguments with `mcp_dart` @@ -59,6 +60,13 @@ Both directions use `@Schemable()`, the same generated parts, and the same build command. On a top-level schema it is schema-first; on a class it is class-first. `name` applies only to a schema; every other option applies only to a class, and generation rejects an option set for the other target. +The opt-in `ack_generator:ack_json_schema` builder translates `*.schema.json` +to typed `@Schemable` models where possible and validated 2020-12 value models +otherwise. Set `model_mode: validated` for exact importer-backed parse/encode; +`assert_formats` and `documents` configure format assertions and offline +external resources. Invalid schemas fail with JSON-pointer diagnostics. Use +`unknown_properties: preserve` only when intentionally widening strict objects +for forward-compatible clients. Every annotated library declares both generated parts: diff --git a/packages/ack/README.md b/packages/ack/README.md index acef23ba..717ee8f8 100644 --- a/packages/ack/README.md +++ b/packages/ack/README.md @@ -75,7 +75,8 @@ Exports preserve supported validation behavior, not textual document identity. Full A2UI coverage is not supported. MCP registration compatibility remains separate. See the [JSON Schema guide](https://concepta.dev/documentation/ack/guides/json-schema-integration) -for the supported subset, recursive bundles, and round-trip guarantees. +and [capability matrix](../../docs/json-schema-2020-12-capabilities.md) for +coverage and round-trip guarantees. ## Documentation diff --git a/packages/ack_generator/CHANGELOG.md b/packages/ack_generator/CHANGELOG.md index 04fddef1..f70d6c91 100644 --- a/packages/ack_generator/CHANGELOG.md +++ b/packages/ack_generator/CHANGELOG.md @@ -1,3 +1,18 @@ +## Unreleased + +### Added + +* Opt-in `ack_generator:ack_json_schema` builder generates `@Schemable` schema + libraries and model parts from named draft 2020-12 `$defs` bundles in one + build. `unknown_properties: preserve` widens strict objects explicitly for + forward-compatible clients. Validated value-model fallback now covers valid + draft 2020-12 roots and definitions that cannot become typed Ack fields, + including recursion, dynamic references, composition, and nullable values. + `model_mode: validated` forces exact source-schema parse/encode semantics; + `assert_formats` and offline `documents` options retain format and external + resource behavior. Malformed or unresolved schemas fail with JSON-pointer + diagnostics. + ## 1.7.0-beta.6 ### Changed diff --git a/packages/ack_generator/README.md b/packages/ack_generator/README.md index 229066ad..0922cc63 100644 --- a/packages/ack_generator/README.md +++ b/packages/ack_generator/README.md @@ -8,6 +8,87 @@ come from `package:ack/annotations.dart`. `@AckInfer()` and `@AckModel()` are de spellings of `@Schemable()` until 2.0.0. It also retains the deprecated Ack 1.1 `@AckType()` generator unchanged. +## Generate models from JSON Schema + +The opt-in `ack_json_schema` builder turns a draft 2020-12 JSON Schema into +models. It emits typed `@AckInfer` models for shapes it can represent as Ack +fields. For other valid 2020-12 shapes, it emits validated +value models backed by `Ack.fromJsonSchema`, preserving every supported +assertion. Name the source `lib/contract.schema.json`; named `$defs` entries +produce models, and a schema with root assertions also produces `Root` +(`DocumentRoot` when `$defs/Root` already owns that name): + +```json +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "User": { + "type": "object", + "properties": { "name": { "type": "string" } }, + "required": ["name"], + "additionalProperties": false + } + } +} +``` + +Enable the builder in the consuming package's `build.yaml`: + +```yaml +targets: + $default: + builders: + ack_generator:ack_json_schema: + enabled: true + options: + unknown_properties: schema + assert_formats: false + model_mode: auto # use validated for exact source-schema parity + # Optional offline resources: retrieval URI -> package asset path. + # documents: + # "https://example.test/common.json": "lib/common.json" +``` + +Run `dart run build_runner build`. Import `contract.schema.dart` to use the +generated models. A typed model also writes `contract.schema.ack.dart` and +`contract.schema.ack.g.dart` and offers typed fields and `copyWith`. A validated +value model needs no parts: `User.parse(value)` returns a wrapper whose +immutable `value` is the validated JSON value; `User.encode(model)`, +`User.safeEncode(model)`, and `model.toJson()` revalidate on output. The +`userSchema` declaration and `User.schema` expose the exact source validator. +Do not hand-edit generated files. + +The default `unknown_properties: schema` follows each object's +`additionalProperties`. Choose `preserve` for a forward-compatible client: it +widens `additionalProperties: false` to `true` at every object, records every +widened JSON pointer in the generated header, and captures unknown fields in +`additionalProperties`. The source JSON Schema is never rewritten. This is a +deliberate change to the effective validation contract. + +Typed output covers named non-null objects, references to named definitions, +inline objects, maps, arrays with non-null items, common primitives and bounds, +string enums, and simple unions. Other valid shapes—including recursive and +dynamic references, composition, nullable roots and items, `multipleOf`, +unevaluated keywords, and format assertions—use validated value models. +`assert_formats: true` opts into format assertions under the standard dialect; +unknown asserted formats and unresolved or malformed schemas fail the build +with a JSON-pointer diagnostic. External resources are read only from the +explicit `documents` mapping; the builder never fetches URLs. The complete +required 2020-12 corpus generates offline with its supplied resources. Custom +vocabulary support is limited to what `Ack.fromJsonSchema` implements. + +Definitions with non-Dart names receive stable UpperCamelCase wrapper names. +If the entire document uses fallback, all its models are value wrappers so +references and dynamic scope stay bound to the same effective source document. +Validated wrappers do not expose inferred fields or typed `copyWith`. + +An integral JSON number beyond Dart's `int` range is a known edge case: +`Ack.fromJsonSchema` can accept it as an integral `double`, while a typed +`Ack.integer()` model cannot represent it as an `int`. String length bounds also +differ for astral Unicode characters (code points in the importer versus UTF-16 +units in the fluent schema). Set `model_mode: validated` when these edge cases +matter; typed output retains the original v1 contract for compatibility. + ## Schema-first usage ```dart diff --git a/packages/ack_generator/build.yaml b/packages/ack_generator/build.yaml index 7358d5b7..bbe8ade7 100644 --- a/packages/ack_generator/build.yaml +++ b/packages/ack_generator/build.yaml @@ -34,3 +34,14 @@ builders: auto_apply: none build_to: source required_inputs: [".ack.dart"] + + ack_json_schema: + import: "package:ack_generator/builder.dart" + builder_factories: ["ackJsonSchemaBuilder"] + build_extensions: {".schema.json": [".schema.dart"]} + auto_apply: none + build_to: source + runs_before: ["ack_generator:ack_models"] + defaults: + options: + unknown_properties: schema diff --git a/packages/ack_generator/lib/src/analyzer/schema_first_names.dart b/packages/ack_generator/lib/src/analyzer/schema_first_names.dart new file mode 100644 index 00000000..3873699d --- /dev/null +++ b/packages/ack_generator/lib/src/analyzer/schema_first_names.dart @@ -0,0 +1,111 @@ +/// Member names every schema-first model generates, so a field cannot use +/// them. +const ackSchemaFirstReservedMembers = { + r'$ack', + 'parse', + 'safeParse', + 'fromJson', + 'toJson', + 'safeToJson', + 'copyWith', + '_fromAckRuntime', + '_toAckRuntime', + 'hashCode', + 'noSuchMethod', + 'toString', + 'runtimeType', +}; + +/// Dart reserved words, which cannot be field names. +const ackDartReservedWords = { + 'abstract', + 'as', + 'assert', + 'async', + 'await', + 'base', + 'break', + 'case', + 'catch', + 'class', + 'const', + 'continue', + 'covariant', + 'default', + 'deferred', + 'do', + 'dynamic', + 'else', + 'enum', + 'export', + 'extends', + 'extension', + 'external', + 'factory', + 'false', + 'final', + 'finally', + 'for', + 'get', + 'hide', + 'if', + 'implements', + 'import', + 'in', + 'interface', + 'is', + 'late', + 'library', + 'mixin', + 'new', + 'null', + 'of', + 'on', + 'operator', + 'part', + 'required', + 'rethrow', + 'return', + 'sealed', + 'set', + 'show', + 'static', + 'super', + 'switch', + 'sync', + 'this', + 'throw', + 'true', + 'try', + 'typedef', + 'var', + 'void', + 'when', + 'while', + 'with', + 'yield', +}; + +/// Why [key] cannot be a schema-first field name, or `null` when it can. +/// +/// [capturesExtras] is whether the model stores unknown properties in its +/// generated `additionalProperties` field. +String? ackSchemaFirstFieldNameProblem( + String key, { + required bool capturesExtras, +}) { + if (key.startsWith('_')) { + return "cannot start with '_' (private Dart member)."; + } + if (!RegExp(r'^[A-Za-z$][A-Za-z0-9_$]*$').hasMatch(key) || + ackDartReservedWords.contains(key)) { + return 'cannot be represented as a Dart field name.'; + } + if (ackSchemaFirstReservedMembers.contains(key)) { + return 'conflicts with generated/Object member "$key".'; + } + if (capturesExtras && key == 'additionalProperties') { + return 'conflicts with the generated additional-properties member.'; + } + return null; +} diff --git a/packages/ack_generator/lib/src/analyzer/schema_model_graph_builder.dart b/packages/ack_generator/lib/src/analyzer/schema_model_graph_builder.dart index 129262f9..b315fd9c 100644 --- a/packages/ack_generator/lib/src/analyzer/schema_model_graph_builder.dart +++ b/packages/ack_generator/lib/src/analyzer/schema_model_graph_builder.dart @@ -9,6 +9,7 @@ import 'package:analyzer/dart/element/type.dart'; import 'package:source_gen/source_gen.dart'; import '../json/helper_names.dart'; +import 'schema_first_names.dart'; import '../models/schema_model_graph.dart'; import 'generated_companion_visibility.dart'; @@ -52,91 +53,6 @@ typedef _SchemaTypes = ({AckInferRef boundary, AckInferRef runtime}); final class SchemaModelGraphBuilder { SchemaModelGraphBuilder(this.library); - static const _reservedMembers = { - r'$ack', - 'parse', - 'safeParse', - 'fromJson', - 'toJson', - 'safeToJson', - 'copyWith', - '_fromAckRuntime', - '_toAckRuntime', - 'hashCode', - 'noSuchMethod', - 'toString', - 'runtimeType', - }; - - static const _dartKeywords = { - 'abstract', - 'as', - 'assert', - 'async', - 'await', - 'base', - 'break', - 'case', - 'catch', - 'class', - 'const', - 'continue', - 'covariant', - 'default', - 'deferred', - 'do', - 'dynamic', - 'else', - 'enum', - 'export', - 'extends', - 'extension', - 'external', - 'factory', - 'false', - 'final', - 'finally', - 'for', - 'get', - 'hide', - 'if', - 'implements', - 'import', - 'in', - 'interface', - 'is', - 'late', - 'library', - 'mixin', - 'new', - 'null', - 'of', - 'on', - 'operator', - 'part', - 'required', - 'rethrow', - 'return', - 'sealed', - 'set', - 'show', - 'static', - 'super', - 'switch', - 'sync', - 'this', - 'throw', - 'true', - 'try', - 'typedef', - 'var', - 'void', - 'when', - 'while', - 'with', - 'yield', - }; - static const _oneWayTransformMethods = { 'transform', 'trim', @@ -517,7 +433,7 @@ final class SchemaModelGraphBuilder { } final jsonKey = (entry.key as SimpleStringLiteral).value; _rejectInvalidMemberName(jsonKey, path, declaration.element); - if (_reservedMembers.contains(jsonKey)) { + if (ackSchemaFirstReservedMembers.contains(jsonKey)) { throw InvalidGenerationSource( '$path.$jsonKey conflicts with generated/Object member "$jsonKey".', element: declaration.element, @@ -606,7 +522,7 @@ final class SchemaModelGraphBuilder { ); } _rejectInvalidMemberName(discriminatorKey, path, declaration.element); - if (_reservedMembers.contains(discriminatorKey) || + if (ackSchemaFirstReservedMembers.contains(discriminatorKey) || discriminatorKey == 'additionalProperties') { throw InvalidGenerationSource( '$path.$discriminatorKey conflicts with a generated member or Dart ' @@ -1653,7 +1569,7 @@ final class SchemaModelGraphBuilder { element: element, ); } - if (_reservedMembers.contains(bridgeName)) { + if (ackSchemaFirstReservedMembers.contains(bridgeName)) { throw InvalidGenerationSource( '$path.$fieldName generates helper "$bridgeName" that conflicts ' 'with a generated member.', @@ -1747,7 +1663,7 @@ final class SchemaModelGraphBuilder { ); } if (!RegExp(r'^[A-Za-z$][A-Za-z0-9_$]*$').hasMatch(jsonKey) || - _dartKeywords.contains(jsonKey)) { + ackDartReservedWords.contains(jsonKey)) { throw InvalidGenerationSource( '$path.$jsonKey cannot be represented as a Dart field name.', element: element, diff --git a/packages/ack_generator/lib/src/builder.dart b/packages/ack_generator/lib/src/builder.dart index dd98d00b..9d85104d 100644 --- a/packages/ack_generator/lib/src/builder.dart +++ b/packages/ack_generator/lib/src/builder.dart @@ -3,6 +3,8 @@ import 'package:source_gen/source_gen.dart'; import 'generator.dart'; import 'json/ack_json_generator.dart'; +import 'json_schema/json_schema_builder.dart'; +import 'json_schema/json_schema_library.dart'; import 'model_generator.dart'; /// Header for the modern parts. @@ -41,3 +43,11 @@ Builder ackModelJsonBuilder(BuilderOptions options) { options: options, ); } + +/// Creates the opt-in builder that generates `@AckInfer` schema declarations +/// from `*.schema.json` JSON Schema bundles. +/// +/// Enable it in a consumer's `build.yaml` as `ack_generator:ack_json_schema`. +Builder ackJsonSchemaBuilder(BuilderOptions options) => AckJsonSchemaBuilder( + AckJsonSchemaLibraryOptions.fromConfig(options.config), +); diff --git a/packages/ack_generator/lib/src/builders/model_emitter.dart b/packages/ack_generator/lib/src/builders/model_emitter.dart index 3393f339..26879f75 100644 --- a/packages/ack_generator/lib/src/builders/model_emitter.dart +++ b/packages/ack_generator/lib/src/builders/model_emitter.dart @@ -304,9 +304,7 @@ return switch (value[${dartStringLiteral(node.discriminatorKey)}]) { ..name = field.dartName ..modifier = FieldModifier.final$ ..type = refer(_fieldType(field)) - ..docs.addAll([ - if (field.description != null) '/// ${field.description}', - ]), + ..docs.addAll(_descriptionDocs(field.description)), ); Field _additionalPropertiesField() => Field( @@ -1072,7 +1070,15 @@ return $helper({ List _docs(AckModelNode node, String kind) => [ '/// $kind generated from `${node.id.declarationName}`.', - if (node.description != null) '/// ${node.description}', + ..._descriptionDocs(node.description), + ]; + + /// One doc-comment line per description line, so multi-line descriptions + /// keep the generated declaration valid. + List _descriptionDocs(String? description) => [ + if (description != null) + for (final line in description.split(RegExp(r'\r?\n'))) + line.trimRight().isEmpty ? '///' : '/// ${line.trimRight()}', ]; Expression _jsonMarker() { diff --git a/packages/ack_generator/lib/src/json_schema/json_schema_builder.dart b/packages/ack_generator/lib/src/json_schema/json_schema_builder.dart new file mode 100644 index 00000000..cd2657e2 --- /dev/null +++ b/packages/ack_generator/lib/src/json_schema/json_schema_builder.dart @@ -0,0 +1,72 @@ +import 'dart:convert'; + +import 'package:build/build.dart'; +import 'package:pub_semver/pub_semver.dart'; + +import 'json_schema_library.dart'; + +/// Generates `.schema.dart` `@AckInfer` declarations from each +/// `.schema.json` draft 2020-12 bundle. +/// +/// The generated library declares the model parts that `ack_models` and +/// `ack_model_json` then fill in the same build. +final class AckJsonSchemaBuilder implements Builder { + /// Creates the builder with its `build.yaml` [options]. + AckJsonSchemaBuilder(this.options); + + /// The parsed builder options. + final AckJsonSchemaLibraryOptions options; + + @override + final buildExtensions = const { + '.schema.json': ['.schema.dart'], + }; + + @override + Future build(BuildStep buildStep) async { + final input = buildStep.inputId; + final Object? document; + try { + document = jsonDecode(await buildStep.readAsString(input)); + } on FormatException catch (error) { + throw FormatException('${input.uri} is not valid JSON: ${error.message}'); + } + final documents = {}; + for (final entry in options.documentPaths.entries) { + final asset = AssetId(input.package, entry.value); + if (!await buildStep.canRead(asset)) { + throw FormatException( + '${input.uri} cannot read document ${entry.key} at ${entry.value}.', + ); + } + try { + final decoded = jsonDecode(await buildStep.readAsString(asset)); + if (decoded == null) { + throw const FormatException('Expected a schema object or boolean.'); + } + documents[input.uri.resolveUri(entry.key)] = decoded; + } on FormatException catch (error) { + throw FormatException( + '${input.uri} document ${entry.key} is not valid JSON: ' + '${error.message}', + ); + } + } + final packageConfig = await buildStep.packageConfig; + final language = packageConfig[input.package]?.languageVersion; + final library = generateAckSchemaLibrary( + document: document, + documentUri: input.uri, + partStem: input.pathSegments.last.replaceFirst(RegExp(r'\.json$'), ''), + options: options, + documents: documents, + languageVersion: language == null + ? null + : Version(language.major, language.minor, 0), + ); + await buildStep.writeAsString( + input.changeExtension('.dart'), + library.source, + ); + } +} diff --git a/packages/ack_generator/lib/src/json_schema/json_schema_generation_exception.dart b/packages/ack_generator/lib/src/json_schema/json_schema_generation_exception.dart new file mode 100644 index 00000000..b89acb01 --- /dev/null +++ b/packages/ack_generator/lib/src/json_schema/json_schema_generation_exception.dart @@ -0,0 +1,33 @@ +/// One reason a JSON Schema document cannot generate Ack models. +final class AckJsonSchemaDiagnostic { + /// Creates a diagnostic for the node at [pointer]. + const AckJsonSchemaDiagnostic(this.pointer, this.message); + + /// The JSON pointer of the offending node, such as `#/$defs/User/type`. + final String pointer; + + /// What is unsupported and, where possible, how to fix it. + final String message; + + @override + String toString() => '$pointer: $message'; +} + +/// Thrown when a JSON Schema document cannot generate Ack models. +final class AckJsonSchemaGenerationException implements Exception { + /// Creates an exception for [documentUri] with every [diagnostics] entry. + AckJsonSchemaGenerationException(this.documentUri, this.diagnostics) + : assert(diagnostics.isNotEmpty, 'An exception needs a diagnostic.'); + + /// The document that failed. + final Uri documentUri; + + /// Every problem found, in document order. + final List diagnostics; + + @override + String toString() => [ + 'Cannot generate Ack models from $documentUri:', + for (final diagnostic in diagnostics) ' $documentUri$diagnostic', + ].join('\n'); +} diff --git a/packages/ack_generator/lib/src/json_schema/json_schema_library.dart b/packages/ack_generator/lib/src/json_schema/json_schema_library.dart new file mode 100644 index 00000000..9511f013 --- /dev/null +++ b/packages/ack_generator/lib/src/json_schema/json_schema_library.dart @@ -0,0 +1,1277 @@ +import 'dart:convert'; + +import 'package:ack/ack.dart'; +import 'package:dart_style/dart_style.dart'; +import 'package:pub_semver/pub_semver.dart'; + +import '../analyzer/schema_first_names.dart'; +import '../json/helper_names.dart'; +import 'json_schema_generation_exception.dart'; + +/// How generated models treat properties a JSON Schema object doesn't declare. +enum AckUnknownPropertiesMode { + /// Follow the schema: `additionalProperties: false` rejects unknown keys. + schema, + + /// Accept and keep unknown keys on every object, for clients that must + /// tolerate additive server changes to strict schemas. + preserve, +} + +/// Whether to prefer typed fields or exact source-schema value wrappers. +enum AckJsonSchemaModelMode { auto, validated } + +/// Options for [generateAckSchemaLibrary]. +final class AckJsonSchemaLibraryOptions { + /// Creates generation options. + const AckJsonSchemaLibraryOptions({ + this.unknownProperties = AckUnknownPropertiesMode.schema, + this.documentPaths = const {}, + this.assertFormats = false, + this.modelMode = AckJsonSchemaModelMode.auto, + }); + + /// Parses builder options from `build.yaml`. + /// + /// Throws an [ArgumentError] for an unknown key or value. + factory AckJsonSchemaLibraryOptions.fromConfig(Map config) { + final unknown = config.keys.toSet().difference({ + 'unknown_properties', + 'documents', + 'assert_formats', + 'model_mode', + }); + if (unknown.isNotEmpty) { + throw ArgumentError( + 'Unknown ack_json_schema options: ${unknown.join(', ')}. ' + 'Supported: unknown_properties, documents, assert_formats, model_mode.', + ); + } + final mode = config['unknown_properties'] ?? 'schema'; + final paths = config['documents'] ?? const {}; + if (paths is! Map || + paths.keys.any((key) => key is! String) || + paths.values.any((value) => value is! String)) { + throw ArgumentError.value( + paths, + 'documents', + 'Must map retrieval URI strings to package asset paths.', + ); + } + final assertFormats = config['assert_formats'] ?? false; + if (assertFormats is! bool) { + throw ArgumentError.value( + assertFormats, + 'assert_formats', + 'Must be a boolean.', + ); + } + return AckJsonSchemaLibraryOptions( + assertFormats: assertFormats, + modelMode: switch (config['model_mode'] ?? 'auto') { + 'auto' => AckJsonSchemaModelMode.auto, + 'validated' => AckJsonSchemaModelMode.validated, + final value => throw ArgumentError.value( + value, + 'model_mode', + 'Must be "auto" or "validated".', + ), + }, + documentPaths: { + for (final entry in paths.entries) + Uri.parse(entry.key as String): entry.value as String, + }, + unknownProperties: switch (mode) { + 'schema' => AckUnknownPropertiesMode.schema, + 'preserve' => AckUnknownPropertiesMode.preserve, + _ => throw ArgumentError.value( + mode, + 'unknown_properties', + 'Must be "schema" or "preserve".', + ), + }, + ); + } + + /// How objects treat undeclared properties. + final AckUnknownPropertiesMode unknownProperties; + + /// External schema resources declared in `build.yaml`, keyed by retrieval + /// URI and stored at package-local asset paths. + final Map documentPaths; + + /// Assert known formats even under the standard 2020-12 dialect. + final bool assertFormats; + + /// `auto` retains typed v1 models where possible; `validated` uses exact + /// source-schema value wrappers for every model. + final AckJsonSchemaModelMode modelMode; +} + +/// A generated Dart library and the document it was checked against. +final class AckJsonSchemaLibrary { + const AckJsonSchemaLibrary._({ + required this.source, + required this.effectiveDocument, + required this.widenedPointers, + }); + + /// The formatted Dart source. + final String source; + + /// The input document with the option widenings applied. + /// + /// Validated value models use `Ack.fromJsonSchema` directly. Typed models + /// retain the documented integer-range and string-length edge cases. + final Map effectiveDocument; + + /// Pointers of objects whose unknown-property rule an option widened. + final List widenedPointers; +} + +/// Generates typed Ack models or validated value models from a draft 2020-12 +/// [document]. +/// +/// [documentUri] identifies the document in diagnostics and resolves its +/// references. [documents] supplies offline external resources. [partStem] +/// names typed model parts (`.ack.dart`); value-model fallback needs no +/// parts. Set [includeParts] to `false` for a plain typed schema library. +/// +/// Throws an [AckJsonSchemaGenerationException] for invalid or unresolvable +/// schema resources, with source pointers where available. +AckJsonSchemaLibrary generateAckSchemaLibrary({ + required Object? document, + required Uri documentUri, + required String partStem, + AckJsonSchemaLibraryOptions options = const AckJsonSchemaLibraryOptions(), + Map documents = const {}, + bool includeParts = true, + Version? languageVersion, +}) { + if (options.modelMode == AckJsonSchemaModelMode.auto) { + final translator = _Translator(documentUri, options, documents); + final effective = translator.prepare(document); + if (effective != null) translator.translate(effective); + if (translator.diagnostics.isEmpty) { + final unformatted = _emit( + translator, + documentUri: documentUri, + partStem: partStem, + includeParts: includeParts, + ); + return AckJsonSchemaLibrary._( + source: DartFormatter( + languageVersion: + languageVersion ?? DartFormatter.latestLanguageVersion, + ).format(unformatted), + effectiveDocument: effective!, + widenedPointers: List.unmodifiable(translator.widened), + ); + } + } + + // The typed translator is intentionally conservative. A valid document it + // cannot lower still generates a value model with the source validator as + // its parse/encode boundary, so no 2020-12 assertion is silently dropped. + final fallbackDocuments = { + for (final entry in documents.entries) entry.key: _deepCopy(entry.value)!, + }; + final fallback = _Translator(documentUri, options, fallbackDocuments); + final fallbackDocument = fallback.prepareFallback(document); + if (fallback.diagnostics.isNotEmpty) { + throw AckJsonSchemaGenerationException(documentUri, fallback.diagnostics); + } + return AckJsonSchemaLibrary._( + source: + DartFormatter( + languageVersion: + languageVersion ?? DartFormatter.latestLanguageVersion, + ).format( + _emitFallback( + fallbackDocument!, + documentUri: documentUri, + documents: fallbackDocuments, + assertFormats: options.assertFormats, + widened: fallback.widened, + ), + ), + effectiveDocument: fallbackDocument, + widenedPointers: List.unmodifiable(fallback.widened), + ); +} + +const _annotations = { + 'title', + 'description', + 'default', + 'examples', + r'$comment', + 'deprecated', + 'readOnly', + 'writeOnly', +}; + +const _typeKeywords = { + 'string': {'type', 'minLength', 'maxLength', 'pattern'}, + 'integer': { + 'type', + 'minimum', + 'maximum', + 'exclusiveMinimum', + 'exclusiveMaximum', + }, + 'number': { + 'type', + 'minimum', + 'maximum', + 'exclusiveMinimum', + 'exclusiveMaximum', + }, + 'boolean': {'type'}, + 'array': {'type', 'items', 'minItems', 'maxItems', 'uniqueItems'}, + 'object': { + 'type', + 'properties', + 'required', + 'additionalProperties', + 'propertyNames', + }, +}; + +// Dart names that a generated library already uses unprefixed. +const _reservedModelNames = { + 'Object', + 'String', + 'List', + 'Map', + 'MapEntry', + 'Set', + 'Iterable', + 'Iterator', + 'Future', + 'Comparable', + 'Null', + 'Never', + 'Function', + 'Type', + 'Record', + 'Enum', + 'Symbol', + 'Duration', + 'DateTime', + 'Uri', + 'RegExp', +}; + +final class _Declaration { + _Declaration(this.name, this.code); + + final String name; + final String code; +} + +final class _Expr { + const _Expr(this.code, {this.nullable = false, this.reference}); + + final String code; + final bool nullable; + + /// The model this expression names, when it is a model reference. + final String? reference; +} + +final class _Translator { + _Translator(this.documentUri, this.options, this.documents); + + final Uri documentUri; + final AckJsonSchemaLibraryOptions options; + final Map documents; + final diagnostics = []; + final declarations = <_Declaration>[]; + final widened = []; + + final _modelNames = {}; + final _edges = >{}; + final _edgePointers = <(String, String), String>{}; + late Map _defs; + + void _fail(String pointer, String message) => + diagnostics.add(AckJsonSchemaDiagnostic(pointer, message)); + + /// Validates the document shape and names, applies widenings, and gates the + /// result through `Ack.fromJsonSchema`. Returns `null` on failure. + Map? prepare(Object? document) { + if (document is! Map) { + _fail('#', 'Expected a JSON object with a "\$defs" map.'); + return null; + } + const rootKeys = { + r'$schema', + r'$defs', + r'$comment', + 'title', + 'description', + }; + for (final key in document.keys) { + if (!rootKeys.contains(key)) { + _fail( + '#/${_token(key)}', + 'Root keyword "$key" is not supported. Put each model under ' + '"\$defs".', + ); + } + } + final defs = document[r'$defs']; + if (defs is! Map || defs.isEmpty) { + _fail('#/\$defs', 'Expected a non-empty "\$defs" map of models.'); + return null; + } + for (final name in defs.keys) { + final pointer = '#/\$defs/${_token(name)}'; + if (!RegExp(r'^[A-Z][A-Za-z0-9]*$').hasMatch(name)) { + _fail( + pointer, + 'Model name "$name" must be UpperCamelCase letters and digits.', + ); + } else if (_reservedModelNames.contains(name)) { + _fail(pointer, 'Model name "$name" shadows a dart:core type.'); + } + } + if (diagnostics.isNotEmpty) return null; + + final effective = _deepCopy(document)! as Map; + if (options.unknownProperties == AckUnknownPropertiesMode.preserve) { + _widen(effective, '#'); + } + _gate(effective, defs.keys); + return diagnostics.isEmpty ? effective : null; + } + + /// Prepares a 2020-12 schema and its supplied resources for value models. + Map? prepareFallback(Object? document) { + if (document is! Map && document is! bool) { + _fail('#', 'Expected a JSON Schema object or boolean.'); + return null; + } + final effective = document is bool + ? { + 'allOf': [document], + } + : _deepCopy(document)! as Map; + final defs = effective[r'$defs']; + if (defs != null && defs is! Map) { + _fail(r'#/$defs', 'Expected a map of schemas.'); + return null; + } + if (options.unknownProperties == AckUnknownPropertiesMode.preserve) { + _widen(effective, '#'); + for (final entry in documents.entries) { + _widen(entry.value, '${entry.key}#'); + } + } + try { + Ack.fromJsonSchema( + effective, + baseUri: documentUri, + documents: documents, + assertFormats: options.assertFormats, + ); + } on JsonSchemaImportException catch (error) { + for (final diagnostic in error.diagnostics) { + _fail(diagnostic.pointer, '${diagnostic.message} (${diagnostic.code})'); + } + } + if (defs case final Map schemas when schemas.isNotEmpty) { + _gate(effective, schemas.keys); + } + return diagnostics.isEmpty ? effective : null; + } + + /// Sets `additionalProperties: false` to `true` on every schema, walking + /// only keywords whose values are schemas. + void _widen(Object? node, String pointer) { + if (node is! Map) return; + if (node['additionalProperties'] == false) { + node['additionalProperties'] = true; + widened.add(pointer); + } + for (final keyword in const [ + r'$defs', + 'definitions', + 'properties', + 'patternProperties', + 'dependentSchemas', + ]) { + if (node[keyword] case final Map schemas) { + for (final MapEntry(:key, :value) in schemas.entries) { + _widen(value, '$pointer/$keyword/${_token(key)}'); + } + } + } + for (final keyword in const [ + 'items', + 'additionalProperties', + 'propertyNames', + 'contains', + 'not', + 'if', + 'then', + 'else', + 'unevaluatedProperties', + 'unevaluatedItems', + ]) { + _widen(node[keyword], '$pointer/$keyword'); + } + for (final keyword in const ['anyOf', 'allOf', 'oneOf', 'prefixItems']) { + if (node[keyword] case final List schemas) { + for (var i = 0; i < schemas.length; i++) { + _widen(schemas[i], '$pointer/$keyword/$i'); + } + } + } + } + + void _gate(Map effective, Iterable names) { + try { + Ack.fromJsonSchema( + { + 'allOf': [ + for (final name in names) + { + r'$ref': documentUri + .replace(fragment: '/\$defs/${_token(name)}') + .toString(), + }, + ], + }, + baseUri: documentUri.resolve('__ack_json_schema_gate__.json'), + documents: {...documents, documentUri: effective}, + assertFormats: options.assertFormats, + ); + } on JsonSchemaImportException catch (error) { + for (final diagnostic in error.diagnostics) { + _fail(diagnostic.pointer, '${diagnostic.message} (${diagnostic.code})'); + } + } + } + + void translate(Map effective) { + _defs = effective[r'$defs']! as Map; + _modelNames.addAll(_defs.keys); + for (final MapEntry(key: name, value: node) in _defs.entries) { + final pointer = '#/\$defs/$name'; + final expr = _expr( + node, + pointer, + owner: name, + hoistName: name, + root: true, + ); + if (expr == null) continue; + if (expr.nullable) { + _fail( + pointer, + 'A "\$defs" model cannot be nullable. Make the properties that ' + 'reference it nullable instead.', + ); + } else if (expr.reference != null) { + _fail( + pointer, + 'A model that only references another model is not ' + 'supported. Reference "${expr.reference}" directly.', + ); + } else { + declarations.add(_Declaration(name, expr.code)); + } + } + _rejectCycles(); + } + + _Expr? _expr( + Object? node, + String pointer, { + required String owner, + required String hoistName, + bool root = false, + }) { + if (node == true) return const _Expr('ack.Ack.any()', nullable: true); + if (node == false) { + _fail(pointer, 'The "false" schema accepts no value.'); + return null; + } + if (node is! Map) { + _fail(pointer, 'Expected a schema object.'); + return null; + } + final keywords = node.keys.where((key) => !_annotations.contains(key)); + final keys = keywords.toSet(); + final description = node['description']; + String describe(String code) => description is String + ? '$code.describe(${_literal(description)})' + : code; + + if (keys.isEmpty) { + return _Expr(describe('ack.Ack.any()'), nullable: true); + } + if (keys.contains(r'$ref')) { + if (keys.length > 1) { + _fail(pointer, 'Keywords next to "\$ref" are not supported.'); + return null; + } + return _ref(node[r'$ref'], '$pointer/\$ref', owner); + } + for (final keyword in ['allOf', 'oneOf', 'not', 'if', 'then', 'else']) { + if (keys.contains(keyword)) { + _fail('$pointer/$keyword', 'Keyword "$keyword" is not supported.'); + return null; + } + } + if (keys.contains('anyOf')) { + if (keys.length > 1) { + _fail(pointer, 'Keywords next to "anyOf" are not supported.'); + return null; + } + return _anyOf( + node['anyOf'], + pointer, + owner, + hoistName, + describe, + root: root, + ); + } + if (keys.contains('enum')) return _enum(node, pointer, keys, describe); + if (keys.contains('const')) return _const(node, pointer, keys, describe); + + final type = node['type']; + if (type == null) { + _fail(pointer, 'Add a "type"; untyped schemas are not supported.'); + return null; + } + final types = type is List ? type : [type]; + final nullable = types.contains('null'); + final nonNull = [ + for (final t in types) + if (t != 'null') t, + ]; + if (nonNull.isEmpty) { + _fail( + '$pointer/type', + 'A schema that only accepts null is not ' + 'supported.', + ); + return null; + } + if (nonNull.length > 1) { + if (keys.length > 1) { + _fail(pointer, 'A multi-type schema cannot also have constraints.'); + return null; + } + final union = _scalarUnion(nonNull, '$pointer/type'); + return union == null ? null : _Expr(describe(union), nullable: nullable); + } + final single = nonNull.single! as String; + final allowed = _typeKeywords[single]; + if (allowed == null) { + _fail('$pointer/type', 'Type "$single" is not supported.'); + return null; + } + for (final keyword in keys.difference(allowed)) { + _fail( + '$pointer/${_token(keyword)}', + 'Keyword "$keyword" cannot be represented in a generated model.', + ); + } + if (!allowed.containsAll(keys)) return null; + + if (single == 'object') { + final object = _object(node, pointer, owner, hoistName, root: root); + if (object == null) return null; + return object.reference == null + ? _Expr(describe(object.code), nullable: nullable) + : _Expr(object.code, nullable: nullable, reference: object.reference); + } + final code = switch (single) { + 'string' => _string(node, pointer), + 'integer' => _integer(node), + 'number' => _number(node), + 'boolean' => 'ack.Ack.boolean()', + _ => _array(node, pointer, owner, hoistName), + }; + if (code == null) return null; + return _Expr(describe(code), nullable: nullable); + } + + _Expr? _ref(Object? ref, String pointer, String owner) { + final match = ref is String + ? RegExp(r'^#/\$defs/([A-Za-z0-9]+)$').firstMatch(ref) + : null; + final name = match?.group(1); + if (name == null || !_defs.containsKey(name)) { + _fail( + pointer, + 'Only "#/\$defs/" references to a model in this ' + 'document are supported.', + ); + return null; + } + _edge(owner, name, pointer); + return _Expr(_variable(name), reference: name); + } + + _Expr? _anyOf( + Object? branches, + String pointer, + String owner, + String hoistName, + String Function(String) describe, { + required bool root, + }) { + final flat = <(Object?, String)>[]; + void flatten(Object? list, String at) { + if (list is! List) return; + for (var i = 0; i < list.length; i++) { + final branch = list[i]; + final branchPointer = '$at/anyOf/$i'; + if (branch is Map && + branch.keys.every( + (k) => k == 'anyOf' || _annotations.contains(k), + ) && + branch.containsKey('anyOf')) { + flatten(branch['anyOf'], branchPointer); + } else { + flat.add((branch, branchPointer)); + } + } + } + + flatten(branches, pointer); + bool isNull(Object? branch) => + branch is Map && + branch.keys.every((k) => k == 'type' || _annotations.contains(k)) && + (branch['type'] == 'null' || + (branch['type'] is List && _listEquals(branch['type'], ['null']))); + final nullable = flat.any((b) => isNull(b.$1)); + final rest = [ + for (final branch in flat) + if (!isNull(branch.$1)) branch, + ]; + if (rest.isEmpty) { + _fail(pointer, 'An "anyOf" that only accepts null is not supported.'); + return null; + } + if (rest.length == 1) { + final (branch, branchPointer) = rest.single; + final inner = _expr( + branch, + branchPointer, + owner: owner, + hoistName: hoistName, + root: root, + ); + if (inner == null) return null; + return _Expr( + inner.reference == null ? describe(inner.code) : inner.code, + nullable: nullable || inner.nullable, + reference: inner.reference, + ); + } + final types = []; + for (final (branch, branchPointer) in rest) { + final bare = + branch is Map && + branch.keys.every((k) => k == 'type' || _annotations.contains(k)) && + branch['type'] is String; + if (!bare) { + _fail( + branchPointer, + 'Only unions of bare string, number, and boolean ' + 'types are supported.', + ); + return null; + } + types.add(branch['type']); + } + final union = _scalarUnion(types, pointer); + return union == null ? null : _Expr(describe(union), nullable: nullable); + } + + String? _scalarUnion(List types, String pointer) { + const checks = {'string': 'String', 'number': 'num', 'boolean': 'bool'}; + final dartTypes = []; + for (final type in types) { + final check = checks[type]; + if (check == null) { + _fail( + pointer, + 'Type "$type" cannot be part of a union; only string, ' + 'number, and boolean can.', + ); + return null; + } + if (!dartTypes.contains(check)) dartTypes.add(check); + } + final test = dartTypes.map((t) => 'value is $t').join(' || '); + final names = [ + for (final type in types.toSet()) + if (type == 'number') 'a number' else 'a $type', + ]; + return 'ack.Ack.any().refine((value) => $test, ' + 'message: ${_literal('Expected ${names.join(' or ')}.')})'; + } + + _Expr? _enum( + Map node, + String pointer, + Set keys, + String Function(String) describe, + ) { + if (!{'enum', 'type'}.containsAll(keys)) { + _fail( + pointer, + 'Keywords next to "enum" other than "type" are not ' + 'supported.', + ); + return null; + } + final type = node['type']; + final types = type == null ? null : (type is List ? type : [type]); + if (types != null && + (types.any((t) => t != 'string' && t != 'null') || + !types.contains('string'))) { + _fail('$pointer/type', 'A string enum needs a string-compatible type.'); + return null; + } + final values = node['enum']; + if (values is! List) return null; + final allowsNull = types == null || types.contains('null'); + final strings = []; + for (var i = 0; i < values.length; i++) { + final value = values[i]; + if (value == null) continue; + if (value is! String) { + _fail('$pointer/enum/$i', 'Only string enum values are supported.'); + return null; + } + if (!strings.contains(value)) strings.add(value); + } + if (strings.isEmpty) { + _fail('$pointer/enum', 'An enum needs at least one string value.'); + return null; + } + return _Expr( + describe('ack.Ack.enumString([${strings.map(_literal).join(', ')}])'), + nullable: allowsNull && values.contains(null), + ); + } + + _Expr? _const( + Map node, + String pointer, + Set keys, + String Function(String) describe, + ) { + final value = node['const']; + final type = node['type']; + final types = type == null ? null : (type is List ? type : [type]); + if (value is! String || + !{'const', 'type'}.containsAll(keys) || + (types != null && + (types.any((t) => t != 'string' && t != 'null') || + !types.contains('string')))) { + _fail( + '$pointer/const', + 'Only a string "const" with an optional string-compatible "type" ' + 'is supported.', + ); + return null; + } + return _Expr(describe('ack.Ack.literal(${_literal(value)})')); + } + + String? _string(Map node, String pointer) { + final buffer = StringBuffer('ack.Ack.string()'); + if (node['minLength'] case final int n) buffer.write('.minLength($n)'); + if (node['maxLength'] case final int n) buffer.write('.maxLength($n)'); + if (node['pattern'] case final String pattern) { + if (RegExp(r'\\[pPu]\{').hasMatch(pattern)) { + _fail( + '$pointer/pattern', + r'Unicode-mode patterns (\p{…}, \P{…}, ' + r'\u{…}) are not supported.', + ); + return null; + } + buffer.write('.matches(${_literal(pattern)})'); + } + return buffer.toString(); + } + + String _integer(Map node) { + final buffer = StringBuffer('ack.Ack.integer()'); + int? integral(Object? value) => + value is num && value == value.truncate() ? value.toInt() : null; + if (node['minimum'] case final num n) { + buffer.write('.min(${integral(n) ?? n.ceil()})'); + } + if (node['exclusiveMinimum'] case final num n) { + final exact = integral(n); + buffer.write( + exact == null ? '.min(${n.ceil()})' : '.greaterThan($exact)', + ); + } + if (node['maximum'] case final num n) { + buffer.write('.max(${integral(n) ?? n.floor()})'); + } + if (node['exclusiveMaximum'] case final num n) { + final exact = integral(n); + buffer.write(exact == null ? '.max(${n.floor()})' : '.lessThan($exact)'); + } + return buffer.toString(); + } + + String _number(Map node) { + final buffer = StringBuffer('ack.Ack.number()'); + for (final (keyword, method) in [ + ('minimum', 'min'), + ('exclusiveMinimum', 'greaterThan'), + ('maximum', 'max'), + ('exclusiveMaximum', 'lessThan'), + ]) { + if (node[keyword] case final num n) buffer.write('.$method(${_num(n)})'); + } + return buffer.toString(); + } + + String? _array( + Map node, + String pointer, + String owner, + String hoistName, + ) { + if (!node.containsKey('items')) { + _fail(pointer, 'Arrays need an "items" schema.'); + return null; + } + final item = _expr( + node['items'], + '$pointer/items', + owner: owner, + hoistName: '${hoistName}Item', + ); + if (item == null) return null; + if (item.nullable) { + _fail( + '$pointer/items', + 'Nullable array items are not supported; Ack ' + 'lists cannot contain null.', + ); + return null; + } + final buffer = StringBuffer('ack.Ack.list(${item.code})'); + if (node['minItems'] case final int n) buffer.write('.minItems($n)'); + if (node['maxItems'] case final int n) buffer.write('.maxItems($n)'); + if (node['uniqueItems'] == true) buffer.write('.unique()'); + return buffer.toString(); + } + + _Expr? _object( + Map node, + String pointer, + String owner, + String hoistName, { + required bool root, + }) { + final properties = node['properties']; + final additional = node['additionalProperties']; + final propertyNames = node['propertyNames']; + final open = + additional == null || + additional == true || + (additional is Map && additional.isEmpty); + final hasProperties = + properties is Map && properties.isNotEmpty; + + final required = { + for (final key in node['required'] as List? ?? const []) + if (key is String) key, + }; + final declared = properties is Map + ? properties.keys.toSet() + : {}; + final missingRequired = required.difference(declared); + for (final missing in missingRequired) { + _fail( + '$pointer/required', + 'Required property "$missing" is not declared in "properties".', + ); + } + if (missingRequired.isNotEmpty) return null; + + if (propertyNames != null && + (hasProperties || !_jsonEquals(propertyNames, {'type': 'string'}))) { + _fail( + '$pointer/propertyNames', + 'Only "propertyNames": {"type": ' + '"string"} on a map-shaped object is supported.', + ); + return null; + } + if (!hasProperties && root && additional is Map && additional.isNotEmpty) { + _fail( + pointer, + 'A "\$defs" model must be an object with "properties"; ' + 'map-shaped models are not supported.', + ); + return null; + } + if (!hasProperties && !root && (open || additional is Map)) { + // A map-shaped object: string keys to one value schema. + if (open) return const _Expr('ack.Ack.map(ack.Ack.any().nullable())'); + final value = _expr( + additional, + '$pointer/additionalProperties', + owner: owner, + hoistName: '${hoistName}Value', + ); + if (value == null) return null; + return _Expr( + 'ack.Ack.map(${value.code}${value.nullable ? '.nullable()' : ''})', + ); + } + if (additional is Map && additional.isNotEmpty) { + _fail( + '$pointer/additionalProperties', + 'An "additionalProperties" ' + 'schema next to "properties" is not supported.', + ); + return null; + } + + final entries = []; + final bridgeNames = { + if (open) ackBridgePascal('additionalProperties'), + }; + if (properties is Map) { + for (final MapEntry(key: key, value: value) in properties.entries) { + final fieldPointer = '$pointer/properties/${_token(key)}'; + if (key == hoistName) { + _fail( + fieldPointer, + 'Property "$key" would have the same name as its generated ' + 'model class.', + ); + continue; + } + final problem = ackSchemaFirstFieldNameProblem( + key, + capturesExtras: open, + ); + if (problem != null) { + _fail(fieldPointer, 'Property "$key" $problem'); + continue; + } + if (!bridgeNames.add(ackBridgePascal(key))) { + _fail( + fieldPointer, + 'Property "$key" collides with another generated field bridge.', + ); + continue; + } + final field = _expr( + value, + fieldPointer, + owner: owner, + hoistName: '$hoistName${key[0].toUpperCase()}${key.substring(1)}', + ); + if (field == null) continue; + entries.add( + '${_literal(key)}: ${field.code}' + '${field.nullable ? '.nullable()' : ''}' + '${required.contains(key) ? '' : '.optional()'}', + ); + } + } + final extras = open ? ', additionalProperties: true' : ''; + final code = 'ack.Ack.object({${entries.join(', ')}}$extras)'; + if (root) return _Expr(code); + + if (_modelNames.contains(hoistName)) { + _fail( + pointer, + 'The generated model name "$hoistName" for this inline ' + 'object is already used. Move it to "\$defs" under a unique name.', + ); + return null; + } + _modelNames.add(hoistName); + final description = node['description']; + declarations.add( + _Declaration( + hoistName, + description is String + ? '$code.describe(${_literal(description)})' + : code, + ), + ); + _edge(owner, hoistName, pointer); + return _Expr(_variable(hoistName), reference: hoistName); + } + + void _edge(String from, String to, String pointer) { + (_edges[from] ??= {}).add(to); + _edgePointers[(from, to)] ??= pointer; + } + + void _rejectCycles() { + final state = {}; + void visit(String node) { + state[node] = 1; + for (final next in _edges[node] ?? const {}) { + if (state[next] == 1) { + _fail( + _edgePointers[(node, next)]!, + 'Recursive references are not ' + 'supported yet ("$node" → "$next").', + ); + } else if (state[next] == null) { + visit(next); + } + } + state[node] = 2; + } + + for (final name in _defs.keys) { + if (state[name] == null) visit(name); + } + } +} + +String _emit( + _Translator translator, { + required Uri documentUri, + required String partStem, + required bool includeParts, +}) { + final source = documentUri.pathSegments.isEmpty + ? '$documentUri' + : documentUri.pathSegments.last; + final buffer = StringBuffer() + ..writeln('// GENERATED CODE - DO NOT MODIFY BY HAND') + ..writeln('// dart format width=80') + ..writeln('// ignore_for_file: type=lint') + ..writeln('// coverage:ignore-file') + ..writeln('//') + ..writeln('// Generated from $source by ack_generator:ack_json_schema.'); + if (translator.widened.isNotEmpty) { + buffer.writeln('// unknown_properties: preserve widened these objects:'); + for (final pointer in translator.widened) { + buffer.writeln('// - ${_commentText(pointer)}'); + } + } + buffer + ..writeln() + ..writeln("import 'package:ack/ack.dart' as ack;") + ..writeln(); + if (includeParts) { + buffer + ..writeln("part '$partStem.ack.dart';") + ..writeln("part '$partStem.ack.g.dart';") + ..writeln(); + } + for (final declaration in translator.declarations) { + buffer + ..writeln('@ack.Schemable(name: ${_literal(declaration.name)})') + ..writeln('final ${_variable(declaration.name)} = ${declaration.code};') + ..writeln(); + } + return buffer.toString(); +} + +String _emitFallback( + Map document, { + required Uri documentUri, + required Map documents, + required bool assertFormats, + required List widened, +}) { + final defs = document[r'$defs'] as Map? ?? const {}; + const rootAnnotations = { + r'$schema', + r'$defs', + r'$comment', + 'title', + 'description', + }; + final hasRootModel = + defs.isEmpty || + document.keys.any((key) => !rootAnnotations.contains(key)); + final names = {}; + String uniqueName(String key) { + final words = key + .split(RegExp(r'[^A-Za-z0-9]+')) + .where((word) => word.isNotEmpty) + .toList(); + var base = words.isEmpty + ? 'Definition' + : words + .map((word) => '${word[0].toUpperCase()}${word.substring(1)}') + .join(); + if (RegExp(r'^[0-9]').hasMatch(base)) base = 'Definition$base'; + if (_reservedModelNames.contains(base)) base = '${base}Model'; + var name = base; + var suffix = 2; + while (!names.add(name)) { + name = '$base${suffix++}'; + } + return name; + } + + final definitions = <(String, String)>[ + for (final key in defs.keys) (uniqueName(key), key), + ]; + final rootName = hasRootModel + ? uniqueName(names.contains('Root') ? 'DocumentRoot' : 'Root') + : null; + final models = <(String, String?)>[ + if (rootName != null) (rootName, null), + ...definitions, + ]; + final uri = _literal(documentUri.toString()); + final lookupUri = _literal( + documentUri.resolve('__ack_json_schema_generated__.json').toString(), + ); + final buffer = StringBuffer() + ..writeln('// GENERATED CODE - DO NOT MODIFY BY HAND') + ..writeln('// dart format width=80') + ..writeln('// ignore_for_file: type=lint') + ..writeln('// coverage:ignore-file') + ..writeln('//') + ..writeln('// Draft 2020-12 value models retain source-schema validation.') + ..writeln( + '// Complex shapes use Object? values, not inferred typed fields.', + ); + if (widened.isNotEmpty) { + buffer.writeln('// unknown_properties: preserve widened these objects:'); + for (final pointer in widened) { + buffer.writeln('// - ${_commentText(pointer)}'); + } + } + buffer + ..writeln() + ..writeln("import 'dart:convert';") + ..writeln("import 'package:ack/ack.dart' as ack;") + ..writeln() + ..writeln('final Object _jsonSchemaDocument =') + ..writeln(' jsonDecode(${_literal(jsonEncode(document))}) as Object;') + ..writeln('final Map _jsonSchemaDocuments = {') + ..writeln(' Uri.parse($uri): _jsonSchemaDocument,'); + for (final entry in documents.entries) { + buffer.writeln( + ' Uri.parse(${_literal(entry.key.toString())}): ' + 'jsonDecode(${_literal(jsonEncode(entry.value))}) as Object,', + ); + } + buffer + ..writeln('};') + ..writeln(); + for (final (name, definition) in models) { + final variable = _variable(name); + final formatOption = assertFormats ? ', assertFormats: true' : ''; + final schemaExpression = definition == null + ? 'ack.Ack.fromJsonSchema(_jsonSchemaDocument, ' + 'baseUri: Uri.parse($uri), documents: {' + 'for (final entry in _jsonSchemaDocuments.entries) ' + 'if (entry.key != Uri.parse($uri)) entry.key: entry.value}' + '$formatOption)' + : 'ack.Ack.fromJsonSchema(' + "{r'\$ref': ${_literal(documentUri.replace(fragment: '/\$defs/${_token(definition)}').toString())}}, " + 'baseUri: Uri.parse($lookupUri), ' + 'documents: _jsonSchemaDocuments$formatOption)'; + buffer + ..writeln('final $variable = $schemaExpression;') + ..writeln() + ..writeln('/// A validated Draft 2020-12 value model.') + ..writeln('final class $name {') + ..writeln(' const $name._(this.value);') + ..writeln() + ..writeln(' /// The parsed, immutable JSON value.') + ..writeln(' final Object? value;') + ..writeln() + ..writeln(' /// The exact source-schema validator for this model.') + ..writeln( + ' static ack.AckSchema get schema => $variable;', + ) + ..writeln() + ..writeln(' static $name parse(Object? value) =>') + ..writeln(' safeParse(value).getOrThrow()!;') + ..writeln() + ..writeln(' static ack.SchemaResult<$name> safeParse(Object? value) =>') + ..writeln( + ' schema.safeParse(value).map((parsed) => $name._(parsed));', + ) + ..writeln() + ..writeln(' static Object? encode($name model) =>') + ..writeln(' safeEncode(model).getOrThrow();') + ..writeln() + ..writeln(' static ack.SchemaResult safeEncode($name model) =>') + ..writeln(' schema.safeEncode(model.value);') + ..writeln() + ..writeln(' Object? toJson() => encode(this);') + ..writeln() + ..writeln(' static Map toJsonSchema() =>') + ..writeln(' schema.toJsonSchema();') + ..writeln('}') + ..writeln(); + } + return buffer.toString(); +} + +String _variable(String modelName) => + '${modelName[0].toLowerCase()}${modelName.substring(1)}Schema'; + +String _commentText(String value) => value + .replaceAll('\\', r'\\') + .replaceAll('\r', r'\r') + .replaceAll('\n', r'\n') + .replaceAll('\t', r'\t') + .replaceAll('\u2028', r'\u2028') + .replaceAll('\u2029', r'\u2029'); + +/// A single-quoted Dart string literal with every special character escaped, +/// so descriptions, patterns, and keys stay one simple literal. +String _literal(String value) { + final buffer = StringBuffer("'"); + for (final rune in value.runes) { + buffer.write(switch (rune) { + 0x5C => r'\\', + 0x27 => r"\'", + 0x24 => r'\$', + 0x0A => r'\n', + 0x0D => r'\r', + 0x09 => r'\t', + < 0x20 => '\\u{${rune.toRadixString(16)}}', + _ => String.fromCharCode(rune), + }); + } + buffer.write("'"); + return buffer.toString(); +} + +String _num(num value) => + value is int || value != value.truncate() ? '$value' : '${value.toInt()}'; + +String _token(String key) => key.replaceAll('~', '~0').replaceAll('/', '~1'); + +Object? _deepCopy(Object? value) => switch (value) { + final Map map => { + for (final MapEntry(:key, :value) in map.entries) key: _deepCopy(value), + }, + final List list => [for (final item in list) _deepCopy(item)], + _ => value, +}; + +bool _jsonEquals(Object? a, Object? b) => jsonEncode(a) == jsonEncode(b); + +bool _listEquals(Object? a, List b) => + a is List && _jsonEquals(a, b); diff --git a/packages/ack_generator/pubspec.yaml b/packages/ack_generator/pubspec.yaml index fc2d6619..d4f5f7a3 100644 --- a/packages/ack_generator/pubspec.yaml +++ b/packages/ack_generator/pubspec.yaml @@ -26,6 +26,8 @@ dependencies: json_serializable: ^6.14.1 logging: ^1.3.0 meta: ^1.15.0 + package_config: ^2.1.0 + pub_semver: ^2.1.4 # Ack packages (versions are overridden locally by Melos) ack: ^1.7.0-beta.3 diff --git a/packages/ack_generator/test/integration/ack_infer_models_test.dart b/packages/ack_generator/test/integration/ack_infer_models_test.dart index c59a53df..3389d867 100644 --- a/packages/ack_generator/test/integration/ack_infer_models_test.dart +++ b/packages/ack_generator/test/integration/ack_infer_models_test.dart @@ -61,6 +61,35 @@ final emptySchema = Ack.object({}); }, ); + test('emits multi-line descriptions as one doc line each', () async { + await _build( + { + 'doc.dart': + ''' +$_imports +part 'doc.ack.dart'; +part 'doc.ack.g.dart'; + +''' + r''' +@AckInfer() +final docSchema = Ack.object({ + 'price': Ack.integer().describe('The asking price.\n\nIn \'USD\' \$ \\ */.'), +}).describe('A listing.\r\nFrom the MLS.'); +''', + }, + outputs: { + 'test_pkg|lib/doc.ack.dart': decodedMatches( + allOf([ + contains(' /// The asking price.\n ///\n'), + contains(" /// In 'USD' \$ \\ */.\n"), + contains('/// A listing.\n/// From the MLS.\n'), + ]), + ), + }, + ); + }); + test('evaluates const additionalProperties references', () async { await _build( { diff --git a/packages/ack_generator/test/integration/json_schema_runtime_build_test.dart b/packages/ack_generator/test/integration/json_schema_runtime_build_test.dart new file mode 100644 index 00000000..5c856845 --- /dev/null +++ b/packages/ack_generator/test/integration/json_schema_runtime_build_test.dart @@ -0,0 +1,234 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; + +Future _run(Directory project, List args) => + Process.run('dart', args, workingDirectory: project.path); + +void _succeeds(ProcessResult result, String command) { + expect( + result.exitCode, + 0, + reason: '$command failed\n${result.stdout}\n${result.stderr}', + ); +} + +void main() { + test( + 'JSON Schema builds into usable models in one deterministic build', + () async { + var repository = Directory.current; + while (!Directory( + p.join(repository.path, 'packages', 'ack_generator'), + ).existsSync()) { + repository = repository.parent; + } + final project = await Directory.systemTemp.createTemp('ack_json_schema_'); + try { + Directory(p.join(project.path, 'lib')).createSync(); + Directory(p.join(project.path, 'test')).createSync(); + File(p.join(project.path, 'pubspec.yaml')).writeAsStringSync(''' +name: ack_json_schema_runtime +publish_to: none +environment: + sdk: '>=3.9.0 <4.0.0' +dependencies: + ack: + path: ${p.join(repository.path, 'packages', 'ack')} +dev_dependencies: + ack_generator: + path: ${p.join(repository.path, 'packages', 'ack_generator')} + build_runner: ^2.15.0 + test: ^1.29.0 +dependency_overrides: + ack: + path: ${p.join(repository.path, 'packages', 'ack')} +'''); + File(p.join(project.path, 'build.yaml')).writeAsStringSync(''' +targets: + \$default: + builders: + ack_generator:ack_json_schema: + enabled: true + options: + unknown_properties: preserve + assert_formats: true + documents: + "https://example.test/flag.json": "lib/flag.json" +'''); + File(p.join(project.path, 'lib', 'flag.json')).writeAsStringSync( + '{"type":"object","properties":{"flag":{"type":"boolean"}},' + '"required":["flag"],"additionalProperties":false}', + ); + final schema = File( + p.join(project.path, 'lib', 'contract.schema.json'), + ); + schema.writeAsStringSync(r''' +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "Photo": { + "type": "object", + "properties": {"url": {"type": "string"}}, + "required": ["url"], + "additionalProperties": false + }, + "Listing": { + "type": "object", + "properties": { + "key": {"type": "string"}, + "photo": {"$ref": "#/$defs/Photo"} + }, + "required": ["key"], + "additionalProperties": false + } + } +} +'''); + File( + p.join(project.path, 'test', 'models_test.dart'), + ).writeAsStringSync(r''' +import 'package:ack_json_schema_runtime/contract.schema.dart'; +import 'package:test/test.dart'; + +void main() { + test('parse, preserve extras, round-trip, and typed copyWith', () { + final listing = Listing.parse({ + 'key': 'abc', + 'photo': {'url': 'https://example.test/1', 'newField': 1}, + 'serverField': true, + }); + expect(listing.key, 'abc'); + expect(listing.photo?.url, 'https://example.test/1'); + expect(listing.photo?.additionalProperties, {'newField': 1}); + expect(listing.additionalProperties, {'serverField': true}); + expect(listing.toJson(), { + 'key': 'abc', + 'photo': {'url': 'https://example.test/1', 'newField': 1}, + 'serverField': true, + }); + expect(listing.copyWith(key: 'def').key, 'def'); + expect(listing.copyWith(), listing); + }); +} +'''); + + File( + p.join(project.path, 'lib', 'advanced.schema.json'), + ).writeAsStringSync(r''' +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "Node": { + "$dynamicAnchor": "node", + "type": "object", + "properties": { + "value": {"type": "integer"}, + "next": {"$dynamicRef": "#node"} + }, + "required": ["value"], + "unevaluatedProperties": false + }, + "Choice": { + "oneOf": [ + {"type": "string", "format": "email"}, + {"type": "number", "multipleOf": 0.5} + ] + }, + "External": { + "$ref": "https://example.test/flag.json" + } + }, + "anyOf": [ + {"$ref": "#/$defs/Node"}, + {"$ref": "#/$defs/Choice"}, + {"$ref": "#/$defs/External"} + ] +} +'''); + File( + p.join(project.path, 'test', 'advanced_test.dart'), + ).writeAsStringSync(r''' +import 'package:ack_json_schema_runtime/advanced.schema.dart'; +import 'package:test/test.dart'; + +void main() { + test('validated value models preserve 2020-12 parse and encode', () { + final tree = {'value': 1, 'next': {'value': 2}}; + final node = Node.parse(tree); + expect(node.value, tree); + expect(Node.encode(node), tree); + expect(node.toJson(), tree); + expect(Node.safeParse({'value': 1, 'next': null}).isFail, isTrue); + expect(Node.safeParse({'value': 1, 'extra': true}).isFail, isTrue); + expect(Choice.safeParse('a@example.test').isOk, isTrue); + expect(Choice.safeParse('not-an-email').isFail, isTrue); + expect(Choice.safeParse(1.5).isOk, isTrue); + expect(Choice.safeParse(1.3).isFail, isTrue); + expect(Root.safeParse(tree).isOk, isTrue); + expect(Root.safeParse(1.3).isFail, isTrue); + expect(External.safeParse({'flag': true, 'new': 1}).isOk, isTrue); + expect(External.safeParse(1).isFail, isTrue); + expect(Root.encode(Root.parse('a@example.test')), 'a@example.test'); + }); +} +'''); + + _succeeds(await _run(project, ['pub', 'get']), 'dart pub get'); + _succeeds( + await _run(project, ['run', 'build_runner', 'build']), + 'first build_runner build', + ); + final outputs = { + for (final suffix in ['.dart', '.ack.dart', '.ack.g.dart']) + suffix: File( + p.join(project.path, 'lib', 'contract.schema$suffix'), + ).readAsStringSync(), + }; + expect(outputs['.dart'], contains('unknown_properties: preserve')); + expect(outputs['.ack.dart'], contains('class Listing')); + final advancedOutput = File( + p.join(project.path, 'lib', 'advanced.schema.dart'), + ).readAsStringSync(); + expect(advancedOutput, contains('final class Node')); + expect(advancedOutput, contains('final class Choice')); + expect(advancedOutput, contains('final class Root')); + _succeeds( + await _run(project, ['analyze', '--fatal-infos']), + 'dart analyze --fatal-infos', + ); + _succeeds(await _run(project, ['test']), 'dart test'); + _succeeds( + await _run(project, ['run', 'build_runner', 'build']), + 'second build_runner build', + ); + expect({ + for (final suffix in outputs.keys) + suffix: File( + p.join(project.path, 'lib', 'contract.schema$suffix'), + ).readAsStringSync(), + }, outputs); + expect( + File( + p.join(project.path, 'lib', 'advanced.schema.dart'), + ).readAsStringSync(), + advancedOutput, + ); + + schema.writeAsStringSync(r''' +{"$defs":{"Broken":{"$ref":"#/$defs/Missing"}}} +'''); + final rejected = await _run(project, ['run', 'build_runner', 'build']); + expect(rejected.exitCode, isNot(0)); + expect( + '${rejected.stdout}\n${rejected.stderr}', + contains(r'#/$defs/Broken/$ref'), + ); + } finally { + project.deleteSync(recursive: true); + } + }, + timeout: const Timeout(Duration(minutes: 5)), + ); +} diff --git a/packages/ack_generator/test/json_schema/fixture_library.dart b/packages/ack_generator/test/json_schema/fixture_library.dart new file mode 100644 index 00000000..b8235168 --- /dev/null +++ b/packages/ack_generator/test/json_schema/fixture_library.dart @@ -0,0 +1,28 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:ack_generator/src/json_schema/json_schema_library.dart'; + +/// Directory of the JSON Schema fixtures and their golden schema libraries. +const fixtureDirectory = 'test/json_schema/fixtures'; + +/// The fixture `.schema.json` as a decoded document. +Map fixtureDocument(String name) => + jsonDecode(File('$fixtureDirectory/$name.schema.json').readAsStringSync()) + as Map; + +/// The URI the fixture `.schema.json` is generated under. +Uri fixtureUri(String name) => + Uri.parse('package:ack_generator_fixtures/$name.schema.json'); + +/// Generates the parts-free schema library for the fixture [name]. +AckJsonSchemaLibrary generateFixture( + String name, { + AckUnknownPropertiesMode unknownProperties = AckUnknownPropertiesMode.schema, +}) => generateAckSchemaLibrary( + document: fixtureDocument(name), + documentUri: fixtureUri(name), + partStem: '$name.schema', + includeParts: false, + options: AckJsonSchemaLibraryOptions(unknownProperties: unknownProperties), +); diff --git a/packages/ack_generator/test/json_schema/fixtures/mapping.schema.dart b/packages/ack_generator/test/json_schema/fixtures/mapping.schema.dart new file mode 100644 index 00000000..63fbbbc4 --- /dev/null +++ b/packages/ack_generator/test/json_schema/fixtures/mapping.schema.dart @@ -0,0 +1,51 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// dart format width=80 +// ignore_for_file: type=lint +// coverage:ignore-file +// +// Generated from mapping.schema.json by ack_generator:ack_json_schema. + +import 'package:ack/ack.dart' as ack; + +@ack.Schemable(name: 'ListingAddress') +final listingAddressSchema = ack.Ack.object({'city': ack.Ack.string()}); + +@ack.Schemable(name: 'Listing') +final listingSchema = ack.Ack.object({ + 'key': ack.Ack.string() + .minLength(2) + .maxLength(8) + .describe('The key, with \'quotes\', \$dollars, and \\backslashes\\.'), + 'code': ack.Ack.string().matches('^[A-Z]{2}-\\d+\$').optional(), + 'rooms': ack.Ack.integer().min(1).max(9), + 'floors': ack.Ack.integer().greaterThan(0).max(4).optional(), + 'price': ack.Ack.number().min(0).lessThan(1000000.5).optional(), + 'active': ack.Ack.boolean(), + 'status': ack.Ack.enumString(['Active', 'Closed']).nullable().optional(), + 'kind': ack.Ack.literal('residential').optional(), + 'tags': ack.Ack.list( + ack.Ack.string(), + ).minItems(1).maxItems(3).unique().optional(), + 'notes': ack.Ack.string().nullable().optional(), + 'nickname': ack.Ack.string().nullable().optional(), + 'streetNumber': ack.Ack.any() + .refine( + (value) => value is String || value is num, + message: 'Expected a string or a number.', + ) + .nullable() + .optional(), + 'extra': ack.Ack.any().nullable().optional(), + 'labels': ack.Ack.map(ack.Ack.integer()).optional(), + 'meta': ack.Ack.map(ack.Ack.any().nullable()).optional(), + 'address': listingAddressSchema.optional(), + 'photo': photoSchema.optional(), + 'cover': photoSchema.nullable().optional(), + 'photos': ack.Ack.list(photoSchema).optional(), +}).describe('A listing.\nWith a second line.'); + +@ack.Schemable(name: 'Photo') +final photoSchema = ack.Ack.object({ + 'url': ack.Ack.string(), + 'width': ack.Ack.integer().optional(), +}, additionalProperties: true); diff --git a/packages/ack_generator/test/json_schema/fixtures/mapping.schema.json b/packages/ack_generator/test/json_schema/fixtures/mapping.schema.json new file mode 100644 index 00000000..b9ecd704 --- /dev/null +++ b/packages/ack_generator/test/json_schema/fixtures/mapping.schema.json @@ -0,0 +1,77 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "Listing": { + "type": "object", + "description": "A listing.\nWith a second line.", + "properties": { + "key": { + "type": "string", + "minLength": 2, + "maxLength": 8, + "description": "The key, with 'quotes', $dollars, and \\backslashes\\." + }, + "code": { + "type": "string", + "pattern": "^[A-Z]{2}-\\d+$", + "examples": ["AB-12"] + }, + "rooms": { "type": "integer", "minimum": 1, "maximum": 9 }, + "floors": { + "type": "integer", + "exclusiveMinimum": 0, + "exclusiveMaximum": 4.5 + }, + "price": { + "type": "number", + "minimum": 0, + "exclusiveMaximum": 1000000.5 + }, + "active": { "type": "boolean" }, + "status": { "enum": ["Active", "Closed", "Active", null] }, + "kind": { "const": "residential" }, + "tags": { + "type": "array", + "items": { "type": "string" }, + "minItems": 1, + "maxItems": 3, + "uniqueItems": true + }, + "notes": { "anyOf": [{ "type": "string" }, { "type": "null" }] }, + "nickname": { "type": ["string", "null"] }, + "streetNumber": { + "anyOf": [ + { "anyOf": [{ "type": "string" }, { "type": "number" }] }, + { "type": "null" } + ] + }, + "extra": {}, + "labels": { + "type": "object", + "propertyNames": { "type": "string" }, + "additionalProperties": { "type": "integer" } + }, + "meta": { "type": "object", "additionalProperties": true }, + "address": { + "type": "object", + "properties": { "city": { "type": "string" } }, + "required": ["city"], + "additionalProperties": false + }, + "photo": { "$ref": "#/$defs/Photo" }, + "cover": { "anyOf": [{ "$ref": "#/$defs/Photo" }, { "type": "null" }] }, + "photos": { "type": "array", "items": { "$ref": "#/$defs/Photo" } } + }, + "required": ["key", "rooms", "active"], + "additionalProperties": false + }, + "Photo": { + "type": "object", + "properties": { + "url": { "type": "string" }, + "width": { "type": "integer" } + }, + "required": ["url"] + } + } +} diff --git a/packages/ack_generator/test/json_schema/fixtures/mapping_preserve.schema.dart b/packages/ack_generator/test/json_schema/fixtures/mapping_preserve.schema.dart new file mode 100644 index 00000000..3c1ea588 --- /dev/null +++ b/packages/ack_generator/test/json_schema/fixtures/mapping_preserve.schema.dart @@ -0,0 +1,56 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// dart format width=80 +// ignore_for_file: type=lint +// coverage:ignore-file +// +// Generated from mapping.schema.json by ack_generator:ack_json_schema. +// unknown_properties: preserve widened these objects: +// - #/$defs/Listing +// - #/$defs/Listing/properties/address + +import 'package:ack/ack.dart' as ack; + +@ack.Schemable(name: 'ListingAddress') +final listingAddressSchema = ack.Ack.object({ + 'city': ack.Ack.string(), +}, additionalProperties: true); + +@ack.Schemable(name: 'Listing') +final listingSchema = ack.Ack.object({ + 'key': ack.Ack.string() + .minLength(2) + .maxLength(8) + .describe('The key, with \'quotes\', \$dollars, and \\backslashes\\.'), + 'code': ack.Ack.string().matches('^[A-Z]{2}-\\d+\$').optional(), + 'rooms': ack.Ack.integer().min(1).max(9), + 'floors': ack.Ack.integer().greaterThan(0).max(4).optional(), + 'price': ack.Ack.number().min(0).lessThan(1000000.5).optional(), + 'active': ack.Ack.boolean(), + 'status': ack.Ack.enumString(['Active', 'Closed']).nullable().optional(), + 'kind': ack.Ack.literal('residential').optional(), + 'tags': ack.Ack.list( + ack.Ack.string(), + ).minItems(1).maxItems(3).unique().optional(), + 'notes': ack.Ack.string().nullable().optional(), + 'nickname': ack.Ack.string().nullable().optional(), + 'streetNumber': ack.Ack.any() + .refine( + (value) => value is String || value is num, + message: 'Expected a string or a number.', + ) + .nullable() + .optional(), + 'extra': ack.Ack.any().nullable().optional(), + 'labels': ack.Ack.map(ack.Ack.integer()).optional(), + 'meta': ack.Ack.map(ack.Ack.any().nullable()).optional(), + 'address': listingAddressSchema.optional(), + 'photo': photoSchema.optional(), + 'cover': photoSchema.nullable().optional(), + 'photos': ack.Ack.list(photoSchema).optional(), +}, additionalProperties: true).describe('A listing.\nWith a second line.'); + +@ack.Schemable(name: 'Photo') +final photoSchema = ack.Ack.object({ + 'url': ack.Ack.string(), + 'width': ack.Ack.integer().optional(), +}, additionalProperties: true); diff --git a/packages/ack_generator/test/json_schema/instance_corpus.dart b/packages/ack_generator/test/json_schema/instance_corpus.dart new file mode 100644 index 00000000..5a5b10a8 --- /dev/null +++ b/packages/ack_generator/test/json_schema/instance_corpus.dart @@ -0,0 +1,220 @@ +/// Instances that probe whether two validators agree on a JSON Schema model. +/// +/// Starts from one valid instance and applies one mutation at a time: remove a +/// property, set it to `null`, replace it with a value of each JSON type, step +/// over a bound, and add unknown keys, including inside nested objects. +final class InstanceCorpus { + /// Creates a corpus over the `$defs` of an effective document. + InstanceCorpus(Map document) + : _defs = document[r'$defs']! as Map; + + final Map _defs; + + static const _probes = [ + 's', + 0, + 1, + 1.0, + 1.5, + true, + [], + {}, + ]; + + /// Every probe instance for the model named [name]. + List instancesFor(String name) { + final schema = _defs[name]; + final full = valid(schema)! as Map; + return [ + full, + _requiredOnly(schema, full), + {}, + null, + 'not an object', + ..._objectMutations(schema, full, depth: 0), + ]; + } + + /// A canonical valid value for [schema]. + Object? valid(Object? schema) { + final node = _resolve(schema); + if (node is! Map) return 'any'; + if (node['anyOf'] case final List branches) { + return valid(branches.firstWhere((b) => !_isNull(b))); + } + if (node['enum'] case final List values) { + return values.firstWhere((v) => v != null); + } + if (node.containsKey('const')) return node['const']; + return switch (_type(node)) { + 'string' => _validString(node), + 'integer' => _validInteger(node), + 'number' => _validNumber(node), + 'boolean' => true, + 'array' => _validArray(node), + 'object' => _validObject(node), + _ => 'any', + }; + } + + Iterable _objectMutations( + Object? schema, + Map instance, { + required int depth, + }) sync* { + final node = _resolve(schema); + if (node is! Map) return; + final properties = node['properties']; + if (properties is Map) { + for (final MapEntry(key: key, value: property) in properties.entries) { + final without = {...instance}..remove(key); + yield without; + for (final value in [null, ..._probes, ..._boundaryValues(property)]) { + yield {...instance, key: value}; + } + final nested = _resolve(_nonNull(property)); + final nestedValue = instance[key]; + if (depth == 0 && + nested is Map && + _type(nested) == 'object' && + nestedValue is Map) { + for (final mutation in _objectMutations( + nested, + nestedValue, + depth: depth + 1, + )) { + yield {...instance, key: mutation}; + } + } + } + } + yield {...instance, '__x': 1}; + yield {...instance, '__x': null}; + } + + List _boundaryValues(Object? schema) { + final node = _resolve(_nonNull(schema)); + if (node is! Map) return const []; + return [ + if (node['minLength'] case final int n when n > 0) 'a' * (n - 1), + if (node['maxLength'] case final int n) 'a' * (n + 1), + if (node['minLength'] case final int n) 'a' * n, + if (node['maxLength'] case final int n) 'a' * n, + for (final keyword in const [ + 'minimum', + 'maximum', + 'exclusiveMinimum', + 'exclusiveMaximum', + ]) + if (node[keyword] case final num n) ...[ + n, + n - 1, + n + 1, + n - 0.5, + n + 0.5, + ], + if (node['enum'] != null || node['const'] != null) 'not-a-member', + // Every member, plus near misses, so a dropped or case-folded member + // shows up as a disagreement. + for (final member in [ + ...?(node['enum'] as List?), + if (node['const'] case final String value) value, + ]) + if (member is String) ...[member, '${member}x', member.toLowerCase()], + if (_type(node) == 'array') ...[ + [null], + [valid(node['items']), valid(node['items'])], + [for (var i = 0; i < ((node['maxItems'] as int?) ?? 3) + 1; i++) 'x$i'], + for (final probe in _probes) [probe], + ], + if (_type(node) == 'object' && node['properties'] == null) ...[ + {'k': 's'}, + {'k': 1}, + {'k': null}, + ], + ]; + } + + Map _requiredOnly( + Object? schema, + Map full, + ) { + final node = _resolve(schema)! as Map; + final required = {...?(node['required'] as List?)}; + return { + for (final MapEntry(:key, :value) in full.entries) + if (required.contains(key)) key: value, + }; + } + + String _validString(Map node) { + if (node['examples'] case [final String example, ...]) return example; + final minimum = (node['minLength'] as int?) ?? 1; + return 'a' * (minimum < 1 ? 1 : minimum); + } + + int _validInteger(Map node) { + final exclusive = node['exclusiveMinimum'] as num?; + final minimum = node['minimum'] as num?; + return switch ((minimum, exclusive)) { + (final num m, _) => m.ceil(), + (_, final num e) => e.floor() + 1, + _ => 1, + }; + } + + num _validNumber(Map node) { + final minimum = node['minimum'] as num?; + final exclusive = node['exclusiveMinimum'] as num?; + return minimum ?? (exclusive == null ? 1.5 : exclusive + 0.5); + } + + List _validArray(Map node) { + final count = (node['minItems'] as int?) ?? 1; + final item = valid(node['items']); + return [ + for (var i = 0; i < count; i++) + node['uniqueItems'] == true && item is String ? '$item$i' : item, + ]; + } + + Map _validObject(Map node) { + final properties = node['properties']; + if (properties is Map) { + return { + for (final MapEntry(:key, :value) in properties.entries) + key: valid(value), + }; + } + final additional = node['additionalProperties']; + return { + 'k': additional is Map && additional.isNotEmpty ? valid(additional) : 'v', + }; + } + + Object? _resolve(Object? schema) { + if (schema is Map && schema[r'$ref'] is String) { + final ref = schema[r'$ref']! as String; + return _defs[ref.substring(r'#/$defs/'.length)]; + } + return schema; + } + + Object? _nonNull(Object? schema) { + if (schema is Map && schema['anyOf'] is List) { + final branches = (schema['anyOf']! as List).where((b) => !_isNull(b)); + return branches.length == 1 ? _nonNull(branches.single) : schema; + } + return schema; + } + + static bool _isNull(Object? schema) => + schema is Map && (schema['type'] == 'null'); + + static String? _type(Map node) => switch (node['type']) { + final String type => type, + final List types => + types.firstWhere((t) => t != 'null') as String?, + _ => null, + }; +} diff --git a/packages/ack_generator/test/json_schema/json_schema_equivalence_test.dart b/packages/ack_generator/test/json_schema/json_schema_equivalence_test.dart new file mode 100644 index 00000000..f9fb1d8b --- /dev/null +++ b/packages/ack_generator/test/json_schema/json_schema_equivalence_test.dart @@ -0,0 +1,72 @@ +import 'dart:convert'; + +import 'package:ack/ack.dart'; +import 'package:ack_generator/src/json_schema/json_schema_library.dart'; +import 'package:test/test.dart'; + +import 'fixture_library.dart'; +import 'fixtures/mapping.schema.dart' as schema_mode; +import 'fixtures/mapping_preserve.schema.dart' as preserve_mode; +import 'instance_corpus.dart'; + +/// Checks acceptance criterion 1: over a generated instance corpus, each +/// generated schema accepts exactly what `Ack.fromJsonSchema` accepts for the +/// effective document. +void main() { + final cases = { + AckUnknownPropertiesMode.schema: { + 'Listing': schema_mode.listingSchema, + 'Photo': schema_mode.photoSchema, + }, + AckUnknownPropertiesMode.preserve: { + 'Listing': preserve_mode.listingSchema, + 'Photo': preserve_mode.photoSchema, + }, + }; + + for (final MapEntry(key: mode, value: generated) in cases.entries) { + group('unknown_properties: ${mode.name}', () { + final effective = generateFixture( + 'mapping', + unknownProperties: mode, + ).effectiveDocument; + final corpus = InstanceCorpus(effective); + final uri = fixtureUri('mapping'); + + for (final MapEntry(key: name, value: schema) in generated.entries) { + test('$name agrees with Ack.fromJsonSchema', () { + final oracle = Ack.fromJsonSchema( + {r'$ref': '$uri#/\$defs/$name'}, + baseUri: uri.resolve('__oracle__.json'), + documents: {uri: effective}, + ); + final instances = corpus.instancesFor(name); + final mismatches = [ + for (final instance in instances) + if (oracle.safeParse(instance).isOk != + schema.safeParse(instance).isOk) + 'importer ${oracle.safeParse(instance).isOk ? 'accepts' : 'rejects'}, ' + 'generated ${schema.safeParse(instance).isOk ? 'accepts' : 'rejects'}: ' + '${jsonEncode(instance)}', + ]; + final properties = + ((effective[r'$defs']! as Map)[name]! as Map)['properties']! + as Map; + final accepted = instances.where((i) => oracle.safeParse(i).isOk); + // A broken corpus would pass trivially; require real coverage of + // both outcomes and at least ten probes per declared property. + expect( + instances.length, + greaterThanOrEqualTo(10 * properties.length), + ); + expect(accepted, isNotEmpty); + expect(accepted.length, lessThan(instances.length)); + expect(mismatches, isEmpty); + printOnFailure( + '$name: ${instances.length} instances, ${accepted.length} valid', + ); + }); + } + }); + } +} diff --git a/packages/ack_generator/test/json_schema/json_schema_golden_test.dart b/packages/ack_generator/test/json_schema/json_schema_golden_test.dart new file mode 100644 index 00000000..86e6133d --- /dev/null +++ b/packages/ack_generator/test/json_schema/json_schema_golden_test.dart @@ -0,0 +1,36 @@ +import 'dart:io'; + +import 'package:ack_generator/src/json_schema/json_schema_library.dart'; +import 'package:test/test.dart'; + +import 'fixture_library.dart'; + +/// The committed schema libraries the equivalence test imports. +/// +/// Regenerate them with `ACK_UPDATE_GOLDENS=1 dart test +/// test/json_schema/json_schema_golden_test.dart`. +const _goldens = { + 'mapping.schema.dart': ('mapping', AckUnknownPropertiesMode.schema), + 'mapping_preserve.schema.dart': ( + 'mapping', + AckUnknownPropertiesMode.preserve, + ), +}; + +void main() { + final update = Platform.environment['ACK_UPDATE_GOLDENS'] == '1'; + + for (final MapEntry(key: golden, value: (fixture, mode)) + in _goldens.entries) { + test('$golden matches the generated library', () { + final source = generateFixture(fixture, unknownProperties: mode).source; + final file = File('$fixtureDirectory/$golden'); + if (update) file.writeAsStringSync(source); + expect( + file.readAsStringSync(), + source, + reason: 'Regenerate with ACK_UPDATE_GOLDENS=1.', + ); + }); + } +} diff --git a/packages/ack_generator/test/json_schema/json_schema_library_test.dart b/packages/ack_generator/test/json_schema/json_schema_library_test.dart new file mode 100644 index 00000000..71b29899 --- /dev/null +++ b/packages/ack_generator/test/json_schema/json_schema_library_test.dart @@ -0,0 +1,543 @@ +import 'package:ack_generator/src/json_schema/json_schema_generation_exception.dart'; +import 'package:ack_generator/src/json_schema/json_schema_library.dart'; +import 'package:test/test.dart'; + +final _uri = Uri.parse('package:test_pkg/model.schema.json'); + +AckJsonSchemaLibrary _generate( + Object? document, { + AckUnknownPropertiesMode unknownProperties = AckUnknownPropertiesMode.schema, + AckJsonSchemaModelMode modelMode = AckJsonSchemaModelMode.auto, +}) => generateAckSchemaLibrary( + document: document, + documentUri: _uri, + partStem: 'model.schema', + includeParts: false, + options: AckJsonSchemaLibraryOptions( + unknownProperties: unknownProperties, + modelMode: modelMode, + ), +); + +Map _model( + Map properties, { + List required = const [], +}) => { + r'$defs': { + 'Model': { + 'type': 'object', + 'properties': properties, + if (required.isNotEmpty) 'required': required, + }, + }, +}; + +/// The generated source without whitespace or formatter trailing commas, so +/// expectations don't depend on line breaks. +String _squash(String source) => source + .replaceAll(RegExp(r'\s'), '') + .replaceAll(',)', ')') + .replaceAll(',}', '}') + .replaceAll(',]', ']'); + +String _field(Object? schema, {bool required = false}) => _squash( + _generate(_model({'f': schema}, required: [if (required) 'f'])).source, +); + +List _diagnostics(Object? document) { + try { + _generate(document); + } on AckJsonSchemaGenerationException catch (error) { + return error.diagnostics; + } + fail('Expected generation to fail.'); +} + +Matcher _diagnostic(String pointer, String message) => + isA() + .having((d) => d.pointer, 'pointer', pointer) + .having((d) => d.message, 'message', contains(message)); + +void main() { + group('maps', () { + final rows = { + 'a string': ({'type': 'string'}, "'f':ack.Ack.string().optional()"), + 'string length bounds': ( + {'type': 'string', 'minLength': 2, 'maxLength': 8}, + 'ack.Ack.string().minLength(2).maxLength(8).optional()', + ), + 'an escaped pattern': ( + {'type': 'string', 'pattern': r'^[A-Z]{2}-\d+$'}, + r"ack.Ack.string().matches('^[A-Z]{2}-\\d+\$')", + ), + 'integer bounds': ( + {'type': 'integer', 'minimum': 1, 'maximum': 9}, + 'ack.Ack.integer().min(1).max(9)', + ), + 'fractional integer bounds inward': ( + {'type': 'integer', 'minimum': 1.5, 'exclusiveMaximum': 4.5}, + 'ack.Ack.integer().min(2).max(4)', + ), + 'integral exclusive integer bounds': ( + {'type': 'integer', 'exclusiveMinimum': 0, 'exclusiveMaximum': 5}, + 'ack.Ack.integer().greaterThan(0).lessThan(5)', + ), + 'a number as num': ( + {'type': 'number', 'minimum': 0, 'exclusiveMaximum': 1.5}, + 'ack.Ack.number().min(0).lessThan(1.5)', + ), + 'a boolean': ({'type': 'boolean'}, 'ack.Ack.boolean()'), + 'a deduped enum with null': ( + { + 'enum': ['A', 'B', 'A', null], + }, + "ack.Ack.enumString(['A','B']).nullable()", + ), + 'a string-typed enum ignoring a null member': ( + { + 'type': 'string', + 'enum': ['A', null], + }, + "ack.Ack.enumString(['A']).optional()", + ), + 'a string const': ({'const': 'x'}, "ack.Ack.literal('x')"), + 'array bounds and uniqueness': ( + { + 'type': 'array', + 'items': {'type': 'string'}, + 'minItems': 1, + 'maxItems': 3, + 'uniqueItems': true, + }, + 'ack.Ack.list(ack.Ack.string()).minItems(1).maxItems(3).unique()', + ), + 'anyOf with null': ( + { + 'anyOf': [ + {'type': 'string'}, + {'type': 'null'}, + ], + }, + 'ack.Ack.string().nullable()', + ), + 'a nullable type list': ( + { + 'type': ['integer', 'null'], + }, + 'ack.Ack.integer().nullable()', + ), + 'a nested scalar union': ( + { + 'anyOf': [ + { + 'anyOf': [ + {'type': 'string'}, + {'type': 'number'}, + ], + }, + {'type': 'null'}, + ], + }, + 'ack.Ack.any().refine((value)=>valueisString||valueisnum,' + "message:'Expectedastringoranumber.').nullable()", + ), + 'the empty schema': ({}, 'ack.Ack.any().nullable()'), + 'a map with typed values': ( + { + 'type': 'object', + 'propertyNames': {'type': 'string'}, + 'additionalProperties': {'type': 'integer'}, + }, + 'ack.Ack.map(ack.Ack.integer())', + ), + 'an open map': ( + {'type': 'object'}, + 'ack.Ack.map(ack.Ack.any().nullable())', + ), + 'a description with special characters': ( + {'type': 'string', 'description': "It's \$5\n\\ok"}, + r"ack.Ack.string().describe('It\'s\$5\n\\ok')", + ), + }; + for (final MapEntry(key: name, value: (schema, expected)) in rows.entries) { + test(name, () => expect(_field(schema), contains(expected))); + } + + test('a required property without optional()', () { + expect( + _field({'type': 'string'}, required: true), + contains("'f':ack.Ack.string()}"), + ); + }); + + test('an inline object as a hoisted named model', () { + final source = _field({ + 'type': 'object', + 'properties': { + 'city': {'type': 'string'}, + }, + 'additionalProperties': false, + }); + expect(source, contains("'f':modelFSchema.optional()")); + expect( + source, + contains( + "@ack.Schemable(name:'ModelF')finalmodelFSchema=" + "ack.Ack.object({'city':ack.Ack.string().optional()})", + ), + ); + }); + + test(r'a $ref as the referenced model', () { + final source = _squash( + _generate({ + r'$defs': { + 'Model': { + 'type': 'object', + 'properties': { + 'photo': {r'$ref': r'#/$defs/Photo'}, + }, + }, + 'Photo': {'type': 'object', 'properties': {}}, + }, + }).source, + ); + expect(source, contains("'photo':photoSchema.optional()")); + }); + + test('models in document order with explicit names', () { + final source = _generate({ + r'$defs': { + 'Beta': {'type': 'object'}, + 'Alpha': {'type': 'object'}, + }, + }).source; + expect( + source.indexOf("Schemable(name: 'Beta')"), + lessThan(source.indexOf("Schemable(name: 'Alpha')")), + ); + expect(source, contains('final betaSchema = ack.Ack.object(')); + }); + }); + + group('unknown_properties: preserve', () { + test('widens strict objects and records them', () { + final library = _generate({ + r'$defs': { + 'Model': { + 'type': 'object', + 'properties': { + 'address': { + 'type': 'object', + 'properties': { + 'street': {'type': 'string'}, + }, + 'additionalProperties': false, + }, + }, + 'additionalProperties': false, + }, + }, + }, unknownProperties: AckUnknownPropertiesMode.preserve); + + expect(library.widenedPointers, [ + r'#/$defs/Model', + r'#/$defs/Model/properties/address', + ]); + expect( + library.source, + contains(r'// - #/$defs/Model/properties/address'), + ); + final model = + (library.effectiveDocument[r'$defs']! as Map)['Model']! as Map; + expect(model['additionalProperties'], isTrue); + }); + + test('leaves the input document unchanged', () { + final document = + _model({ + 'f': {'type': 'string'}, + }) + ..[r'$defs'] = { + 'Model': { + 'type': 'object', + 'properties': { + 'f': {'type': 'string'}, + }, + 'additionalProperties': false, + }, + }; + _generate(document, unknownProperties: AckUnknownPropertiesMode.preserve); + expect( + ((document[r'$defs']! as Map)['Model']! as Map)['additionalProperties'], + isFalse, + ); + }); + + test('records fallback widenings in supplied resources', () { + final externalUri = Uri.parse('https://example.test/external.json'); + final external = { + 'type': 'object', + 'additionalProperties': false, + }; + final library = generateAckSchemaLibrary( + document: {r'$ref': externalUri.toString()}, + documentUri: _uri, + partStem: 'model.schema', + includeParts: false, + options: const AckJsonSchemaLibraryOptions( + unknownProperties: AckUnknownPropertiesMode.preserve, + ), + documents: {externalUri: external}, + ); + expect(external['additionalProperties'], isFalse); + expect(library.widenedPointers, ['$externalUri#']); + expect(library.source, contains('// - $externalUri#')); + }); + + test('escapes schema keys in generated widening comments', () { + final library = _generate( + _model({ + 'x\nfinal injected = true;': { + 'type': 'object', + 'additionalProperties': false, + }, + }), + unknownProperties: AckUnknownPropertiesMode.preserve, + ); + expect(library.source, contains(r'x\nfinal injected = true;')); + expect(library.source, isNot(contains('\nfinal injected = true;'))); + }); + }); + + group('Draft 2020-12 validated fallback', () { + final examples = { + 'composition': { + 'allOf': [ + {'type': 'string'}, + {'minLength': 2}, + ], + }, + 'format annotation': {'type': 'string', 'format': 'email'}, + 'multipleOf': {'type': 'number', 'multipleOf': 0.1}, + 'nullable array items': { + 'type': 'array', + 'items': { + 'type': ['string', 'null'], + }, + }, + 'unconstrained array': {'type': 'array'}, + 'mixed enum': { + 'enum': ['a', 1, null], + }, + 'conditional': { + 'if': { + 'properties': { + 'kind': {'const': 'a'}, + }, + }, + 'then': { + 'required': ['name'], + }, + }, + 'unevaluated properties': { + 'type': 'object', + 'properties': { + 'name': {'type': 'string'}, + }, + 'unevaluatedProperties': false, + }, + }; + for (final MapEntry(:key, :value) in examples.entries) { + test(key, () { + final library = _generate(_model({'f': value})); + expect(library.source, contains('final class Model')); + expect(library.source, contains('Ack.fromJsonSchema')); + expect(library.source, isNot(contains('@ack.Schemable'))); + expect(library.source, isNot(contains("part 'model.schema.ack.dart'"))); + }); + } + + test(r'generates a root model without $defs', () { + final library = _generate({ + 'type': 'array', + 'prefixItems': [ + {'type': 'string'}, + {'type': 'integer'}, + ], + 'items': false, + }); + expect(library.source, contains('final class Root')); + expect(library.source, contains('rootSchema')); + }); + + test('generates boolean root schemas', () { + final library = _generate(false); + expect(library.source, contains('final class Root')); + expect(library.effectiveDocument, { + 'allOf': [false], + }); + }); + + test( + 'validated mode uses the exact importer for a typed-compatible model', + () { + final library = _generate( + _model({ + 'count': {'type': 'integer'}, + }), + modelMode: AckJsonSchemaModelMode.validated, + ); + expect(library.source, contains('final class Model')); + expect(library.source, isNot(contains('@ack.Schemable'))); + }, + ); + + test('assigns unique stable names to arbitrary definition keys', () { + final library = _generate({ + r'$defs': { + 'my-model': {'type': 'string'}, + 'my model': {'type': 'integer'}, + 'String': {'type': 'null'}, + }, + }); + expect(library.source, contains('final class MyModel ')); + expect(library.source, contains('final class MyModel2 ')); + expect(library.source, contains('final class StringModel ')); + expect(_generate(library.effectiveDocument).source, library.source); + }); + + test( + 'keeps a named Root definition when the document has a root model', + () { + final library = _generate({ + r'$defs': { + 'Root': {'type': 'string', 'format': 'email'}, + }, + r'$ref': r'#/$defs/Root', + }); + expect(library.source, contains('final class Root ')); + expect(library.source, contains('final class DocumentRoot ')); + }, + ); + + test('generates recursive and nullable definition wrappers', () { + final library = _generate({ + r'$defs': { + 'Node': { + 'anyOf': [ + { + 'type': 'object', + 'properties': { + 'next': {r'$ref': r'#/$defs/Node'}, + }, + }, + {'type': 'null'}, + ], + }, + }, + }); + expect(library.source, contains('final class Node')); + expect(library.source, contains('nodeSchema')); + }); + + test('reports invalid references at their JSON pointer', () { + expect( + _diagnostics( + _model({ + 'f': {r'$ref': r'#/$defs/Missing'}, + }), + ), + [ + _diagnostic( + r'#/$defs/Model/properties/f/$ref', + 'Unresolved schema reference', + ), + ], + ); + }); + + test('still rejects malformed and unsupported source semantics', () { + for (final (schema, keyword) in <(Object?, String)>[ + ({'type': 'not-a-type'}, 'type'), + ({'pattern': '['}, 'pattern'), + ( + { + r'$vocabulary': {'https://example.test/required': true}, + }, + r'$vocabulary', + ), + ]) { + expect(_diagnostics(_model({'f': schema})), [ + isA().having( + (diagnostic) => diagnostic.pointer, + 'pointer', + r'#/$defs/Model/properties/f/' + keyword, + ), + ]); + } + expect(_diagnostics(null), [_diagnostic('#', 'Expected')]); + }); + }); + + group('options', () { + test('parse unknown_properties', () { + expect( + AckJsonSchemaLibraryOptions.fromConfig(const { + 'unknown_properties': 'preserve', + }).unknownProperties, + AckUnknownPropertiesMode.preserve, + ); + expect( + AckJsonSchemaLibraryOptions.fromConfig(const {}).unknownProperties, + AckUnknownPropertiesMode.schema, + ); + final configured = AckJsonSchemaLibraryOptions.fromConfig(const { + 'assert_formats': true, + 'documents': {'https://example.test/schema.json': 'lib/schema.json'}, + }); + expect(configured.assertFormats, isTrue); + expect(configured.modelMode, AckJsonSchemaModelMode.auto); + expect(configured.documentPaths, { + Uri.parse('https://example.test/schema.json'): 'lib/schema.json', + }); + expect( + AckJsonSchemaLibraryOptions.fromConfig(const { + 'model_mode': 'validated', + }).modelMode, + AckJsonSchemaModelMode.validated, + ); + }); + + test('reject unknown keys and values', () { + expect( + () => AckJsonSchemaLibraryOptions.fromConfig(const {'typo': 1}), + throwsArgumentError, + ); + expect( + () => AckJsonSchemaLibraryOptions.fromConfig(const { + 'unknown_properties': 'loose', + }), + throwsArgumentError, + ); + expect( + () => AckJsonSchemaLibraryOptions.fromConfig(const { + 'assert_formats': 'yes', + }), + throwsArgumentError, + ); + expect( + () => AckJsonSchemaLibraryOptions.fromConfig(const { + 'documents': ['lib/schema.json'], + }), + throwsArgumentError, + ); + expect( + () => AckJsonSchemaLibraryOptions.fromConfig(const { + 'model_mode': 'loose', + }), + throwsArgumentError, + ); + }); + }); +} diff --git a/packages/ack_generator/test/json_schema/json_schema_required_corpus_test.dart b/packages/ack_generator/test/json_schema/json_schema_required_corpus_test.dart new file mode 100644 index 00000000..fe5e3036 --- /dev/null +++ b/packages/ack_generator/test/json_schema/json_schema_required_corpus_test.dart @@ -0,0 +1,46 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:ack_generator/src/json_schema/json_schema_library.dart'; +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; + +void main() { + const fixtureRoot = '../ack/test/fixtures/json_schema_2020_12_full'; + final remoteRoot = Directory('$fixtureRoot/remotes/draft2020-12'); + final documents = { + for (final file in remoteRoot.listSync(recursive: true).whereType()) + if (file.path.endsWith('.json')) + Uri.parse( + 'http://localhost:1234/draft2020-12/' + '${p.relative(file.path, from: remoteRoot.path).replaceAll('\\', '/')}', + ): jsonDecode( + file.readAsStringSync(), + )!, + }; + final corpus = Directory('$fixtureRoot/draft2020-12'); + + group('complete required 2020-12 generation corpus', () { + for (final file in corpus.listSync().whereType()) { + if (!file.path.endsWith('.json')) continue; + final filename = p.basename(file.path); + final groups = (jsonDecode(file.readAsStringSync()) as List) + .cast>(); + for (final group in groups) { + test('$filename: ${group['description']}', () { + final generated = generateAckSchemaLibrary( + document: group['schema'], + documentUri: Uri.parse( + 'http://localhost:1234/draft2020-12/__case__.json', + ), + partStem: 'case.schema', + documents: documents, + includeParts: false, + ); + expect(generated.source, contains('final class Root')); + expect(generated.effectiveDocument, isNotEmpty); + }); + } + } + }); +} diff --git a/packages/ack_json_schema_builder/CHANGELOG.md b/packages/ack_json_schema_builder/CHANGELOG.md index 21ad1303..37667e17 100644 --- a/packages/ack_json_schema_builder/CHANGELOG.md +++ b/packages/ack_json_schema_builder/CHANGELOG.md @@ -2,8 +2,15 @@ ### Changed -* Imports inherit the expanded Draft 2020-12 runtime validator. Formats are - annotations by default; the bridge's public API is unchanged. +* Imports use the expanded Draft 2020-12 importer and its `assertFormats` + API. `toJsonSchemaBuilder()` preserves imported Draft 2020-12 resources by + default; `toJsonSchemaBuilderDraft7()` lowers compatible imported schemas to + the legacy 1.x Draft-7 shape. + +### Added + +* `Schema.toAckSchema(assertFormats: true)` enables format assertions when + importing a Draft 2020-12 model. Formats remain annotations by default. ## 1.7.0-beta.6 diff --git a/packages/ack_json_schema_builder/README.md b/packages/ack_json_schema_builder/README.md index 7f787fb6..6e190ebe 100644 --- a/packages/ack_json_schema_builder/README.md +++ b/packages/ack_json_schema_builder/README.md @@ -33,22 +33,20 @@ the source keyword and location. `toAckSchema()` returns the executable validator. Exports preserve supported validation behavior, not textual round-trip identity. -The bridge inherits ACK's required draft 2020-12 validation support, including -patterns, multiples, dynamic references, conditionals, unevaluated keywords, -and offline standard meta-schemas. Formats are annotations by default. To -assert supported formats before the bridge exposes an assertion option, use -`Ack.fromJsonSchema(..., assertFormats: true)` directly. The bridge's default -export remains the stable Draft-7 representation. Full A2UI support and MCP -registration compatibility remain separate work. -See the [import guide](https://concepta.dev/documentation/ack/guides/json-schema-integration) -for coverage and reference/diagnostic behavior. +The importer validates and round-trips the complete required draft 2020-12 +test suite, including patterns, `multipleOf`, dynamic references, +`unevaluated*`, conditionals, and standard meta-schemas. Formats are annotations +by default; use `toAckSchema(assertFormats: true)` to enforce supported formats. +Unknown asserted formats fail compilation. Optional vocabulary coverage and +typed model generation remain narrower than runtime validation; see the +[capability matrix](../../docs/json-schema-2020-12-capabilities.md). ## Installation ```yaml dependencies: - ack: ^1.6.2 - ack_json_schema_builder: ^1.6.2 + ack: ^1.7.0-beta.6 + ack_json_schema_builder: ^1.7.0-beta.6 json_schema_builder: ^0.1.3 ``` @@ -95,9 +93,12 @@ other logic that JSON Schema cannot represent. ### Target Schema Support -The converter emits ACK's generic Draft-7 JSON Schema map before constructing -the `json_schema_builder` schema. If a downstream validator or consumer ignores -a JSON Schema keyword, validate with ACK after parsing. +The converter uses `AckSchema.toJsonSchema()` before constructing the +`json_schema_builder` schema, preserving imported Draft 2020-12 resources by +default and emitting Draft-7 for native ACK schemas. To lower an imported +schema into legacy Draft-7 `definitions`, call +`schema.toJsonSchemaBuilderDraft7()`. If a validator ignores a keyword, +validate with ACK. ```dart final schema = Ack.date().min(DateTime(2026)); diff --git a/packages/ack_json_schema_builder/lib/ack_json_schema_builder.dart b/packages/ack_json_schema_builder/lib/ack_json_schema_builder.dart index dcd542d3..1ae3e10e 100644 --- a/packages/ack_json_schema_builder/lib/ack_json_schema_builder.dart +++ b/packages/ack_json_schema_builder/lib/ack_json_schema_builder.dart @@ -1,7 +1,9 @@ /// Bidirectional JSON Schema Builder bridge for ACK validation library. /// -/// Converts ACK validation schemas to json_schema_builder Schema format -/// from ACK's generic Draft-7 JSON Schema renderer. +/// Converts ACK validation schemas to json_schema_builder Schema format, +/// preserving imported Draft 2020-12 resources by default and emitting ACK's +/// Draft-7 representation for native schemas. Use [toJsonSchemaBuilderDraft7] +/// when an imported schema must be lowered to Draft-7 definitions. /// Imports draft 2020-12 builder models through [AckSchemaImportExtension]. /// /// ## Usage @@ -25,15 +27,19 @@ import 'package:json_schema_builder/json_schema_builder.dart' as jsb; /// Imports json_schema_builder models using ACK's JSON Schema importer. extension AckSchemaImportExtension on jsb.Schema { - /// Strictly converts this draft 2020-12 model to an ACK validator. + /// Converts this draft 2020-12 model to an ACK validator. + /// Formats are annotations unless [assertFormats] is true or the model's + /// dialect requires format assertions. /// Unsupported semantics throw [JsonSchemaImportException]. AckSchema toAckSchema({ Uri? baseUri, Map documents = const {}, + bool assertFormats = false, }) => Ack.fromJsonSchema( value, baseUri: baseUri, documents: documents.map((uri, schema) => MapEntry(uri, schema.value)), + assertFormats: assertFormats, ); } @@ -41,11 +47,16 @@ extension AckSchemaImportExtension on jsb.Schema { extension JsonSchemaBuilderExtension on AckSchema { /// Converts this ACK schema to json_schema_builder Schema format. /// - /// Returns a json_schema_builder [Schema] instance from ACK's generic - /// Draft-7 JSON Schema map. + /// Returns a json_schema_builder [Schema] instance from ACK's JSON Schema + /// map, preserving imported Draft 2020-12 resources by default. jsb.Schema toJsonSchemaBuilder() { return jsb.Schema.fromMap(toJsonSchema()); } + + /// Converts imports using the legacy Draft-7 `definitions` / `$ref` lowering. + jsb.Schema toJsonSchemaBuilderDraft7() { + return jsb.Schema.fromMap(toJsonSchemaDraft7()); + } } /// Converts a [AckSchemaModel] model directly to json_schema_builder [Schema] format. diff --git a/packages/ack_json_schema_builder/test/from_json_schema_builder_test.dart b/packages/ack_json_schema_builder/test/from_json_schema_builder_test.dart index 0d225647..2c511420 100644 --- a/packages/ack_json_schema_builder/test/from_json_schema_builder_test.dart +++ b/packages/ack_json_schema_builder/test/from_json_schema_builder_test.dart @@ -19,10 +19,29 @@ void main() { expect(strict.safeParse({}).isFail, isTrue); }); - test('builder bridge treats formats as annotations by default', () { + test('builder bridge forwards the format assertion policy', () { final model = jsb.Schema.fromMap({'type': 'string', 'format': 'email'}); - final schema = model.toAckSchema(); - expect(schema.safeParse('not an email').isOk, isTrue); - expect(schema.safeParse(42).isFail, isTrue); + expect(model.toAckSchema().safeParse('not an email').isOk, isTrue); + + final asserted = model.toAckSchema(assertFormats: true); + expect(asserted.safeParse('ada@example.com').isOk, isTrue); + expect(asserted.safeParse('not an email').isFail, isTrue); }); + + test( + 'builder exports preserve 2020-12 by default and expose Draft-7 lowering', + () { + final imported = Ack.fromJsonSchema({'type': 'string', 'minLength': 2}); + final exported = imported.toJsonSchemaBuilder().value as Map; + final legacy = imported.toJsonSchemaBuilderDraft7().value as Map; + + expect(exported['type'], 'string'); + expect( + exported[r'$schema'], + 'https://json-schema.org/draft/2020-12/schema', + ); + expect(legacy['definitions'], isA>()); + expect(legacy[r'$schema'], isNull); + }, + ); }