diff --git a/.eslintignore b/.eslintignore index 300e20a2828..c096a6c526b 100644 --- a/.eslintignore +++ b/.eslintignore @@ -12,10 +12,13 @@ /docs/latex/ /vendor/ /tests/coverage/ + +# Test data /tests/phpunit/data/registration/duplicate_keys.json /tests/phpunit/data/resourceloader/codex/ /tests/phpunit/data/resourceloader/codex-devmode/ /tests/phpunit/unit/includes/Settings/Source/fixtures/bad.json +/tests/phpunit/**/*malformed*.json /maintenance/benchmarks/data/ # Nested projects diff --git a/docs/rest/mwapi-1.0.json b/docs/rest/mwapi-1.0.json index 42b8495e6c0..7e4f9d41a4d 100644 --- a/docs/rest/mwapi-1.0.json +++ b/docs/rest/mwapi-1.0.json @@ -1,5 +1,5 @@ { - "$schema": "http://json-schema.org/draft-06/schema#", + "$schema": "http://json-schema.org/draft-04/schema#", "$id": "https://www.mediawiki.org/schema/mwapi-1.0", "title": "MediaWiki REST API module definition", "description": "Module definition files provide meta-data about modules and define the available routes. They are similar to OpenAPI specs.", @@ -65,12 +65,11 @@ }, "OperationImpl": { "oneOf": [ - { "$ref": "#/definitions/Handler" }, - { "$ref": "#/definitions/Redirect" } - ], - "additionalProperties": false + { "$ref": "#/definitions/WithHandler" }, + { "$ref": "#/definitions/WithRedirect" } + ] }, - "Handler": { + "WithHandler": { "required": [ "handler" ], "properties": { "handler": { @@ -104,9 +103,10 @@ "item": { "type": "string" }, "description": "List of services to pass as arguments. Each name will be looked up in MediaWikiServices. If the service is unknown the parameter is set to 'null' instead of causing an error." } - } + }, + "additionalProperties": true }, - "Redirect": { + "WithRedirect": { "required": [ "redirect" ], "properties": { "redirect": { @@ -115,7 +115,8 @@ "properties": { "path": { "type": "string" }, "code": { "type": "integer" } - } + }, + "additionalProperties": false } } } diff --git a/includes/Rest/content.v1.json b/includes/Rest/content.v1.json index 56881f7f7a2..1bd196c861b 100644 --- a/includes/Rest/content.v1.json +++ b/includes/Rest/content.v1.json @@ -2,7 +2,9 @@ "mwapi": "1.0.0", "moduleId": "content/v1", "info": { - "version": "1.0" + "version": "1.0", + "title": "Page content", + "description": "Provides access to the content and meta-data of pages and revisions" }, "paths": { "/page": { diff --git a/tests/common/TestsAutoLoader.php b/tests/common/TestsAutoLoader.php index e0161e4a193..3fd1f1831b5 100644 --- a/tests/common/TestsAutoLoader.php +++ b/tests/common/TestsAutoLoader.php @@ -53,6 +53,7 @@ $wgAutoloadClasses += [ # tests/phpunit 'DynamicPropertyTestHelper' => "$testDir/phpunit/DynamicPropertyTestHelper.php", 'EmptyResourceLoader' => "$testDir/phpunit/ResourceLoaderTestCase.php", + 'JsonSchemaAssertionTrait' => "$testDir/phpunit/JsonSchemaAssertionTrait.php", 'MediaWiki\\Tests\\ResourceLoader\\EmptyResourceLoader' => "$testDir/phpunit/ResourceLoaderTestCase.php", 'HamcrestPHPUnitIntegration' => "$testDir/phpunit/HamcrestPHPUnitIntegration.php", 'MediaWikiCoversValidator' => "$testDir/phpunit/MediaWikiCoversValidator.php", diff --git a/tests/phpunit/JsonSchemaAssertionTrait.php b/tests/phpunit/JsonSchemaAssertionTrait.php new file mode 100644 index 00000000000..9e82438d5e5 --- /dev/null +++ b/tests/phpunit/JsonSchemaAssertionTrait.php @@ -0,0 +1,113 @@ + $resources Mapping of schema IDs to local files, + * to avoid loading schemas over the network during testing. + */ + private function assertMatchesJsonSchema( $schema, $json, array $resources = [] ): void { + if ( is_string( $json ) ) { + try { + $json = json_decode( $json, false, JSON_THROW_ON_ERROR ); + } catch ( JsonException $ex ) { + self::fail( 'Invalid JSON: ' . $ex->getMessage() ); + } + } + if ( is_string( $schema ) ) { + // Let the JsonException propagate, this indicates a bug in the test, + // not a test failure. + $schema = self::loadJsonData( $schema ); + } + + $factory = new Factory(); + + foreach ( $resources as $id => $rc ) { + $factory->getSchemaStorage()->addSchema( + rtrim( $id, '#' ), + self::loadJsonData( $rc ) + ); + } + + $validator = new Validator( $factory ); + $validator->validate( + $json, $schema, + Constraint::CHECK_MODE_TYPE_CAST + ); + + if ( !$validator->isValid() ) { + foreach ( $validator->getErrors() as $error ) { + $error = json_encode( $error ); + self::fail( "JSON schema validation failed: $error" ); + } + } + + $this->addToAssertionCount( 1 ); + } + + /** + * Validate a JSON schema. + * + * Currently only works for schemas that match Draft-4. + * + * @param array $schema The schema to validate. + * @throws LogicException if the schema is invalid + */ + public function assertValidJsonSchema( array $schema ): void { + // Load the draft-04 schema from the local file + $metaSchema = MW_INSTALL_PATH . '/vendor/justinrainbow/json-schema/dist/' . + 'schema/json-schema-draft-04.json'; + + self::assertMatchesJsonSchema( $metaSchema, $schema ); + } + +} diff --git a/tests/phpunit/integration/includes/Rest/Handler/ModuleSpecHandlerTest.php b/tests/phpunit/integration/includes/Rest/Handler/ModuleSpecHandlerTest.php index 3043490c607..99d08b3d4d5 100644 --- a/tests/phpunit/integration/includes/Rest/Handler/ModuleSpecHandlerTest.php +++ b/tests/phpunit/integration/includes/Rest/Handler/ModuleSpecHandlerTest.php @@ -2,6 +2,7 @@ namespace MediaWiki\Tests\Rest\Handler; +use JsonSchemaAssertionTrait; use MediaWiki\Config\ServiceOptions; use MediaWiki\Context\RequestContext; use MediaWiki\MainConfigNames; @@ -28,6 +29,7 @@ use Wikimedia\ParamValidator\ParamValidator; */ class ModuleSpecHandlerTest extends MediaWikiIntegrationTestCase { use HandlerTestTrait; + use JsonSchemaAssertionTrait; private function createRouter( RequestInterface $request, @@ -81,31 +83,11 @@ class ModuleSpecHandlerTest extends MediaWikiIntegrationTestCase { ); } - private static function assertWellFormedOAS( array $spec ) { - $requiredTop = [ - 'openapi', - 'info', - 'servers', - 'paths', - 'components' - ]; - - foreach ( $requiredTop as $key ) { - Assert::assertArrayHasKey( $key, $spec ); - } - - Assert::assertSame( '3.0.0', $spec['openapi'] ); - - $requiredInfo = [ - 'title', - 'version', - ]; - - foreach ( $requiredInfo as $key ) { - Assert::assertArrayHasKey( $key, $spec['info'] ); - } - - Assert::assertNotEmpty( $spec['servers'] ); + private function assertWellFormedOAS( array $spec ) { + $this->assertMatchesJsonSchema( + __DIR__ . '/data/OpenApi-3.0.json', + $spec + ); } private static function assertContainsRecursive( @@ -240,8 +222,8 @@ class ModuleSpecHandlerTest extends MediaWikiIntegrationTestCase { $data = json_decode( (string)$response->getBody(), true ); $this->assertIsArray( $data, 'Body must be a JSON array' ); - self::assertWellFormedOAS( $data ); - self::assertContainsRecursive( $expected, $data ); + $this->assertWellFormedOAS( $data ); + $this->assertContainsRecursive( $expected, $data ); } public static function newFooBarHandler() { diff --git a/tests/phpunit/integration/includes/Rest/Handler/data/OpenApi-3.0.json b/tests/phpunit/integration/includes/Rest/Handler/data/OpenApi-3.0.json new file mode 100644 index 00000000000..bb8fb21cdb7 --- /dev/null +++ b/tests/phpunit/integration/includes/Rest/Handler/data/OpenApi-3.0.json @@ -0,0 +1,1666 @@ +{ + "id": "https://spec.openapis.org/oas/3.0/schema/2021-09-28", + "$schema": "http://json-schema.org/draft-04/schema#", + "description": "The description of OpenAPI v3.0.x documents, as defined by https://spec.openapis.org/oas/v3.0.3", + "type": "object", + "required": [ + "openapi", + "info", + "paths" + ], + "properties": { + "openapi": { + "type": "string", + "pattern": "^3\\.0\\.\\d(-.+)?$" + }, + "info": { + "$ref": "#/definitions/Info" + }, + "externalDocs": { + "$ref": "#/definitions/ExternalDocumentation" + }, + "servers": { + "type": "array", + "items": { + "$ref": "#/definitions/Server" + } + }, + "security": { + "type": "array", + "items": { + "$ref": "#/definitions/SecurityRequirement" + } + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/definitions/Tag" + }, + "uniqueItems": true + }, + "paths": { + "$ref": "#/definitions/Paths" + }, + "components": { + "$ref": "#/definitions/Components" + } + }, + "patternProperties": { + "^x-": { + } + }, + "additionalProperties": false, + "definitions": { + "Reference": { + "type": "object", + "required": [ + "$ref" + ], + "patternProperties": { + "^\\$ref$": { + "type": "string", + "format": "uri-reference" + } + } + }, + "Info": { + "type": "object", + "required": [ + "title", + "version" + ], + "properties": { + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "termsOfService": { + "type": "string", + "format": "uri-reference" + }, + "contact": { + "$ref": "#/definitions/Contact" + }, + "license": { + "$ref": "#/definitions/License" + }, + "version": { + "type": "string" + } + }, + "patternProperties": { + "^x-": { + } + }, + "additionalProperties": false + }, + "Contact": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri-reference" + }, + "email": { + "type": "string", + "format": "email" + } + }, + "patternProperties": { + "^x-": { + } + }, + "additionalProperties": false + }, + "License": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri-reference" + } + }, + "patternProperties": { + "^x-": { + } + }, + "additionalProperties": false + }, + "Server": { + "type": "object", + "required": [ + "url" + ], + "properties": { + "url": { + "type": "string" + }, + "description": { + "type": "string" + }, + "variables": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/ServerVariable" + } + } + }, + "patternProperties": { + "^x-": { + } + }, + "additionalProperties": false + }, + "ServerVariable": { + "type": "object", + "required": [ + "default" + ], + "properties": { + "enum": { + "type": "array", + "items": { + "type": "string" + } + }, + "default": { + "type": "string" + }, + "description": { + "type": "string" + } + }, + "patternProperties": { + "^x-": { + } + }, + "additionalProperties": false + }, + "Components": { + "type": "object", + "properties": { + "schemas": { + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9\\.\\-_]+$": { + "oneOf": [ + { + "$ref": "#/definitions/Schema" + }, + { + "$ref": "#/definitions/Reference" + } + ] + } + } + }, + "responses": { + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9\\.\\-_]+$": { + "oneOf": [ + { + "$ref": "#/definitions/Reference" + }, + { + "$ref": "#/definitions/Response" + } + ] + } + } + }, + "parameters": { + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9\\.\\-_]+$": { + "oneOf": [ + { + "$ref": "#/definitions/Reference" + }, + { + "$ref": "#/definitions/Parameter" + } + ] + } + } + }, + "examples": { + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9\\.\\-_]+$": { + "oneOf": [ + { + "$ref": "#/definitions/Reference" + }, + { + "$ref": "#/definitions/Example" + } + ] + } + } + }, + "requestBodies": { + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9\\.\\-_]+$": { + "oneOf": [ + { + "$ref": "#/definitions/Reference" + }, + { + "$ref": "#/definitions/RequestBody" + } + ] + } + } + }, + "headers": { + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9\\.\\-_]+$": { + "oneOf": [ + { + "$ref": "#/definitions/Reference" + }, + { + "$ref": "#/definitions/Header" + } + ] + } + } + }, + "securitySchemes": { + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9\\.\\-_]+$": { + "oneOf": [ + { + "$ref": "#/definitions/Reference" + }, + { + "$ref": "#/definitions/SecurityScheme" + } + ] + } + } + }, + "links": { + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9\\.\\-_]+$": { + "oneOf": [ + { + "$ref": "#/definitions/Reference" + }, + { + "$ref": "#/definitions/Link" + } + ] + } + } + }, + "callbacks": { + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9\\.\\-_]+$": { + "oneOf": [ + { + "$ref": "#/definitions/Reference" + }, + { + "$ref": "#/definitions/Callback" + } + ] + } + } + } + }, + "patternProperties": { + "^x-": { + } + }, + "additionalProperties": false + }, + "Schema": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "multipleOf": { + "type": "number", + "minimum": 0, + "exclusiveMinimum": true + }, + "maximum": { + "type": "number" + }, + "exclusiveMaximum": { + "type": "boolean", + "default": false + }, + "minimum": { + "type": "number" + }, + "exclusiveMinimum": { + "type": "boolean", + "default": false + }, + "maxLength": { + "type": "integer", + "minimum": 0 + }, + "minLength": { + "type": "integer", + "minimum": 0, + "default": 0 + }, + "pattern": { + "type": "string", + "format": "regex" + }, + "maxItems": { + "type": "integer", + "minimum": 0 + }, + "minItems": { + "type": "integer", + "minimum": 0, + "default": 0 + }, + "uniqueItems": { + "type": "boolean", + "default": false + }, + "maxProperties": { + "type": "integer", + "minimum": 0 + }, + "minProperties": { + "type": "integer", + "minimum": 0, + "default": 0 + }, + "required": { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1, + "uniqueItems": true + }, + "enum": { + "type": "array", + "items": { + }, + "minItems": 1, + "uniqueItems": false + }, + "type": { + "type": "string", + "enum": [ + "array", + "boolean", + "integer", + "number", + "object", + "string" + ] + }, + "not": { + "oneOf": [ + { + "$ref": "#/definitions/Schema" + }, + { + "$ref": "#/definitions/Reference" + } + ] + }, + "allOf": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/definitions/Schema" + }, + { + "$ref": "#/definitions/Reference" + } + ] + } + }, + "oneOf": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/definitions/Schema" + }, + { + "$ref": "#/definitions/Reference" + } + ] + } + }, + "anyOf": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/definitions/Schema" + }, + { + "$ref": "#/definitions/Reference" + } + ] + } + }, + "items": { + "oneOf": [ + { + "$ref": "#/definitions/Schema" + }, + { + "$ref": "#/definitions/Reference" + } + ] + }, + "properties": { + "type": "object", + "additionalProperties": { + "oneOf": [ + { + "$ref": "#/definitions/Schema" + }, + { + "$ref": "#/definitions/Reference" + } + ] + } + }, + "additionalProperties": { + "oneOf": [ + { + "$ref": "#/definitions/Schema" + }, + { + "$ref": "#/definitions/Reference" + }, + { + "type": "boolean" + } + ], + "default": true + }, + "description": { + "type": "string" + }, + "format": { + "type": "string" + }, + "default": { + }, + "nullable": { + "type": "boolean", + "default": false + }, + "discriminator": { + "$ref": "#/definitions/Discriminator" + }, + "readOnly": { + "type": "boolean", + "default": false + }, + "writeOnly": { + "type": "boolean", + "default": false + }, + "example": { + }, + "externalDocs": { + "$ref": "#/definitions/ExternalDocumentation" + }, + "deprecated": { + "type": "boolean", + "default": false + }, + "xml": { + "$ref": "#/definitions/XML" + } + }, + "patternProperties": { + "^x-": { + } + }, + "additionalProperties": false + }, + "Discriminator": { + "type": "object", + "required": [ + "propertyName" + ], + "properties": { + "propertyName": { + "type": "string" + }, + "mapping": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + }, + "XML": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "namespace": { + "type": "string", + "format": "uri" + }, + "prefix": { + "type": "string" + }, + "attribute": { + "type": "boolean", + "default": false + }, + "wrapped": { + "type": "boolean", + "default": false + } + }, + "patternProperties": { + "^x-": { + } + }, + "additionalProperties": false + }, + "Response": { + "type": "object", + "required": [ + "description" + ], + "properties": { + "description": { + "type": "string" + }, + "headers": { + "type": "object", + "additionalProperties": { + "oneOf": [ + { + "$ref": "#/definitions/Header" + }, + { + "$ref": "#/definitions/Reference" + } + ] + } + }, + "content": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/MediaType" + } + }, + "links": { + "type": "object", + "additionalProperties": { + "oneOf": [ + { + "$ref": "#/definitions/Link" + }, + { + "$ref": "#/definitions/Reference" + } + ] + } + } + }, + "patternProperties": { + "^x-": { + } + }, + "additionalProperties": false + }, + "MediaType": { + "type": "object", + "properties": { + "schema": { + "oneOf": [ + { + "$ref": "#/definitions/Schema" + }, + { + "$ref": "#/definitions/Reference" + } + ] + }, + "example": { + }, + "examples": { + "type": "object", + "additionalProperties": { + "oneOf": [ + { + "$ref": "#/definitions/Example" + }, + { + "$ref": "#/definitions/Reference" + } + ] + } + }, + "encoding": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/Encoding" + } + } + }, + "patternProperties": { + "^x-": { + } + }, + "additionalProperties": false, + "allOf": [ + { + "$ref": "#/definitions/ExampleXORExamples" + } + ] + }, + "Example": { + "type": "object", + "properties": { + "summary": { + "type": "string" + }, + "description": { + "type": "string" + }, + "value": { + }, + "externalValue": { + "type": "string", + "format": "uri-reference" + } + }, + "patternProperties": { + "^x-": { + } + }, + "additionalProperties": false + }, + "Header": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "required": { + "type": "boolean", + "default": false + }, + "deprecated": { + "type": "boolean", + "default": false + }, + "allowEmptyValue": { + "type": "boolean", + "default": false + }, + "style": { + "type": "string", + "enum": [ + "simple" + ], + "default": "simple" + }, + "explode": { + "type": "boolean" + }, + "allowReserved": { + "type": "boolean", + "default": false + }, + "schema": { + "oneOf": [ + { + "$ref": "#/definitions/Schema" + }, + { + "$ref": "#/definitions/Reference" + } + ] + }, + "content": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/MediaType" + }, + "minProperties": 1, + "maxProperties": 1 + }, + "example": { + }, + "examples": { + "type": "object", + "additionalProperties": { + "oneOf": [ + { + "$ref": "#/definitions/Example" + }, + { + "$ref": "#/definitions/Reference" + } + ] + } + } + }, + "patternProperties": { + "^x-": { + } + }, + "additionalProperties": false, + "allOf": [ + { + "$ref": "#/definitions/ExampleXORExamples" + }, + { + "$ref": "#/definitions/SchemaXORContent" + } + ] + }, + "Paths": { + "type": "object", + "patternProperties": { + "^\\/": { + "$ref": "#/definitions/PathItem" + }, + "^x-": { + } + }, + "additionalProperties": false + }, + "PathItem": { + "type": "object", + "properties": { + "$ref": { + "type": "string" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + }, + "servers": { + "type": "array", + "items": { + "$ref": "#/definitions/Server" + } + }, + "parameters": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/definitions/Parameter" + }, + { + "$ref": "#/definitions/Reference" + } + ] + }, + "uniqueItems": true + } + }, + "patternProperties": { + "^(get|put|post|delete|options|head|patch|trace)$": { + "$ref": "#/definitions/Operation" + }, + "^x-": { + } + }, + "additionalProperties": false + }, + "Operation": { + "type": "object", + "required": [ + "responses" + ], + "properties": { + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + }, + "externalDocs": { + "$ref": "#/definitions/ExternalDocumentation" + }, + "operationId": { + "type": "string" + }, + "parameters": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/definitions/Parameter" + }, + { + "$ref": "#/definitions/Reference" + } + ] + }, + "uniqueItems": true + }, + "requestBody": { + "oneOf": [ + { + "$ref": "#/definitions/RequestBody" + }, + { + "$ref": "#/definitions/Reference" + } + ] + }, + "responses": { + "$ref": "#/definitions/Responses" + }, + "callbacks": { + "type": "object", + "additionalProperties": { + "oneOf": [ + { + "$ref": "#/definitions/Callback" + }, + { + "$ref": "#/definitions/Reference" + } + ] + } + }, + "deprecated": { + "type": "boolean", + "default": false + }, + "security": { + "type": "array", + "items": { + "$ref": "#/definitions/SecurityRequirement" + } + }, + "servers": { + "type": "array", + "items": { + "$ref": "#/definitions/Server" + } + } + }, + "patternProperties": { + "^x-": { + } + }, + "additionalProperties": false + }, + "Responses": { + "type": "object", + "properties": { + "default": { + "oneOf": [ + { + "$ref": "#/definitions/Response" + }, + { + "$ref": "#/definitions/Reference" + } + ] + } + }, + "patternProperties": { + "^[1-5](?:\\d{2}|XX)$": { + "oneOf": [ + { + "$ref": "#/definitions/Response" + }, + { + "$ref": "#/definitions/Reference" + } + ] + }, + "^x-": { + } + }, + "minProperties": 1, + "additionalProperties": false + }, + "SecurityRequirement": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "Tag": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "externalDocs": { + "$ref": "#/definitions/ExternalDocumentation" + } + }, + "patternProperties": { + "^x-": { + } + }, + "additionalProperties": false + }, + "ExternalDocumentation": { + "type": "object", + "required": [ + "url" + ], + "properties": { + "description": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri-reference" + } + }, + "patternProperties": { + "^x-": { + } + }, + "additionalProperties": false + }, + "ExampleXORExamples": { + "description": "Example and examples are mutually exclusive", + "not": { + "required": [ + "example", + "examples" + ] + } + }, + "SchemaXORContent": { + "description": "Schema and content are mutually exclusive, at least one is required", + "not": { + "required": [ + "schema", + "content" + ] + }, + "oneOf": [ + { + "required": [ + "schema" + ] + }, + { + "required": [ + "content" + ], + "description": "Some properties are not allowed if content is present", + "allOf": [ + { + "not": { + "required": [ + "style" + ] + } + }, + { + "not": { + "required": [ + "explode" + ] + } + }, + { + "not": { + "required": [ + "allowReserved" + ] + } + }, + { + "not": { + "required": [ + "example" + ] + } + }, + { + "not": { + "required": [ + "examples" + ] + } + } + ] + } + ] + }, + "Parameter": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "in": { + "type": "string" + }, + "description": { + "type": "string" + }, + "required": { + "type": "boolean", + "default": false + }, + "deprecated": { + "type": "boolean", + "default": false + }, + "allowEmptyValue": { + "type": "boolean", + "default": false + }, + "style": { + "type": "string" + }, + "explode": { + "type": "boolean" + }, + "allowReserved": { + "type": "boolean", + "default": false + }, + "schema": { + "oneOf": [ + { + "$ref": "#/definitions/Schema" + }, + { + "$ref": "#/definitions/Reference" + } + ] + }, + "content": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/MediaType" + }, + "minProperties": 1, + "maxProperties": 1 + }, + "example": { + }, + "examples": { + "type": "object", + "additionalProperties": { + "oneOf": [ + { + "$ref": "#/definitions/Example" + }, + { + "$ref": "#/definitions/Reference" + } + ] + } + } + }, + "patternProperties": { + "^x-": { + } + }, + "additionalProperties": false, + "required": [ + "name", + "in" + ], + "allOf": [ + { + "$ref": "#/definitions/ExampleXORExamples" + }, + { + "$ref": "#/definitions/SchemaXORContent" + }, + { + "$ref": "#/definitions/ParameterLocation" + } + ] + }, + "ParameterLocation": { + "description": "Parameter location", + "oneOf": [ + { + "description": "Parameter in path", + "required": [ + "required" + ], + "properties": { + "in": { + "enum": [ + "path" + ] + }, + "style": { + "enum": [ + "matrix", + "label", + "simple" + ], + "default": "simple" + }, + "required": { + "enum": [ + true + ] + } + } + }, + { + "description": "Parameter in query", + "properties": { + "in": { + "enum": [ + "query" + ] + }, + "style": { + "enum": [ + "form", + "spaceDelimited", + "pipeDelimited", + "deepObject" + ], + "default": "form" + } + } + }, + { + "description": "Parameter in header", + "properties": { + "in": { + "enum": [ + "header" + ] + }, + "style": { + "enum": [ + "simple" + ], + "default": "simple" + } + } + }, + { + "description": "Parameter in cookie", + "properties": { + "in": { + "enum": [ + "cookie" + ] + }, + "style": { + "enum": [ + "form" + ], + "default": "form" + } + } + } + ] + }, + "RequestBody": { + "type": "object", + "required": [ + "content" + ], + "properties": { + "description": { + "type": "string" + }, + "content": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/MediaType" + } + }, + "required": { + "type": "boolean", + "default": false + } + }, + "patternProperties": { + "^x-": { + } + }, + "additionalProperties": false + }, + "SecurityScheme": { + "oneOf": [ + { + "$ref": "#/definitions/APIKeySecurityScheme" + }, + { + "$ref": "#/definitions/HTTPSecurityScheme" + }, + { + "$ref": "#/definitions/OAuth2SecurityScheme" + }, + { + "$ref": "#/definitions/OpenIdConnectSecurityScheme" + } + ] + }, + "APIKeySecurityScheme": { + "type": "object", + "required": [ + "type", + "name", + "in" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "apiKey" + ] + }, + "name": { + "type": "string" + }, + "in": { + "type": "string", + "enum": [ + "header", + "query", + "cookie" + ] + }, + "description": { + "type": "string" + } + }, + "patternProperties": { + "^x-": { + } + }, + "additionalProperties": false + }, + "HTTPSecurityScheme": { + "type": "object", + "required": [ + "scheme", + "type" + ], + "properties": { + "scheme": { + "type": "string" + }, + "bearerFormat": { + "type": "string" + }, + "description": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "http" + ] + } + }, + "patternProperties": { + "^x-": { + } + }, + "additionalProperties": false, + "oneOf": [ + { + "description": "Bearer", + "properties": { + "scheme": { + "type": "string", + "pattern": "^[Bb][Ee][Aa][Rr][Ee][Rr]$" + } + } + }, + { + "description": "Non Bearer", + "not": { + "required": [ + "bearerFormat" + ] + }, + "properties": { + "scheme": { + "not": { + "type": "string", + "pattern": "^[Bb][Ee][Aa][Rr][Ee][Rr]$" + } + } + } + } + ] + }, + "OAuth2SecurityScheme": { + "type": "object", + "required": [ + "type", + "flows" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "oauth2" + ] + }, + "flows": { + "$ref": "#/definitions/OAuthFlows" + }, + "description": { + "type": "string" + } + }, + "patternProperties": { + "^x-": { + } + }, + "additionalProperties": false + }, + "OpenIdConnectSecurityScheme": { + "type": "object", + "required": [ + "type", + "openIdConnectUrl" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "openIdConnect" + ] + }, + "openIdConnectUrl": { + "type": "string", + "format": "uri-reference" + }, + "description": { + "type": "string" + } + }, + "patternProperties": { + "^x-": { + } + }, + "additionalProperties": false + }, + "OAuthFlows": { + "type": "object", + "properties": { + "implicit": { + "$ref": "#/definitions/ImplicitOAuthFlow" + }, + "password": { + "$ref": "#/definitions/PasswordOAuthFlow" + }, + "clientCredentials": { + "$ref": "#/definitions/ClientCredentialsFlow" + }, + "authorizationCode": { + "$ref": "#/definitions/AuthorizationCodeOAuthFlow" + } + }, + "patternProperties": { + "^x-": { + } + }, + "additionalProperties": false + }, + "ImplicitOAuthFlow": { + "type": "object", + "required": [ + "authorizationUrl", + "scopes" + ], + "properties": { + "authorizationUrl": { + "type": "string", + "format": "uri-reference" + }, + "refreshUrl": { + "type": "string", + "format": "uri-reference" + }, + "scopes": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "patternProperties": { + "^x-": { + } + }, + "additionalProperties": false + }, + "PasswordOAuthFlow": { + "type": "object", + "required": [ + "tokenUrl", + "scopes" + ], + "properties": { + "tokenUrl": { + "type": "string", + "format": "uri-reference" + }, + "refreshUrl": { + "type": "string", + "format": "uri-reference" + }, + "scopes": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "patternProperties": { + "^x-": { + } + }, + "additionalProperties": false + }, + "ClientCredentialsFlow": { + "type": "object", + "required": [ + "tokenUrl", + "scopes" + ], + "properties": { + "tokenUrl": { + "type": "string", + "format": "uri-reference" + }, + "refreshUrl": { + "type": "string", + "format": "uri-reference" + }, + "scopes": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "patternProperties": { + "^x-": { + } + }, + "additionalProperties": false + }, + "AuthorizationCodeOAuthFlow": { + "type": "object", + "required": [ + "authorizationUrl", + "tokenUrl", + "scopes" + ], + "properties": { + "authorizationUrl": { + "type": "string", + "format": "uri-reference" + }, + "tokenUrl": { + "type": "string", + "format": "uri-reference" + }, + "refreshUrl": { + "type": "string", + "format": "uri-reference" + }, + "scopes": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "patternProperties": { + "^x-": { + } + }, + "additionalProperties": false + }, + "Link": { + "type": "object", + "properties": { + "operationId": { + "type": "string" + }, + "operationRef": { + "type": "string", + "format": "uri-reference" + }, + "parameters": { + "type": "object", + "additionalProperties": { + } + }, + "requestBody": { + }, + "description": { + "type": "string" + }, + "server": { + "$ref": "#/definitions/Server" + } + }, + "patternProperties": { + "^x-": { + } + }, + "additionalProperties": false, + "not": { + "description": "Operation Id and Operation Ref are mutually exclusive", + "required": [ + "operationId", + "operationRef" + ] + } + }, + "Callback": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/PathItem" + }, + "patternProperties": { + "^x-": { + } + } + }, + "Encoding": { + "type": "object", + "properties": { + "contentType": { + "type": "string" + }, + "headers": { + "type": "object", + "additionalProperties": { + "oneOf": [ + { + "$ref": "#/definitions/Header" + }, + { + "$ref": "#/definitions/Reference" + } + ] + } + }, + "style": { + "type": "string", + "enum": [ + "form", + "spaceDelimited", + "pipeDelimited", + "deepObject" + ] + }, + "explode": { + "type": "boolean" + }, + "allowReserved": { + "type": "boolean", + "default": false + } + }, + "patternProperties": { + "^x-": { + } + }, + "additionalProperties": false + } + } +} diff --git a/tests/phpunit/structure/RestStructureTest.php b/tests/phpunit/structure/RestStructureTest.php index 2b707739896..be25eb41c60 100644 --- a/tests/phpunit/structure/RestStructureTest.php +++ b/tests/phpunit/structure/RestStructureTest.php @@ -1,7 +1,5 @@ addToAssertionCount( 1 ); + $this->assertValidJsonSchema( $settings[ ArrayDef::PARAM_SCHEMA ] ); } catch ( LogicException $e ) { $this->fail( "Invalid JSON schema for parameter {$settings['name']}: " . $e->getMessage() ); } @@ -358,28 +356,33 @@ class RestStructureTest extends MediaWikiIntegrationTestCase { } } - /** - * Validate a JSON schema. - * - * @param array $schema The schema to validate. - * @throws LogicException if the schema is invalid - */ - public static function validateSchema( array $schema ): void { - $validator = new JsonValidator(); - // Load the draft-04 schema from the local file - $draft04Schema = json_decode( file_get_contents( __DIR__ . '/../../../vendor/justinrainbow/json-schema/dist/schema/json-schema-draft-04.json' ) ); + public function provideModuleDefinitionFiles() { + $conf = MediaWikiServices::getInstance()->getMainConfig(); + $entryPoint = TestingAccessWrapper::newFromClass( EntryPoint::class ); + $routeFiles = $entryPoint->getRouteFiles( $conf ); - // Validate the schema itself against the meta-schema - $validator->validate( $schema, $draft04Schema, Constraint::CHECK_MODE_TYPE_CAST ); - - if ( !$validator->isValid() ) { - $errors = $validator->getErrors(); - $messages = array_map( static function ( $error ) { - return sprintf( "[%s] %s", $error['property'], $error['message'] ); - }, $errors ); - - throw new LogicException( "Invalid JSON schema: " . implode( "; ", $messages ) ); + foreach ( $routeFiles as $file ) { + $moduleSpec = self::loadJsonData( $file ); + if ( !isset( $moduleSpec->mwapi ) ) { + // old-school flat route file, skip + continue; + } + yield $file => [ $moduleSpec ]; } } + /** + * @dataProvider provideModuleDefinitionFiles + */ + public function testModuleDefinitionFiles( stdClass $moduleSpec ) { + $schemaFile = MW_INSTALL_PATH . '/docs/rest/mwapi-1.0.json'; + + $resolve = [ + 'https://spec.openapis.org/oas/3.0/schema/2021-09-28#' => + __DIR__ . '/../integration/includes/Rest/Handler/data/OpenApi-3.0.json', + ]; + + $this->assertMatchesJsonSchema( $schemaFile, $moduleSpec, $resolve ); + } + } diff --git a/tests/phpunit/unit/tests/JsonSchemaAssertionTraitTest.php b/tests/phpunit/unit/tests/JsonSchemaAssertionTraitTest.php new file mode 100644 index 00000000000..21442163b86 --- /dev/null +++ b/tests/phpunit/unit/tests/JsonSchemaAssertionTraitTest.php @@ -0,0 +1,56 @@ +assertMatchesJsonSchema( $schemaFile, $jsonString, [ + 'https://www.mediawiki.org/test-schema/test1#' => "$dir/schema1.json", + 'https://www.mediawiki.org/test-schema/test2#' => "$dir/schema2.json", + 'https://www.mediawiki.org/test-schema/test3#' => "$dir/schema3.json", + ] ); + } + + public static function provideInvalidJson() { + $dir = __DIR__ . '/json'; + foreach ( glob( __DIR__ . '/json/invalid*.json' ) as $file ) { + yield $file => [ $file, "$dir/schema1.json" ]; + } + } + + /** + * @dataProvider provideInvalidJson + */ + public function testAssertMatchesJsonSchema_invalid( $dataFile, $schemaFile ) { + $dir = __DIR__ . '/json'; + $jsonString = file_get_contents( $dataFile ); + + $this->expectException( AssertionFailedError::class ); + $this->assertMatchesJsonSchema( $schemaFile, $jsonString, + [ + 'https://www.mediawiki.org/test-schema/test1#' => "$dir/schema1.json", + 'https://www.mediawiki.org/test-schema/test2#' => "$dir/schema2.json", + 'https://www.mediawiki.org/test-schema/test3#' => "$dir/schema3.json", + ] + ); + } + +} diff --git a/tests/phpunit/unit/tests/json/invalid-malformed.json b/tests/phpunit/unit/tests/json/invalid-malformed.json new file mode 100644 index 00000000000..a5221f8f32c --- /dev/null +++ b/tests/phpunit/unit/tests/json/invalid-malformed.json @@ -0,0 +1,3 @@ +{ + 'test' = 'this is not json', +} diff --git a/tests/phpunit/unit/tests/json/invalid1.json b/tests/phpunit/unit/tests/json/invalid1.json new file mode 100644 index 00000000000..82d4d437d74 --- /dev/null +++ b/tests/phpunit/unit/tests/json/invalid1.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://www.mediawiki.org/test-schema/test1", + "context": "MISSING USER" +} diff --git a/tests/phpunit/unit/tests/json/invalid2.json b/tests/phpunit/unit/tests/json/invalid2.json new file mode 100644 index 00000000000..02b40aba0e2 --- /dev/null +++ b/tests/phpunit/unit/tests/json/invalid2.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://www.mediawiki.org/test-schema/test1", + "context": "test", + "user": { + "name": [ "NOT A STRING" ], + "contact": { + "email": "jsmith@example.test", + "phone": { + "country-code": "++49", + "number": "12345678" + } + } + } +} diff --git a/tests/phpunit/unit/tests/json/invalid3.json b/tests/phpunit/unit/tests/json/invalid3.json new file mode 100644 index 00000000000..593ffbf4af8 --- /dev/null +++ b/tests/phpunit/unit/tests/json/invalid3.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://www.mediawiki.org/test-schema/test1", + "context": "test", + "user": { + "name": "jasmin", + "contact": { + "email": "jsmith@example.test", + "phone": { + "number": "MISSING COUNTRY CODE" + } + } + } +} diff --git a/tests/phpunit/unit/tests/json/schema1.json b/tests/phpunit/unit/tests/json/schema1.json new file mode 100644 index 00000000000..b74f5407bc8 --- /dev/null +++ b/tests/phpunit/unit/tests/json/schema1.json @@ -0,0 +1,14 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "$id": "https://www.mediawiki.org/test-schema/test1", + "type": "object", + "required": [ "context", "user" ], + "properties": { + "user": { "$ref": "https://www.mediawiki.org/test-schema/test2#/definitions/User" }, + "context": { "$ref": "#/definitions/Context" } + }, + "definitions": { + "Context": { "type": "string" } + }, + "additionalProperties": false +} diff --git a/tests/phpunit/unit/tests/json/schema2.json b/tests/phpunit/unit/tests/json/schema2.json new file mode 100644 index 00000000000..5683473132a --- /dev/null +++ b/tests/phpunit/unit/tests/json/schema2.json @@ -0,0 +1,21 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "$id": "https://www.mediawiki.org/test-schema/test2", + "definitions": { + "User": { + "required": [ "name" ], + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "contact": { + "$ref": "#/definitions/Contact" + } + } + }, + "Contact": { + "$ref": "https://www.mediawiki.org/test-schema/test3#/definitions/Contact" + } + } +} diff --git a/tests/phpunit/unit/tests/json/schema3.json b/tests/phpunit/unit/tests/json/schema3.json new file mode 100644 index 00000000000..61de9919048 --- /dev/null +++ b/tests/phpunit/unit/tests/json/schema3.json @@ -0,0 +1,21 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "$id": "https://www.mediawiki.org/test-schema/test3", + "definitions": { + "Contact": { + "properties": { + "email": { "type": "string" }, + "phone": { "$ref": "#/definitions/Phone" } + }, + "additionalProperties": false + }, + "Phone": { + "required": [ "country-code", "number" ], + "properties": { + "country-code": { "type": "string" }, + "number": { "type": "string" } + } + } + } + +} diff --git a/tests/phpunit/unit/tests/json/valid1.json b/tests/phpunit/unit/tests/json/valid1.json new file mode 100644 index 00000000000..4e3ed243759 --- /dev/null +++ b/tests/phpunit/unit/tests/json/valid1.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://www.mediawiki.org/test-schema/test1", + "context": "test", + "user": { + "name": "jasmin", + "contact": { + "email": "jsmith@example.test", + "phone": { + "country-code": "++49", + "number": "12345678" + } + } + } +} diff --git a/tests/phpunit/unit/tests/json/valid2.json b/tests/phpunit/unit/tests/json/valid2.json new file mode 100644 index 00000000000..cd77a3f4962 --- /dev/null +++ b/tests/phpunit/unit/tests/json/valid2.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://www.mediawiki.org/test-schema/test2", + "context": "test", + "contact": { + "email": "jsmith@example.test", + "phone": { + "country-code": "++49", + "number": "12345678" + } + } +}