diff --git a/schemas/site-api-envelope.schema.json b/schemas/site-api-envelope.schema.json index 1633cbea..df64e518 100644 --- a/schemas/site-api-envelope.schema.json +++ b/schemas/site-api-envelope.schema.json @@ -4,10 +4,21 @@ "title": "RetroBIOS static API v1 envelope", "type": "object", "additionalProperties": false, - "required": ["schema_version", "generated_at", "kind", "count", "items"], + "required": [ + "schema_version", + "generated_at", + "kind", + "count", + "items" + ], "properties": { - "schema_version": { "const": 1 }, - "generated_at": { "type": "string", "format": "date-time" }, + "schema_version": { + "const": 1 + }, + "generated_at": { + "type": "string", + "format": "date-time" + }, "kind": { "type": "string", "enum": [ @@ -18,10 +29,238 @@ "file-locations" ] }, - "count": { "type": "integer", "minimum": 0 }, + "count": { + "type": "integer", + "minimum": 0 + }, "items": { "type": "array", - "items": { "type": "object" } + "items": { + "type": "object" + } } - } + }, + "description": "Every endpoint shares this envelope. Within v1 an item keeps the fields listed for its kind and their types: fields may be added, never renamed or removed. A breaking change takes a new prefix.", + "allOf": [ + { + "if": { + "properties": { + "kind": { + "const": "catalog" + } + } + }, + "then": { + "properties": { + "items": { + "items": { + "type": "object", + "required": [ + "bytes", + "media_type", + "sha256", + "title", + "url" + ], + "properties": { + "title": { + "type": "string" + }, + "url": { + "type": "string" + }, + "media_type": { + "type": "string" + }, + "bytes": { + "type": "integer", + "minimum": 0 + }, + "sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + } + } + } + } + } + } + }, + { + "if": { + "properties": { + "kind": { + "const": "emulators" + } + } + }, + "then": { + "properties": { + "items": { + "items": { + "type": "object", + "required": [ + "id", + "profile" + ], + "properties": { + "id": { + "type": "string" + }, + "profile": { + "type": "object" + } + } + } + } + } + } + }, + { + "if": { + "properties": { + "kind": { + "const": "platforms" + } + } + }, + "then": { + "properties": { + "items": { + "items": { + "type": "object", + "required": [ + "contract", + "coverage", + "id", + "name" + ], + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "contract": { + "type": "object" + }, + "coverage": { + "type": "object" + } + } + } + } + } + } + }, + { + "if": { + "properties": { + "kind": { + "const": "verification-and-coverage-gaps" + } + } + }, + "then": { + "properties": { + "items": { + "items": { + "type": "object", + "required": [ + "destination", + "discrepancy", + "emulator", + "expected", + "in_repo", + "layer", + "name", + "platform", + "platform_id", + "reason", + "required", + "status", + "system" + ], + "properties": { + "name": { + "type": "string" + }, + "platform": { + "type": "string" + }, + "platform_id": { + "type": "string" + }, + "system": { + "type": "string" + }, + "emulator": { + "type": "string" + }, + "destination": { + "type": "string" + }, + "status": { + "type": "string" + }, + "layer": { + "type": "string" + }, + "reason": { + "type": "string" + }, + "expected": { + "type": "string" + }, + "discrepancy": { + "type": "string" + }, + "required": { + "type": "boolean" + }, + "in_repo": { + "type": [ + "boolean", + "string" + ] + } + } + } + } + } + } + }, + { + "if": { + "properties": { + "kind": { + "const": "file-locations" + } + } + }, + "then": { + "properties": { + "items": { + "items": { + "type": "object", + "required": [ + "sha1", + "url" + ], + "properties": { + "sha1": { + "type": "string", + "pattern": "^[0-9a-f]{40}$" + }, + "url": { + "type": "string" + } + } + } + } + } + } + } + ] } diff --git a/tests/test_audit_regressions.py b/tests/test_audit_regressions.py index 03fcdf53..1547bfa1 100644 --- a/tests/test_audit_regressions.py +++ b/tests/test_audit_regressions.py @@ -5706,5 +5706,25 @@ class WhatOnlyAnInstallProvidesIsListed(unittest.TestCase): self.assertEqual([row["path"] for row in rows], [str(Path(tmp, "icon.png"))]) +class TheApiSchemaHoldsTheFieldsItPromises(unittest.TestCase): + """The data page promises fields are only added within v1, and the + published schema left items unconstrained: renaming url in + file-locations.json passed validation and broke consumers silently.""" + + def test_a_renamed_field_is_refused(self): + try: + import jsonschema + except ImportError: + self.skipTest("jsonschema is tooling only") + schema = json.loads((ROOT / "schemas" / "site-api-envelope.schema.json").read_text()) + envelope = {"schema_version": 1, "generated_at": "2026-10-20T00:00:00Z", + "kind": "file-locations", "count": 1, + "items": [{"sha1": "a" * 40, "url": "https://x/y"}]} + jsonschema.validate(envelope, schema) + envelope["items"] = [{"sha1": "a" * 40, "href": "https://x/y"}] + with self.assertRaises(jsonschema.ValidationError): + jsonschema.validate(envelope, schema) + + if __name__ == "__main__": unittest.main()