This is an automated email from the ASF dual-hosted git repository.
kaxilnaik pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/main by this push:
new 5ac251bd538 Add schema-validated registry API contracts with API
Explorer (#63025)
5ac251bd538 is described below
commit 5ac251bd5385efb51ac4013f4534ee1932425359
Author: Kaxil Naik <[email protected]>
AuthorDate: Fri Mar 13 04:09:05 2026 +0000
Add schema-validated registry API contracts with API Explorer (#63025)
Pydantic contract models validate every JSON payload the registry
produces (providers catalog, modules, parameters, connections,
version metadata, provider versions). Validation runs at generation
time — no separate jsonschema layer needed.
An OpenAPI 3.1 spec is generated from these contracts at build time
and powers an interactive API Explorer at /api-explorer/, using
swagger-ui vendored from node_modules (Apache Infra blocks external
CDN via CSP). The explorer shares the site layout (header, nav,
footer) and uses swagger-ui's native dark-mode class for theme sync.
Also adds a /api/providers/{id}/versions.json endpoint used by CI
to publish new provider versions without a full site rebuild.
---
.../utils/publish_registry_versions.py | 2 +
.../conftest.py => export_registry_schemas.py} | 27 ++
dev/registry/extract_connections.py | 18 +-
dev/registry/extract_metadata.py | 3 +-
dev/registry/extract_parameters.py | 19 +-
dev/registry/extract_versions.py | 25 +-
dev/registry/merge_registry_data.py | 9 +-
dev/registry/pyproject.toml | 5 +-
dev/registry/registry_contract_models.py | 472 +++++++++++++++++++++
dev/registry/tests/test_merge_registry_data.py | 88 +++-
.../tests/test_registry_contract_models.py | 103 +++++
registry/.eleventy.js | 8 +
registry/.gitignore | 3 +
registry/AGENTS.md | 8 +-
registry/README.md | 16 +
registry/package.json | 6 +-
registry/pnpm-lock.yaml | 15 +
registry/src/_data/openapiSpec.js | 33 ++
registry/src/_data/providerVersionPayloads.js | 48 +++
registry/src/_includes/base.njk | 2 +
registry/src/api-explorer.njk | 46 ++
registry/src/api/openapi-json.njk | 5 +
registry/src/api/provider-versions.njk | 13 +
registry/src/css/main.css | 6 +
registry/src/js/theme.js | 11 +-
registry/src/sitemap.njk | 1 +
26 files changed, 930 insertions(+), 62 deletions(-)
diff --git a/dev/breeze/src/airflow_breeze/utils/publish_registry_versions.py
b/dev/breeze/src/airflow_breeze/utils/publish_registry_versions.py
index 0643c453713..0d5e76cda5a 100644
--- a/dev/breeze/src/airflow_breeze/utils/publish_registry_versions.py
+++ b/dev/breeze/src/airflow_breeze/utils/publish_registry_versions.py
@@ -145,6 +145,8 @@ def publish_versions(s3_bucket: str, providers_json_path:
Path | None = None) ->
# If the declared latest isn't deployed yet, use the highest deployed
version.
effective_latest = latest if latest in versions else versions[0]
+ if effective_latest not in versions:
+ raise ValueError(f"latest version {effective_latest!r} not in
versions list for {pid}")
data = {"versions": versions, "latest": effective_latest}
key = (
f"{prefix}/api/providers/{pid}/versions.json" if prefix else
f"api/providers/{pid}/versions.json"
diff --git a/dev/registry/tests/conftest.py
b/dev/registry/export_registry_schemas.py
similarity index 53%
rename from dev/registry/tests/conftest.py
rename to dev/registry/export_registry_schemas.py
index 21d298ede6e..b44f214a3f4 100644
--- a/dev/registry/tests/conftest.py
+++ b/dev/registry/export_registry_schemas.py
@@ -1,3 +1,4 @@
+#!/usr/bin/env python3
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
@@ -14,4 +15,30 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
+"""Generate the OpenAPI spec for the registry API from shared Pydantic
contracts.
+
+Usage:
+ python dev/registry/export_registry_schemas.py
+"""
+
from __future__ import annotations
+
+import json
+from pathlib import Path
+
+from registry_contract_models import build_openapi_document
+
+AIRFLOW_ROOT = Path(__file__).resolve().parents[2]
+OPENAPI_PATH = AIRFLOW_ROOT / "registry" / "schemas" / "openapi.json"
+
+
+def main() -> int:
+ content = json.dumps(build_openapi_document(), indent=2, sort_keys=True) +
"\n"
+ OPENAPI_PATH.parent.mkdir(parents=True, exist_ok=True)
+ OPENAPI_PATH.write_text(content)
+ print(f" wrote {OPENAPI_PATH}")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/dev/registry/extract_connections.py
b/dev/registry/extract_connections.py
index 0f74e677d29..ce81cc43a27 100644
--- a/dev/registry/extract_connections.py
+++ b/dev/registry/extract_connections.py
@@ -43,6 +43,8 @@ from datetime import datetime, timezone
from pathlib import Path
from typing import Any
+from registry_contract_models import validate_provider_connections
+
AIRFLOW_ROOT = Path(__file__).parent.parent.parent
SCRIPT_DIR = Path(__file__).parent
@@ -264,13 +266,15 @@ def main():
version_dir = output_dir / "versions" / pid / version
version_dir.mkdir(parents=True, exist_ok=True)
- provider_data = {
- "provider_id": pid,
- "provider_name": provider_names.get(pid, pid),
- "version": version,
- "generated_at": generated_at,
- "connection_types": conns,
- }
+ provider_data = validate_provider_connections(
+ {
+ "provider_id": pid,
+ "provider_name": provider_names.get(pid, pid),
+ "version": version,
+ "generated_at": generated_at,
+ "connection_types": conns,
+ }
+ )
with open(version_dir / "connections.json", "w") as f:
json.dump(provider_data, f, separators=(",", ":"))
written += 1
diff --git a/dev/registry/extract_metadata.py b/dev/registry/extract_metadata.py
index 25149e39977..6676d85c414 100644
--- a/dev/registry/extract_metadata.py
+++ b/dev/registry/extract_metadata.py
@@ -43,6 +43,7 @@ from typing import Any
import tomllib
import yaml
+from registry_contract_models import validate_providers_catalog
# External endpoints used by metadata extraction.
PYPISTATS_RECENT_URL =
"https://pypistats.org/api/packages/{package_name}/recent"
@@ -626,7 +627,7 @@ def main():
break
new_providers.sort(key=lambda p: p["name"].lower())
- providers_json = {"providers": new_providers}
+ providers_json = validate_providers_catalog({"providers": new_providers})
# Write output files to all output directories.
# Inside breeze, registry/ is not mounted so OUTPUT_DIR writes are lost.
diff --git a/dev/registry/extract_parameters.py
b/dev/registry/extract_parameters.py
index 69d7c307653..26f09320d78 100644
--- a/dev/registry/extract_parameters.py
+++ b/dev/registry/extract_parameters.py
@@ -53,6 +53,7 @@ from pathlib import Path
import yaml
from extract_metadata import fetch_provider_inventory, read_inventory
+from registry_contract_models import validate_modules_catalog,
validate_provider_parameters
from registry_tools.types import BASE_CLASS_IMPORTS, CLASS_LEVEL_SECTIONS,
MODULE_LEVEL_SECTIONS
AIRFLOW_ROOT = Path(__file__).parent.parent.parent
@@ -768,13 +769,15 @@ def _write_parameter_files(
version_dir = output_dir / "versions" / pid / version
version_dir.mkdir(parents=True, exist_ok=True)
- provider_data = {
- "provider_id": pid,
- "provider_name": provider_names.get(pid, pid),
- "version": version,
- "generated_at": generated_at,
- "classes": classes,
- }
+ provider_data = validate_provider_parameters(
+ {
+ "provider_id": pid,
+ "provider_name": provider_names.get(pid, pid),
+ "version": version,
+ "generated_at": generated_at,
+ "classes": classes,
+ }
+ )
with open(version_dir / "parameters.json", "w") as f:
json.dump(provider_data, f, separators=(",", ":"))
written += 1
@@ -921,7 +924,7 @@ def _main_discover(
# With --provider, the output would be incomplete and would clobber the
# full modules.json from a previous build.
if not only_provider:
- modules_json = {"modules": all_discovered}
+ modules_json = validate_modules_catalog({"modules": all_discovered})
output_dirs = [SCRIPT_DIR, AIRFLOW_ROOT / "registry" / "src" / "_data"]
for out_dir in output_dirs:
if not out_dir.parent.exists():
diff --git a/dev/registry/extract_versions.py b/dev/registry/extract_versions.py
index 38257f19070..5fb5719a95f 100644
--- a/dev/registry/extract_versions.py
+++ b/dev/registry/extract_versions.py
@@ -47,6 +47,7 @@ from pathlib import Path
from typing import Any
import tomllib
+from registry_contract_models import validate_provider_version_metadata
try:
import yaml
@@ -389,17 +390,19 @@ def extract_version_data(
modules = extract_modules_from_yaml(provider_yaml, tag, layout, dir_path,
provider_id, version)
module_counts = count_modules(modules)
- return {
- "provider_id": provider_id,
- "version": version,
- "generated_at": datetime.now(timezone.utc).isoformat(),
- "requires_python": pyproject_data["requires_python"],
- "dependencies": pyproject_data["dependencies"],
- "optional_extras": pyproject_data["optional_extras"],
- "connection_types": connection_types,
- "module_counts": module_counts,
- "modules": modules,
- }
+ return validate_provider_version_metadata(
+ {
+ "provider_id": provider_id,
+ "version": version,
+ "generated_at": datetime.now(timezone.utc).isoformat(),
+ "requires_python": pyproject_data["requires_python"],
+ "dependencies": pyproject_data["dependencies"],
+ "optional_extras": pyproject_data["optional_extras"],
+ "connection_types": connection_types,
+ "module_counts": module_counts,
+ "modules": modules,
+ }
+ )
def extract_and_write_version_data(provider_id: str, version: str, dir_path:
str) -> dict[str, Any] | None:
diff --git a/dev/registry/merge_registry_data.py
b/dev/registry/merge_registry_data.py
index 46f32afb576..bf8696a8b37 100644
--- a/dev/registry/merge_registry_data.py
+++ b/dev/registry/merge_registry_data.py
@@ -37,6 +37,8 @@ import argparse
import json
from pathlib import Path
+from registry_contract_models import validate_modules_catalog,
validate_providers_catalog
+
def merge(
existing_providers_path: Path,
@@ -72,10 +74,13 @@ def merge(
date_by_provider = {p["id"]: p.get("last_updated", "") for p in
merged_providers}
merged_modules.sort(key=lambda m: date_by_provider.get(m["provider_id"],
""), reverse=True)
+ providers_payload = validate_providers_catalog({"providers":
merged_providers})
+ modules_payload = validate_modules_catalog({"modules": merged_modules})
+
# Write merged output
output_dir.mkdir(parents=True, exist_ok=True)
- (output_dir / "providers.json").write_text(json.dumps({"providers":
merged_providers}, indent=2) + "\n")
- (output_dir / "modules.json").write_text(json.dumps({"modules":
merged_modules}, indent=2) + "\n")
+ (output_dir / "providers.json").write_text(json.dumps(providers_payload,
indent=2) + "\n")
+ (output_dir / "modules.json").write_text(json.dumps(modules_payload,
indent=2) + "\n")
print(f"Merged {len(new_ids)} updated provider(s) into
{len(merged_providers)} total providers")
print(f"Total modules: {len(merged_modules)}")
diff --git a/dev/registry/pyproject.toml b/dev/registry/pyproject.toml
index 042458c20ab..30d70642216 100644
--- a/dev/registry/pyproject.toml
+++ b/dev/registry/pyproject.toml
@@ -32,7 +32,10 @@ description = "Extraction and build tools for the Airflow
Provider Registry"
version = "0.0.1"
requires-python = ">=3.10"
classifiers = ["Private :: Do Not Upload"]
-dependencies = ["pyyaml>=6.0.3"]
+dependencies = [
+ "pydantic>=2.12.0",
+ "pyyaml>=6.0.3",
+]
[dependency-groups]
dev = ["pytest"]
diff --git a/dev/registry/registry_contract_models.py
b/dev/registry/registry_contract_models.py
new file mode 100644
index 00000000000..119a0227c1f
--- /dev/null
+++ b/dev/registry/registry_contract_models.py
@@ -0,0 +1,472 @@
+#!/usr/bin/env python3
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""Shared contracts for registry generator payloads and API schemas.
+
+The generator scripts use these Pydantic models to validate payloads before
+writing JSON files. The same models are also used to export JSON Schema
+artifacts and to build the OpenAPI document for the registry API.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Any
+
+from pydantic import BaseModel, ConfigDict, Field, model_validator
+
+
+class CategoryContract(BaseModel):
+ model_config = ConfigDict(extra="forbid")
+
+ id: str
+ name: str
+ module_count: int = 0
+
+
+class DownloadStatsContract(BaseModel):
+ model_config = ConfigDict(extra="forbid")
+
+ weekly: int = 0
+ monthly: int = 0
+ total: int = 0
+
+
+class ConnectionTypeContract(BaseModel):
+ model_config = ConfigDict(extra="forbid")
+
+ conn_type: str
+ hook_class: str = ""
+ docs_url: str | None = None
+
+
+class ProviderContract(BaseModel):
+ """Top-level provider entry in providers.json."""
+
+ model_config = ConfigDict(extra="forbid")
+
+ id: str
+ name: str
+ package_name: str
+ description: str
+ lifecycle: str = "production"
+ logo: str | None = None
+ version: str
+ versions: list[str]
+ airflow_versions: list[str] = Field(default_factory=list)
+ pypi_downloads: DownloadStatsContract =
Field(default_factory=DownloadStatsContract)
+ module_counts: dict[str, int] = Field(default_factory=dict)
+ categories: list[CategoryContract] = Field(default_factory=list)
+ connection_types: list[ConnectionTypeContract] =
Field(default_factory=list)
+ requires_python: str = ""
+ dependencies: list[str] = Field(default_factory=list)
+ optional_extras: dict[str, list[str]] = Field(default_factory=dict)
+ dependents: list[str] = Field(default_factory=list)
+ related_providers: list[str] = Field(default_factory=list)
+ docs_url: str
+ source_url: str
+ pypi_url: str
+ first_released: str = ""
+ last_updated: str = ""
+
+
+class ProvidersCatalogContract(BaseModel):
+ model_config = ConfigDict(extra="forbid")
+
+ providers: list[ProviderContract]
+
+
+class ModuleContract(BaseModel):
+ """A registry module entry.
+
+ ``module_path`` is optional for older versioned metadata generated from git
+ tags where only import paths are available.
+ """
+
+ model_config = ConfigDict(extra="forbid")
+
+ id: str | None = None
+ name: str
+ type: str
+ import_path: str
+ module_path: str | None = None
+ short_description: str
+ docs_url: str
+ source_url: str
+ category: str
+ provider_id: str | None = None
+ provider_name: str | None = None
+
+
+class ModulesCatalogContract(BaseModel):
+ model_config = ConfigDict(extra="forbid")
+
+ modules: list[ModuleContract]
+
+
+class ProviderModulesContract(BaseModel):
+ model_config = ConfigDict(extra="forbid")
+
+ provider_id: str
+ provider_name: str
+ version: str
+ modules: list[ModuleContract]
+
+
+class ParameterContract(BaseModel):
+ model_config = ConfigDict(extra="forbid")
+
+ name: str
+ type: str | None = None
+ default: Any = None
+ required: bool
+ origin: str
+ description: str | None = None
+
+
+class ClassParametersEntryContract(BaseModel):
+ model_config = ConfigDict(extra="forbid", populate_by_name=True)
+
+ name: str
+ type: str
+ mro_chain: list[str] = Field(alias="mro", serialization_alias="mro")
+ parameters: list[ParameterContract]
+
+
+class ProviderParametersContract(BaseModel):
+ model_config = ConfigDict(extra="forbid")
+
+ provider_id: str
+ provider_name: str
+ version: str
+ generated_at: str | None = None
+ classes: dict[str, ClassParametersEntryContract]
+
+
+class StandardConnectionFieldContract(BaseModel):
+ model_config = ConfigDict(extra="forbid")
+
+ visible: bool
+ label: str
+ placeholder: str | None = None
+
+
+class CustomConnectionFieldContract(BaseModel):
+ # Keep this extensible for provider-specific form metadata.
+ model_config = ConfigDict(extra="allow")
+
+ label: str
+ type: Any
+ default: Any = None
+ format: str | None = None
+ description: str | None = None
+ is_sensitive: bool = False
+ enum: list[Any] | None = None
+ minimum: int | float | None = None
+ maximum: int | float | None = None
+
+
+class ProviderConnectionTypeContract(BaseModel):
+ # Keep this extensible for provider-specific hook metadata.
+ model_config = ConfigDict(extra="allow")
+
+ connection_type: str
+ hook_class: str | None = None
+ standard_fields: dict[str, StandardConnectionFieldContract]
+ custom_fields: dict[str, CustomConnectionFieldContract] =
Field(default_factory=dict)
+
+
+class ProviderConnectionsContract(BaseModel):
+ model_config = ConfigDict(extra="forbid")
+
+ provider_id: str
+ provider_name: str
+ version: str
+ generated_at: str | None = None
+ connection_types: list[ProviderConnectionTypeContract]
+
+
+class ProviderVersionMetadataContract(BaseModel):
+ model_config = ConfigDict(extra="forbid")
+
+ provider_id: str
+ version: str
+ generated_at: str
+ requires_python: str
+ dependencies: list[str]
+ optional_extras: dict[str, list[str]]
+ connection_types: list[ConnectionTypeContract]
+ module_counts: dict[str, int]
+ modules: list[ModuleContract]
+
+
+class ProviderVersionsContract(BaseModel):
+ model_config = ConfigDict(extra="forbid")
+
+ latest: str
+ versions: list[str]
+
+ @model_validator(mode="after")
+ def ensure_latest_is_listed(self) -> ProviderVersionsContract:
+ if self.latest not in self.versions:
+ raise ValueError("latest version must be included in versions
list")
+ return self
+
+
+def _validate(model_type: type[BaseModel], payload: dict[str, Any]) ->
dict[str, Any]:
+ model_type.model_validate(payload)
+ return payload
+
+
+def validate_providers_catalog(payload: dict[str, Any]) -> dict[str, Any]:
+ return _validate(ProvidersCatalogContract, payload)
+
+
+def validate_modules_catalog(payload: dict[str, Any]) -> dict[str, Any]:
+ return _validate(ModulesCatalogContract, payload)
+
+
+def validate_provider_modules(payload: dict[str, Any]) -> dict[str, Any]:
+ return _validate(ProviderModulesContract, payload)
+
+
+def validate_provider_parameters(payload: dict[str, Any]) -> dict[str, Any]:
+ return _validate(ProviderParametersContract, payload)
+
+
+def validate_provider_connections(payload: dict[str, Any]) -> dict[str, Any]:
+ return _validate(ProviderConnectionsContract, payload)
+
+
+def validate_provider_version_metadata(payload: dict[str, Any]) -> dict[str,
Any]:
+ return _validate(ProviderVersionMetadataContract, payload)
+
+
+def validate_provider_versions(payload: dict[str, Any]) -> dict[str, Any]:
+ return _validate(ProviderVersionsContract, payload)
+
+
+@dataclass(frozen=True)
+class OpenApiEndpoint:
+ path: str
+ tag: str
+ operation_id: str
+ summary: str
+ response_description: str
+ response_component: str
+ parameters: tuple[str, ...] = ()
+ include_not_found: bool = False
+
+
+OPENAPI_ENDPOINTS: tuple[OpenApiEndpoint, ...] = (
+ OpenApiEndpoint(
+ path="/api/providers.json",
+ tag="Catalog",
+ operation_id="listProviders",
+ summary="List providers",
+ response_description="Provider catalog.",
+ response_component="ProvidersCatalogPayload",
+ ),
+ OpenApiEndpoint(
+ path="/api/modules.json",
+ tag="Catalog",
+ operation_id="listModules",
+ summary="List modules",
+ response_description="Module catalog.",
+ response_component="ModulesCatalogPayload",
+ ),
+ OpenApiEndpoint(
+ path="/api/providers/{providerId}/modules.json",
+ tag="Providers",
+ operation_id="getProviderModulesLatest",
+ summary="Get provider modules (latest)",
+ response_description="Provider modules for latest version.",
+ response_component="ProviderModulesPayload",
+ parameters=("ProviderId",),
+ include_not_found=True,
+ ),
+ OpenApiEndpoint(
+ path="/api/providers/{providerId}/parameters.json",
+ tag="Providers",
+ operation_id="getProviderParametersLatest",
+ summary="Get provider parameters (latest)",
+ response_description="Provider parameters for latest version.",
+ response_component="ProviderParametersPayload",
+ parameters=("ProviderId",),
+ include_not_found=True,
+ ),
+ OpenApiEndpoint(
+ path="/api/providers/{providerId}/connections.json",
+ tag="Providers",
+ operation_id="getProviderConnectionsLatest",
+ summary="Get provider connections (latest)",
+ response_description="Provider connections for latest version.",
+ response_component="ProviderConnectionsPayload",
+ parameters=("ProviderId",),
+ include_not_found=True,
+ ),
+ OpenApiEndpoint(
+ path="/api/providers/{providerId}/{version}/modules.json",
+ tag="Provider Versions",
+ operation_id="getProviderModulesByVersion",
+ summary="Get provider modules (versioned)",
+ response_description="Versioned provider modules.",
+ response_component="ProviderModulesPayload",
+ parameters=("ProviderId", "Version"),
+ include_not_found=True,
+ ),
+ OpenApiEndpoint(
+ path="/api/providers/{providerId}/{version}/parameters.json",
+ tag="Provider Versions",
+ operation_id="getProviderParametersByVersion",
+ summary="Get provider parameters (versioned)",
+ response_description="Versioned provider parameters.",
+ response_component="ProviderParametersPayload",
+ parameters=("ProviderId", "Version"),
+ include_not_found=True,
+ ),
+ OpenApiEndpoint(
+ path="/api/providers/{providerId}/{version}/connections.json",
+ tag="Provider Versions",
+ operation_id="getProviderConnectionsByVersion",
+ summary="Get provider connections (versioned)",
+ response_description="Versioned provider connections.",
+ response_component="ProviderConnectionsPayload",
+ parameters=("ProviderId", "Version"),
+ include_not_found=True,
+ ),
+ OpenApiEndpoint(
+ path="/api/providers/{providerId}/versions.json",
+ tag="Provider Versions",
+ operation_id="getProviderVersions",
+ summary="Get provider versions",
+ response_description="Published provider versions.",
+ response_component="ProviderVersionsPayload",
+ parameters=("ProviderId",),
+ include_not_found=True,
+ ),
+)
+
+
+def _strip_schema_meta(schema: dict[str, Any]) -> dict[str, Any]:
+ sanitized = dict(schema)
+ sanitized.pop("$schema", None)
+ sanitized.pop("$id", None)
+ return sanitized
+
+
+_OPENAPI_COMPONENT_MODELS: dict[str, type[BaseModel]] = {
+ "ProvidersCatalogPayload": ProvidersCatalogContract,
+ "ModulesCatalogPayload": ModulesCatalogContract,
+ "ProviderModulesPayload": ProviderModulesContract,
+ "ProviderParametersPayload": ProviderParametersContract,
+ "ProviderConnectionsPayload": ProviderConnectionsContract,
+ "ProviderVersionMetadataPayload": ProviderVersionMetadataContract,
+ "ProviderVersionsPayload": ProviderVersionsContract,
+}
+
+
+def _collect_openapi_component_schemas(
+ root_models: dict[str, type[BaseModel]],
+) -> dict[str, dict[str, Any]]:
+ components: dict[str, dict[str, Any]] = {}
+ for schema_name, model_type in root_models.items():
+ schema =
model_type.model_json_schema(ref_template="#/components/schemas/{model}")
+ defs = schema.pop("$defs", {})
+ schema = _strip_schema_meta(schema)
+ if schema_name in components and components[schema_name] != schema:
+ raise ValueError(f"Conflicting OpenAPI schema definition for
{schema_name}")
+ components[schema_name] = schema
+ for def_name, def_schema in defs.items():
+ cleaned = _strip_schema_meta(def_schema)
+ if def_name in components and components[def_name] != cleaned:
+ raise ValueError(f"Conflicting OpenAPI schema definition for
{def_name}")
+ components.setdefault(def_name, cleaned)
+ return components
+
+
+def _build_openapi_get_operation(endpoint: OpenApiEndpoint) -> dict[str, Any]:
+ operation: dict[str, Any] = {
+ "tags": [endpoint.tag],
+ "operationId": endpoint.operation_id,
+ "summary": endpoint.summary,
+ "responses": {
+ "200": {
+ "description": endpoint.response_description,
+ "content": {
+ "application/json": {
+ "schema": {"$ref":
f"#/components/schemas/{endpoint.response_component}"}
+ }
+ },
+ }
+ },
+ }
+ if endpoint.parameters:
+ operation["parameters"] = [
+ {"$ref": f"#/components/parameters/{parameter_name}"} for
parameter_name in endpoint.parameters
+ ]
+ if endpoint.include_not_found:
+ operation["responses"]["404"] = {"$ref":
"#/components/responses/NotFound"}
+ return operation
+
+
+def _build_openapi_paths(endpoints: tuple[OpenApiEndpoint, ...]) -> dict[str,
dict[str, Any]]:
+ return {endpoint.path: {"get": _build_openapi_get_operation(endpoint)} for
endpoint in endpoints}
+
+
+def build_openapi_document() -> dict[str, Any]:
+ """Build OpenAPI 3.1 schema from shared registry contracts."""
+
+ component_schemas =
_collect_openapi_component_schemas(_OPENAPI_COMPONENT_MODELS)
+
+ return {
+ "openapi": "3.1.0",
+ "jsonSchemaDialect": "https://spec.openapis.org/oas/3.1/dialect/base",
+ "info": {
+ "title": "Airflow Registry API",
+ "version": "1.0.0",
+ "description": "JSON endpoints for Apache Airflow provider and
module discovery.",
+ },
+ "tags": [
+ {"name": "Catalog", "description": "Global registry datasets."},
+ {"name": "Providers", "description": "Provider-scoped latest
metadata."},
+ {"name": "Provider Versions", "description": "Version-specific
provider metadata."},
+ ],
+ "paths": _build_openapi_paths(OPENAPI_ENDPOINTS),
+ "components": {
+ "parameters": {
+ "ProviderId": {
+ "name": "providerId",
+ "in": "path",
+ "required": True,
+ "schema": {"type": "string"},
+ "description": "Provider identifier (for example:
amazon).",
+ },
+ "Version": {
+ "name": "version",
+ "in": "path",
+ "required": True,
+ "schema": {"type": "string"},
+ "description": "Provider version (for example: 9.22.0).",
+ },
+ },
+ "responses": {
+ "NotFound": {"description": "Static endpoint file not found."},
+ },
+ "schemas": component_schemas,
+ },
+ }
diff --git a/dev/registry/tests/test_merge_registry_data.py
b/dev/registry/tests/test_merge_registry_data.py
index d30df57931b..537245569aa 100644
--- a/dev/registry/tests/test_merge_registry_data.py
+++ b/dev/registry/tests/test_merge_registry_data.py
@@ -37,14 +37,58 @@ def _write_json(path: Path, data: dict) -> Path:
return path
+def _provider(provider_id: str, name: str, last_updated: str) -> dict:
+ return {
+ "id": provider_id,
+ "name": name,
+ "package_name": f"apache-airflow-providers-{provider_id}",
+ "description": f"{name} provider",
+ "lifecycle": "production",
+ "logo": None,
+ "version": "1.0.0",
+ "versions": ["1.0.0"],
+ "airflow_versions": ["3.0+"],
+ "pypi_downloads": {"weekly": 0, "monthly": 0, "total": 0},
+ "module_counts": {"operator": 1},
+ "categories": [],
+ "connection_types": [],
+ "requires_python": ">=3.10",
+ "dependencies": [],
+ "optional_extras": {},
+ "dependents": [],
+ "related_providers": [],
+ "docs_url": "https://example.invalid/docs",
+ "source_url": "https://example.invalid/source",
+ "pypi_url": "https://example.invalid/pypi",
+ "first_released": "",
+ "last_updated": last_updated,
+ }
+
+
+def _module(module_id: str, provider_id: str) -> dict:
+ return {
+ "id": module_id,
+ "name": "ExampleOperator",
+ "type": "operator",
+ "import_path":
f"airflow.providers.{provider_id}.operators.example.ExampleOperator",
+ "module_path": f"airflow.providers.{provider_id}.operators.example",
+ "short_description": "Example operator",
+ "docs_url": "https://example.invalid/docs",
+ "source_url": "https://example.invalid/source",
+ "category": "test",
+ "provider_id": provider_id,
+ "provider_name": provider_id.capitalize(),
+ }
+
+
class TestMerge:
def test_replaces_existing_provider(self, tmp_path, output_dir):
existing_providers = _write_json(
tmp_path / "existing_providers.json",
{
"providers": [
- {"id": "amazon", "name": "Amazon", "last_updated":
"2024-01-01"},
- {"id": "google", "name": "Google", "last_updated":
"2024-02-01"},
+ _provider("amazon", "Amazon", "2024-01-01"),
+ _provider("google", "Google", "2024-02-01"),
]
},
)
@@ -52,18 +96,18 @@ class TestMerge:
tmp_path / "existing_modules.json",
{
"modules": [
- {"id": "amazon-s3-op", "provider_id": "amazon"},
- {"id": "google-bq-op", "provider_id": "google"},
+ _module("amazon-s3-op", "amazon"),
+ _module("google-bq-op", "google"),
]
},
)
new_providers = _write_json(
tmp_path / "new_providers.json",
- {"providers": [{"id": "amazon", "name": "Amazon", "last_updated":
"2025-01-01"}]},
+ {"providers": [_provider("amazon", "Amazon", "2025-01-01")]},
)
new_modules = _write_json(
tmp_path / "new_modules.json",
- {"modules": [{"id": "amazon-s3-op-v2", "provider_id": "amazon"}]},
+ {"modules": [_module("amazon-s3-op-v2", "amazon")]},
)
merge(existing_providers, existing_modules, new_providers,
new_modules, output_dir)
@@ -88,19 +132,19 @@ class TestMerge:
def test_adds_new_provider(self, tmp_path, output_dir):
existing_providers = _write_json(
tmp_path / "existing_providers.json",
- {"providers": [{"id": "google", "name": "Google", "last_updated":
"2024-02-01"}]},
+ {"providers": [_provider("google", "Google", "2024-02-01")]},
)
existing_modules = _write_json(
tmp_path / "existing_modules.json",
- {"modules": [{"id": "google-bq-op", "provider_id": "google"}]},
+ {"modules": [_module("google-bq-op", "google")]},
)
new_providers = _write_json(
tmp_path / "new_providers.json",
- {"providers": [{"id": "amazon", "name": "Amazon", "last_updated":
"2025-01-01"}]},
+ {"providers": [_provider("amazon", "Amazon", "2025-01-01")]},
)
new_modules = _write_json(
tmp_path / "new_modules.json",
- {"modules": [{"id": "amazon-s3-op", "provider_id": "amazon"}]},
+ {"modules": [_module("amazon-s3-op", "amazon")]},
)
merge(existing_providers, existing_modules, new_providers,
new_modules, output_dir)
@@ -112,12 +156,12 @@ class TestMerge:
def test_providers_sorted_by_name(self, tmp_path, output_dir):
existing_providers = _write_json(
tmp_path / "existing_providers.json",
- {"providers": [{"id": "zzz", "name": "Zzz Provider",
"last_updated": ""}]},
+ {"providers": [_provider("zzz", "Zzz Provider", "")]},
)
existing_modules = _write_json(tmp_path / "existing_modules.json",
{"modules": []})
new_providers = _write_json(
tmp_path / "new_providers.json",
- {"providers": [{"id": "aaa", "name": "Aaa Provider",
"last_updated": ""}]},
+ {"providers": [_provider("aaa", "Aaa Provider", "")]},
)
new_modules = _write_json(tmp_path / "new_modules.json", {"modules":
[]})
@@ -132,21 +176,21 @@ class TestMerge:
tmp_path / "existing_providers.json",
{
"providers": [
- {"id": "old", "name": "Old Provider", "last_updated":
"2020-01-01"},
+ _provider("old", "Old Provider", "2020-01-01"),
]
},
)
existing_modules = _write_json(
tmp_path / "existing_modules.json",
- {"modules": [{"id": "old-mod", "provider_id": "old"}]},
+ {"modules": [_module("old-mod", "old")]},
)
new_providers = _write_json(
tmp_path / "new_providers.json",
- {"providers": [{"id": "new", "name": "New Provider",
"last_updated": "2025-06-01"}]},
+ {"providers": [_provider("new", "New Provider", "2025-06-01")]},
)
new_modules = _write_json(
tmp_path / "new_modules.json",
- {"modules": [{"id": "new-mod", "provider_id": "new"}]},
+ {"modules": [_module("new-mod", "new")]},
)
merge(existing_providers, existing_modules, new_providers,
new_modules, output_dir)
@@ -159,17 +203,17 @@ class TestMerge:
def test_missing_existing_modules_file(self, tmp_path, output_dir):
existing_providers = _write_json(
tmp_path / "existing_providers.json",
- {"providers": [{"id": "google", "name": "Google", "last_updated":
""}]},
+ {"providers": [_provider("google", "Google", "")]},
)
# existing_modules file does not exist
existing_modules = tmp_path / "nonexistent_modules.json"
new_providers = _write_json(
tmp_path / "new_providers.json",
- {"providers": [{"id": "amazon", "name": "Amazon", "last_updated":
""}]},
+ {"providers": [_provider("amazon", "Amazon", "")]},
)
new_modules = _write_json(
tmp_path / "new_modules.json",
- {"modules": [{"id": "amazon-s3-op", "provider_id": "amazon"}]},
+ {"modules": [_module("amazon-s3-op", "amazon")]},
)
merge(existing_providers, existing_modules, new_providers,
new_modules, output_dir)
@@ -184,11 +228,11 @@ class TestMerge:
existing_modules = _write_json(tmp_path / "existing_modules.json",
{"modules": []})
new_providers = _write_json(
tmp_path / "new_providers.json",
- {"providers": [{"id": "amazon", "name": "Amazon", "last_updated":
""}]},
+ {"providers": [_provider("amazon", "Amazon", "")]},
)
new_modules = _write_json(
tmp_path / "new_modules.json",
- {"modules": [{"id": "amazon-s3-op", "provider_id": "amazon"}]},
+ {"modules": [_module("amazon-s3-op", "amazon")]},
)
merge(existing_providers, existing_modules, new_providers,
new_modules, output_dir)
@@ -206,7 +250,7 @@ class TestMerge:
existing_modules = _write_json(tmp_path / "existing_modules.json",
{"modules": []})
new_providers = _write_json(
tmp_path / "new_providers.json",
- {"providers": [{"id": "test", "name": "Test", "last_updated":
""}]},
+ {"providers": [_provider("test", "Test", "")]},
)
new_modules = _write_json(tmp_path / "new_modules.json", {"modules":
[]})
diff --git a/dev/registry/tests/test_registry_contract_models.py
b/dev/registry/tests/test_registry_contract_models.py
new file mode 100644
index 00000000000..146a16ff3e6
--- /dev/null
+++ b/dev/registry/tests/test_registry_contract_models.py
@@ -0,0 +1,103 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""Unit tests for dev/registry/registry_contract_models.py."""
+
+from __future__ import annotations
+
+import pytest
+from pydantic import ValidationError
+from registry_contract_models import (
+ build_openapi_document,
+ validate_provider_parameters,
+ validate_provider_version_metadata,
+ validate_provider_versions,
+)
+
+
+def test_build_openapi_document_produces_valid_spec():
+ doc = build_openapi_document()
+ assert doc["openapi"] == "3.1.0"
+ assert "/api/providers.json" in doc["paths"]
+ assert "/api/providers/{providerId}/versions.json" in doc["paths"]
+ # Every endpoint $ref should resolve to a component schema
+ for path_item in doc["paths"].values():
+ ref =
path_item["get"]["responses"]["200"]["content"]["application/json"]["schema"]["$ref"]
+ schema_name = ref.split("/")[-1]
+ assert schema_name in doc["components"]["schemas"], f"{ref} not in
component schemas"
+
+
+def test_validate_provider_versions_requires_latest_in_versions():
+ with pytest.raises(ValidationError):
+ validate_provider_versions({"latest": "2.0.0", "versions": ["1.0.0"]})
+
+
+def test_validate_provider_parameters_preserves_mro_alias():
+ payload = {
+ "provider_id": "test",
+ "provider_name": "Test",
+ "version": "1.0.0",
+ "generated_at": "2026-02-18T00:00:00+00:00",
+ "classes": {
+ "airflow.providers.test.mod.Example": {
+ "name": "Example",
+ "type": "operator",
+ "mro": ["airflow.providers.test.mod.Example"],
+ "parameters": [
+ {
+ "name": "value",
+ "type": "str",
+ "default": None,
+ "required": True,
+ "origin": "airflow.providers.test.mod.Example",
+ "description": None,
+ }
+ ],
+ }
+ },
+ }
+ validated = validate_provider_parameters(payload)
+ class_entry = validated["classes"]["airflow.providers.test.mod.Example"]
+ assert "mro" in class_entry
+ assert "mro_chain" not in class_entry
+
+
+def
test_validate_version_metadata_accepts_legacy_version_modules_without_ids():
+ payload = {
+ "provider_id": "test",
+ "version": "0.9.0",
+ "generated_at": "2026-02-18T00:00:00+00:00",
+ "requires_python": ">=3.10",
+ "dependencies": [],
+ "optional_extras": {},
+ "connection_types": [{"conn_type": "test", "hook_class": "x.y.Hook"}],
+ "module_counts": {"operator": 1},
+ "modules": [
+ {
+ "name": "LegacyOperator",
+ "type": "operator",
+ "import_path":
"airflow.providers.test.operators.legacy.LegacyOperator",
+ "short_description": "Legacy module shape from older
backfills.",
+ "docs_url": "https://example.invalid/docs",
+ "source_url": "https://example.invalid/source",
+ "category": "test",
+ }
+ ],
+ }
+ validated = validate_provider_version_metadata(payload)
+ assert "id" not in validated["modules"][0]
+ assert "provider_id" not in validated["modules"][0]
+ assert "provider_name" not in validated["modules"][0]
diff --git a/registry/.eleventy.js b/registry/.eleventy.js
index 90a7f336d5d..bdd5ce7b20c 100644
--- a/registry/.eleventy.js
+++ b/registry/.eleventy.js
@@ -26,6 +26,14 @@ module.exports = function(eleventyConfig) {
// Copy public directory contents to root of output
eleventyConfig.addPassthroughCopy({ "public": "/" });
+ // Vendor swagger-ui from node_modules (external CDN blocked by Apache Infra
CSP)
+ eleventyConfig.addPassthroughCopy({
+ "node_modules/swagger-ui-dist/swagger-ui.css":
"vendor/swagger-ui/swagger-ui.css",
+ });
+ eleventyConfig.addPassthroughCopy({
+ "node_modules/swagger-ui-dist/swagger-ui-bundle.js":
"vendor/swagger-ui/swagger-ui-bundle.js",
+ });
+
// Watch CSS and JS for changes
eleventyConfig.addWatchTarget("src/css/");
eleventyConfig.addWatchTarget("src/js/");
diff --git a/registry/.gitignore b/registry/.gitignore
index 89b43d0381e..f23bd271507 100644
--- a/registry/.gitignore
+++ b/registry/.gitignore
@@ -6,6 +6,9 @@ node_modules/
_site/
dist/
+# Generated OpenAPI spec (produced by dev/registry/export_registry_schemas.py
at build time)
+schemas/
+
# Provider logos (copied from providers/*/docs/integration-logos/ at build
time)
public/logos/
diff --git a/registry/AGENTS.md b/registry/AGENTS.md
index d036969c2d8..fc7ce31fb0f 100644
--- a/registry/AGENTS.md
+++ b/registry/AGENTS.md
@@ -10,9 +10,9 @@ This document contains rules, patterns, and guidelines for
working on the Airflo
### 0. Minimal JavaScript
- Keep JavaScript to minimum necessary
-- Theme toggle only
+- Theme toggle + progressive enhancement (filters, search)
- No frameworks (no React, Vue, etc.)
-- Progressive enhancement approach
+- Exception: swagger-ui is vendored from `node_modules/swagger-ui-dist` for
the API Explorer
## 1. Quality Standards
@@ -386,7 +386,9 @@ Key files in the project:
- `src/css/main.css` - Main styles
- `src/css/tokens.css` - Design tokens
-- `src/` - All page templates (index, provider-detail, providers, explore,
stats)
+- `src/` - All page templates (index, provider-detail, providers, explore,
stats, api-explorer)
+- `../dev/registry/registry_contract_models.py` - Pydantic contracts for all
JSON payloads
+- `../dev/registry/export_registry_schemas.py` - Generates OpenAPI spec from
contracts
## Development Server
diff --git a/registry/README.md b/registry/README.md
index 828749d2792..2d0d354c59f 100644
--- a/registry/README.md
+++ b/registry/README.md
@@ -125,6 +125,15 @@ extraction:
types, defaults, and docstrings. Writes
`versions/{provider_id}/{version}/parameters.json`.
+**`registry_contract_models.py`** defines Pydantic models that validate the
shape of
+every JSON payload the registry produces. Each extraction script calls
+`_validate(ModelType, payload)` before writing JSON — this catches schema
drift at
+generation time without a separate jsonschema layer. The same models generate
the
+OpenAPI 3.1 spec served at `/api/openapi.json`.
+
+**`export_registry_schemas.py`** generates `registry/schemas/openapi.json`
from the
+contract models. It runs automatically via `pnpm prebuild` before Eleventy
builds.
+
**`extract_connections.py`** (runs inside breeze) reads `connection-types` from
`provider.yaml`, falling back to runtime inspection of hook
`get_connection_form_widgets()` and `get_ui_field_behaviour()`. Writes
@@ -177,6 +186,8 @@ the same Sphinx build that generates the docs.
| `statsData.js` | Checked-in | Computed statistics (lifecycle counts, top
providers, etc.) |
| `providerVersions.js` | Checked-in | Builds the provider × version page
collection |
| `latestVersionData.js` | Checked-in | Latest version parameters/connections
lookup |
+| `openapiSpec.js` | Checked-in | Builds OpenAPI 3.1 spec from Pydantic
contract models |
+| `providerVersionPayloads.js` | Checked-in | Generates
`/api/providers/{id}/versions.json` payloads |
### Pages
@@ -188,6 +199,7 @@ the same Sphinx build that generates the docs.
| Statistics | `src/stats.njk` | `/stats/` |
| Provider Detail | `src/provider-detail.njk` | `/providers/{id}/` (redirects
to latest) |
| Provider Version | `src/provider-version.njk` | `/providers/{id}/{version}/`
|
+| API Explorer | `src/api-explorer.njk` | `/api-explorer/` |
### Client-Side JavaScript
@@ -272,6 +284,10 @@ providing programmatic access to provider and module data:
- `/api/providers/{id}/{version}/modules.json` — Version-specific modules
- `/api/providers/{id}/{version}/parameters.json` — Version-specific parameters
- `/api/providers/{id}/{version}/connections.json` — Version-specific
connections
+- `/api/openapi.json` — OpenAPI 3.1 spec (generated at build time from
Pydantic contracts)
+
+An interactive **API Explorer** at `/api-explorer/` renders the OpenAPI spec
using
+swagger-ui (vendored from `node_modules/swagger-ui-dist`).
## Incremental Builds
diff --git a/registry/package.json b/registry/package.json
index b1d680c1e46..af27cb96fd6 100644
--- a/registry/package.json
+++ b/registry/package.json
@@ -4,11 +4,13 @@
"description": "Apache Airflow Provider Registry",
"scripts": {
"dev": "REGISTRY_PATH_PREFIX=/ pnpm build && REGISTRY_PATH_PREFIX=/
eleventy --serve --port=8080",
- "build": "eleventy",
+ "prebuild": "uv run python ../dev/registry/export_registry_schemas.py",
+ "build": "rm -rf _site && eleventy",
"postbuild": "cleancss -o _site/css/main.css _site/css/main.css && node
scripts/build-pagefind-index.mjs"
},
"dependencies": {
- "@11ty/eleventy": "^3.1.2"
+ "@11ty/eleventy": "^3.1.2",
+ "swagger-ui-dist": "^5.32.0"
},
"devDependencies": {
"clean-css-cli": "^5.6.3",
diff --git a/registry/pnpm-lock.yaml b/registry/pnpm-lock.yaml
index 3b183677cf0..59787d2b244 100644
--- a/registry/pnpm-lock.yaml
+++ b/registry/pnpm-lock.yaml
@@ -17,6 +17,9 @@ importers:
'@11ty/eleventy':
specifier: ^3.1.2
version: 3.1.2
+ swagger-ui-dist:
+ specifier: ^5.32.0
+ version: 5.32.0
devDependencies:
clean-css-cli:
specifier: ^5.6.3
@@ -93,6 +96,9 @@ packages:
cpu: [x64]
os: [win32]
+ '@scarf/[email protected]':
+ resolution: {integrity:
sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==}
+
'@sindresorhus/[email protected]':
resolution: {integrity:
sha512-MkngSCRZ8JdSOCHRaYd+D01XhvU3Hjy6MGl06zhOk614hp9EOAp5gIkBeQg7wtmxpitU6eAL4kdiRMcJa2dlrw==}
engines: {node: '>=12'}
@@ -593,6 +599,9 @@ packages:
resolution: {integrity:
sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==}
engines: {node: '>=0.10.0'}
+ [email protected]:
+ resolution: {integrity:
sha512-nKZB0OuDvacB0s/lC2gbge+RigYvGRGpLLMWMFxaTUwfM+CfndVk9Th2IaTinqXiz6Mn26GK2zriCpv6/+5m3Q==}
+
[email protected]:
resolution: {integrity:
sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
engines: {node: '>=12.0.0'}
@@ -747,6 +756,8 @@ snapshots:
'@pagefind/[email protected]':
optional: true
+ '@scarf/[email protected]': {}
+
'@sindresorhus/[email protected]':
dependencies:
'@sindresorhus/transliterate': 1.6.0
@@ -1191,6 +1202,10 @@ snapshots:
[email protected]: {}
+ [email protected]:
+ dependencies:
+ '@scarf/scarf': 1.4.0
+
[email protected]:
dependencies:
fdir: 6.5.0([email protected])
diff --git a/registry/src/_data/openapiSpec.js
b/registry/src/_data/openapiSpec.js
new file mode 100644
index 00000000000..2b068a490e0
--- /dev/null
+++ b/registry/src/_data/openapiSpec.js
@@ -0,0 +1,33 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+const fs = require("fs");
+const path = require("path");
+
+const OPENAPI_SCHEMA_PATH = path.join(__dirname, "..", "..", "schemas",
"openapi.json");
+
+module.exports = function() {
+ if (!fs.existsSync(OPENAPI_SCHEMA_PATH)) {
+ throw new Error(
+ `Missing OpenAPI schema artifact at ${OPENAPI_SCHEMA_PATH}. ` +
+ "Run `pnpm build` (prebuild generates it) or `uv run python
../dev/registry/export_registry_schemas.py`.",
+ );
+ }
+ return JSON.parse(fs.readFileSync(OPENAPI_SCHEMA_PATH, "utf8"));
+};
diff --git a/registry/src/_data/providerVersionPayloads.js
b/registry/src/_data/providerVersionPayloads.js
new file mode 100644
index 00000000000..908cde16cac
--- /dev/null
+++ b/registry/src/_data/providerVersionPayloads.js
@@ -0,0 +1,48 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+const providersData = require("./providers.json");
+const buildProviderVersions = require("./providerVersions");
+
+module.exports = function () {
+ const providerVersions = buildProviderVersions();
+ const payloads = {};
+
+ for (const provider of providersData.providers) {
+ payloads[provider.id] = {
+ latest: provider.version,
+ versions: [provider.version],
+ };
+ }
+
+ for (const pv of providerVersions) {
+ if (!pv || !pv.provider || !pv.provider.id || !pv.isLatest) {
+ continue;
+ }
+ const versions = Array.isArray(pv.availableVersions) &&
pv.availableVersions.length > 0
+ ? pv.availableVersions
+ : [pv.provider.version];
+ payloads[pv.provider.id] = {
+ latest: pv.provider.version,
+ versions,
+ };
+ }
+
+ return payloads;
+};
diff --git a/registry/src/_includes/base.njk b/registry/src/_includes/base.njk
index 86e12b41891..74553ffcf4c 100644
--- a/registry/src/_includes/base.njk
+++ b/registry/src/_includes/base.njk
@@ -63,6 +63,7 @@
<a href="{{ '/providers/' | url }}">Providers</a>
<a href="{{ '/stats/' | url }}">Stats</a>
<a href="{{ '/explore/' | url }}">Explore</a>
+ <a href="{{ '/api-explorer/' | url }}">API Explorer</a>
<a href="https://airflow.apache.org/docs/" target="_blank"
rel="noopener">
Docs
<svg width="12" height="12" fill="none" stroke="currentColor"
viewBox="0 0 24 24" aria-hidden="true"
style="display:inline;vertical-align:middle;margin-left:2px">
@@ -98,6 +99,7 @@
<a href="{{ '/providers/' | url }}">Providers</a>
<a href="{{ '/stats/' | url }}">Stats</a>
<a href="{{ '/explore/' | url }}">Explore</a>
+ <a href="{{ '/api-explorer/' | url }}">API Explorer</a>
<a href="https://airflow.apache.org/docs/" target="_blank"
rel="noopener">Docs</a>
</nav>
</div>
diff --git a/registry/src/api-explorer.njk b/registry/src/api-explorer.njk
new file mode 100644
index 00000000000..40b7166028a
--- /dev/null
+++ b/registry/src/api-explorer.njk
@@ -0,0 +1,46 @@
+---
+layout: base.njk
+title: API Explorer
+description: Interactive explorer for the Airflow Registry JSON API.
+permalink: "/api-explorer/"
+mainClass: "api-explorer-page"
+---
+<link rel="stylesheet" href="{{ '/vendor/swagger-ui/swagger-ui.css' | url }}">
+<div id="swagger-ui"></div>
+<script src="{{ '/vendor/swagger-ui/swagger-ui-bundle.js' | url }}"></script>
+<script>
+ (function () {
+ const openApiUrl = new URL("../api/openapi.json",
window.location.href).toString();
+ const serverPath = new URL("../",
window.location.href).pathname.replace(/\/$/, "") || "/";
+
+ async function renderSwaggerUi() {
+ try {
+ const response = await fetch(openApiUrl, { credentials: "same-origin"
});
+ if (!response.ok) {
+ throw new Error(`OpenAPI load failed: ${response.status}`);
+ }
+ const spec = await response.json();
+ spec.servers = [{ url: serverPath, description: "Registry base path"
}];
+ SwaggerUIBundle({
+ spec,
+ dom_id: "#swagger-ui",
+ deepLinking: true,
+ displayRequestDuration: true,
+ docExpansion: "list",
+ defaultModelsExpandDepth: -1,
+ tryItOutEnabled: true,
+ supportedSubmitMethods: ["get"],
+ presets: [SwaggerUIBundle.presets.apis],
+ });
+ } catch (error) {
+ document.getElementById("swagger-ui").innerHTML =
+ "<p style=\"padding: 1rem; font-family: sans-serif; color: #444;\">"
+
+ "Could not load interactive console. " +
+ "<a href=\"" + openApiUrl + "\" target=\"_blank\"
rel=\"noopener\">Open raw OpenAPI document</a>." +
+ "</p>";
+ }
+ }
+
+ renderSwaggerUi();
+ })();
+</script>
diff --git a/registry/src/api/openapi-json.njk
b/registry/src/api/openapi-json.njk
new file mode 100644
index 00000000000..9263bccdc5f
--- /dev/null
+++ b/registry/src/api/openapi-json.njk
@@ -0,0 +1,5 @@
+---
+permalink: "/api/openapi.json"
+eleventyExcludeFromCollections: true
+---
+{{ openapiSpec | dump | safe }}
diff --git a/registry/src/api/provider-versions.njk
b/registry/src/api/provider-versions.njk
new file mode 100644
index 00000000000..ed306f12319
--- /dev/null
+++ b/registry/src/api/provider-versions.njk
@@ -0,0 +1,13 @@
+---
+pagination:
+ data: providers.providers
+ size: 1
+ alias: provider
+permalink: "/api/providers/{{ provider.id }}/versions.json"
+eleventyExcludeFromCollections: true
+---
+{%- if providerVersionPayloads[provider.id] -%}
+{{ providerVersionPayloads[provider.id] | dump | safe }}
+{%- else -%}
+{{ { "latest": provider.version, "versions": [provider.version] } | dump |
safe }}
+{%- endif -%}
diff --git a/registry/src/css/main.css b/registry/src/css/main.css
index e602fd7f1b0..a0adbd071c0 100644
--- a/registry/src/css/main.css
+++ b/registry/src/css/main.css
@@ -4222,3 +4222,9 @@ main {
.error-links a:hover {
opacity: 0.9;
}
+
+/* API Explorer (swagger-ui inside site layout) */
+.api-explorer-page .swagger-ui .topbar,
+.api-explorer-page .swagger-ui .scheme-container { display: none; }
+.api-explorer-page .swagger-ui .info { margin: var(--space-4) 0; }
+.api-explorer-page { padding-bottom: var(--space-8); }
diff --git a/registry/src/js/theme.js b/registry/src/js/theme.js
index e4c8e4f2258..a95d7d00e0c 100644
--- a/registry/src/js/theme.js
+++ b/registry/src/js/theme.js
@@ -29,15 +29,16 @@
return html.style.colorScheme || 'dark';
}
- // Update icon visibility based on current theme
- function updateIcons() {
+ // Update icon visibility and swagger-ui dark mode class
+ function updateTheme() {
const isLight = getCurrentTheme() === 'light';
if (sunIcon) sunIcon.style.display = isLight ? 'block' : 'none';
if (moonIcon) moonIcon.style.display = isLight ? 'none' : 'block';
+ html.classList.toggle('dark-mode', !isLight);
}
- // Initialize icons on load
- updateIcons();
+ // Initialize on load
+ updateTheme();
// Toggle theme
if (themeToggle) {
@@ -47,7 +48,7 @@
html.style.colorScheme = newTheme;
localStorage.setItem('theme', newTheme);
- updateIcons();
+ updateTheme();
});
}
})();
diff --git a/registry/src/sitemap.njk b/registry/src/sitemap.njk
index 0678f3ad862..5a90e55079b 100644
--- a/registry/src/sitemap.njk
+++ b/registry/src/sitemap.njk
@@ -8,6 +8,7 @@ eleventyExcludeFromCollections: true
<url><loc>https://airflow.apache.org/registry/providers/</loc></url>
<url><loc>https://airflow.apache.org/registry/explore/</loc></url>
<url><loc>https://airflow.apache.org/registry/stats/</loc></url>
+ <url><loc>https://airflow.apache.org/registry/api-explorer/</loc></url>
{%- for provider in providers.providers %}
<url><loc>https://airflow.apache.org/registry/providers/{{ provider.id }}/{{
provider.version }}/</loc></url>
{%- endfor %}