diff --git a/openapi_to_fastapi/cli.py b/openapi_to_fastapi/cli.py index b01101a..7c46680 100644 --- a/openapi_to_fastapi/cli.py +++ b/openapi_to_fastapi/cli.py @@ -13,7 +13,6 @@ from openapi_to_fastapi.validator.core import BaseValidator, DefaultValidator from .routes import SpecRouter -from .validator import ihan_standards logger = logging.getLogger("openapi_to_fastapi_cli") coloredlogs.install(logger=logger, fmt="%(message)s") @@ -75,7 +74,7 @@ def validate_specs(path: Path, modules: List[str], extra_validators: List[str]) def _load_extra_validator_modules(modules: List[str]) -> list: - validator_modules = [ihan_standards] + validator_modules = [] for module_path in modules: module_name = f"oas_models_{uuid.uuid4()}" spec = importlib.util.spec_from_file_location(module_name, module_path) diff --git a/openapi_to_fastapi/tests/conftest.py b/openapi_to_fastapi/tests/conftest.py index 8189c6b..56d0d39 100644 --- a/openapi_to_fastapi/tests/conftest.py +++ b/openapi_to_fastapi/tests/conftest.py @@ -23,8 +23,8 @@ def client(app): @pytest.fixture -def ihan_client(specs_root): +def definitions_client(specs_root): app = FastAPI() - spec_router = SpecRouter(specs_root / "ihan") + spec_router = SpecRouter(specs_root / "definitions") app.include_router(spec_router.to_fastapi_router()) return TestClient(app) diff --git a/openapi_to_fastapi/tests/data/ihan/CoffeeBrewer.json b/openapi_to_fastapi/tests/data/definitions/CoffeeBrewer.json similarity index 100% rename from openapi_to_fastapi/tests/data/ihan/CoffeeBrewer.json rename to openapi_to_fastapi/tests/data/definitions/CoffeeBrewer.json diff --git a/openapi_to_fastapi/tests/data/ihan/CompanyBasicInfo.json b/openapi_to_fastapi/tests/data/definitions/CompanyBasicInfo.json similarity index 100% rename from openapi_to_fastapi/tests/data/ihan/CompanyBasicInfo.json rename to openapi_to_fastapi/tests/data/definitions/CompanyBasicInfo.json diff --git a/openapi_to_fastapi/tests/data/ihan/WeatherCurrentMetric.json b/openapi_to_fastapi/tests/data/definitions/WeatherCurrentMetric.json similarity index 100% rename from openapi_to_fastapi/tests/data/ihan/WeatherCurrentMetric.json rename to openapi_to_fastapi/tests/data/definitions/WeatherCurrentMetric.json diff --git a/openapi_to_fastapi/tests/data/pet_store/pet_store.json b/openapi_to_fastapi/tests/data/pet_store/pet_store.json deleted file mode 100644 index ece4f7b..0000000 --- a/openapi_to_fastapi/tests/data/pet_store/pet_store.json +++ /dev/null @@ -1,165 +0,0 @@ -{ - "openapi": "3.0.0", - "info": { - "version": "1.0.0", - "title": "Swagger Petstore", - "license": { - "name": "MIT" - } - }, - "servers": [ - { - "url": "http://petstore.swagger.io/v1" - } - ], - "paths": { - "/pets": { - "get": { - "summary": "List all pets", - "operationId": "listPets", - "tags": ["pets"], - "parameters": [ - { - "name": "limit", - "in": "query", - "description": "How many items to return at one time (max 100)", - "required": false, - "schema": { - "type": "integer", - "format": "int32" - } - } - ], - "responses": { - "200": { - "description": "A paged array of pets", - "headers": { - "x-next": { - "description": "A link to the next page of responses", - "schema": { - "type": "string" - } - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Pets" - } - } - } - }, - "default": { - "description": "unexpected error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - } - } - }, - "post": { - "summary": "Create a pet", - "operationId": "createPets", - "tags": ["pets"], - "responses": { - "201": { - "description": "Null response" - }, - "default": { - "description": "unexpected error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - } - } - } - }, - "/pets/{petId}": { - "get": { - "summary": "Info for a specific pet", - "operationId": "showPetById", - "tags": ["pets"], - "parameters": [ - { - "name": "petId", - "in": "path", - "required": true, - "description": "The id of the pet to retrieve", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Expected response to a valid request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Pet" - } - } - } - }, - "default": { - "description": "unexpected error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - } - } - } - } - }, - "components": { - "schemas": { - "Pet": { - "type": "object", - "required": ["id", "name"], - "properties": { - "id": { - "type": "integer", - "format": "int64" - }, - "name": { - "type": "string" - }, - "tag": { - "type": "string" - } - } - }, - "Pets": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Pet" - } - }, - "Error": { - "type": "object", - "required": ["code", "message"], - "properties": { - "code": { - "type": "integer", - "format": "int32" - }, - "message": { - "type": "string" - } - } - } - } - } -} diff --git a/openapi_to_fastapi/tests/test_ihan_standards.py b/openapi_to_fastapi/tests/test_ihan_standards.py deleted file mode 100644 index 6cc00fa..0000000 --- a/openapi_to_fastapi/tests/test_ihan_standards.py +++ /dev/null @@ -1,161 +0,0 @@ -import json -from copy import deepcopy -from pathlib import Path - -import pytest - -from ..routes import SpecRouter -from ..validator import InvalidJSON, UnsupportedVersion -from ..validator import ihan_standards as ihan - -# Note: It's easier to get some 100% valid spec and corrupt it -# instead of having multiple incorrect specs in the repo - -SPECS_ROOT_DIR = Path(__file__).absolute().parent / "data" -COMPANY_BASIC_INFO: dict = json.loads( - (SPECS_ROOT_DIR / "ihan" / "CompanyBasicInfo.json").read_text(encoding="utf8") -) - - -def check_validation_error(tmp_path, spec: dict, exception): - spec_path = tmp_path / "spec.json" - spec_path.write_text(json.dumps(spec)) - with pytest.raises(exception): - SpecRouter(spec_path, [ihan.IhanStandardsValidator]) - - -@pytest.mark.parametrize("method", ["get", "put", "delete"]) -def test_standards_has_non_post_method(method, tmp_path): - spec = deepcopy(COMPANY_BASIC_INFO) - spec["paths"]["/Company/BasicInfo"][method] = { - "description": "Method which should not exist" - } - check_validation_error(tmp_path, spec, ihan.OnlyPostMethodAllowed) - - -def test_post_method_is_missing(tmp_path): - spec = deepcopy(COMPANY_BASIC_INFO) - del spec["paths"]["/Company/BasicInfo"]["post"] - check_validation_error(tmp_path, spec, ihan.PostMethodIsMissing) - - -def test_many_endpoints(tmp_path): - spec = deepcopy(COMPANY_BASIC_INFO) - spec["paths"]["/pets"] = {"post": {"description": "Pet store, why not?"}} - check_validation_error(tmp_path, spec, ihan.OnlyOneEndpointAllowed) - - -def test_no_endpoints(tmp_path): - spec = deepcopy(COMPANY_BASIC_INFO) - del spec["paths"] - check_validation_error(tmp_path, spec, ihan.NoEndpointsDefined) - - -def test_missing_field_body_is_fine(tmp_path): - spec = deepcopy(COMPANY_BASIC_INFO) - del spec["paths"]["/Company/BasicInfo"]["post"]["requestBody"] - spec_path = tmp_path / "spec.json" - spec_path.write_text(json.dumps(spec)) - SpecRouter(spec_path, [ihan.IhanStandardsValidator]) - - -def test_missing_200_response(tmp_path): - spec = deepcopy(COMPANY_BASIC_INFO) - del spec["paths"]["/Company/BasicInfo"]["post"]["responses"]["200"] - check_validation_error(tmp_path, spec, ihan.ResponseBodyMissing) - - -def test_wrong_content_type_of_request_body(tmp_path): - spec = deepcopy(COMPANY_BASIC_INFO) - request_body = spec["paths"]["/Company/BasicInfo"]["post"]["requestBody"] - schema = deepcopy(request_body["content"]["application/json"]) - request_body["content"]["text/plan"] = schema - del request_body["content"]["application/json"] - check_validation_error(tmp_path, spec, ihan.WrongContentType) - - -def test_wrong_content_type_of_response(tmp_path): - spec = deepcopy(COMPANY_BASIC_INFO) - response = spec["paths"]["/Company/BasicInfo"]["post"]["responses"]["200"] - schema = deepcopy(response["content"]["application/json"]) - response["content"]["text/plan"] = schema - del response["content"]["application/json"] - check_validation_error(tmp_path, spec, ihan.WrongContentType) - - -def test_component_schema_is_missing(tmp_path): - spec = deepcopy(COMPANY_BASIC_INFO) - del spec["components"]["schemas"] - check_validation_error(tmp_path, spec, ihan.SchemaMissing) - - -@pytest.mark.parametrize( - "model_name", ["BasicCompanyInfoRequest", "BasicCompanyInfoResponse"] -) -def test_component_is_missing(model_name, tmp_path): - spec = deepcopy(COMPANY_BASIC_INFO) - del spec["components"]["schemas"][model_name] - check_validation_error(tmp_path, spec, ihan.SchemaMissing) - - -def test_non_existing_component_defined_in_body(tmp_path): - spec = deepcopy(COMPANY_BASIC_INFO) - body = spec["paths"]["/Company/BasicInfo"]["post"]["requestBody"] - body["content"]["application/json"]["schema"]["$ref"] += "blah" - check_validation_error(tmp_path, spec, ihan.SchemaMissing) - - -def test_non_existing_component_defined_in_response(tmp_path): - spec = deepcopy(COMPANY_BASIC_INFO) - resp_200 = spec["paths"]["/Company/BasicInfo"]["post"]["responses"]["200"] - resp_200["content"]["application/json"]["schema"]["$ref"] += "blah" - check_validation_error(tmp_path, spec, ihan.SchemaMissing) - - -def test_auth_header_is_missing(tmp_path): - spec = deepcopy(COMPANY_BASIC_INFO) - x_app_provider_header = { - "schema": {"type": "string"}, - "in": "header", - "name": "X-Authorization-Provider", - "description": "Provider domain", - } - spec["paths"]["/Company/BasicInfo"]["post"]["parameters"] = [x_app_provider_header] - check_validation_error(tmp_path, spec, ihan.AuthorizationHeaderMissing) - - -def test_auth_provider_header_is_missing(tmp_path): - spec = deepcopy(COMPANY_BASIC_INFO) - auth_header = { - "schema": {"type": "string"}, - "in": "header", - "name": "Authorization", - "description": "User bearer token", - } - spec["paths"]["/Company/BasicInfo"]["post"]["parameters"] = [auth_header] - check_validation_error(tmp_path, spec, ihan.AuthProviderHeaderMissing) - - -def test_servers_are_defined(tmp_path): - spec = deepcopy(COMPANY_BASIC_INFO) - spec["servers"] = [{"url": "http://example.com"}] - check_validation_error(tmp_path, spec, ihan.ServersShouldNotBeDefined) - - -def test_security_is_defined(tmp_path): - spec = deepcopy(COMPANY_BASIC_INFO) - spec["paths"]["/Company/BasicInfo"]["post"]["security"] = {} - check_validation_error(tmp_path, spec, ihan.SecurityShouldNotBeDefined) - - -def test_loading_non_json_file(tmp_path): - spec_path = tmp_path / "spec.json" - spec_path.write_text("weirdo content") - with pytest.raises(InvalidJSON): - SpecRouter(spec_path, [ihan.IhanStandardsValidator]) - - -def test_loading_unsupported_version(tmp_path): - spec = deepcopy(COMPANY_BASIC_INFO) - spec["openapi"] = "999.999.999" - check_validation_error(tmp_path, spec, UnsupportedVersion) diff --git a/openapi_to_fastapi/tests/test_router.py b/openapi_to_fastapi/tests/test_router.py index 1897f4f..22a2ed9 100644 --- a/openapi_to_fastapi/tests/test_router.py +++ b/openapi_to_fastapi/tests/test_router.py @@ -6,7 +6,7 @@ from openapi_to_fastapi.model_generator import load_models from openapi_to_fastapi.routes import SpecRouter -# values aligned with the response defined in the data/ihan/CompanyBasicInfo.json +# values aligned with the response defined in the data/definitions/CompanyBasicInfo.json company_basic_info_resp = { "name": "Company", "companyId": "test", @@ -15,16 +15,16 @@ } -def test_routes_are_created(ihan_client, specs_root): - assert ihan_client.post("/Company/BasicInfo").status_code != 404 - assert ihan_client.post("/Non/Existing/Stuff").status_code == 404 +def test_routes_are_created(definitions_client, specs_root): + assert definitions_client.post("/Company/BasicInfo").status_code != 404 + assert definitions_client.post("/Non/Existing/Stuff").status_code == 404 - assert ihan_client.get("/Company/BasicInfo").status_code == 405 - assert ihan_client.get("/Non/Existing/Stuff").status_code == 404 + assert definitions_client.get("/Company/BasicInfo").status_code == 405 + assert definitions_client.get("/Non/Existing/Stuff").status_code == 404 def test_pydantic_model_loading(specs_root): - path = specs_root / "ihan" / "CompanyBasicInfo.json" + path = specs_root / "definitions" / "CompanyBasicInfo.json" raw_spec = path.read_text(encoding="utf8") module = load_models(raw_spec, "/Company/BasicInfo") assert module.BasicCompanyInfoRequest @@ -45,7 +45,7 @@ def test_pydantic_model_loading(specs_root): def test_weather_route_payload_errors(app, specs_root, client, snapshot): - spec_router = SpecRouter(specs_root / "ihan") + spec_router = SpecRouter(specs_root / "definitions") @spec_router.post("/Weather/Current/Metric") def weather_metric(request): @@ -63,7 +63,7 @@ def weather_metric(request): def test_company_custom_post_route(app, client, specs_root, snapshot): - spec_router = SpecRouter(specs_root / "ihan") + spec_router = SpecRouter(specs_root / "definitions") @spec_router.post("/Company/BasicInfo") def weather_metric(request): @@ -77,7 +77,7 @@ def weather_metric(request): def test_default_post_handler(app, client, specs_root, snapshot): - spec_router = SpecRouter(specs_root / "ihan") + spec_router = SpecRouter(specs_root / "definitions") @spec_router.post() def company_info(request): @@ -90,7 +90,7 @@ def company_info(request): def test_custom_route_definitions(app, client, specs_root, snapshot): - spec_router = SpecRouter(specs_root / "ihan") + spec_router = SpecRouter(specs_root / "definitions") @spec_router.post("/Weather/Current/Metric") def weather_metric(request, vendor: str, auth_header: str = Header(...)): @@ -103,7 +103,7 @@ def weather_metric(request, vendor: str, auth_header: str = Header(...)): def test_response_model_is_parsed(app, client, specs_root): - spec_router = SpecRouter(specs_root / "ihan") + spec_router = SpecRouter(specs_root / "definitions") @spec_router.post("/Weather/Current/Metric") def weather_metric(request): @@ -123,7 +123,7 @@ def weather_metric(request): def test_routes_meta_info(app, client, specs_root): - spec_router = SpecRouter(specs_root / "ihan") + spec_router = SpecRouter(specs_root / "definitions") @spec_router.post( "/Weather/Current/Metric", @@ -149,7 +149,7 @@ def weather_metric(request): def test_routes_meta_info_custom_name(app, client, specs_root): - spec_router = SpecRouter(specs_root / "ihan") + spec_router = SpecRouter(specs_root / "definitions") def name_factory(path="", **kwargs): return path[1:] @@ -172,7 +172,7 @@ def weather_metric(request): def test_custom_route_name_for_default_post(app, client, specs_root): - spec_router = SpecRouter(specs_root / "ihan") + spec_router = SpecRouter(specs_root / "definitions") def name_factory(path="", **kwargs): return path[1:] @@ -193,7 +193,7 @@ def weather_metric(request): def test_headers_in_route_info_post(app, client, specs_root): - spec_router = SpecRouter(specs_root / "ihan") + spec_router = SpecRouter(specs_root / "definitions") post_map = spec_router.post_map company_basic_info_headers = post_map["/Company/BasicInfo"].headers @@ -207,7 +207,7 @@ def test_headers_in_route_info_post(app, client, specs_root): def test_deprecated(app, specs_root): - spec_router = SpecRouter(specs_root / "ihan") + spec_router = SpecRouter(specs_root / "definitions") router = spec_router.to_fastapi_router() app.include_router(router) @@ -218,7 +218,7 @@ def test_deprecated(app, specs_root): def test_custom_responses(app, specs_root): brew_spec = "/draft/Appliances/CoffeeBrewer" - spec_router = SpecRouter(specs_root / "ihan") + spec_router = SpecRouter(specs_root / "definitions") router = spec_router.to_fastapi_router() app.include_router(router) diff --git a/openapi_to_fastapi/validator/ihan_standards.py b/openapi_to_fastapi/validator/ihan_standards.py deleted file mode 100644 index 2e8e329..0000000 --- a/openapi_to_fastapi/validator/ihan_standards.py +++ /dev/null @@ -1,129 +0,0 @@ -from .core import BaseValidator, OpenApiValidationError - - -class IhanStandardError(OpenApiValidationError): - pass - - -class WrongContentType(IhanStandardError): - pass - - -class SchemaMissing(IhanStandardError): - pass - - -class NoEndpointsDefined(IhanStandardError): - pass - - -class OnlyOneEndpointAllowed(IhanStandardError): - pass - - -class PostMethodIsMissing(IhanStandardError): - pass - - -class OnlyPostMethodAllowed(IhanStandardError): - pass - - -class RequestBodyMissing(IhanStandardError): - pass - - -class ResponseBodyMissing(IhanStandardError): - pass - - -class AuthorizationHeaderMissing(IhanStandardError): - pass - - -class AuthProviderHeaderMissing(IhanStandardError): - pass - - -class ServersShouldNotBeDefined(IhanStandardError): - pass - - -class SecurityShouldNotBeDefined(IhanStandardError): - pass - - -def validate_component_schema(spec: dict, components_schema: dict): - if not spec["content"].get("application/json"): - raise WrongContentType("Model description must be in application/json format") - ref = spec["content"]["application/json"].get("schema", {}).get("$ref") - if not ref: - raise SchemaMissing( - 'Request or response model is missing from "schema/$ref" section' - ) - if not ref.startswith("#/components/schemas/"): - raise SchemaMissing( - "Request and response models must be defined in the" - '"#/components/schemas/" section' - ) - model_name = ref.split("/")[-1] - if not components_schema.get(model_name): - raise SchemaMissing(f"Component schema is missing for {model_name}") - - -def validate_spec(spec: dict): - """ - Validate that OpenAPI spec looks like a data product definition. For example, that - it only has one POST method defined. - - :param spec: OpenAPI spec - :raises OpenApiValidationError: When OpenAPI spec is incorrect - """ - if "servers" in spec: - raise ServersShouldNotBeDefined('"servers" section found') - - paths = spec.get("paths", {}) - if not paths: - raise NoEndpointsDefined - if len(paths) > 1: - raise OnlyOneEndpointAllowed - - post_route = {} - for name, path in paths.items(): - methods = list(path) - if "post" not in methods: - raise PostMethodIsMissing - if methods != ["post"]: - raise OnlyPostMethodAllowed - post_route = path["post"] - - component_schemas = spec.get("components", {}).get("schemas") - if not component_schemas: - raise SchemaMissing('No "components/schemas" section defined') - - if "security" in post_route: - raise SecurityShouldNotBeDefined('"security" section found') - - if post_route.get("requestBody", {}).get("content"): - validate_component_schema(post_route["requestBody"], component_schemas) - - responses = post_route.get("responses", {}) - if not responses.get("200") or not responses["200"].get("content"): - raise ResponseBodyMissing - validate_component_schema(responses["200"], component_schemas) - - headers = [ - param.get("name", "").lower() - for param in post_route.get("parameters", []) - if param.get("in") == "header" - ] - if "authorization" not in headers: - raise AuthorizationHeaderMissing - if "x-authorization-provider" not in headers: - raise AuthProviderHeaderMissing - - -class IhanStandardsValidator(BaseValidator): - def validate_spec(self, spec: dict): - # just to reduce indentation - return validate_spec(spec) diff --git a/poetry.lock b/poetry.lock index af179de..40e3a30 100644 --- a/poetry.lock +++ b/poetry.lock @@ -94,13 +94,13 @@ uvloop = ["uvloop (>=0.15.2)"] [[package]] name = "certifi" -version = "2022.12.7" +version = "2023.7.22" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.6" files = [ - {file = "certifi-2022.12.7-py3-none-any.whl", hash = "sha256:4ad3232f5e926d6718ec31cfc1fcadfde020920e278684144551c91769c7bc18"}, - {file = "certifi-2022.12.7.tar.gz", hash = "sha256:35824b4c3a97115964b408844d64aa14db1cc518f6562e8d7261699d1350a9e3"}, + {file = "certifi-2023.7.22-py3-none-any.whl", hash = "sha256:92d6037539857d8206b8f6ae472e8b77db8058fec5937a1ef3f54304089edbb9"}, + {file = "certifi-2023.7.22.tar.gz", hash = "sha256:539cc1d13202e33ca466e88b2807e29f4c13049d6d87031a3c110744495cb082"}, ] [[package]] diff --git a/pyproject.toml b/pyproject.toml index 41530c7..5240e39 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "openapi-to-fastapi" -version = "0.11.2" +version = "0.12.0" description = "Create FastAPI routes from OpenAPI spec" authors = ["IOXIO Ltd"] license = "BSD-3-Clause"