diff --git a/CHANGELOG.md b/CHANGELOG.md index 151286b..084c5f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +- **Added.** OpenAPI 3.1 schemas now support `patternProperties` and + `propertyNames`. Their subschemas are compiled and checked at contract load, + and direction-specific `readOnly`/`writeOnly` handling reaches nested + `patternProperties` schemas. + ## 0.15.0 — 2026-09-20 - **Added.** `Contract::validateWebhook(string $name, RequestInterface diff --git a/README.md b/README.md index d3af1b3..78ac568 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ Every Schema Object the validators will read — each parameter's, each request and response media type's, each response header's and multipart part header's — is compiled while the contract is built, in the direction it will be read in, so a schema this package cannot evaluate (an assertion -keyword outside the support matrix such as `patternProperties`, a `$schema` +keyword outside the support matrix such as `prefixItems`, a `$schema` naming another dialect, an OAS 3.0 `exclusiveMinimum` written as a number, a `pattern` the backend cannot parse, wherever it is nested) is `InvalidContract` out of the factory and never out of a `validate*()` call. diff --git a/README.ru.md b/README.ru.md index 699a9c8..a9f16f4 100644 --- a/README.ru.md +++ b/README.ru.md @@ -56,7 +56,7 @@ $contract = Contract::fromFile('openapi.yaml'); // нужен symfony/yaml каждого media type запроса и ответа, у каждого заголовка ответа и заголовка multipart-части, — компилируется при сборке контракта, в том направлении, в котором будет прочитан, поэтому схема, которую пакет не может вычислить -(assertion-ключевое слово вне матрицы поддержки вроде `patternProperties`, +(assertion-ключевое слово вне матрицы поддержки вроде `prefixItems`, `$schema` с другим диалектом, `exclusiveMinimum` числом под OAS 3.0, `pattern`, который бэкенд не разбирает, — на любой глубине), это `InvalidContract` из фабрики и никогда — из `validate*()`. diff --git a/src/Internal/Schema/SchemaCompiler.php b/src/Internal/Schema/SchemaCompiler.php index 823adaa..ddd0d2d 100644 --- a/src/Internal/Schema/SchemaCompiler.php +++ b/src/Internal/Schema/SchemaCompiler.php @@ -39,6 +39,7 @@ final class SchemaCompiler 'additionalProperties' => true, 'items' => false, 'not' => false, + 'propertyNames' => false, ]; /** @var list */ @@ -66,6 +67,7 @@ final class SchemaCompiler private const array SCHEMA_MAP_KEYWORDS = [ '$defs', 'properties', + 'patternProperties', ]; /** @var list */ @@ -76,9 +78,7 @@ final class SchemaCompiler 'dependentSchemas', 'else', 'if', - 'patternProperties', 'prefixItems', - 'propertyNames', 'then', 'unevaluatedItems', 'unevaluatedProperties', @@ -113,7 +113,7 @@ public function compile(array $schema, SchemaDialect $dialect): \stdClass private function shareSchemaGraph(\stdClass $schema): array { $referenceFree = !property_exists($schema, '$ref'); - foreach (['additionalProperties', 'items', 'not'] as $keyword) { + foreach (['additionalProperties', 'items', 'not', 'propertyNames'] as $keyword) { /** @var bool|\stdClass|null $member */ $member = $schema->{$keyword} ?? null; if ($member instanceof \stdClass) { @@ -138,7 +138,7 @@ private function shareSchemaGraph(\stdClass $schema): array } $schema->{$keyword} = $members; } - foreach (['properties', '$defs'] as $keyword) { + foreach (['properties', 'patternProperties', '$defs'] as $keyword) { /** @var mixed $members */ $members = $schema->{$keyword} ?? null; if (!$members instanceof \stdClass) { diff --git a/src/Internal/Schema/SchemaValidator.php b/src/Internal/Schema/SchemaValidator.php index 29ca5e9..ad40a60 100644 --- a/src/Internal/Schema/SchemaValidator.php +++ b/src/Internal/Schema/SchemaValidator.php @@ -40,7 +40,7 @@ final class SchemaValidator * alone on purpose — what a `readOnly` property means under a negation * is not something either specification says. */ - private const array DIRECTIONAL_KEYWORDS = ['$defs', 'properties', 'items', 'additionalProperties', 'allOf', 'anyOf', 'oneOf']; + private const array DIRECTIONAL_KEYWORDS = ['$defs', 'properties', 'patternProperties', 'propertyNames', 'items', 'additionalProperties', 'allOf', 'anyOf', 'oneOf']; /** * Every keyword under which the compiler emits a subschema, by the shape @@ -50,9 +50,9 @@ final class SchemaValidator * @var array{single: list, list: list, map: list} */ private const array SUBSCHEMA_KEYWORDS = [ - 'single' => ['additionalProperties', 'items', 'not'], + 'single' => ['additionalProperties', 'items', 'not', 'propertyNames'], 'list' => ['allOf', 'anyOf', 'oneOf'], - 'map' => ['$defs', 'properties'], + 'map' => ['$defs', 'properties', 'patternProperties'], ]; /** @@ -590,7 +590,7 @@ public function effectiveSchema(array $schema, SchemaDirection $direction): arra $defs[$name] = $this->effectiveSchema($def, $direction); } $schema['$defs'] = $defs; - } elseif ($keyword === 'properties' && is_array($schema[$keyword])) { + } elseif (($keyword === 'properties' || $keyword === 'patternProperties') && is_array($schema[$keyword])) { $flag = $direction->foreignFlag(); $properties = []; /** @var array $foreign */ @@ -606,18 +606,20 @@ public function effectiveSchema(array $schema, SchemaDirection $direction): arra continue; } /** @var array $property */ - if (($property[$flag] ?? false) === true) { + if ($keyword === 'properties' && ($property[$flag] ?? false) === true) { $foreign[(string) $name] = true; } $properties[$name] = $this->effectiveSchema($property, $direction); } - $schema['properties'] = $properties; - /** @var mixed $required */ - $required = $schema['required'] ?? null; - if (is_array($required)) { - $schema['required'] = array_values(array_filter($required, static fn(mixed $name): bool => !is_string($name) || !isset($foreign[$name]))); + $schema[$keyword] = $properties; + if ($keyword === 'properties') { + /** @var mixed $required */ + $required = $schema['required'] ?? null; + if (is_array($required)) { + $schema['required'] = array_values(array_filter($required, static fn(mixed $name): bool => !is_string($name) || !isset($foreign[$name]))); + } } - } elseif (($keyword === 'items' || $keyword === 'additionalProperties') && is_array($schema[$keyword]) && !array_is_list($schema[$keyword])) { + } elseif (($keyword === 'items' || $keyword === 'additionalProperties' || $keyword === 'propertyNames') && is_array($schema[$keyword]) && !array_is_list($schema[$keyword])) { /** @var array $items */ $items = $schema[$keyword]; $schema[$keyword] = $this->effectiveSchema($items, $direction); diff --git a/src/SchemaCheck.php b/src/SchemaCheck.php index 749c5a9..0358040 100644 --- a/src/SchemaCheck.php +++ b/src/SchemaCheck.php @@ -95,8 +95,9 @@ public static function isMultipleOf(int|float $value, int|float $divisor): bool * * A property the other direction owns (`readOnly` on a request, * `writeOnly` on a response) loses its `required` entry and keeps its - * subschema; the rewrite recurses through `properties`, `items`, - * `additionalProperties`, `allOf`, `anyOf` and `oneOf`, leaves `not` + * subschema; the rewrite recurses through `properties`, + * `patternProperties`, `propertyNames`, `items`, `additionalProperties`, + * `allOf`, `anyOf` and `oneOf`, leaves `not` * alone, and is idempotent. Members it does not read are passed through * as written. The dialect plays no part: the rewrite is the same under * OAS 3.0 and 3.1. diff --git a/tests/ContractTest.php b/tests/ContractTest.php index 670e9bd..9b12602 100644 --- a/tests/ContractTest.php +++ b/tests/ContractTest.php @@ -65,6 +65,26 @@ public function matchesConcretePathBeforeTemplate(): void Assert::same($matched->pathParameters, []); } + public function validatesPatternPropertiesAndPropertyNamesInContracts(): void + { + $contract = Contract::fromArray([ + 'openapi' => '3.1.0', + 'paths' => ['/labels' => ['get' => ['responses' => [ + '200' => ['content' => ['application/json' => ['schema' => [ + 'type' => 'object', + 'patternProperties' => ['^x-' => ['type' => 'integer', 'minimum' => 1]], + 'propertyNames' => ['pattern' => '^x-[a-z]+$'], + 'additionalProperties' => false, + ]]]], + ]]]], + ]); + $response = static fn(string $body): Response => new Response(200, ['Content-Type' => 'application/json'], $body); + + Assert::true($contract->validateResponse('GET /labels', $response('{"x-count":2}'))->isValid()); + Assert::false($contract->validateResponse('GET /labels', $response('{"x-count":0}'))->isValid()); + Assert::false($contract->validateResponse('GET /labels', $response('{"count":2}'))->isValid()); + } + public function decodesPathParameterExactlyOnce(): void { $contract = Contract::fromArray([ @@ -2001,7 +2021,6 @@ public static function unsupportedSchemaPositionProvider(): iterable $response = static fn(array $schema): array => $operation(['responses' => ['200' => ['content' => ['application/json' => ['schema' => $schema]]]]]); $unsupported = 'assertion is outside the v0.1 support matrix'; - yield 'patternProperties on a parameter' => [$parameter(['type' => 'object', 'patternProperties' => ['^x' => ['type' => 'string']]]), $unsupported]; yield 'a draft-07 $schema on a request body' => [ ['openapi' => '3.1.0', 'paths' => ['/b' => ['post' => [ 'requestBody' => ['content' => ['application/json' => ['schema' => ['$schema' => 'http://json-schema.org/draft-07/schema#', 'type' => 'object']]]], @@ -2014,7 +2033,6 @@ public static function unsupportedSchemaPositionProvider(): iterable ['openapi' => '3.0.3', 'paths' => ['/q' => ['get' => ['responses' => ['200' => ['headers' => ['X-N' => ['schema' => ['type' => 'integer', 'exclusiveMinimum' => 5]]]]]]]]], 'OAS 3.0 requires a boolean', ]; - yield 'propertyNames on a response body' => [$response(['type' => 'object', 'propertyNames' => ['pattern' => '^x']]), $unsupported]; yield 'a minimum that is not a number, under items of a parameter' => [$parameter(['type' => 'array', 'items' => ['type' => 'integer', 'minimum' => '5']]), 'minimum must contain a valid number']; yield 'an if/then on a multipart part header' => [ ['openapi' => '3.1.0', 'paths' => ['/b' => ['post' => [ diff --git a/tests/SchemaCheckTest.php b/tests/SchemaCheckTest.php index 00d8a66..9759200 100644 --- a/tests/SchemaCheckTest.php +++ b/tests/SchemaCheckTest.php @@ -167,6 +167,14 @@ public static function directionalSchemaProvider(): iterable yield 'closed object' => [[...$item, 'additionalProperties' => false], (object) ['id' => 1, 'name' => 'a']]; yield 'list of items' => [['type' => 'array', 'items' => $item], [(object) ['name' => 'a']]]; yield 'map of items' => [['type' => 'object', 'additionalProperties' => $item], (object) ['k' => (object) ['name' => 'a']]]; + yield 'pattern properties' => [[ + 'type' => 'object', + 'patternProperties' => ['^x-' => $item], + ], (object) ['x-item' => (object) ['name' => 'a']]]; + yield 'property names' => [[ + 'type' => 'object', + 'propertyNames' => ['pattern' => '^x-'], + ], (object) ['x-item' => 'a']]; yield 'composition' => [['allOf' => [$item, ['type' => 'object']]], (object) ['name' => 'a']]; yield 'negation is left alone' => [['not' => $item], (object) ['name' => 'a']]; yield 'boolean member passes through' => [['type' => 'object', 'properties' => ['open' => true], 'required' => ['open']], (object) []]; diff --git a/tests/SchemaValidatorTest.php b/tests/SchemaValidatorTest.php index 412185b..8558a24 100644 --- a/tests/SchemaValidatorTest.php +++ b/tests/SchemaValidatorTest.php @@ -268,6 +268,47 @@ public function filtersDirectionalPropertiesInsideNestedStructures(): void Assert::false($validator->isValid((object) ['k' => $value], ['type' => 'object', 'additionalProperties' => $object], SchemaDialect::OpenApi31, SchemaDirection::Response)); } + public function evaluatesPatternPropertiesAndPropertyNames(): void + { + $validator = new SchemaValidator(); + $patternProperties = [ + 'type' => 'object', + 'patternProperties' => ['^x-' => ['type' => 'integer']], + 'additionalProperties' => false, + ]; + $propertyNames = [ + 'type' => 'object', + 'propertyNames' => ['pattern' => '^x-[a-z]+$'], + ]; + + Assert::true($validator->isValid((object) ['x-count' => 3], $patternProperties, SchemaDialect::OpenApi31)); + Assert::false($validator->isValid((object) ['x-count' => '3'], $patternProperties, SchemaDialect::OpenApi31)); + Assert::false($validator->isValid((object) ['count' => 3], $patternProperties, SchemaDialect::OpenApi31)); + Assert::true($validator->isValid((object) ['x-count' => 3], $propertyNames, SchemaDialect::OpenApi31)); + Assert::false($validator->isValid((object) ['count' => 3], $propertyNames, SchemaDialect::OpenApi31)); + } + + public function patternSchemasApplyTogetherAndPropertyNamesChecksDeclaredKeys(): void + { + $validator = new SchemaValidator(); + $schema = [ + 'type' => 'object', + 'properties' => ['x-id' => ['type' => 'integer']], + 'patternProperties' => [ + '^x-' => ['minimum' => 1], + 'id$' => ['maximum' => 5], + ], + 'propertyNames' => ['pattern' => '^x-'], + 'additionalProperties' => true, + ]; + + Assert::true($validator->isValid((object) ['x-id' => 3], $schema, SchemaDialect::OpenApi31)); + Assert::false($validator->isValid((object) ['x-id' => 6], $schema, SchemaDialect::OpenApi31)); + Assert::false($validator->isValid((object) ['x-id' => 0], $schema, SchemaDialect::OpenApi31)); + Assert::true($validator->isValid((object) ['x-other' => 1], $schema, SchemaDialect::OpenApi31)); + Assert::false($validator->isValid((object) ['count' => 1], $schema, SchemaDialect::OpenApi31)); + } + /** * The guards `SchemaCheck` relies on live here, and mutants in this class * are matched to this test class alone — see the package AGENTS.md on diff --git a/tests/fixtures/generated-corpus/requests.json b/tests/fixtures/generated-corpus/requests.json index 8890e86..b46f85c 100644 --- a/tests/fixtures/generated-corpus/requests.json +++ b/tests/fixtures/generated-corpus/requests.json @@ -1055,6 +1055,36 @@ } } }, + "/pattern-labels": { + "post": { + "operationId": "patternLabels.create", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "minProperties": 1, + "maxProperties": 4, + "patternProperties": { + "^x-[a-z]+$": { + "type": "integer", + "minimum": 1 + } + }, + "propertyNames": { + "pattern": "^x-" + }, + "additionalProperties": false + } + } + } + }, + "responses": { + "204": [] + } + } + }, "/sparse": { "post": { "operationId": "sparse.create", @@ -7924,6 +7954,450 @@ "valid": true } }, + { + "name": "patternLabels.create/additional-properties/1", + "document": "zoo", + "operationId": "patternLabels.create", + "request": { + "method": "POST", + "target": "/pattern-labels", + "headers": { + "Content-Type": [ + "application/json" + ] + }, + "body": { + "encoding": "utf8", + "content": "{\"x-kdglq\":678,\"__openapi_extra_property__\":true}" + } + }, + "expect": { + "valid": false, + "misuse": { + "kind": "additional-properties", + "location": "body", + "name": "__openapi_extra_property__" + } + } + }, + { + "name": "patternLabels.create/additional-properties/2", + "document": "zoo", + "operationId": "patternLabels.create", + "request": { + "method": "POST", + "target": "/pattern-labels", + "headers": { + "Content-Type": [ + "application/json" + ] + }, + "body": { + "encoding": "utf8", + "content": "{\"x-aigkk\":525,\"x-pia\":515,\"x-jrczac\":1,\"x-vgaqff\":1,\"__openapi_extra_property__\":true}" + } + }, + "expect": { + "valid": false, + "misuse": { + "kind": "additional-properties", + "location": "body", + "name": "__openapi_extra_property__" + } + } + }, + { + "name": "patternLabels.create/additional-properties/3", + "document": "zoo", + "operationId": "patternLabels.create", + "request": { + "method": "POST", + "target": "/pattern-labels", + "headers": { + "Content-Type": [ + "application/json" + ] + }, + "body": { + "encoding": "utf8", + "content": "{\"x-hccknzqg\":1000,\"x-ydozdgu\":319,\"x-q\":853,\"x-ay\":859,\"__openapi_extra_property__\":true}" + } + }, + "expect": { + "valid": false, + "misuse": { + "kind": "additional-properties", + "location": "body", + "name": "__openapi_extra_property__" + } + } + }, + { + "name": "patternLabels.create/json-syntax/1", + "document": "zoo", + "operationId": "patternLabels.create", + "request": { + "method": "POST", + "target": "/pattern-labels", + "headers": { + "Content-Type": [ + "application/json" + ] + }, + "body": { + "encoding": "utf8", + "content": "{\"malformed\":" + } + }, + "expect": { + "valid": false, + "misuse": { + "kind": "json-syntax", + "location": "body", + "name": "body" + } + } + }, + { + "name": "patternLabels.create/json-syntax/2", + "document": "zoo", + "operationId": "patternLabels.create", + "request": { + "method": "POST", + "target": "/pattern-labels", + "headers": { + "Content-Type": [ + "application/json" + ] + }, + "body": { + "encoding": "utf8", + "content": "{\"malformed\":" + } + }, + "expect": { + "valid": false, + "misuse": { + "kind": "json-syntax", + "location": "body", + "name": "body" + } + } + }, + { + "name": "patternLabels.create/json-syntax/3", + "document": "zoo", + "operationId": "patternLabels.create", + "request": { + "method": "POST", + "target": "/pattern-labels", + "headers": { + "Content-Type": [ + "application/json" + ] + }, + "body": { + "encoding": "utf8", + "content": "{\"malformed\":" + } + }, + "expect": { + "valid": false, + "misuse": { + "kind": "json-syntax", + "location": "body", + "name": "body" + } + } + }, + { + "name": "patternLabels.create/media-type/1", + "document": "zoo", + "operationId": "patternLabels.create", + "request": { + "method": "POST", + "target": "/pattern-labels", + "headers": { + "Content-Type": [ + "application/x-openapi-misuse" + ] + }, + "body": { + "encoding": "utf8", + "content": "{\"x-kdglq\":678}" + } + }, + "expect": { + "valid": false, + "misuse": { + "kind": "media-type", + "location": "body", + "name": "body" + } + } + }, + { + "name": "patternLabels.create/media-type/2", + "document": "zoo", + "operationId": "patternLabels.create", + "request": { + "method": "POST", + "target": "/pattern-labels", + "headers": { + "Content-Type": [ + "application/x-openapi-misuse" + ] + }, + "body": { + "encoding": "utf8", + "content": "{\"x-aigkk\":525,\"x-pia\":515,\"x-jrczac\":1,\"x-vgaqff\":1}" + } + }, + "expect": { + "valid": false, + "misuse": { + "kind": "media-type", + "location": "body", + "name": "body" + } + } + }, + { + "name": "patternLabels.create/media-type/3", + "document": "zoo", + "operationId": "patternLabels.create", + "request": { + "method": "POST", + "target": "/pattern-labels", + "headers": { + "Content-Type": [ + "application/x-openapi-misuse" + ] + }, + "body": { + "encoding": "utf8", + "content": "{\"x-hccknzqg\":1000,\"x-ydozdgu\":319,\"x-q\":853,\"x-ay\":859}" + } + }, + "expect": { + "valid": false, + "misuse": { + "kind": "media-type", + "location": "body", + "name": "body" + } + } + }, + { + "name": "patternLabels.create/valid/1", + "document": "zoo", + "operationId": "patternLabels.create", + "request": { + "method": "POST", + "target": "/pattern-labels", + "headers": { + "Content-Type": [ + "application/json" + ] + }, + "body": { + "encoding": "utf8", + "content": "{\"x-kdglq\":678}" + } + }, + "expect": { + "valid": true + } + }, + { + "name": "patternLabels.create/valid/10", + "document": "zoo", + "operationId": "patternLabels.create", + "request": { + "method": "POST", + "target": "/pattern-labels", + "headers": { + "Content-Type": [ + "application/json" + ] + }, + "body": { + "encoding": "utf8", + "content": "{\"x-jtrpxof\":1,\"x-dmhammhy\":168,\"x-rrui\":323,\"x-vypw\":360}" + } + }, + "expect": { + "valid": true + } + }, + { + "name": "patternLabels.create/valid/2", + "document": "zoo", + "operationId": "patternLabels.create", + "request": { + "method": "POST", + "target": "/pattern-labels", + "headers": { + "Content-Type": [ + "application/json" + ] + }, + "body": { + "encoding": "utf8", + "content": "{\"x-aigkk\":525,\"x-pia\":515,\"x-jrczac\":1,\"x-vgaqff\":1}" + } + }, + "expect": { + "valid": true + } + }, + { + "name": "patternLabels.create/valid/3", + "document": "zoo", + "operationId": "patternLabels.create", + "request": { + "method": "POST", + "target": "/pattern-labels", + "headers": { + "Content-Type": [ + "application/json" + ] + }, + "body": { + "encoding": "utf8", + "content": "{\"x-hccknzqg\":1000,\"x-ydozdgu\":319,\"x-q\":853,\"x-ay\":859}" + } + }, + "expect": { + "valid": true + } + }, + { + "name": "patternLabels.create/valid/4", + "document": "zoo", + "operationId": "patternLabels.create", + "request": { + "method": "POST", + "target": "/pattern-labels", + "headers": { + "Content-Type": [ + "application/json" + ] + }, + "body": { + "encoding": "utf8", + "content": "{\"x-pajk\":1,\"x-gewlhcba\":10,\"x-arlmjhr\":415,\"x-upd\":538}" + } + }, + "expect": { + "valid": true + } + }, + { + "name": "patternLabels.create/valid/5", + "document": "zoo", + "operationId": "patternLabels.create", + "request": { + "method": "POST", + "target": "/pattern-labels", + "headers": { + "Content-Type": [ + "application/json" + ] + }, + "body": { + "encoding": "utf8", + "content": "{\"x-ct\":915}" + } + }, + "expect": { + "valid": true + } + }, + { + "name": "patternLabels.create/valid/6", + "document": "zoo", + "operationId": "patternLabels.create", + "request": { + "method": "POST", + "target": "/pattern-labels", + "headers": { + "Content-Type": [ + "application/json" + ] + }, + "body": { + "encoding": "utf8", + "content": "{\"x-cu\":1,\"x-xc\":1000,\"x-owvzagxb\":921}" + } + }, + "expect": { + "valid": true + } + }, + { + "name": "patternLabels.create/valid/7", + "document": "zoo", + "operationId": "patternLabels.create", + "request": { + "method": "POST", + "target": "/pattern-labels", + "headers": { + "Content-Type": [ + "application/json" + ] + }, + "body": { + "encoding": "utf8", + "content": "{\"x-l\":1,\"x-bodvigxz\":1,\"x-djlg\":886,\"x-f\":896}" + } + }, + "expect": { + "valid": true + } + }, + { + "name": "patternLabels.create/valid/8", + "document": "zoo", + "operationId": "patternLabels.create", + "request": { + "method": "POST", + "target": "/pattern-labels", + "headers": { + "Content-Type": [ + "application/json" + ] + }, + "body": { + "encoding": "utf8", + "content": "{\"x-o\":476,\"x-n\":605,\"x-kf\":888}" + } + }, + "expect": { + "valid": true + } + }, + { + "name": "patternLabels.create/valid/9", + "document": "zoo", + "operationId": "patternLabels.create", + "request": { + "method": "POST", + "target": "/pattern-labels", + "headers": { + "Content-Type": [ + "application/json" + ] + }, + "body": { + "encoding": "utf8", + "content": "{\"x-snu\":496,\"x-yuzhngrw\":339}" + } + }, + "expect": { + "valid": true + } + }, { "name": "precise.get/boundary/1", "document": "zoo",