From 6ff38722f0b1a6ec5ac01757cc3398ca2082d2e3 Mon Sep 17 00:00:00 2001 From: awarde96 Date: Wed, 29 Jul 2026 17:10:38 +0200 Subject: [PATCH 1/2] Add schema bundling tools and multi-dialect publish workflow Create tooling to bundle JSON schema fragments (from PR #226) into single-file schemas and convert to draft-07, 2019-09, and 2020-12 dialects. The 2020-12 output enables OpenAPI 3.1 compatibility. New files: - standard/schema/root.json: fragment entry point - tools/: bundle_schema, convert_dialect, patch_schema - requirements.txt - .github/workflows/publish-schema.yml: CI pipeline publishing all three dialect versions to gh-pages --- .github/workflows/publish-schema.yml | 52 +++++++ requirements.txt | 1 + standard/schema/root.json | 81 ++++++++++ tools/__init__.py | 0 tools/bundle_schema.py | 87 +++++++++++ tools/convert_dialect.py | 216 +++++++++++++++++++++++++++ tools/patch_schema.py | 42 ++++++ 7 files changed, 479 insertions(+) create mode 100644 .github/workflows/publish-schema.yml create mode 100644 requirements.txt create mode 100644 standard/schema/root.json create mode 100644 tools/__init__.py create mode 100644 tools/bundle_schema.py create mode 100644 tools/convert_dialect.py create mode 100644 tools/patch_schema.py diff --git a/.github/workflows/publish-schema.yml b/.github/workflows/publish-schema.yml new file mode 100644 index 000000000..c49a63670 --- /dev/null +++ b/.github/workflows/publish-schema.yml @@ -0,0 +1,52 @@ +name: Publish schema + +on: + push: + branches: + - master + paths: + - 'standard/schema/**' + - 'tools/**' + +jobs: + bundle-and-publish: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.x' + + - name: Install dependencies + run: pip install -r requirements.txt + + - name: Bundle schema fragments + run: python -m tools.bundle_schema --schema-dir standard/schema --root /schemas/coveragejson --out dist/bundle.json + + - name: Convert to draft-07 + run: | + python -m tools.convert_dialect dist/bundle.json --dialect draft-07 --out dist/draft-07/coveragejson.json + python -m tools.patch_schema dist/draft-07/coveragejson.json --set-id "https://schemas.opengis.net/covjson/1.0/coveragejson.json" + + - name: Convert to 2019-09 + run: | + python -m tools.convert_dialect dist/bundle.json --dialect 2019-09 --out dist/2019-09/coveragejson.json + python -m tools.patch_schema dist/2019-09/coveragejson.json --set-id "https://schemas.opengis.net/covjson/1.0/coveragejson.json" + + - name: Convert to 2020-12 + run: | + python -m tools.convert_dialect dist/bundle.json --dialect 2020-12 --out dist/2020-12/coveragejson.json + python -m tools.patch_schema dist/2020-12/coveragejson.json --set-id "https://schemas.opengis.net/covjson/1.0/coveragejson.json" + + - name: Remove intermediate bundle + run: rm dist/bundle.json + + - name: Publish to GitHub Pages + uses: peaceiris/actions-gh-pages@v4 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./dist + publish_branch: gh-pages diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 000000000..d89304b1a --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +jsonschema diff --git a/standard/schema/root.json b/standard/schema/root.json new file mode 100644 index 000000000..d48ecc02c --- /dev/null +++ b/standard/schema/root.json @@ -0,0 +1,81 @@ +{ + "$id": "/schemas/coveragejson", + "description": "A CoverageJSON object", + "type": "object", + "properties": { + "type": { + "enum": [ + "Domain", + "NdArray", + "TiledNdArray", + "Coverage", + "CoverageCollection" + ] + } + }, + "required": [ + "type" + ], + "allOf": [ + { + "if": { + "properties": { + "type": { + "const": "Domain" + } + } + }, + "then": { + "$ref": "/schemas/domain" + } + }, + { + "if": { + "properties": { + "type": { + "const": "NdArray" + } + } + }, + "then": { + "$ref": "/schemas/ndArray" + } + }, + { + "if": { + "properties": { + "type": { + "const": "TiledNdArray" + } + } + }, + "then": { + "$ref": "/schemas/tiledNdArray" + } + }, + { + "if": { + "properties": { + "type": { + "const": "Coverage" + } + } + }, + "then": { + "$ref": "/schemas/coverage" + } + }, + { + "if": { + "properties": { + "type": { + "const": "CoverageCollection" + } + } + }, + "then": { + "$ref": "/schemas/coverageCollection" + } + } + ] +} diff --git a/tools/__init__.py b/tools/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tools/bundle_schema.py b/tools/bundle_schema.py new file mode 100644 index 000000000..537d12f62 --- /dev/null +++ b/tools/bundle_schema.py @@ -0,0 +1,87 @@ +# Bundles all referenced schema fragments into a single root schema. +# Adapted from https://github.com/covjson/covjson-validator/blob/main/tools/bundle_schema.py +# +# Follows the method described in: +# https://datatracker.ietf.org/doc/html/draft-bhutton-json-schema-00#section-9.3.1 + +import argparse +import json +import copy +import os + + +def walk_dict(obj, match_key, fn): + """Recursively walk a dict and call fn(obj, key, value) for every match_key found.""" + for key, value in list(obj.items()): + if key == match_key: + fn(obj, key, value) + elif isinstance(value, dict): + walk_dict(value, match_key, fn) + elif isinstance(value, list): + for item in value: + if isinstance(item, dict): + walk_dict(item, match_key, fn) + + +def create_schema_store(schema_dir): + """Load all JSON schema fragments from a directory into a dict keyed by $id.""" + schema_store = {} + for entry in os.scandir(schema_dir): + if entry.is_file() and entry.path.endswith(".json"): + with open(entry.path) as f: + schema = json.load(f) + if "$id" in schema: + schema_store[schema["$id"]] = schema + return schema_store + + +def bundle_schema(schema_store, root_schema_id): + """Bundle all referenced schemas into the root schema using $defs.""" + root_schema = copy.deepcopy(schema_store[root_schema_id]) + + # Collect all referenced schema IDs recursively + refs = set() + + def record_ref(obj, key, value): + if value in schema_store: + refs.add(value) + + done = set() + todo = {root_schema_id} + while todo: + for schema_id in todo: + walk_dict(schema_store[schema_id], "$ref", record_ref) + done.add(schema_id) + todo = refs - done + + # Remove self-reference if present + refs.discard(root_schema_id) + + # Embed referenced schemas under $defs + if refs: + defs = root_schema.setdefault("$defs", {}) + for schema_id in sorted(refs): + defs[schema_id] = copy.deepcopy(schema_store[schema_id]) + + return root_schema + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Bundle CoverageJSON schema fragments into a single file") + parser.add_argument("--schema-dir", default="standard/schema", + help="Directory containing schema fragment JSON files") + parser.add_argument("--root", default="/schemas/coveragejson", + help="$id of the root schema fragment") + parser.add_argument("--out", default="dist/coveragejson.json", + help="Output file path") + args = parser.parse_args() + + store = create_schema_store(args.schema_dir) + schema = bundle_schema(store, args.root) + + os.makedirs(os.path.dirname(args.out), exist_ok=True) + with open(args.out, "w") as f: + json.dump(schema, f, indent=2) + f.write("\n") + + print(f"Bundled schema written to {args.out}") diff --git a/tools/convert_dialect.py b/tools/convert_dialect.py new file mode 100644 index 000000000..9df924993 --- /dev/null +++ b/tools/convert_dialect.py @@ -0,0 +1,216 @@ +# Converts a bundled schema to a specific JSON Schema dialect. +# +# Supported target dialects: +# - draft-07 (http://json-schema.org/draft-07/schema) +# - 2019-09 (https://json-schema.org/draft/2019-09/schema) +# - 2020-12 (https://json-schema.org/draft/2020-12/schema) +# +# The bundled input (from bundle_schema.py) uses $defs and /schemas/... $ref +# style which is native to 2019-09 and 2020-12. For draft-07, additional +# transformations are applied ($defs → definitions, $ref isolation, etc.). + +import argparse +import json +import copy +import os + +import jsonschema + + +DIALECTS = { + "draft-07": "http://json-schema.org/draft-07/schema", + "2019-09": "https://json-schema.org/draft/2019-09/schema", + "2020-12": "https://json-schema.org/draft/2020-12/schema", +} + + +def walk_dict(obj, match_key, fn): + """Recursively walk a dict and call fn(obj, key, value) for every match_key found.""" + for key, value in list(obj.items()): + if key == match_key: + fn(obj, key, value) + elif isinstance(value, dict): + walk_dict(value, match_key, fn) + elif isinstance(value, list): + for item in value: + if isinstance(item, dict): + walk_dict(item, match_key, fn) + + +def _convert_to_modern(root_schema, dialect_uri): + """Convert bundled schema to 2019-09 or 2020-12 dialect. + + These dialects natively support $defs and $ref alongside other keywords, + so the main work is rewriting $ref values from /schemas/... to #/$defs/... + and setting the $schema declaration. + """ + out = copy.deepcopy(root_schema) + out.pop("$schema", None) + + # Reorder so $schema is first + result = {"$schema": dialect_uri} + result.update(out) + out = result + + # Strip $id from embedded $defs entries (they are local definitions now) + schema_id_prefix = "/schemas/" + + def strip_def_ids(obj, key, value): + assert key == "$defs" + cleaned = {} + for name, schema in value.items(): + if "$id" in schema: + def_name = schema["$id"] + if def_name.startswith(schema_id_prefix): + def_name = def_name[len(schema_id_prefix):] + del schema["$id"] + else: + def_name = name + cleaned[def_name] = schema + obj["$defs"] = dict(sorted(cleaned.items())) + + walk_dict(out, "$defs", strip_def_ids) + + # Collect all definition names for validation + all_defs = set() + if "$defs" in out: + all_defs.update(out["$defs"].keys()) + + # Rewrite $ref values from /schemas/... to #/$defs/... + defs_prefix = "#/$defs/" + + def patch_ref(obj, key, value): + assert key == "$ref" + new_value = value.replace("/schemas/", defs_prefix) + if not new_value.startswith(defs_prefix): + raise ValueError(f"Invalid $ref value '{value}'") + def_name = new_value[len(defs_prefix):] + assert def_name in all_defs, \ + f"$ref '{value}' -> '{def_name}' not in $defs: {sorted(all_defs)}" + obj[key] = new_value + + walk_dict(out, "$ref", patch_ref) + + return out + + +def _convert_to_draft07(root_schema): + """Convert bundled schema to draft-07 dialect. + + Draft-07 differences from modern dialects: + - Uses "definitions" instead of "$defs" + - $ref cannot coexist with other keywords (must wrap in allOf) + - "dependentSchemas" must be renamed to "dependencies" + """ + out = copy.deepcopy(root_schema) + out.pop("$schema", None) + + result = {"$schema": DIALECTS["draft-07"]} + result.update(out) + out = result + + # Move $defs → definitions + definitions = {} + out["definitions"] = definitions + schema_id_prefix = "/schemas/" + + def move_defs(obj, key, value): + assert key == "$defs" + for name, schema in value.items(): + if "$id" in schema: + def_name = schema["$id"] + if def_name.startswith(schema_id_prefix): + def_name = def_name[len(schema_id_prefix):] + del schema["$id"] + else: + def_name = name + assert def_name not in definitions, \ + f"Duplicate definition '{def_name}'" + definitions[def_name] = schema + del obj["$defs"] + + walk_dict(out, "$defs", move_defs) + out["definitions"] = dict(sorted(definitions.items())) + + # Rewrite $ref to #/definitions/... + def patch_ref(obj, key, value): + assert key == "$ref" + defs_prefix = "#/definitions/" + new_value = value.replace("/schemas/", defs_prefix) + new_value = new_value.replace("#/$defs/", defs_prefix) + if not new_value.startswith(defs_prefix): + raise ValueError(f"Invalid $ref '{value}'") + def_name = new_value[len(defs_prefix):] + assert def_name in definitions, \ + f"$ref '{value}' -> '{def_name}' not in definitions" + obj[key] = new_value + + walk_dict(out, "$ref", patch_ref) + + # In draft-07, $ref must stand alone — wrap with allOf if siblings exist + def isolate_ref(obj, key, value): + assert key == "$ref" + other_keywords = [ + k for k in obj.keys() + if k != "$ref" and not k.startswith("$") and k != "definitions" + ] + if other_keywords: + all_of = [ + {"$ref": value}, + {k: obj[k] for k in other_keywords}, + ] + del obj["$ref"] + for k in other_keywords: + del obj[k] + obj["allOf"] = all_of + + walk_dict(out, "$ref", isolate_ref) + + # Rename dependentSchemas → dependencies + def patch_dependent_schemas(obj, key, value): + assert key == "dependentSchemas" + assert "dependencies" not in obj + obj["dependencies"] = value + del obj["dependentSchemas"] + + walk_dict(out, "dependentSchemas", patch_dependent_schemas) + + # Validate against draft-07 meta schema + jsonschema.Draft7Validator.check_schema(out) + + return out + + +def convert_dialect(root_schema, dialect): + """Convert a bundled schema to the specified dialect.""" + if dialect == "draft-07": + return _convert_to_draft07(root_schema) + elif dialect in ("2019-09", "2020-12"): + return _convert_to_modern(root_schema, DIALECTS[dialect]) + else: + raise ValueError(f"Unsupported dialect '{dialect}'. Choose from: {list(DIALECTS.keys())}") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser( + description="Convert a bundled JSON Schema to a specific dialect") + parser.add_argument("input_path", help="Path to bundled schema JSON") + parser.add_argument("--dialect", required=True, choices=list(DIALECTS.keys()), + help="Target JSON Schema dialect") + parser.add_argument("--out", dest="output_path", + help="Output file (default: overwrite input)") + args = parser.parse_args() + if args.output_path is None: + args.output_path = args.input_path + + with open(args.input_path) as f: + schema = json.load(f) + + schema = convert_dialect(schema, args.dialect) + + os.makedirs(os.path.dirname(args.output_path) or ".", exist_ok=True) + with open(args.output_path, "w") as f: + json.dump(schema, f, indent=2) + f.write("\n") + + print(f"Converted to {args.dialect} -> {args.output_path}") diff --git a/tools/patch_schema.py b/tools/patch_schema.py new file mode 100644 index 000000000..9852095d3 --- /dev/null +++ b/tools/patch_schema.py @@ -0,0 +1,42 @@ +# Patches properties of an existing bundled schema (e.g. set or drop $id). +# +# Adapted from https://github.com/covjson/covjson-validator/blob/main/tools/patch_schema.py + +import argparse +import json +import copy + + +def patch_schema(schema, set_id=None, drop_id=False): + """Patch properties of the given schema.""" + schema = copy.deepcopy(schema) + + if drop_id: + schema.pop("$id", None) + elif set_id: + schema["$id"] = set_id + + return schema + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Patch properties of a JSON Schema file") + parser.add_argument("input_path") + parser.add_argument("--out", dest="output_path", + help="Output file (default: overwrite input)") + parser.add_argument("--set-id", type=str, help="Set $id property value") + parser.add_argument("--drop-id", action="store_true", help="Drop $id property") + args = parser.parse_args() + if args.output_path is None: + args.output_path = args.input_path + + with open(args.input_path) as f: + schema = json.load(f) + + schema = patch_schema(schema, args.set_id, args.drop_id) + + with open(args.output_path, "w") as f: + json.dump(schema, f, indent=2) + f.write("\n") + + print(f"Patched schema written to {args.output_path}") From 6465f469f35342200724c4fb9868ce5460742846 Mon Sep 17 00:00:00 2001 From: awarde96 Date: Wed, 29 Jul 2026 17:31:40 +0200 Subject: [PATCH 2/2] Only push build artifacts to master not to gh-pages --- .github/workflows/publish-schema.yml | 40 +++++++++++++++++----------- 1 file changed, 24 insertions(+), 16 deletions(-) diff --git a/.github/workflows/publish-schema.yml b/.github/workflows/publish-schema.yml index c49a63670..69234e51d 100644 --- a/.github/workflows/publish-schema.yml +++ b/.github/workflows/publish-schema.yml @@ -1,4 +1,4 @@ -name: Publish schema +name: Build schema on: push: @@ -9,9 +9,12 @@ on: - 'tools/**' jobs: - bundle-and-publish: + build-and-commit: runs-on: ubuntu-latest + permissions: + contents: write + steps: - uses: actions/checkout@v4 @@ -24,29 +27,34 @@ jobs: run: pip install -r requirements.txt - name: Bundle schema fragments - run: python -m tools.bundle_schema --schema-dir standard/schema --root /schemas/coveragejson --out dist/bundle.json + run: python -m tools.bundle_schema --schema-dir standard/schema --root /schemas/coveragejson --out build/bundle.json - name: Convert to draft-07 run: | - python -m tools.convert_dialect dist/bundle.json --dialect draft-07 --out dist/draft-07/coveragejson.json - python -m tools.patch_schema dist/draft-07/coveragejson.json --set-id "https://schemas.opengis.net/covjson/1.0/coveragejson.json" + python -m tools.convert_dialect build/bundle.json --dialect draft-07 --out standard/schema/build/draft-07/coveragejson.json + python -m tools.patch_schema standard/schema/build/draft-07/coveragejson.json --set-id "https://schemas.opengis.net/covjson/1.0/coveragejson.json" - name: Convert to 2019-09 run: | - python -m tools.convert_dialect dist/bundle.json --dialect 2019-09 --out dist/2019-09/coveragejson.json - python -m tools.patch_schema dist/2019-09/coveragejson.json --set-id "https://schemas.opengis.net/covjson/1.0/coveragejson.json" + python -m tools.convert_dialect build/bundle.json --dialect 2019-09 --out standard/schema/build/2019-09/coveragejson.json + python -m tools.patch_schema standard/schema/build/2019-09/coveragejson.json --set-id "https://schemas.opengis.net/covjson/1.0/coveragejson.json" - name: Convert to 2020-12 run: | - python -m tools.convert_dialect dist/bundle.json --dialect 2020-12 --out dist/2020-12/coveragejson.json - python -m tools.patch_schema dist/2020-12/coveragejson.json --set-id "https://schemas.opengis.net/covjson/1.0/coveragejson.json" + python -m tools.convert_dialect build/bundle.json --dialect 2020-12 --out standard/schema/build/2020-12/coveragejson.json + python -m tools.patch_schema standard/schema/build/2020-12/coveragejson.json --set-id "https://schemas.opengis.net/covjson/1.0/coveragejson.json" - name: Remove intermediate bundle - run: rm dist/bundle.json + run: rm -rf build - - name: Publish to GitHub Pages - uses: peaceiris/actions-gh-pages@v4 - with: - github_token: ${{ secrets.GITHUB_TOKEN }} - publish_dir: ./dist - publish_branch: gh-pages + - name: Commit generated schemas + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add standard/schema/build/ + if git diff --cached --quiet; then + echo "No changes to generated schemas" + else + git commit -m "build: regenerate bundled schemas from fragments" + git push + fi