diff --git a/.github/workflows/code-quality.yaml b/.github/workflows/code-quality.yaml index 02ea7471..e5efaaf3 100644 --- a/.github/workflows/code-quality.yaml +++ b/.github/workflows/code-quality.yaml @@ -86,7 +86,7 @@ jobs: poetry install - name: Run static code analyser run: | - ${{ matrix.command }} + ${{ matrix.command }} || (poetry install && ${{ matrix.command }}) staticNode: runs-on: ubuntu-latest @@ -94,7 +94,7 @@ jobs: matrix: command: - npx cspell "open_alchemy/**/*.py" "open_alchemy/**/*.json" "open_alchemy/**/*.j2" "docs/**/*.rst" "docs/**/*/yml" "docs/**/*.yaml" "tests/**/*.py" "*.yaml" "*.json" "*.yml" "examples/**/*.py" "examples/**/*.yaml" "examples/**/*.yml" - - find examples -name "*spec.yml" ! -path "*/remote/*" | xargs -n 1 sh -c 'npx swagger-cli validate $0 || exit 255' + - find examples -name "*spec.yml" ! -path "*/remote/*" ! -path "*/openapi-3-1/*" | xargs -n 1 sh -c 'npx swagger-cli validate $0 || exit 255' steps: - uses: actions/checkout@v2 - name: Set up Node @@ -155,7 +155,7 @@ jobs: - name: Build the documentation run: | cd docs - poetry run make html + poetry run make html || (poetry install && poetry run make html) - name: Upload documentation for release if: startsWith(github.ref, 'refs/tags/') uses: actions/upload-artifact@v2.2.2 @@ -271,21 +271,19 @@ jobs: shell: bash - uses: actions/checkout@v2 - name: Get latest Changelog Entry - id: changelog_entry + id: changelog_reader uses: mindsers/changelog-reader-action@v2 with: version: v${{ steps.tag_name.outputs.current_version }} path: ./CHANGELOG.md - - name: Retrieve packages - uses: actions/download-artifact@v2.0.8 - with: - name: wheel - path: dist/ - - name: Publish the release - uses: softprops/action-gh-release@v1 + - name: Create Release + id: create_release + uses: actions/create-release@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: - body: ${{ steps.changelog_entry.outputs.log_entry }} - files: | - dist/* + tag_name: ${{ steps.changelog_reader.outputs.version }} + release_name: Release ${{ steps.changelog_reader.outputs.version }} + body: ${{ steps.changelog_reader.outputs.changes }} + prerelease: ${{ steps.changelog_reader.outputs.status == 'prereleased' }} + draft: ${{ steps.changelog_reader.outputs.status == 'unreleased' }} diff --git a/CHANGELOG.md b/CHANGELOG.md index c96d879a..ea56ba87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## [v2.2.0] - 2021-01-23 +### Added + +- Add support for OpenAPI 3.1. [#276] + +## [2.2.0] - 2021-01-23 ### Fixed @@ -17,27 +21,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Caching validation results to speed up startup. [#251] -## [v2.1.0] - 2020-12-20 +## [2.1.0] - 2020-12-20 ### Added - Add support for namespaced `x-open-alchemy-` prefix on top of the shorter `x-` prefix for extension properties. [#236] -## [v2.0.2] - 2020-12-19 +## [2.0.2] - 2020-12-19 ### Changed - Changed from `setup.py` to poetry -## [v2.0.1] - 2020-12-08 +## [2.0.1] - 2020-12-08 ### Added - Add version, title and description (if defined) into the JSON OpenAPI specification stored with the package generated by the build module. -## [v2.0.0] - 2020-11-15 +## [2.0.0] - 2020-11-15 ### Added @@ -79,7 +83,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `True`. _This means that a pure model reference (a schema with only the `$ref` key) can no longer be used to change the name of a model._ [#189] -## [v1.6.0] - 2020-10-10 +## [1.6.0] - 2020-10-10 ### Added @@ -94,7 +98,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 directory. - Drop support for Python 3.6 and add support for Python 3.9. [#198] -## [v1.5.4] - 2020-08-30 +## [1.5.4] - 2020-08-30 ### Changed @@ -106,20 +110,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Correct `format` key to no longer have a trailing `_` for artifacts. -## [v1.5.2] - 2020-08-29 +## [1.5.2] - 2020-08-29 ### Changed - Expose function that collects artifacts for the models. - Expose function that collects artifacts for the model properties. -## [v1.5.1] - 2020-08-23 +## [1.5.1] - 2020-08-23 ### Added - Add support for arbitrary mix in classes. -## [v1.5.0] - 2020-08-22 +## [1.5.0] - 2020-08-22 ### Added @@ -135,13 +139,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Change schema validation to process properties even if the model is not valid. -## [v1.4.3] - 2020-08-16 +## [1.4.3] - 2020-08-16 ### Removed - Remove dependency on black -## [v1.4.2] - 2020-08-16 +## [1.4.2] - 2020-08-16 ### Fixed @@ -149,13 +153,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 exceptions - Add black dependency back in -## [v1.4.1] - 2020-08-09 +## [1.4.1] - 2020-08-09 ### Removed - Remove black dependency -## [v1.4.0] - 2020-08-09 +## [1.4.0] - 2020-08-09 ### Added @@ -480,21 +484,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [1.1.1]: https://github.com/jdkandersson/OpenAlchemy/releases/1.1.1 [1.2.0]: https://github.com/jdkandersson/OpenAlchemy/releases/1.2.0 [1.3.0]: https://github.com/jdkandersson/OpenAlchemy/releases/1.3.0 -[v1.4.0]: https://github.com/jdkandersson/OpenAlchemy/releases/1.4.0 -[v1.4.1]: https://github.com/jdkandersson/OpenAlchemy/releases/1.4.1 -[v1.4.2]: https://github.com/jdkandersson/OpenAlchemy/releases/1.4.2 -[v1.4.3]: https://github.com/jdkandersson/OpenAlchemy/releases/1.4.3 -[v1.5.0]: https://github.com/jdkandersson/OpenAlchemy/releases/1.5.0 -[v1.5.1]: https://github.com/jdkandersson/OpenAlchemy/releases/1.5.1 -[v1.5.2]: https://github.com/jdkandersson/OpenAlchemy/releases/1.5.2 -[v1.5.3]: https://github.com/jdkandersson/OpenAlchemy/releases/1.5.3 -[v1.5.4]: https://github.com/jdkandersson/OpenAlchemy/releases/1.5.4 -[v1.6.0]: https://github.com/jdkandersson/OpenAlchemy/releases/1.6.0 -[v2.0.0]: https://github.com/jdkandersson/OpenAlchemy/releases/2.0.0 -[v2.0.1]: https://github.com/jdkandersson/OpenAlchemy/releases/2.0.1 -[v2.0.2]: https://github.com/jdkandersson/OpenAlchemy/releases/2.0.2 -[v2.1.0]: https://github.com/jdkandersson/OpenAlchemy/releases/2.1.0 -[v2.2.0]: https://github.com/jdkandersson/OpenAlchemy/releases/2.2.0 +[1.4.0]: https://github.com/jdkandersson/OpenAlchemy/releases/1.4.0 +[1.4.1]: https://github.com/jdkandersson/OpenAlchemy/releases/1.4.1 +[1.4.2]: https://github.com/jdkandersson/OpenAlchemy/releases/1.4.2 +[1.4.3]: https://github.com/jdkandersson/OpenAlchemy/releases/1.4.3 +[1.5.0]: https://github.com/jdkandersson/OpenAlchemy/releases/1.5.0 +[1.5.1]: https://github.com/jdkandersson/OpenAlchemy/releases/1.5.1 +[1.5.2]: https://github.com/jdkandersson/OpenAlchemy/releases/1.5.2 +[1.5.3]: https://github.com/jdkandersson/OpenAlchemy/releases/1.5.3 +[1.5.4]: https://github.com/jdkandersson/OpenAlchemy/releases/1.5.4 +[1.6.0]: https://github.com/jdkandersson/OpenAlchemy/releases/1.6.0 +[2.0.0]: https://github.com/jdkandersson/OpenAlchemy/releases/2.0.0 +[2.0.1]: https://github.com/jdkandersson/OpenAlchemy/releases/2.0.1 +[2.0.2]: https://github.com/jdkandersson/OpenAlchemy/releases/2.0.2 +[2.1.0]: https://github.com/jdkandersson/OpenAlchemy/releases/2.1.0 +[2.2.0]: https://github.com/jdkandersson/OpenAlchemy/releases/2.2.0 [///]: # "Issue/PR links" [#189]: https://github.com/jdkandersson/OpenAlchemy/issues/189 [#190]: https://github.com/jdkandersson/OpenAlchemy/issues/190 @@ -505,3 +509,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [#236]: https://github.com/jdkandersson/OpenAlchemy/issues/236 [#251]: https://github.com/jdkandersson/OpenAlchemy/issues/251 [#255]: https://github.com/jdkandersson/OpenAlchemy/issues/255 +[#276]: https://github.com/jdkandersson/OpenAlchemy/issues/276 diff --git a/README.md b/README.md index cd0aa5d3..b56a020d 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,8 @@ Translates an OpenAPI schema to SQLAlchemy models. +Supports OpenAPI 3.0 and 3.1. + Get started with the online editor that will guide you through using your existing OpenAPI specification to define your database schema and offers installing your models using `pip`: diff --git a/docs/source/examples/alembic.rst b/docs/source/examples/alembic.rst index 624f3127..a5efe56c 100644 --- a/docs/source/examples/alembic.rst +++ b/docs/source/examples/alembic.rst @@ -12,3 +12,6 @@ models which means that they work with Alembic. `Alembic documentation `_ Documentation for Alembic. + +.. seealso:: + :ref:`getting-started` diff --git a/docs/source/examples/all_of.rst b/docs/source/examples/all_of.rst index dad95fbd..ef295c84 100644 --- a/docs/source/examples/all_of.rst +++ b/docs/source/examples/all_of.rst @@ -66,3 +66,6 @@ OpenAlchemy will generate the following typed models: .. literalinclude:: ../../../examples/all_of/model_models_auto.py :language: python :linenos: + +.. seealso:: + :ref:`getting-started` diff --git a/docs/source/examples/composite_index.rst b/docs/source/examples/composite_index.rst index 71770a42..5d67decc 100644 --- a/docs/source/examples/composite_index.rst +++ b/docs/source/examples/composite_index.rst @@ -36,3 +36,6 @@ OpenAlchemy will generate the following typed models: .. literalinclude:: ../../../examples/composite_index/models_auto.py :language: python :linenos: + +.. seealso:: + :ref:`getting-started` diff --git a/docs/source/examples/composite_unique.rst b/docs/source/examples/composite_unique.rst index 07233172..4bf2c488 100644 --- a/docs/source/examples/composite_unique.rst +++ b/docs/source/examples/composite_unique.rst @@ -36,3 +36,6 @@ OpenAlchemy will generate the following typed models: .. literalinclude:: ../../../examples/composite_unique/models_auto.py :language: python :linenos: + +.. seealso:: + :ref:`getting-started` diff --git a/docs/source/examples/connexion.rst b/docs/source/examples/connexion.rst index 9ff9f278..b2bfb257 100644 --- a/docs/source/examples/connexion.rst +++ b/docs/source/examples/connexion.rst @@ -67,3 +67,6 @@ The duplication of the data schema has been reduced by defining the SQLAlchemy models based on the OpenAPI specification. This means that, to change the database schema, the OpenAPI specification has to be updated and vice-versa. This ensures that the two are always in synch and up to date. + +.. seealso:: + :ref:`getting-started` diff --git a/docs/source/examples/default.rst b/docs/source/examples/default.rst index cadb702d..29885c01 100644 --- a/docs/source/examples/default.rst +++ b/docs/source/examples/default.rst @@ -37,3 +37,6 @@ OpenAlchemy will generate the following typed models: .. literalinclude:: ../../../examples/default/models_auto.py :language: python :linenos: + +.. seealso:: + :ref:`getting-started` diff --git a/docs/source/examples/index.rst b/docs/source/examples/index.rst index 6683c592..d59eddc8 100644 --- a/docs/source/examples/index.rst +++ b/docs/source/examples/index.rst @@ -8,6 +8,7 @@ Examples connexion alembic simple + nullable namespaced default server_default diff --git a/docs/source/examples/inheritance.rst b/docs/source/examples/inheritance.rst index 9428b414..81effd83 100644 --- a/docs/source/examples/inheritance.rst +++ b/docs/source/examples/inheritance.rst @@ -88,3 +88,6 @@ OpenAlchemy will generate the following typed models: .. literalinclude:: ../../../examples/inheritance/single_models_auto.py :language: python :linenos: + +.. seealso:: + :ref:`getting-started` diff --git a/docs/source/examples/json.rst b/docs/source/examples/json.rst index 5d6d26c3..6f7aa07c 100644 --- a/docs/source/examples/json.rst +++ b/docs/source/examples/json.rst @@ -37,3 +37,6 @@ OpenAlchemy will generate the following typed models: .. literalinclude:: ../../../examples/json/models_auto.py :language: python :linenos: + +.. seealso:: + :ref:`getting-started` diff --git a/docs/source/examples/mixins.rst b/docs/source/examples/mixins.rst index 218886f1..91c2ff97 100644 --- a/docs/source/examples/mixins.rst +++ b/docs/source/examples/mixins.rst @@ -35,3 +35,6 @@ OpenAlchemy will generate the following typed models: .. literalinclude:: ../../../examples/mixins/models_auto.py :language: python :linenos: + +.. seealso:: + :ref:`getting-started` diff --git a/docs/source/examples/namespaced.rst b/docs/source/examples/namespaced.rst index b5bf8ad5..2e25d248 100644 --- a/docs/source/examples/namespaced.rst +++ b/docs/source/examples/namespaced.rst @@ -31,3 +31,6 @@ SQLAlchemy models: .. literalinclude:: ../../../examples/namespaced/models_auto.py :language: python :linenos: + +.. seealso:: + :ref:`getting-started` diff --git a/docs/source/examples/nullable.rst b/docs/source/examples/nullable.rst new file mode 100644 index 00000000..a37dde97 --- /dev/null +++ b/docs/source/examples/nullable.rst @@ -0,0 +1,39 @@ +Nullable +======== + +A property can be set to be nullable using the :samp:`nullable` property for +OpenAPI 3.0: + +.. literalinclude:: ../../../examples/nullable/openapi-3-0/example-spec.yml + :language: yaml + :linenos: + +Or by including :samp:`null` in the :samp:`type` array for OpenAPI 3.1: + +.. literalinclude:: ../../../examples/nullable/openapi-3-1/example-spec.yml + :language: yaml + :linenos: + +The following example models file makes use of the OpenAPI specification to +define the SQLAlchemy models: + +.. literalinclude:: ../../../examples/nullable/openapi-3-1/models.py + :language: python + :linenos: + +This models file instructs OpenAlchemy to construct the SQLAlchemy models +equivalent to the following traditional SQLAlchemy models.py file: + +.. literalinclude:: ../../../examples/nullable/openapi-3-1/models_traditional.py + :language: python + :linenos: + +OpenAlchemy also generates a fully type hinted version of the generated +SQLAlchemy models: + +.. literalinclude:: ../../../examples/nullable/openapi-3-1/models_auto.py + :language: python + :linenos: + +.. seealso:: + :ref:`getting-started` diff --git a/docs/source/examples/read_only.rst b/docs/source/examples/read_only.rst index 6c631908..c0ad66bd 100644 --- a/docs/source/examples/read_only.rst +++ b/docs/source/examples/read_only.rst @@ -35,3 +35,6 @@ OpenAlchemy will generate the following typed models: .. literalinclude:: ../../../examples/read_only/models_auto.py :language: python :linenos: + +.. seealso:: + :ref:`getting-started` diff --git a/docs/source/examples/ref.rst b/docs/source/examples/ref.rst index c09a014d..60ab519d 100644 --- a/docs/source/examples/ref.rst +++ b/docs/source/examples/ref.rst @@ -56,3 +56,6 @@ OpenAlchemy will generate the following typed models: .. literalinclude:: ../../../examples/ref/model_models_auto.py :language: python :linenos: + +.. seealso:: + :ref:`getting-started` diff --git a/docs/source/examples/server_default.rst b/docs/source/examples/server_default.rst index 85373bc3..f326978b 100644 --- a/docs/source/examples/server_default.rst +++ b/docs/source/examples/server_default.rst @@ -42,3 +42,6 @@ OpenAlchemy will generate the following typed models: .. literalinclude:: ../../../examples/server_default/models_auto.py :language: python :linenos: + +.. seealso:: + :ref:`getting-started` diff --git a/docs/source/examples/write_only.rst b/docs/source/examples/write_only.rst index 3a4b9f90..9175f6f4 100644 --- a/docs/source/examples/write_only.rst +++ b/docs/source/examples/write_only.rst @@ -34,3 +34,6 @@ OpenAlchemy will generate the following typed models: .. literalinclude:: ../../../examples/write_only/models_auto.py :language: python :linenos: + +.. seealso:: + :ref:`getting-started` diff --git a/docs/source/technical_details/null.rst b/docs/source/technical_details/null.rst index 5e9b11a4..a32b41b1 100644 --- a/docs/source/technical_details/null.rst +++ b/docs/source/technical_details/null.rst @@ -8,10 +8,11 @@ There are 3 methods used to determine the value of :samp:`nullable` for a :samp:`SQLAlchemy` column. The first is the :samp:`required` property of the schema, the second is whether the column value is generated (using, for example, :samp:`x-autoincrement`) and the third is the :samp:`nullable` -property of an object property. :samp:`nullable` overrides :samp:`required`. -If :samp:`required` would indicate that the column is nullable but the value -is generated, then it is not nullable. The following truth table shows the -logic: +property of an object property or the presence of :samp:`null` if :samp:`type` +is an array (consider these to be equivalent for this discussion). +:samp:`nullable` overrides :samp:`required`. If :samp:`required` would indicate +that the column is nullable but the value is generated, then it is not +nullable. The following truth table shows the logic: +-------------+-----------+-------------------+-----------------+ | required | generated | property nullable | column nullable | diff --git a/docs/source/technical_details/type_mapping.rst b/docs/source/technical_details/type_mapping.rst index 8e4108b7..181b21bf 100644 --- a/docs/source/technical_details/type_mapping.rst +++ b/docs/source/technical_details/type_mapping.rst @@ -34,6 +34,9 @@ following mappings: | :samp:`boolean` | | :samp:`Boolean` | :samp:`bool` | +----------------------+------------------------+-------------------------+---------------------------+ +:samp:`type` as an array is supported, however, exactly one type (other than +:samp:`null`) is required. + String ------ diff --git a/examples/nullable/openapi-3-0/example-spec.yml b/examples/nullable/openapi-3-0/example-spec.yml new file mode 100644 index 00000000..2154b689 --- /dev/null +++ b/examples/nullable/openapi-3-0/example-spec.yml @@ -0,0 +1,52 @@ +openapi: "3.0.0" + +info: + title: Test Schema + description: API to illustrate nullable. + version: "0.1" + +paths: + /employee: + get: + summary: Used to retrieve all employees. + responses: + 200: + description: Return all employees from the database. + content: + application/json: + schema: + type: array + items: + "$ref": "#/components/schemas/Employee" + +components: + schemas: + Employee: + description: Person that works for a company. + type: object + x-tablename: employee + properties: + id: + type: integer + description: Unique identifier for the employee. + example: 0 + x-primary-key: true + x-autoincrement: true + name: + type: string + description: The name of the employee. + example: David Andersson + x-index: true + nullable: true + division: + type: string + description: The part of the company the employee works in. + example: Engineering + x-index: true + salary: + type: number + description: The amount of money the employee is paid. + example: 1000000.00 + required: + - name + - division diff --git a/examples/nullable/openapi-3-0/models.py b/examples/nullable/openapi-3-0/models.py new file mode 100644 index 00000000..e4dd867e --- /dev/null +++ b/examples/nullable/openapi-3-0/models.py @@ -0,0 +1,3 @@ +from open_alchemy import init_yaml + +init_yaml("example-spec.yml", models_filename="models_auto.py") diff --git a/examples/nullable/openapi-3-0/models_auto.py b/examples/nullable/openapi-3-0/models_auto.py new file mode 100644 index 00000000..5d1f3d87 --- /dev/null +++ b/examples/nullable/openapi-3-0/models_auto.py @@ -0,0 +1,127 @@ +"""Autogenerated SQLAlchemy models based on OpenAlchemy models.""" +# pylint: disable=no-member,super-init-not-called,unused-argument + +import typing + +import sqlalchemy +from sqlalchemy import orm + +from open_alchemy import models + +Base = models.Base # type: ignore + + +class _EmployeeDictBase(typing.TypedDict, total=True): + """TypedDict for properties that are required.""" + + name: typing.Optional[str] + division: str + + +class EmployeeDict(_EmployeeDictBase, total=False): + """TypedDict for properties that are not required.""" + + id: int + salary: typing.Optional[float] + + +class TEmployee(typing.Protocol): + """ + SQLAlchemy model protocol. + + Person that works for a company. + + Attrs: + id: Unique identifier for the employee. + name: The name of the employee. + division: The part of the company the employee works in. + salary: The amount of money the employee is paid. + + """ + + # SQLAlchemy properties + __table__: sqlalchemy.Table + __tablename__: str + query: orm.Query + + # Model properties + id: "sqlalchemy.Column[int]" + name: "sqlalchemy.Column[typing.Optional[str]]" + division: "sqlalchemy.Column[str]" + salary: "sqlalchemy.Column[typing.Optional[float]]" + + def __init__( + self, + name: typing.Optional[str], + division: str, + id: typing.Optional[int] = None, + salary: typing.Optional[float] = None, + ) -> None: + """ + Construct. + + Args: + id: Unique identifier for the employee. + name: The name of the employee. + division: The part of the company the employee works in. + salary: The amount of money the employee is paid. + + """ + ... + + @classmethod + def from_dict( + cls, + name: typing.Optional[str], + division: str, + id: typing.Optional[int] = None, + salary: typing.Optional[float] = None, + ) -> "TEmployee": + """ + Construct from a dictionary (eg. a POST payload). + + Args: + id: Unique identifier for the employee. + name: The name of the employee. + division: The part of the company the employee works in. + salary: The amount of money the employee is paid. + + Returns: + Model instance based on the dictionary. + + """ + ... + + @classmethod + def from_str(cls, value: str) -> "TEmployee": + """ + Construct from a JSON string (eg. a POST payload). + + Returns: + Model instance based on the JSON string. + + """ + ... + + def to_dict(self) -> EmployeeDict: + """ + Convert to a dictionary (eg. to send back for a GET request). + + Returns: + Dictionary based on the model instance. + + """ + ... + + def to_str(self) -> str: + """ + Convert to a JSON string (eg. to send back for a GET request). + + Returns: + JSON string based on the model instance. + + """ + ... + + +Employee: typing.Type[TEmployee] = models.Employee # type: ignore diff --git a/examples/nullable/openapi-3-0/models_traditional.py b/examples/nullable/openapi-3-0/models_traditional.py new file mode 100644 index 00000000..f78a4954 --- /dev/null +++ b/examples/nullable/openapi-3-0/models_traditional.py @@ -0,0 +1,14 @@ +import sqlalchemy as sa +from sqlalchemy.ext.declarative import declarative_base + +Base = declarative_base() + + +class Employee(Base): + """Person that works for a company.""" + + __tablename__ = "employee" + id = sa.Column(sa.Integer, primary_key=True, autoincrement=True) + name = sa.Column(sa.String, index=True, nullable=True) + division = sa.Column(sa.String, index=True) + salary = sa.Column(sa.Float) diff --git a/examples/nullable/openapi-3-1/example-spec.yml b/examples/nullable/openapi-3-1/example-spec.yml new file mode 100644 index 00000000..af3de72a --- /dev/null +++ b/examples/nullable/openapi-3-1/example-spec.yml @@ -0,0 +1,51 @@ +openapi: "3.1.0" + +info: + title: Test Schema + description: API to illustrate nullable. + version: "0.1" + +paths: + /employee: + get: + summary: Used to retrieve all employees. + responses: + 200: + description: Return all employees from the database. + content: + application/json: + schema: + type: array + items: + "$ref": "#/components/schemas/Employee" + +components: + schemas: + Employee: + description: Person that works for a company. + type: object + x-tablename: employee + properties: + id: + type: integer + description: Unique identifier for the employee. + example: 0 + x-primary-key: true + x-autoincrement: true + name: + type: [string, "null"] + description: The name of the employee. + example: David Andersson + x-index: true + division: + type: string + description: The part of the company the employee works in. + example: Engineering + x-index: true + salary: + type: number + description: The amount of money the employee is paid. + example: 1000000.00 + required: + - name + - division diff --git a/examples/nullable/openapi-3-1/models.py b/examples/nullable/openapi-3-1/models.py new file mode 100644 index 00000000..e4dd867e --- /dev/null +++ b/examples/nullable/openapi-3-1/models.py @@ -0,0 +1,3 @@ +from open_alchemy import init_yaml + +init_yaml("example-spec.yml", models_filename="models_auto.py") diff --git a/examples/nullable/openapi-3-1/models_auto.py b/examples/nullable/openapi-3-1/models_auto.py new file mode 100644 index 00000000..5d1f3d87 --- /dev/null +++ b/examples/nullable/openapi-3-1/models_auto.py @@ -0,0 +1,127 @@ +"""Autogenerated SQLAlchemy models based on OpenAlchemy models.""" +# pylint: disable=no-member,super-init-not-called,unused-argument + +import typing + +import sqlalchemy +from sqlalchemy import orm + +from open_alchemy import models + +Base = models.Base # type: ignore + + +class _EmployeeDictBase(typing.TypedDict, total=True): + """TypedDict for properties that are required.""" + + name: typing.Optional[str] + division: str + + +class EmployeeDict(_EmployeeDictBase, total=False): + """TypedDict for properties that are not required.""" + + id: int + salary: typing.Optional[float] + + +class TEmployee(typing.Protocol): + """ + SQLAlchemy model protocol. + + Person that works for a company. + + Attrs: + id: Unique identifier for the employee. + name: The name of the employee. + division: The part of the company the employee works in. + salary: The amount of money the employee is paid. + + """ + + # SQLAlchemy properties + __table__: sqlalchemy.Table + __tablename__: str + query: orm.Query + + # Model properties + id: "sqlalchemy.Column[int]" + name: "sqlalchemy.Column[typing.Optional[str]]" + division: "sqlalchemy.Column[str]" + salary: "sqlalchemy.Column[typing.Optional[float]]" + + def __init__( + self, + name: typing.Optional[str], + division: str, + id: typing.Optional[int] = None, + salary: typing.Optional[float] = None, + ) -> None: + """ + Construct. + + Args: + id: Unique identifier for the employee. + name: The name of the employee. + division: The part of the company the employee works in. + salary: The amount of money the employee is paid. + + """ + ... + + @classmethod + def from_dict( + cls, + name: typing.Optional[str], + division: str, + id: typing.Optional[int] = None, + salary: typing.Optional[float] = None, + ) -> "TEmployee": + """ + Construct from a dictionary (eg. a POST payload). + + Args: + id: Unique identifier for the employee. + name: The name of the employee. + division: The part of the company the employee works in. + salary: The amount of money the employee is paid. + + Returns: + Model instance based on the dictionary. + + """ + ... + + @classmethod + def from_str(cls, value: str) -> "TEmployee": + """ + Construct from a JSON string (eg. a POST payload). + + Returns: + Model instance based on the JSON string. + + """ + ... + + def to_dict(self) -> EmployeeDict: + """ + Convert to a dictionary (eg. to send back for a GET request). + + Returns: + Dictionary based on the model instance. + + """ + ... + + def to_str(self) -> str: + """ + Convert to a JSON string (eg. to send back for a GET request). + + Returns: + JSON string based on the model instance. + + """ + ... + + +Employee: typing.Type[TEmployee] = models.Employee # type: ignore diff --git a/examples/nullable/openapi-3-1/models_traditional.py b/examples/nullable/openapi-3-1/models_traditional.py new file mode 100644 index 00000000..f78a4954 --- /dev/null +++ b/examples/nullable/openapi-3-1/models_traditional.py @@ -0,0 +1,14 @@ +import sqlalchemy as sa +from sqlalchemy.ext.declarative import declarative_base + +Base = declarative_base() + + +class Employee(Base): + """Person that works for a company.""" + + __tablename__ = "employee" + id = sa.Column(sa.Integer, primary_key=True, autoincrement=True) + name = sa.Column(sa.String, index=True, nullable=True) + division = sa.Column(sa.String, index=True) + salary = sa.Column(sa.Float) diff --git a/open_alchemy/helpers/peek.py b/open_alchemy/helpers/peek/__init__.py similarity index 81% rename from open_alchemy/helpers/peek.py rename to open_alchemy/helpers/peek/__init__.py index bf2fed95..de8e1fb2 100644 --- a/open_alchemy/helpers/peek.py +++ b/open_alchemy/helpers/peek/__init__.py @@ -6,16 +6,10 @@ from open_alchemy import types from open_alchemy.facades import jsonschema -from . import ext_prop as ext_prop_helper -from . import ref as ref_helper +from .. import ext_prop as ext_prop_helper +from . import helpers - -class PeekValue(types.Protocol): - """Defines interface for peek functions.""" - - def __call__(self, *, schema: types.Schema, schemas: types.Schemas) -> typing.Any: - """Call signature for peek functions.""" - ... +PeekValue = helpers.PeekValue def type_(*, schema: types.Schema, schemas: types.Schemas) -> str: @@ -23,7 +17,7 @@ def type_(*, schema: types.Schema, schemas: types.Schemas) -> str: Get the type of the schema. Raises TypeMissingError if the final schema does not have a type or the value is - not a string. + not a string or list of string or has multiple non-null types. Args: schema: The schema for which to get the type. @@ -36,16 +30,42 @@ def type_(*, schema: types.Schema, schemas: types.Schemas) -> str: value = peek_key(schema=schema, schemas=schemas, key=types.OpenApiProperties.TYPE) if value is None: raise exceptions.TypeMissingError("Every property requires a type.") - if not isinstance(value, str): - raise exceptions.TypeMissingError( - "A type property value must be of type string." - ) - return value + + if isinstance(value, str): + return value + + if isinstance(value, list): + # ignore null + type_values = filter(lambda item: item != "null", value) + + try: + item_value = next(type_values) + except StopIteration as exc: + raise exceptions.TypeMissingError( + "An array type property must have at least 1 element that is not " + "'null'." + ) from exc + + try: + next(type_values) + raise exceptions.TypeMissingError( + "An array type property must have at most 1 element that is not " + "'null'." + ) + except StopIteration: + pass + + if isinstance(item_value, str): + return item_value + + raise exceptions.TypeMissingError( + "A type property value must be of type string or list of string." + ) def nullable(*, schema: types.Schema, schemas: types.Schemas) -> typing.Optional[bool]: """ - Retrieve the nullable property from a property schema. + Retrieve the nullable property from a property schema or null from the type array. Raises MalformedSchemaError if the nullable value is not a boolean. @@ -54,19 +74,27 @@ def nullable(*, schema: types.Schema, schemas: types.Schemas) -> typing.Optional schemas: The schemas for $ref lookup. Returns: - The nullable value. + The nullable value or whether 'null' is in the type array. """ - value = peek_key( + nullable_value = peek_key( schema=schema, schemas=schemas, key=types.OpenApiProperties.NULLABLE ) - if value is None: - return None - if not isinstance(value, bool): + if nullable_value is not None and not isinstance(nullable_value, bool): raise exceptions.MalformedSchemaError( "A nullable value must be of type boolean." ) - return value + + type_value = peek_key( + schema=schema, schemas=schemas, key=types.OpenApiProperties.TYPE + ) + + if nullable_value is None and not isinstance(type_value, list): + return None + + return nullable_value is True or ( + isinstance(type_value, list) and "null" in type_value + ) def format_(*, schema: types.Schema, schemas: types.Schemas) -> typing.Optional[str]: @@ -840,91 +868,7 @@ def peek_key( The key value (if found) or None. """ - return _peek_key(schema, schemas, key, set(), skip_ref=skip_ref) - - -def _check_schema_schemas_dict(schema: types.Schema, schemas: types.Schemas) -> None: - """Check that schema and schemas are dict.""" - # Check schema and schemas are dict - if not isinstance(schema, dict): - raise exceptions.MalformedSchemaError("The schema must be a dictionary.") - if not isinstance(schemas, dict): - raise exceptions.MalformedSchemaError("The schemas must be a dictionary.") - - -def _check_ref_string(ref_value: typing.Any) -> str: - """Check that value of $ref is string.""" - if not isinstance(ref_value, str): - raise exceptions.MalformedSchemaError("The value of $ref must be a string.") - return ref_value - - -def _check_circular_ref(ref_value: str, seen_refs: typing.Set[str]) -> None: - """Check whether ref has ever been seen.""" - if ref_value in seen_refs: - raise exceptions.MalformedSchemaError("Circular reference detected.") - seen_refs.add(ref_value) - - -def _check_all_of_list(all_of: typing.Any) -> list: - """Check that value of allOf is a list.""" - if not isinstance(all_of, list): - raise exceptions.MalformedSchemaError("The value of allOf must be a list.") - return all_of - - -def _check_sub_schema_dict(sub_schema: typing.Any) -> dict: - """Check that a sub schema in an allOf is a dict.""" - if not isinstance(sub_schema, dict): - raise exceptions.MalformedSchemaError( - "The elements of allOf must be dictionaries." - ) - return sub_schema - - -def _peek_key( - schema: types.Schema, - schemas: types.Schemas, - key: str, - seen_refs: typing.Set[str], - skip_ref: typing.Optional[str], -) -> typing.Any: - """Execute peek_key.""" - _check_schema_schemas_dict(schema, schemas) - - # Base case, look for type key - keys = ( - [key.replace("x-", prefix) for prefix in types.KeyPrefixes] - if key.startswith("x-") - else [key] - ) - value = next(filter(lambda value: value is not None, map(schema.get, keys)), None) - if value is not None: - return value - - # Recursive case, look for $ref - ref_value = schema.get(types.OpenApiProperties.REF) - if ref_value is not None: - ref_value_str = _check_ref_string(ref_value) - _check_circular_ref(ref_value_str, seen_refs) - - ref_name, ref_schema = ref_helper.get_ref(ref=ref_value_str, schemas=schemas) - if skip_ref is not None and ref_name == skip_ref: - return None - return _peek_key(ref_schema, schemas, key, seen_refs, skip_ref) - - # Recursive case, look for allOf - all_of = schema.get("allOf") - if all_of is not None: - all_of_list = _check_all_of_list(all_of) - for sub_schema in all_of_list: - sub_schema_dict = _check_sub_schema_dict(sub_schema) - value = _peek_key(sub_schema_dict, schemas, key, seen_refs, skip_ref) - if value is not None: - return value - - # Base case, type or ref not found or no type in allOf - return None + return helpers.peek_key(schema, schemas, key, set(), skip_ref=skip_ref) def prefer_local( @@ -947,48 +891,4 @@ def prefer_local( The value returned by get_value preferably without following any $ref. """ - return _prefer_local(get_value, schema, schemas, set()) - - -def _prefer_local( - get_value: PeekValue, - schema: types.Schema, - schemas: types.Schemas, - seen_refs: typing.Set[str], -) -> typing.Any: - """Execute prefer_local.""" - _check_schema_schemas_dict(schema, schemas) - - # Handle $ref - ref_value = schema.get(types.OpenApiProperties.REF) - if ref_value is not None: - ref_value_str = _check_ref_string(ref_value) - _check_circular_ref(ref_value_str, seen_refs) - - _, ref_schema = ref_helper.get_ref(ref=ref_value_str, schemas=schemas) - return _prefer_local(get_value, ref_schema, schemas, seen_refs) - - # Handle allOf - all_of = schema.get("allOf") - if all_of is not None: - all_of_list = _check_all_of_list(all_of) - all_of_list_dict = map(_check_sub_schema_dict, all_of_list) - # Order putting any $ref last - sorted_all_of = sorted( - all_of_list_dict, - key=lambda sub_schema: sub_schema.get(types.OpenApiProperties.REF) - is not None, - ) - - def map_to_value(sub_schema: types.Schema) -> typing.Any: - """Use get_value to turn the schema into the value.""" - return _prefer_local(get_value, sub_schema, schemas, seen_refs) - - retrieved_values = map(map_to_value, sorted_all_of) - not_none_retrieved_values = filter( - lambda value: value is not None, retrieved_values - ) - retrieved_value = next(not_none_retrieved_values, None) - return retrieved_value - - return get_value(schema=schema, schemas=schemas) + return helpers.prefer_local(get_value, schema, schemas, set()) diff --git a/open_alchemy/helpers/peek/helpers.py b/open_alchemy/helpers/peek/helpers.py new file mode 100644 index 00000000..dc27c66a --- /dev/null +++ b/open_alchemy/helpers/peek/helpers.py @@ -0,0 +1,144 @@ +"""Helpers for the peek functions.""" + +import typing + +from open_alchemy import exceptions +from open_alchemy import types + +from .. import ref as ref_helper + + +class PeekValue(types.Protocol): + """Defines interface for peek functions.""" + + def __call__(self, *, schema: types.Schema, schemas: types.Schemas) -> typing.Any: + """Call signature for peek functions.""" + ... + + +def check_schema_schemas_dict(schema: types.Schema, schemas: types.Schemas) -> None: + """Check that schema and schemas are dict.""" + # Check schema and schemas are dict + if not isinstance(schema, dict): + raise exceptions.MalformedSchemaError("The schema must be a dictionary.") + if not isinstance(schemas, dict): + raise exceptions.MalformedSchemaError("The schemas must be a dictionary.") + + +def check_ref_string(ref_value: typing.Any) -> str: + """Check that value of $ref is string.""" + if not isinstance(ref_value, str): + raise exceptions.MalformedSchemaError("The value of $ref must be a string.") + return ref_value + + +def check_circular_ref(ref_value: str, seen_refs: typing.Set[str]) -> None: + """Check whether ref has ever been seen.""" + if ref_value in seen_refs: + raise exceptions.MalformedSchemaError("Circular reference detected.") + seen_refs.add(ref_value) + + +def check_all_of_list(all_of: typing.Any) -> list: + """Check that value of allOf is a list.""" + if not isinstance(all_of, list): + raise exceptions.MalformedSchemaError("The value of allOf must be a list.") + return all_of + + +def check_sub_schema_dict(sub_schema: typing.Any) -> dict: + """Check that a sub schema in an allOf is a dict.""" + if not isinstance(sub_schema, dict): + raise exceptions.MalformedSchemaError( + "The elements of allOf must be dictionaries." + ) + return sub_schema + + +def peek_key( + schema: types.Schema, + schemas: types.Schemas, + key: str, + seen_refs: typing.Set[str], + skip_ref: typing.Optional[str], +) -> typing.Any: + """Execute peek_key.""" + check_schema_schemas_dict(schema, schemas) + + # Base case, look for type key + keys = ( + [key.replace("x-", prefix) for prefix in types.KeyPrefixes] + if key.startswith("x-") + else [key] + ) + value = next(filter(lambda value: value is not None, map(schema.get, keys)), None) + if value is not None: + return value + + # Recursive case, look for $ref + ref_value = schema.get(types.OpenApiProperties.REF) + if ref_value is not None: + ref_value_str = check_ref_string(ref_value) + check_circular_ref(ref_value_str, seen_refs) + + ref_name, ref_schema = ref_helper.get_ref(ref=ref_value_str, schemas=schemas) + if skip_ref is not None and ref_name == skip_ref: + return None + return peek_key(ref_schema, schemas, key, seen_refs, skip_ref) + + # Recursive case, look for allOf + all_of = schema.get("allOf") + if all_of is not None: + all_of_list = check_all_of_list(all_of) + for sub_schema in all_of_list: + sub_schema_dict = check_sub_schema_dict(sub_schema) + value = peek_key(sub_schema_dict, schemas, key, seen_refs, skip_ref) + if value is not None: + return value + + # Base case, type or ref not found or no type in allOf + return None + + +def prefer_local( + get_value: PeekValue, + schema: types.Schema, + schemas: types.Schemas, + seen_refs: typing.Set[str], +) -> typing.Any: + """Execute prefer_local.""" + check_schema_schemas_dict(schema, schemas) + + # Handle $ref + ref_value = schema.get(types.OpenApiProperties.REF) + if ref_value is not None: + ref_value_str = check_ref_string(ref_value) + check_circular_ref(ref_value_str, seen_refs) + + _, ref_schema = ref_helper.get_ref(ref=ref_value_str, schemas=schemas) + return prefer_local(get_value, ref_schema, schemas, seen_refs) + + # Handle allOf + all_of = schema.get("allOf") + if all_of is not None: + all_of_list = check_all_of_list(all_of) + all_of_list_dict = map(check_sub_schema_dict, all_of_list) + # Order putting any $ref last + sorted_all_of = sorted( + all_of_list_dict, + key=lambda sub_schema: sub_schema.get(types.OpenApiProperties.REF) + is not None, + ) + + def map_to_value(sub_schema: types.Schema) -> typing.Any: + """Use get_value to turn the schema into the value.""" + return prefer_local(get_value, sub_schema, schemas, seen_refs) + + retrieved_values = map(map_to_value, sorted_all_of) + not_none_retrieved_values = filter( + lambda value: value is not None, retrieved_values + ) + retrieved_value = next(not_none_retrieved_values, None) + return retrieved_value + + return get_value(schema=schema, schemas=schemas) diff --git a/tests/examples/test_example_specs.py b/tests/examples/test_example_specs.py index 703ab0b9..3a82060b 100644 --- a/tests/examples/test_example_specs.py +++ b/tests/examples/test_example_specs.py @@ -44,6 +44,20 @@ def cleanup_models(): {}, id="simple Employee all", ), + pytest.param( + "nullable/openapi-3-0/example-spec.yml", + "Employee", + {"division": "division 1"}, + {"name": None, "id": 1, "salary": None}, + id="nullable openapi 3.0 Employee required only", + ), + pytest.param( + "nullable/openapi-3-1/example-spec.yml", + "Employee", + {"division": "division 1"}, + {"name": None, "id": 1, "salary": None}, + id="nullable openapi 3.1 Employee required only", + ), pytest.param( "namespaced/example-spec.yml", "Employee", diff --git a/tests/open_alchemy/helpers/test_peek/test_peek_open_api.py b/tests/open_alchemy/helpers/test_peek/test_peek_open_api.py index 3e397acf..8836e986 100644 --- a/tests/open_alchemy/helpers/test_peek/test_peek_open_api.py +++ b/tests/open_alchemy/helpers/test_peek/test_peek_open_api.py @@ -1,5 +1,7 @@ """Tests for peek helpers.""" +import copy + import pytest from open_alchemy import exceptions @@ -7,23 +9,46 @@ @pytest.mark.parametrize( - "schema, schemas", - [({}, {}), ({"type": True}, {})], - ids=["plain", "not string value"], + "schema", + [ + pytest.param({}, id="plain"), + pytest.param({"type": True}, id="not string value"), + pytest.param({"type": []}, id="array empty"), + pytest.param({"type": [True]}, id="array not string"), + pytest.param({"type": ["null", True]}, id="array with null first not string"), + pytest.param({"type": [True, "null"]}, id="array with null second not string"), + pytest.param({"type": ["type 1", "type 2"]}, id="multiple types not null"), + pytest.param( + {"type": ["type 1", "type 2", "null"]}, id="multiples types with null" + ), + ], ) @pytest.mark.helper -def test_type_no_type(schema, schemas): +def test_type_invalid(schema): """ - GIVEN schema without a type + GIVEN schema with an invalid type WHEN type_ is called with the schema THEN TypeMissingError is raised. """ with pytest.raises(exceptions.TypeMissingError): - peek.type_(schema=schema, schemas=schemas) + peek.type_(schema=schema, schemas={}) VALID_TESTS = [ pytest.param([("type", "type 1")], peek.type_, "type 1", id="type"), + pytest.param([("type", ["type 1"])], peek.type_, "type 1", id="type openapi 3.1"), + pytest.param( + [("type", ["type 1", "null"])], + peek.type_, + "type 1", + id="type openapi 3.1 with null last", + ), + pytest.param( + [("type", ["null", "type 1"])], + peek.type_, + "type 1", + id="type openapi 3.1 with null first", + ), pytest.param([], peek.nullable, None, id="nullable missing"), pytest.param([("nullable", True)], peek.nullable, True, id="nullable defined"), pytest.param( @@ -32,6 +57,54 @@ def test_type_no_type(schema, schemas): False, id="nullable defined different", ), + pytest.param( + [("type", [])], + peek.nullable, + False, + id="nullable openapi 3.1 not null", + ), + pytest.param( + [("type", ["null"])], + peek.nullable, + True, + id="nullable openapi 3.1 null", + ), + pytest.param( + [("type", ["type 1", "null"])], + peek.nullable, + True, + id="nullable openapi 3.1 null with type first", + ), + pytest.param( + [("type", ["null", "type 1"])], + peek.nullable, + True, + id="nullable openapi 3.1 null with type last", + ), + pytest.param( + [("type", []), ("nullable", False)], + peek.nullable, + False, + id="nullable openapi 3.1 false and 3.0 false", + ), + pytest.param( + [("type", ["null"]), ("nullable", False)], + peek.nullable, + True, + id="nullable openapi 3.1 true and 3.0 false", + ), + pytest.param( + [("type", []), ("nullable", True)], + peek.nullable, + True, + id="nullable openapi 3.1 false and 3.0 true", + ), + pytest.param( + [("type", ["null"]), ("nullable", True)], + peek.nullable, + True, + id="nullable openapi 3.1 true and 3.0 true", + ), pytest.param([], peek.format_, None, id="format missing"), pytest.param( [("format", "format 1")], @@ -120,10 +193,12 @@ def test_key_value(key_values, func, expected_value): THEN expected value is returned. """ schema = dict(key_values) + original_schema = copy.deepcopy(schema) returned_type = func(schema=schema, schemas={}) assert returned_type == expected_value + assert schema == original_schema INVALID_TESTS = [ diff --git a/tests/open_alchemy/schemas/validation/property_/relationship/property_/test_one_to_many.py b/tests/open_alchemy/schemas/validation/property_/relationship/property_/test_one_to_many.py index 7161b54e..a97093f4 100644 --- a/tests/open_alchemy/schemas/validation/property_/relationship/property_/test_one_to_many.py +++ b/tests/open_alchemy/schemas/validation/property_/relationship/property_/test_one_to_many.py @@ -26,7 +26,7 @@ ( False, "items property :: malformed schema :: A type property " - "value must be of type string. ", + "value must be of type string or list of string. ", ), id="array items type not string", ), diff --git a/tests/open_alchemy/schemas/validation/property_/relationship/property_/test_x_to_one.py b/tests/open_alchemy/schemas/validation/property_/relationship/property_/test_x_to_one.py index 28137500..cd8ac089 100644 --- a/tests/open_alchemy/schemas/validation/property_/relationship/property_/test_x_to_one.py +++ b/tests/open_alchemy/schemas/validation/property_/relationship/property_/test_x_to_one.py @@ -25,7 +25,8 @@ {}, ( False, - "malformed schema :: A type property value must be of type string. ", + "malformed schema :: A type property value must be of type string or list " + "of string. ", ), id="type not a string", ), diff --git a/tests/open_alchemy/schemas/validation/property_/test_simple.py b/tests/open_alchemy/schemas/validation/property_/test_simple.py index 3cdf1a5d..ef7d9ae3 100644 --- a/tests/open_alchemy/schemas/validation/property_/test_simple.py +++ b/tests/open_alchemy/schemas/validation/property_/test_simple.py @@ -19,7 +19,8 @@ {}, ( False, - "malformed schema :: A type property value must be of type string. ", + "malformed schema :: A type property value must be of type string or list " + "of string. ", ), id="type not a string", ), diff --git a/tests/test_examples b/tests/test_examples index 0a516f3b..81f9c4a5 100755 --- a/tests/test_examples +++ b/tests/test_examples @@ -8,6 +8,14 @@ cd examples/simple python models.py python models_traditional.py cd - +cd examples/nullable/openapi-3-0 +python models.py +python models_traditional.py +cd - +cd examples/nullable/openapi-3-1 +python models.py +python models_traditional.py +cd - cd examples/namespaced python models.py python models_traditional.py