diff --git a/CHANGELOG.md b/CHANGELOG.md index 38bed40483..601cdab211 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +# 38.0.0 [#989](https://github.com/openfisca/openfisca-core/pull/989) + +#### New Features + +- Upgrade OpenAPI specification of the API to v3 from Swagger v2. +- Continuously validate OpenAPI specification. + +#### Breaking changes + +- Drop support for OpenAPI specification v2 and prior. + - Users relying on OpenAPI v2 can use [Swagger Converter](https://converter.swagger.io/api/convert?url=OAS2_YAML_OR_JSON_URL) to migrate ([example](https://web.archive.org/web/20221103230822/https://converter.swagger.io/api/convert?url=https://api.demo.openfisca.org/latest/spec)). + ### 37.0.2 [#1170](https://github.com/openfisca/openfisca-core/pull/1170) #### Technical changes diff --git a/openfisca_web_api/app.py b/openfisca_web_api/app.py index e2244e9ba2..2117fdb631 100644 --- a/openfisca_web_api/app.py +++ b/openfisca_web_api/app.py @@ -118,10 +118,13 @@ def get_entities(): @app.route('/spec') def get_spec(): + scheme = request.environ["wsgi.url_scheme"] + host = request.host + url = f"{scheme}://{host}" + return jsonify({ - **data['openAPI_spec'], - **{'host': request.host}, - **{'schemes': [request.environ['wsgi.url_scheme']]} + **data["openAPI_spec"], + **{"servers": [{"url": url}]}, }) def handle_invalid_json(error): diff --git a/openfisca_web_api/loader/spec.py b/openfisca_web_api/loader/spec.py index 92d617c057..d36fd53aa6 100644 --- a/openfisca_web_api/loader/spec.py +++ b/openfisca_web_api/loader/spec.py @@ -18,18 +18,19 @@ def build_openAPI_specification(api_data): file = open(OPEN_API_CONFIG_FILE, 'r') spec = yaml.safe_load(file) country_package_name = api_data['country_package_metadata']['name'].title() + country_package_version = api_data['country_package_metadata']['version'] dpath.util.new(spec, 'info/title', spec['info']['title'].replace("{COUNTRY_PACKAGE_NAME}", country_package_name)) dpath.util.new(spec, 'info/description', spec['info']['description'].replace("{COUNTRY_PACKAGE_NAME}", country_package_name)) - dpath.util.new(spec, 'info/version', api_data['country_package_metadata']['version']) + dpath.util.new(spec, 'info/version', spec['info']['version'].replace("{COUNTRY_PACKAGE_VERSION}", country_package_version)) for entity in tax_benefit_system.entities: name = entity.key.title() - spec['definitions'][name] = get_entity_json_schema(entity, tax_benefit_system) + spec['components']['schemas'][name] = get_entity_json_schema(entity, tax_benefit_system) situation_schema = get_situation_json_schema(tax_benefit_system) - dpath.util.new(spec, 'definitions/SituationInput', situation_schema) - dpath.util.new(spec, 'definitions/SituationOutput', situation_schema.copy()) - dpath.util.new(spec, 'definitions/Trace/properties/entitiesDescription/properties', { + dpath.util.new(spec, 'components/schemas/SituationInput', situation_schema) + dpath.util.new(spec, 'components/schemas/SituationOutput', situation_schema.copy()) + dpath.util.new(spec, 'components/schemas/Trace/properties/entitiesDescription/properties', { entity.plural: {'type': 'array', 'items': {"type": "string"}} for entity in tax_benefit_system.entities }) @@ -42,24 +43,24 @@ def build_openAPI_specification(api_data): parameter_example = api_data['parameters'][parameter_path] else: parameter_example = next(iter(api_data['parameters'].values())) - dpath.util.new(spec, 'definitions/Parameter/example', parameter_example) + dpath.util.new(spec, 'components/schemas/Parameter/example', parameter_example) if tax_benefit_system.open_api_config.get('variable_example'): variable_example = api_data['variables'][tax_benefit_system.open_api_config['variable_example']] else: variable_example = next(iter(api_data['variables'].values())) - dpath.util.new(spec, 'definitions/Variable/example', variable_example) + dpath.util.new(spec, 'components/schemas/Variable/example', variable_example) if tax_benefit_system.open_api_config.get('simulation_example'): simulation_example = tax_benefit_system.open_api_config['simulation_example'] - dpath.util.new(spec, 'definitions/SituationInput/example', simulation_example) - dpath.util.new(spec, 'definitions/SituationOutput/example', handlers.calculate(tax_benefit_system, deepcopy(simulation_example))) # calculate has side-effects - dpath.util.new(spec, 'definitions/Trace/example', handlers.trace(tax_benefit_system, simulation_example)) + dpath.util.new(spec, 'components/schemas/SituationInput/example', simulation_example) + dpath.util.new(spec, 'components/schemas/SituationOutput/example', handlers.calculate(tax_benefit_system, deepcopy(simulation_example))) # calculate has side-effects + dpath.util.new(spec, 'components/schemas/Trace/example', handlers.trace(tax_benefit_system, simulation_example)) else: message = "No simulation example has been defined for this tax and benefit system. If you are the maintainer of {}, you can define an example by following this documentation: https://openfisca.org/doc/openfisca-web-api/config-openapi.html".format(country_package_name) - dpath.util.new(spec, 'definitions/SituationInput/example', message) - dpath.util.new(spec, 'definitions/SituationOutput/example', message) - dpath.util.new(spec, 'definitions/Trace/example', message) + dpath.util.new(spec, 'components/schemas/SituationInput/example', message) + dpath.util.new(spec, 'components/schemas/SituationOutput/example', message) + dpath.util.new(spec, 'components/schemas/Trace/example', message) return spec @@ -115,7 +116,7 @@ def get_situation_json_schema(tax_benefit_system): entity.plural: { 'type': 'object', 'additionalProperties': { - "$ref": "#/definitions/{}".format(entity.key.title()) + "$ref": "#/components/schemas/{}".format(entity.key.title()) } } for entity in tax_benefit_system.entities diff --git a/openfisca_web_api/openAPI.yml b/openfisca_web_api/openAPI.yml index d0c52f9a14..5eb1d8cf5c 100644 --- a/openfisca_web_api/openAPI.yml +++ b/openfisca_web_api/openAPI.yml @@ -1,374 +1,434 @@ -swagger: "2.0" +openapi: "3.1.0" + info: title: "{COUNTRY_PACKAGE_NAME} Web API" description: "The OpenFisca Web API lets you get up-to-date information and formulas included in the {COUNTRY_PACKAGE_NAME} legislation." - version: null + version: "{COUNTRY_PACKAGE_VERSION}" termsOfService: "https://openfisca.org/doc/licence.html" contact: email: "contact@openfisca.org" license: name: "AGPL" url: "https://www.gnu.org/licenses/agpl-3.0" -host: null -schemes: null + tags: - name: "Parameters" description: "A parameter is a numeric property of the legislation that can evolve over time." externalDocs: description: "Parameters documentation" url: "https://openfisca.org/doc/key-concepts/parameters.html" + - name: "Variables" description: "A variable depends on a person, or an entity (e.g. zip code, salary, income tax)." externalDocs: description: "Variables documentation" url: "https://openfisca.org/doc/key-concepts/variables.html" + - name: "Entities" description: "An entity is a person of a group of individuals (such as a household)." externalDocs: description: "Entities documentation" url: "https://openfisca.org/doc/key-concepts/person,_entities,_role.html" + - name: "Calculations" + - name: "Documentation" + +components: + schemas: + Parameter: + type: "object" + properties: + values: + $ref: "#/components/schemas/Values" + brackets: + type: "object" + additionalProperties: + $ref: "#/components/schemas/Brackets" + subparams: + type: "object" + additionalProperties: + type: "object" + properties: + definition: + type: "string" + metadata: + type: "object" + description: + type: "string" + id: + type: "integer" + format: "string" + source: + type: "string" + + Parameters: + type: "object" + additionalProperties: + type: "object" + properties: + description: + type: "string" + href: + type: "string" + + Variable: + type: "object" + properties: + defaultValue: + type: "string" + definitionPeriod: + type: "string" + enum: + - "MONTH" + - "YEAR" + - "ETERNITY" + description: + type: "string" + entity: + type: "string" + formulas: + type: "object" + additionalProperties: + $ref: "#/components/schemas/Formula" + id: + type: "string" + reference: + type: "array" + items: + type: "string" + source: + type: "string" + valueType: + type: "string" + enum: + - "Int" + - "Float" + - "Boolean" + - "Date" + - "String" + + Variables: + type: "object" + additionalProperties: + type: "object" + properties: + description: + type: "string" + href: + type: "string" + + Formula: + type: "object" + properties: + content: + type: "string" + source: + type: "string" + + Brackets: + type: "object" + additionalProperties: + type: "number" + format: "float" + + Values: + description: "All keys are ISO dates. Values can be numbers, booleans, or arrays of a single type (number, boolean or string)." + type: "object" + additionalProperties: + $ref: "#/components/schemas/Value" + propertyNames: # this keyword is part of JSON Schema but is not supported in OpenAPI Specification at the time of writing, see https://swagger.io/docs/specification/data-models/keywords/#unsupported + pattern: "^[12][0-9]{3}-[01][0-9]-[0-3][0-9]$" # all keys are ISO dates + + Value: + oneOf: + - type: "boolean" + - type: "number" + format: "float" + - type: "array" + items: + oneOf: + - type: "string" + - type: "number" + + Entities: + type: "object" + properties: + description: + type: "string" + documentation: + type: "string" + plural: + type: "string" + roles: + type: "object" + additionalProperties: + $ref: "#/components/schemas/Roles" + + Roles: + type: "object" + properties: + description: + type: "string" + max: + type: "integer" + plural: + type: "string" + + Trace: + type: "object" + properties: + requestedCalculations: + type: "array" + items: + type: "string" + entitiesDescription: + type: "object" + additionalProperties: false # Will be dynamically added by the Web API + trace: + type: "object" + additionalProperties: + type: "object" + properties: + value: + type: "array" + items: {} + dependencies: + type: "array" + items: + type: "string" + parameters: + type: "object" + additionalProperties: + type: "object" + + headers: + Country-Package: + description: "The name of the country package currently loaded in this API server" + schema: + type: "string" + + Country-Package-Version: + description: "The version of the country package currently loaded in this API server" + schema: + type: "string" + pattern: "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(?:-((?:0|[1-9][0-9]*|[0-9]*[a-zA-Z-][0-9a-zA-Z-]*)(?:\\.(?:0|[1-9][0-9]*|[0-9]*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\\+([0-9a-zA-Z-]+(?:\\.[0-9a-zA-Z-]+)*))?$" # adapted from https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string + paths: /calculate: post: summary: "Run a simulation" tags: - - Calculations + - "Calculations" operationId: "calculate" - consumes: - - "application/json" - produces: - - "application/json" - parameters: - - in: "body" - name: "Situation" + requestBody: description: "Describe the situation (persons and entities). Add the variable you wish to calculate in the proper entity, with null as the value. Learn more in our official documentation: https://openfisca.org/doc/openfisca-web-api/input-output-data.html" required: true - schema: - $ref: "#/definitions/SituationInput" + content: + application/json: + schema: + $ref: "#/components/schemas/SituationInput" responses: 200: description: "The calculation result is sent back in the response body" + content: + application/json: + schema: + $ref: "#/components/schemas/SituationOutput" headers: - $ref: "#/commons/Headers" - schema: - $ref: "#/definitions/SituationOutput" + Country-Package: + $ref: "#/components/headers/Country-Package" + Country-Package-Version: + $ref: "#/components/headers/Country-Package-Version" 404: description: "A variable mentioned in the input situation does not exist in the loaded tax and benefit system. Details are sent back in the response body" headers: - $ref: "#/commons/Headers" + Country-Package: + $ref: "#/components/headers/Country-Package" + Country-Package-Version: + $ref: "#/components/headers/Country-Package-Version" 400: description: "The request is invalid. Details about the error are sent back in the response body" headers: - $ref: "#/commons/Headers" + Country-Package: + $ref: "#/components/headers/Country-Package" + Country-Package-Version: + $ref: "#/components/headers/Country-Package-Version" + /parameters: get: tags: - "Parameters" summary: "List all available parameters" operationId: "getParameters" - produces: - - "application/json" responses: 200: description: "The list of parameters is sent back in the response body" + content: + application/json: + schema: + $ref: "#/components/schemas/Parameters" headers: - $ref: "#/commons/Headers" - schema: - $ref: "#/definitions/Parameters" + Country-Package: + $ref: "#/components/headers/Country-Package" + Country-Package-Version: + $ref: "#/components/headers/Country-Package-Version" + /parameter/{parameterID}: get: tags: - "Parameters" summary: "Get information about a specific parameter" operationId: "getParameter" - produces: - - "application/json" parameters: - name: "parameterID" in: "path" description: "ID of parameter. IDs can be obtained by enumerating the /parameters endpoint" required: true - type: "string" + schema: + type: "string" responses: 200: description: "The requested parameter's information is sent back in the response body" + content: + application/json: + schema: + $ref: "#/components/schemas/Parameter" headers: - $ref: "#/commons/Headers" - schema: - $ref: "#/definitions/Parameter" + Country-Package: + $ref: "#/components/headers/Country-Package" + Country-Package-Version: + $ref: "#/components/headers/Country-Package-Version" 404: description: "The requested parameter does not exist" headers: - $ref: "#/commons/Headers" + Country-Package: + $ref: "#/components/headers/Country-Package" + Country-Package-Version: + $ref: "#/components/headers/Country-Package-Version" + /variables: get: tags: - "Variables" summary: "List all available variables" operationId: "getVariables" - produces: - - "application/json" responses: 200: description: "The list of variables is sent back in the response body" + content: + application/json: + schema: + $ref: "#/components/schemas/Variables" headers: - $ref: "#/commons/Headers" - schema: - $ref: "#/definitions/Variables" + Country-Package: + $ref: "#/components/headers/Country-Package" + Country-Package-Version: + $ref: "#/components/headers/Country-Package-Version" + /variable/{variableID}: get: tags: - "Variables" summary: "Get information about a specific variable" operationId: "getVariable" - produces: - - "application/json" parameters: - name: "variableID" in: "path" description: "ID of a variable. IDs can be obtained by enumerating the /variables endpoint." required: true - type: "string" + schema: + type: "string" responses: 200: description: "The requested variable's information is sent back in the response body" + content: + application/json: + schema: + $ref: "#/components/schemas/Variable" headers: - $ref: "#/commons/Headers" - schema: - $ref: "#/definitions/Variable" + Country-Package: + $ref: "#/components/headers/Country-Package" + Country-Package-Version: + $ref: "#/components/headers/Country-Package-Version" 404: description: "The requested variable does not exist" headers: - $ref: "#/commons/Headers" + Country-Package: + $ref: "#/components/headers/Country-Package" + Country-Package-Version: + $ref: "#/components/headers/Country-Package-Version" + /entities: get: tags: - "Entities" summary: "List all available Entities" - operationId: "getVariables" - produces: - - "application/json" + operationId: "getEntities" responses: 200: description: "The list of the entities as well as their information is sent back in the response body" + content: + application/json: + schema: + $ref: "#/components/schemas/Entities" headers: - $ref: "#/commons/Headers" - schema: - $ref: "#/definitions/Entities" + Country-Package: + $ref: "#/components/headers/Country-Package" + Country-Package-Version: + $ref: "#/components/headers/Country-Package-Version" + /trace: post: summary: "Explore a simulation's steps in details." tags: - - Calculations + - "Calculations" operationId: "trace" - consumes: - - "application/json" - produces: - - "application/json" - parameters: - - in: "body" - name: "Situation" + requestBody: description: "Describe the situation (persons and entities). Add the variable you wish to calculate in the proper entity, with null as the value." required: true - schema: - $ref: "#/definitions/SituationInput" + content: + application/json: + schema: + $ref: "#/components/schemas/SituationInput" responses: 200: description: "The calculation details are sent back in the response body" + content: + application/json: + schema: + $ref: "#/components/schemas/Trace" headers: - $ref: "#/commons/Headers" - schema: - $ref: "#/definitions/Trace" + Country-Package: + $ref: "#/components/headers/Country-Package" + Country-Package-Version: + $ref: "#/components/headers/Country-Package-Version" 404: description: "A variable mentioned in the input situation does not exist in the loaded tax and benefit system. Details are sent back in the response body" headers: - $ref: "#/commons/Headers" + Country-Package: + $ref: "#/components/headers/Country-Package" + Country-Package-Version: + $ref: "#/components/headers/Country-Package-Version" 400: description: "The request is invalid. Details about the error are sent back in the response body" headers: - $ref: "#/commons/Headers" + Country-Package: + $ref: "#/components/headers/Country-Package" + Country-Package-Version: + $ref: "#/components/headers/Country-Package-Version" + /spec: get: - summary: Provide the API documentation in an OpenAPI format + summary: "Provide the API documentation in an OpenAPI format" tags: - - Documentation - operationId: spec - produces: - - application/json + - "Documentation" + operationId: "spec" responses: 200: - description: The API documentation is sent back in the response body + description: "The API documentation is sent back in the response body" headers: - $ref: "#/commons/Headers" - -definitions: - Parameter: - type: "object" - properties: - values: - $ref: "#/definitions/Values" - brackets: - type: "object" - additionalProperties: - $ref: "#/definitions/Brackets" - subparams: - type: "object" - additionalProperties: - type: "object" - properties: - definition: - type: "string" - metadata: - type: "object" - description: - type: "string" - id: - type: "integer" - format: "string" - source: - type: "string" - example: null - - Parameters: - type: "object" - additionalProperties: - type: "object" - properties: - description: - type: "string" - href: - type: "string" - - Variable: - type: "object" - properties: - defaultValue: - type: "string" - definitionPeriod: - type: string - enum: - - MONTH - - YEAR - - ETERNITY - description: - type: "string" - entity: - type: "string" - formulas: - type: "object" - additionalProperties: - $ref: "#/definitions/Formula" - id: - type: "string" - reference: - type: "array" - items: - type: "string" - source: - type: "string" - valueType: - type: "string" - enum: - - Int - - Float - - Boolean - - Date - - String - example: null - - Variables: - type: "object" - additionalProperties: - type: "object" - properties: - description: - type: "string" - href: - type: "string" - - Formula: - type: "object" - properties: - content: - type: "string" - source: - type: "string" - - Brackets: - type: "object" - additionalProperties: - type: "number" - format: "float" - - Values: - description: All keys are ISO dates. Values can be numbers, booleans, or arrays of a single type (number, boolean or string). - type: "object" - additionalProperties: true -# propertyNames: # this keyword is part of JSON Schema but is not supported in OpenAPI Specification at the time of writing, see https://swagger.io/docs/specification/data-models/keywords/#unsupported -# pattern: "^[12][0-9]{3}-[01][0-9]-[0-3][0-9]$" # all keys are ISO dates - - Entities: - type: "object" - properties: - description: - type: "string" - documentation: - type: "string" - plural: - type: "string" - roles: - type: "object" - additionalProperties: - $ref: "#/definitions/Roles" - Roles: - type: "object" - properties: - description: - type: "string" - max: - type: "integer" - plural: - type: "string" - SituationInput: null - SituationOutput: null - - Trace: - type: object - properties: - requestedCalculations: - type: array - items: - type: string - entitiesDescription: - type: object - properties: null # Will be dynamically added by the Web API - trace: - type: object - additionalProperties: - type: object - properties: - value: - type: array - items: - type: any - dependencies: - type: array - items: - type: string - parameters: - type: object - additionalProperties: - type: object - - example: null - -commons: - Headers: - Country-Package: - description: "The name of the country package currently loaded in this API server" - type: "string" - Country-Package-Version: - description: "The version of the country package currently loaded in this API server" - type: "string" + Country-Package: + $ref: "#/components/headers/Country-Package" + Country-Package-Version: + $ref: "#/components/headers/Country-Package-Version" diff --git a/setup.py b/setup.py index 3726a3bf4a..2543121ac8 100644 --- a/setup.py +++ b/setup.py @@ -55,6 +55,7 @@ 'flake8-print >= 3.1.0, < 4.0.0', 'flake8-rst-docstrings == 0.2.3', 'mypy == 0.910', + 'openapi-spec-validator >= 0.3.0', 'pycodestyle >= 2.8.0, < 2.9.0', 'pylint == 2.10.2', 'xdoctest >= 1.0.0, < 2.0.0', @@ -62,7 +63,7 @@ setup( name = 'OpenFisca-Core', - version = '37.0.2', + version = '38.0.0', author = 'OpenFisca Team', author_email = 'contact@openfisca.org', classifiers = [ diff --git a/tests/web_api/test_spec.py b/tests/web_api/test_spec.py index 24668dac17..4c34532b73 100644 --- a/tests/web_api/test_spec.py +++ b/tests/web_api/test_spec.py @@ -1,8 +1,10 @@ import dpath.util import json -import pytest from http import client +from openapi_spec_validator import openapi_v3_spec_validator +import pytest + def assert_items_equal(x, y): assert sorted(x) == sorted(y) @@ -34,22 +36,22 @@ def test_paths(body): def test_entity_definition(body): - assert 'parents' in dpath.util.get(body, 'definitions/Household/properties') - assert 'children' in dpath.util.get(body, 'definitions/Household/properties') - assert 'salary' in dpath.util.get(body, 'definitions/Person/properties') - assert 'rent' in dpath.util.get(body, 'definitions/Household/properties') - assert 'number' == dpath.util.get(body, 'definitions/Person/properties/salary/additionalProperties/type') + assert 'parents' in dpath.util.get(body, 'components/schemas/Household/properties') + assert 'children' in dpath.util.get(body, 'components/schemas/Household/properties') + assert 'salary' in dpath.util.get(body, 'components/schemas/Person/properties') + assert 'rent' in dpath.util.get(body, 'components/schemas/Household/properties') + assert 'number' == dpath.util.get(body, 'components/schemas/Person/properties/salary/additionalProperties/type') def test_situation_definition(body): - situation_input = body['definitions']['SituationInput'] - situation_output = body['definitions']['SituationOutput'] + situation_input = body['components']['schemas']['SituationInput'] + situation_output = body['components']['schemas']['SituationOutput'] for situation in situation_input, situation_output: assert 'households' in dpath.util.get(situation, '/properties') assert 'persons' in dpath.util.get(situation, '/properties') - assert "#/definitions/Household" == dpath.util.get(situation, '/properties/households/additionalProperties/$ref') - assert "#/definitions/Person" == dpath.util.get(situation, '/properties/persons/additionalProperties/$ref') + assert "#/components/schemas/Household" == dpath.util.get(situation, '/properties/households/additionalProperties/$ref') + assert "#/components/schemas/Person" == dpath.util.get(situation, '/properties/persons/additionalProperties/$ref') -def test_host(body): - assert 'http' not in body['host'] +def test_respects_spec(body): + assert not [error for error in openapi_v3_spec_validator.iter_errors(body)]