diff --git a/.github/workflows/samples-python-flask-connexion3-server.yaml b/.github/workflows/samples-python-flask-connexion3-server.yaml index f7e59e0ce438..d573e582da2e 100644 --- a/.github/workflows/samples-python-flask-connexion3-server.yaml +++ b/.github/workflows/samples-python-flask-connexion3-server.yaml @@ -1,23 +1,29 @@ -name: Python Flask (Connexion 3) Server +name: Python Flask Server on: push: paths: + - samples/server/petstore/python-flask/** - samples/server/petstore/python-flask-connexion3/** + - modules/openapi-generator/src/main/resources/python-flask/** + - modules/openapi-generator/src/test/resources/3_0/python-flask/** - .github/workflows/samples-python-flask-connexion3-server.yaml pull_request: paths: + - samples/server/petstore/python-flask/** - samples/server/petstore/python-flask-connexion3/** + - modules/openapi-generator/src/main/resources/python-flask/** + - modules/openapi-generator/src/test/resources/3_0/python-flask/** - .github/workflows/samples-python-flask-connexion3-server.yaml jobs: build: - name: Test Python Flask (Connexion 3) server + name: Test Python Flask server (${{ matrix.sample }}) runs-on: ubuntu-latest strategy: fail-fast: false matrix: sample: - # servers + - samples/server/petstore/python-flask/ - samples/server/petstore/python-flask-connexion3/ steps: - uses: actions/checkout@v7 @@ -30,6 +36,12 @@ jobs: python -m pip install --upgrade pip pip install -r requirements.txt pip install -r test-requirements.txt - - name: Test + - name: Test JSON serialization + working-directory: ${{ matrix.sample }} + run: >- + python -m pytest --import-mode=importlib + "$GITHUB_WORKSPACE/modules/openapi-generator/src/test/resources/3_0/python-flask/test_json_serialization.py" + - name: Test existing Connexion 3 sample + if: matrix.sample == 'samples/server/petstore/python-flask-connexion3/' working-directory: ${{ matrix.sample }} run: pytest diff --git a/modules/openapi-generator/src/main/resources/python-flask/README.mustache b/modules/openapi-generator/src/main/resources/python-flask/README.mustache index f36875e45045..5ee43085989d 100644 --- a/modules/openapi-generator/src/main/resources/python-flask/README.mustache +++ b/modules/openapi-generator/src/main/resources/python-flask/README.mustache @@ -8,7 +8,7 @@ is an example of building a OpenAPI-enabled Flask server. This example uses the [Connexion](https://github.com/zalando/connexion) library on top of Flask. ## Requirements -Python 3.5.2+ +Python 3.11+ ## Usage To run the server, please execute the following from the root directory: diff --git a/modules/openapi-generator/src/main/resources/python-flask/__init__test.mustache b/modules/openapi-generator/src/main/resources/python-flask/__init__test.mustache index b1d8768a2060..9c4817d08829 100644 --- a/modules/openapi-generator/src/main/resources/python-flask/__init__test.mustache +++ b/modules/openapi-generator/src/main/resources/python-flask/__init__test.mustache @@ -7,7 +7,7 @@ import connexion {{^useConnexion3}} from flask_testing import TestCase -from {{packageName}}.encoder import JSONEncoder +from {{packageName}}.encoder import JSONProvider class BaseTestCase(TestCase): @@ -15,7 +15,7 @@ class BaseTestCase(TestCase): def create_app(self): logging.getLogger('connexion.operation').setLevel('ERROR') app = connexion.App(__name__, specification_dir='../openapi/') - app.app.json_encoder = JSONEncoder + app.app.json = JSONProvider(app.app) app.add_api('openapi.yaml', pythonic_params=True) return app.app {{/useConnexion3}} diff --git a/modules/openapi-generator/src/main/resources/python-flask/__main__.mustache b/modules/openapi-generator/src/main/resources/python-flask/__main__.mustache index 76ecde91b6aa..4904e43a0eae 100644 --- a/modules/openapi-generator/src/main/resources/python-flask/__main__.mustache +++ b/modules/openapi-generator/src/main/resources/python-flask/__main__.mustache @@ -20,7 +20,7 @@ from {{packageName}} import encoder def main(): {{^useConnexion3}} app = connexion.App(__name__, specification_dir='./openapi/') - app.app.json_encoder = encoder.JSONEncoder + app.app.json = encoder.JSONProvider(app.app) app.add_api('openapi.yaml', arguments={'title': '{{appName}}'}, pythonic_params=True) diff --git a/modules/openapi-generator/src/main/resources/python-flask/encoder.mustache b/modules/openapi-generator/src/main/resources/python-flask/encoder.mustache index 3d9a356dd23b..e26df42b4c6c 100644 --- a/modules/openapi-generator/src/main/resources/python-flask/encoder.mustache +++ b/modules/openapi-generator/src/main/resources/python-flask/encoder.mustache @@ -1,23 +1,5 @@ {{^useConnexion3}} -from connexion.apps.flask_app import FlaskJSONEncoder - -from {{modelPackage}}.base_model import Model - - -class JSONEncoder(FlaskJSONEncoder): - include_nulls = False - - def default(self, o): - if isinstance(o, Model): - dikt = {} - for attr in o.openapi_types: - value = getattr(o, attr) - if value is None and not self.include_nulls: - continue - attr = o.attribute_map[attr] - dikt[attr] = value - return dikt - return FlaskJSONEncoder.default(self, o) +from connexion.apps.flask_app import FlaskJSONProvider {{/useConnexion3}} {{#useConnexion3}} import datetime @@ -26,29 +8,39 @@ import json import uuid from werkzeug.http import http_date +{{/useConnexion3}} from {{modelPackage}}.base_model import Model +def _model_to_dict(model, include_nulls): + dikt = {} + for attr in model.openapi_types: + value = getattr(model, attr) + if value is None and not include_nulls: + continue + dikt[model.attribute_map[attr]] = value + return dikt + + +{{^useConnexion3}} +class JSONProvider(FlaskJSONProvider): + include_nulls = False + + def default(self, o): + if isinstance(o, Model): + return _model_to_dict(o, self.include_nulls) + return super().default(o) +{{/useConnexion3}} +{{#useConnexion3}} class JSONEncoder(json.JSONEncoder): include_nulls = False def default(self, o): if isinstance(o, Model): - dikt = {} - for attr in o.openapi_types: - value = getattr(o, attr) - if value is None and not self.include_nulls: - continue - attr = o.attribute_map[attr] - dikt[attr] = value - return dikt - # Connexion 3 removed FlaskJSONEncoder, which is what used to - # give us this handling for free -- replicate it here so - # date/datetime/Decimal/UUID-typed model attributes (e.g. an - # OpenAPI `date-time` field) still serialize instead of raising - # TypeError. http_date() matches Flask's own default encoder - # behavior for date/datetime (RFC 2822, not ISO 8601). + return _model_to_dict(o, self.include_nulls) + # Preserve this adapter's HTTP-date and string representations. + # Connexion 2 delegates special types to its JSON provider instead. if isinstance(o, datetime.date): return http_date(o) if isinstance(o, (decimal.Decimal, uuid.UUID)): diff --git a/modules/openapi-generator/src/main/resources/python-flask/requirements.mustache b/modules/openapi-generator/src/main/resources/python-flask/requirements.mustache index 50f2fd80ee3f..589507553960 100644 --- a/modules/openapi-generator/src/main/resources/python-flask/requirements.mustache +++ b/modules/openapi-generator/src/main/resources/python-flask/requirements.mustache @@ -1,39 +1,18 @@ {{^useConnexion3}} -connexion[swagger-ui] >= 2.6.0; python_version>="3.6" -# 2.3 is the last version that supports python 3.4-3.5 -connexion[swagger-ui] <= 2.3.0; python_version=="3.5" or python_version=="3.4" -# prevent breaking dependencies from advent of connexion>=3.0 -connexion[swagger-ui] <= 2.14.2; python_version>"3.4" -# connexion requires werkzeug but connexion < 2.4.0 does not install werkzeug -# we must peg werkzeug versions below to fix connexion -# https://github.com/zalando/connexion/pull/1044 -werkzeug == 0.16.1; python_version=="3.5" or python_version=="3.4" +# Connexion 2.x +connexion[swagger-ui]>=2.15.1,<3.0.0 {{/useConnexion3}} {{#useConnexion3}} -# pinned to 3.3.0+ (not just the first 3.0.0 release) since that's the -# earliest version verified to work with the Jsonifier/test_client setup -# below, and the first to officially support Python 3.13/3.14 -connexion[flask,swagger-ui,uvicorn] >= 3.3.0,<4.0.0 +# Connexion 3.x +connexion[flask,swagger-ui,uvicorn]>=3.3.0,<4.0.0 {{/useConnexion3}} -{{^useConnexion3}} -swagger-ui-bundle >= 0.0.2 -{{/useConnexion3}} -{{#useConnexion3}} -# connexion's own swagger-ui extra already requires this floor; pinned -# explicitly here too so requirements.txt is self-documenting. -swagger-ui-bundle >= 1.1.0 -{{/useConnexion3}} -python_dateutil >= 2.6.0 + +Flask>=2.3.3,<4.0.0 +python-dateutil>=2.8.2 +setuptools>=21.0.0 + {{^useConnexion3}} {{#featureCORS}} -# should support both Python 2 and Python 3 -flask-cors >= 3.0.10 +flask-cors>=4.0.0 {{/featureCORS}} {{/useConnexion3}} -setuptools >= 21.0.0 -{{^useConnexion3}} -Flask == 2.1.1 -{{/useConnexion3}} -{{#useConnexion3}} -Flask >= 2.2.0,<4.0.0 -{{/useConnexion3}} diff --git a/modules/openapi-generator/src/main/resources/python-flask/setup.mustache b/modules/openapi-generator/src/main/resources/python-flask/setup.mustache index e1d90d0396eb..afa643467e77 100644 --- a/modules/openapi-generator/src/main/resources/python-flask/setup.mustache +++ b/modules/openapi-generator/src/main/resources/python-flask/setup.mustache @@ -1,27 +1,22 @@ -import sys + from setuptools import setup, find_packages NAME = "{{packageName}}" VERSION = "{{packageVersion}}" -{{#apiInfo}}{{#apis}}{{#-last}} -# To install the library, run the following -# -# python setup.py install -# -# prerequisite: setuptools -# http://pypi.python.org/pypi/setuptools +{{#apiInfo}}{{#apis}}{{#-last}} REQUIRES = [ {{^useConnexion3}} - "connexion>=2.0.2,<=2.14.2", - "swagger-ui-bundle>=0.0.2", + "connexion[swagger-ui]>=2.15.1,<3.0.0", +{{#featureCORS}} + "flask-cors>=4.0.0", +{{/featureCORS}} {{/useConnexion3}} {{#useConnexion3}} "connexion[flask,swagger-ui,uvicorn]>=3.3.0,<4.0.0", - "swagger-ui-bundle>=1.1.0", - "Flask>=2.2.0,<4.0.0", {{/useConnexion3}} - "python_dateutil>=2.6.0" + "Flask>=2.3.3,<4.0.0", + "python-dateutil>=2.8.2", ] setup( @@ -31,13 +26,23 @@ setup( author_email="{{infoEmail}}", url="{{packageUrl}}", keywords=["OpenAPI", "{{appName}}"], + python_requires=">=3.11", install_requires=REQUIRES, - packages=find_packages({{#pythonSrcRoot}}"{{{.}}}"{{/pythonSrcRoot}}),{{#pythonSrcRoot}} - package_dir={"": "{{{.}}}"},{{/pythonSrcRoot}} - package_data={'': ['{{#pythonSrcRoot}}{{{.}}}/{{/pythonSrcRoot}}openapi/openapi.yaml']}, + packages=find_packages({{#pythonSrcRoot}}"{{{.}}}"{{/pythonSrcRoot}}), +{{#pythonSrcRoot}} + package_dir={"": "{{{.}}}"}, +{{/pythonSrcRoot}} + package_data={ + "": [ + "{{#pythonSrcRoot}}{{{.}}}/{{/pythonSrcRoot}}openapi/openapi.yaml" + ] + }, include_package_data=True, entry_points={ - 'console_scripts': ['{{packageName}}={{packageName}}.__main__:main']}, + "console_scripts": [ + "{{packageName}}={{packageName}}.__main__:main" + ] + }, long_description="""\ {{appDescription}} """ diff --git a/modules/openapi-generator/src/main/resources/python-flask/test-requirements.mustache b/modules/openapi-generator/src/main/resources/python-flask/test-requirements.mustache index 7be0853a4a25..d31019c2630a 100644 --- a/modules/openapi-generator/src/main/resources/python-flask/test-requirements.mustache +++ b/modules/openapi-generator/src/main/resources/python-flask/test-requirements.mustache @@ -6,7 +6,7 @@ py>=1.4.31 randomize>=0.13 {{/useNose}} {{^useNose}} -pytest~=7.1.0 +pytest>=7.1.0,<10.0.0 pytest-cov>=2.8.1 pytest-randomly>=1.2.3 {{/useNose}} diff --git a/modules/openapi-generator/src/main/resources/python-flask/travis.mustache b/modules/openapi-generator/src/main/resources/python-flask/travis.mustache index d03f4e0aceeb..e59f169172f8 100644 --- a/modules/openapi-generator/src/main/resources/python-flask/travis.mustache +++ b/modules/openapi-generator/src/main/resources/python-flask/travis.mustache @@ -1,21 +1,12 @@ # ref: https://docs.travis-ci.com/user/languages/python language: python python: -{{^useConnexion3}} - - "3.2" - - "3.3" - - "3.4" - - "3.5" - - "3.6" - - "3.7" - - "3.8" -{{/useConnexion3}} -{{#useConnexion3}} - - "3.9" - - "3.10" - "3.11" - "3.12" -{{/useConnexion3}} + - "3.13" + - "3.14" + - "3.15" + # command to install dependencies install: "pip install -r requirements.txt" # command to run tests diff --git a/modules/openapi-generator/src/main/resources/python-flask/typing_utils.mustache b/modules/openapi-generator/src/main/resources/python-flask/typing_utils.mustache index 74e3c913a7db..3da5cb4fca19 100644 --- a/modules/openapi-generator/src/main/resources/python-flask/typing_utils.mustache +++ b/modules/openapi-generator/src/main/resources/python-flask/typing_utils.mustache @@ -1,30 +1,14 @@ -import sys -if sys.version_info < (3, 7): - import typing +def is_generic(klass): + """Determine whether klass is a generic class.""" + return hasattr(klass, '__origin__') - def is_generic(klass): - """ Determine whether klass is a generic class """ - return type(klass) == typing.GenericMeta - def is_dict(klass): - """ Determine whether klass is a Dict """ - return klass.__extra__ == dict +def is_dict(klass): + """Determine whether klass is a Dict.""" + return getattr(klass, '__origin__', None) is dict - def is_list(klass): - """ Determine whether klass is a List """ - return klass.__extra__ == list -else: - - def is_generic(klass): - """ Determine whether klass is a generic class """ - return hasattr(klass, '__origin__') - - def is_dict(klass): - """ Determine whether klass is a Dict """ - return klass.__origin__ == dict - - def is_list(klass): - """ Determine whether klass is a List """ - return klass.__origin__ == list +def is_list(klass): + """Determine whether klass is a List.""" + return getattr(klass, '__origin__', None) is list diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/python/PythonFlaskConnexionServerCodegenTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/python/PythonFlaskConnexionServerCodegenTest.java index 66d7130aae8e..acc78d426006 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/python/PythonFlaskConnexionServerCodegenTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/python/PythonFlaskConnexionServerCodegenTest.java @@ -11,6 +11,7 @@ import io.swagger.v3.oas.models.media.UUIDSchema; import io.swagger.v3.parser.core.models.ParseOptions; import org.openapitools.codegen.ClientOptInput; +import org.openapitools.codegen.CodegenConstants; import org.openapitools.codegen.CodegenModel; import org.openapitools.codegen.CodegenProperty; import org.openapitools.codegen.DefaultCodegen; @@ -55,6 +56,46 @@ static private String generateFiles(DefaultCodegen codegen, String filePath) thr return outputPath + "/"; } + @DataProvider + public Object[][] jsonSerializerConfigurations() { + return new Object[][] { + {false, "openapi_server"}, + {true, "openapi_server"}, + {false, "custom_server"}, + {true, "custom_server"} + }; + } + + @Test(dataProvider = "jsonSerializerConfigurations", + description = "Configure the supported JSON adapter in both application and test bootstraps") + public void testJsonSerializerConfiguration(boolean useConnexion3, String packageName) throws IOException { + final PythonFlaskConnexionServerCodegen codegen = new PythonFlaskConnexionServerCodegen(); + codegen.additionalProperties().put(PythonFlaskConnexionServerCodegen.USE_CONNEXION_3, useConnexion3); + codegen.additionalProperties().put(CodegenConstants.PACKAGE_NAME, packageName); + final String outputPath = generateFiles(codegen, "src/test/resources/3_0/python-flask/petstore.yaml"); + + final Path encoder = Paths.get(outputPath, packageName, "encoder.py"); + final Path main = Paths.get(outputPath, packageName, "__main__.py"); + final Path tests = Paths.get(outputPath, packageName, "test/__init__.py"); + assertFileContains(encoder, "from " + packageName + ".models.base_model import Model"); + assertFileNotContains(main, "json_encoder"); + assertFileNotContains(tests, "json_encoder"); + + if (useConnexion3) { + assertFileContains(encoder, "class JSONEncoder(json.JSONEncoder):"); + assertFileContains(main, "jsonifier=Jsonifier(cls=encoder.JSONEncoder)"); + assertFileContains(tests, "jsonifier=Jsonifier(cls=JSONEncoder)"); + assertFileNotContains(encoder, "FlaskJSONProvider"); + } else { + assertFileContains(encoder, "from connexion.apps.flask_app import FlaskJSONProvider", + "class JSONProvider(FlaskJSONProvider):"); + assertFileContains(main, "app.app.json = encoder.JSONProvider(app.app)"); + assertFileContains(tests, "from " + packageName + ".encoder import JSONProvider", + "app.app.json = JSONProvider(app.app)"); + assertFileNotContains(encoder, "FlaskJSONEncoder"); + } + } + @Test(description = "UUID model properties require the standard-library UUID import (issue #23897)") public void testUuidModelImport() throws IOException { diff --git a/modules/openapi-generator/src/test/resources/3_0/python-flask/test_json_serialization.py b/modules/openapi-generator/src/test/resources/3_0/python-flask/test_json_serialization.py new file mode 100644 index 000000000000..53e3139255ff --- /dev/null +++ b/modules/openapi-generator/src/test/resources/3_0/python-flask/test_json_serialization.py @@ -0,0 +1,189 @@ +"""Runtime regressions shared by the two generated Flask Petstore samples.""" + +from contextlib import contextmanager +from datetime import date, datetime, timedelta, timezone +from decimal import Decimal +from importlib.metadata import version +import json +from unittest.mock import patch +from uuid import UUID + +import connexion +from flask import Flask +import pytest + +from openapi_server.models.category import Category +from openapi_server.models.order import Order +from openapi_server.models.pet import Pet +from openapi_server.models.tag import Tag + + +CONNEXION_3 = version('connexion').split('.')[0] == '3' + + +@pytest.fixture +def serializer(): + from openapi_server import encoder + + if CONNEXION_3: + return encoder.JSONEncoder() + return encoder.JSONProvider(Flask(__name__)) + + +def serialize(serializer, value): + if CONNEXION_3: + return json.loads(serializer.encode(value)) + return json.loads(serializer.dumps(value)) + + +def pet_and_json(include_nulls=False): + pet = Pet(id=7, name='Fluffy', photo_urls=['https://example.com/pet.png'], + category=Category(id=3), tags=[Tag(id=4, name='friendly')]) + expected = { + 'id': 7, + 'name': 'Fluffy', + 'photoUrls': ['https://example.com/pet.png'], + 'category': {'id': 3}, + 'tags': [{'id': 4, 'name': 'friendly'}], + } + if include_nulls: + expected['category']['name'] = None + expected['status'] = None + return pet, expected + + +@contextmanager +def production_client(): + """Exercise the generated entry point, replacing only the blocking server.""" + from openapi_server.__main__ import main + + app_class = connexion.FlaskApp if CONNEXION_3 else connexion.App + with patch.object(app_class, 'run', autospec=True) as run: + main() + run.assert_called_once() + app = run.call_args.args[0] + client = app.test_client() if CONNEXION_3 else app.app.test_client() + with client as test_client: + yield test_client + + +def assert_json_response(response, expected): + assert response.status_code == 200 + assert response.headers['Content-Type'].split(';')[0] == 'application/json' + body = response.content if CONNEXION_3 else response.data + assert json.loads(body) == expected + + +def test_model_property_names_and_primitives(serializer): + order = Order(id=1, pet_id=7, quantity=0, complete=False) + assert serialize(serializer, order) == { + 'id': 1, 'petId': 7, 'quantity': 0, 'complete': False, + } + + +@pytest.mark.parametrize('include_nulls', [False, True]) +def test_nested_models_and_collections(serializer, include_nulls): + serializer.include_nulls = include_nulls + pet, expected = pet_and_json(include_nulls) + assert serialize(serializer, { + 'pet': pet, + 'pets': [pet, None], + 'byId': {'7': pet}, + 'nested': [{'pets': [pet]}], + 'null': None, + }) == { + 'pet': expected, + 'pets': [expected, None], + 'byId': {'7': expected}, + 'nested': [{'pets': [expected]}], + 'null': None, + } + + +def test_include_nulls_subclass(serializer): + class IncludingNulls(type(serializer)): + include_nulls = True + + serializer = IncludingNulls() if CONNEXION_3 else IncludingNulls(Flask(__name__)) + pet, expected = pet_and_json(include_nulls=True) + assert serialize(serializer, pet) == expected + + +@pytest.mark.parametrize('value,connexion2_json,connexion3_json', [ + (datetime(2024, 4, 5, 6, 7, 8), + '2024-04-05T06:07:08Z', 'Fri, 05 Apr 2024 06:07:08 GMT'), + (datetime(2024, 4, 5, 6, 7, 8, tzinfo=timezone(timedelta(hours=-3))), + '2024-04-05T06:07:08-03:00', 'Fri, 05 Apr 2024 09:07:08 GMT'), + (date(2024, 4, 5), '2024-04-05', 'Fri, 05 Apr 2024 00:00:00 GMT'), + (Decimal('12.50'), 12.5, '12.50'), + (UUID('12345678-1234-5678-1234-567812345678'), + '12345678-1234-5678-1234-567812345678', '12345678-1234-5678-1234-567812345678'), +]) +def test_special_types(serializer, value, connexion2_json, connexion3_json): + expected = connexion3_json if CONNEXION_3 else connexion2_json + assert serialize(serializer, {'value': value}) == {'value': expected} + + +def test_unsupported_type_still_raises(serializer): + with pytest.raises(TypeError): + serialize(serializer, object()) + + +@pytest.mark.parametrize('include_nulls', [False, True]) +def test_model_http_response(include_nulls): + from openapi_server import encoder + + serializer_class = encoder.JSONEncoder if CONNEXION_3 else encoder.JSONProvider + pet, expected = pet_and_json(include_nulls) + with patch.object(serializer_class, 'include_nulls', include_nulls), patch( + 'openapi_server.controllers.pet_controller.get_pet_by_id', autospec=True, + return_value=(pet, 200, {'Content-Type': 'application/json'}), + ), production_client() as client: + response = client.get('/v2/pet/7', headers={'api_key': 'special-key'}) + assert_json_response(response, expected) + + +def test_order_http_response(): + order = Order(id=1, pet_id=7, quantity=2, status='placed', complete=False, + ship_date=datetime(2024, 4, 5, 6, 7, 8, tzinfo=timezone.utc)) + expected = { + 'id': 1, 'petId': 7, 'quantity': 2, 'status': 'placed', 'complete': False, + 'shipDate': ('Fri, 05 Apr 2024 06:07:08 GMT' if CONNEXION_3 + else '2024-04-05T06:07:08+00:00'), + } + with patch( + 'openapi_server.controllers.store_controller.get_order_by_id', autospec=True, + return_value=(order, 200, {'Content-Type': 'application/json'}), + ), production_client() as client: + assert_json_response(client.get('/v2/store/order/1'), expected) + + +def test_generated_test_app_serializes_models(): + from openapi_server.test import BaseTestCase + + pet, expected = pet_and_json() + with patch( + 'openapi_server.controllers.pet_controller.get_pet_by_id', autospec=True, + return_value=(pet, 200, {'Content-Type': 'application/json'}), + ): + case = BaseTestCase() + if CONNEXION_3: + case.setUp() + try: + response = case.client.open('/v2/pet/7', headers={'api_key': 'special-key'}) + finally: + case.tearDown() + else: + with case.create_app().test_client() as client: + response = client.get('/v2/pet/7', headers={'api_key': 'special-key'}) + assert response.status_code == 200 + assert json.loads(response.data) == expected + + +def test_swagger_ui(): + with production_client() as client: + response = client.get('/v2/ui/') + assert response.status_code == 200 + assert response.headers['Content-Type'].split(';')[0] == 'text/html' + body = response.content if CONNEXION_3 else response.data + assert b'swagger-ui' in body diff --git a/samples/server/petstore/python-flask-connexion3/.travis.yml b/samples/server/petstore/python-flask-connexion3/.travis.yml index 6a695d87161a..e59f169172f8 100644 --- a/samples/server/petstore/python-flask-connexion3/.travis.yml +++ b/samples/server/petstore/python-flask-connexion3/.travis.yml @@ -1,10 +1,12 @@ # ref: https://docs.travis-ci.com/user/languages/python language: python python: - - "3.9" - - "3.10" - "3.11" - "3.12" + - "3.13" + - "3.14" + - "3.15" + # command to install dependencies install: "pip install -r requirements.txt" # command to run tests diff --git a/samples/server/petstore/python-flask-connexion3/README.md b/samples/server/petstore/python-flask-connexion3/README.md index 673c8b3b5a01..0127468b3aad 100644 --- a/samples/server/petstore/python-flask-connexion3/README.md +++ b/samples/server/petstore/python-flask-connexion3/README.md @@ -8,7 +8,7 @@ is an example of building a OpenAPI-enabled Flask server. This example uses the [Connexion](https://github.com/zalando/connexion) library on top of Flask. ## Requirements -Python 3.5.2+ +Python 3.11+ ## Usage To run the server, please execute the following from the root directory: diff --git a/samples/server/petstore/python-flask-connexion3/openapi_server/encoder.py b/samples/server/petstore/python-flask-connexion3/openapi_server/encoder.py index 9f7eb07780af..f4f39d6194b3 100644 --- a/samples/server/petstore/python-flask-connexion3/openapi_server/encoder.py +++ b/samples/server/petstore/python-flask-connexion3/openapi_server/encoder.py @@ -8,25 +8,24 @@ from openapi_server.models.base_model import Model +def _model_to_dict(model, include_nulls): + dikt = {} + for attr in model.openapi_types: + value = getattr(model, attr) + if value is None and not include_nulls: + continue + dikt[model.attribute_map[attr]] = value + return dikt + + class JSONEncoder(json.JSONEncoder): include_nulls = False def default(self, o): if isinstance(o, Model): - dikt = {} - for attr in o.openapi_types: - value = getattr(o, attr) - if value is None and not self.include_nulls: - continue - attr = o.attribute_map[attr] - dikt[attr] = value - return dikt - # Connexion 3 removed FlaskJSONEncoder, which is what used to - # give us this handling for free -- replicate it here so - # date/datetime/Decimal/UUID-typed model attributes (e.g. an - # OpenAPI `date-time` field) still serialize instead of raising - # TypeError. http_date() matches Flask's own default encoder - # behavior for date/datetime (RFC 2822, not ISO 8601). + return _model_to_dict(o, self.include_nulls) + # Preserve this adapter's HTTP-date and string representations. + # Connexion 2 delegates special types to its JSON provider instead. if isinstance(o, datetime.date): return http_date(o) if isinstance(o, (decimal.Decimal, uuid.UUID)): diff --git a/samples/server/petstore/python-flask-connexion3/openapi_server/typing_utils.py b/samples/server/petstore/python-flask-connexion3/openapi_server/typing_utils.py index 74e3c913a7db..3da5cb4fca19 100644 --- a/samples/server/petstore/python-flask-connexion3/openapi_server/typing_utils.py +++ b/samples/server/petstore/python-flask-connexion3/openapi_server/typing_utils.py @@ -1,30 +1,14 @@ -import sys -if sys.version_info < (3, 7): - import typing +def is_generic(klass): + """Determine whether klass is a generic class.""" + return hasattr(klass, '__origin__') - def is_generic(klass): - """ Determine whether klass is a generic class """ - return type(klass) == typing.GenericMeta - def is_dict(klass): - """ Determine whether klass is a Dict """ - return klass.__extra__ == dict +def is_dict(klass): + """Determine whether klass is a Dict.""" + return getattr(klass, '__origin__', None) is dict - def is_list(klass): - """ Determine whether klass is a List """ - return klass.__extra__ == list -else: - - def is_generic(klass): - """ Determine whether klass is a generic class """ - return hasattr(klass, '__origin__') - - def is_dict(klass): - """ Determine whether klass is a Dict """ - return klass.__origin__ == dict - - def is_list(klass): - """ Determine whether klass is a List """ - return klass.__origin__ == list +def is_list(klass): + """Determine whether klass is a List.""" + return getattr(klass, '__origin__', None) is list diff --git a/samples/server/petstore/python-flask-connexion3/requirements.txt b/samples/server/petstore/python-flask-connexion3/requirements.txt index e4ecf13f11a1..22311bc59041 100644 --- a/samples/server/petstore/python-flask-connexion3/requirements.txt +++ b/samples/server/petstore/python-flask-connexion3/requirements.txt @@ -1,10 +1,7 @@ -# pinned to 3.3.0+ (not just the first 3.0.0 release) since that's the -# earliest version verified to work with the Jsonifier/test_client setup -# below, and the first to officially support Python 3.13/3.14 -connexion[flask,swagger-ui,uvicorn] >= 3.3.0,<4.0.0 -# connexion's own swagger-ui extra already requires this floor; pinned -# explicitly here too so requirements.txt is self-documenting. -swagger-ui-bundle >= 1.1.0 -python_dateutil >= 2.6.0 -setuptools >= 21.0.0 -Flask >= 2.2.0,<4.0.0 +# Connexion 3.x +connexion[flask,swagger-ui,uvicorn]>=3.3.0,<4.0.0 + +Flask>=2.3.3,<4.0.0 +python-dateutil>=2.8.2 +setuptools>=21.0.0 + diff --git a/samples/server/petstore/python-flask-connexion3/setup.py b/samples/server/petstore/python-flask-connexion3/setup.py index 913927534b9e..ac876d5aa6fb 100644 --- a/samples/server/petstore/python-flask-connexion3/setup.py +++ b/samples/server/petstore/python-flask-connexion3/setup.py @@ -1,21 +1,14 @@ -import sys + from setuptools import setup, find_packages NAME = "openapi_server" VERSION = "1.0.0" -# To install the library, run the following -# -# python setup.py install -# -# prerequisite: setuptools -# http://pypi.python.org/pypi/setuptools REQUIRES = [ "connexion[flask,swagger-ui,uvicorn]>=3.3.0,<4.0.0", - "swagger-ui-bundle>=1.1.0", - "Flask>=2.2.0,<4.0.0", - "python_dateutil>=2.6.0" + "Flask>=2.3.3,<4.0.0", + "python-dateutil>=2.8.2", ] setup( @@ -25,12 +18,20 @@ author_email="", url="", keywords=["OpenAPI", "OpenAPI Petstore"], + python_requires=">=3.11", install_requires=REQUIRES, packages=find_packages(), - package_data={'': ['openapi/openapi.yaml']}, + package_data={ + "": [ + "openapi/openapi.yaml" + ] + }, include_package_data=True, entry_points={ - 'console_scripts': ['openapi_server=openapi_server.__main__:main']}, + "console_scripts": [ + "openapi_server=openapi_server.__main__:main" + ] + }, long_description="""\ This is a sample server Petstore server. For this sample, you can use the api key `special-key` to test the authorization filters. """ diff --git a/samples/server/petstore/python-flask-connexion3/test-requirements.txt b/samples/server/petstore/python-flask-connexion3/test-requirements.txt index 6cfb6fb1ac6f..7f9c5ceda66f 100644 --- a/samples/server/petstore/python-flask-connexion3/test-requirements.txt +++ b/samples/server/petstore/python-flask-connexion3/test-requirements.txt @@ -1,3 +1,3 @@ -pytest~=7.1.0 +pytest>=7.1.0,<10.0.0 pytest-cov>=2.8.1 pytest-randomly>=1.2.3 diff --git a/samples/server/petstore/python-flask/.travis.yml b/samples/server/petstore/python-flask/.travis.yml index ad71ee5ca083..e59f169172f8 100644 --- a/samples/server/petstore/python-flask/.travis.yml +++ b/samples/server/petstore/python-flask/.travis.yml @@ -1,13 +1,12 @@ # ref: https://docs.travis-ci.com/user/languages/python language: python python: - - "3.2" - - "3.3" - - "3.4" - - "3.5" - - "3.6" - - "3.7" - - "3.8" + - "3.11" + - "3.12" + - "3.13" + - "3.14" + - "3.15" + # command to install dependencies install: "pip install -r requirements.txt" # command to run tests diff --git a/samples/server/petstore/python-flask/README.md b/samples/server/petstore/python-flask/README.md index 673c8b3b5a01..0127468b3aad 100644 --- a/samples/server/petstore/python-flask/README.md +++ b/samples/server/petstore/python-flask/README.md @@ -8,7 +8,7 @@ is an example of building a OpenAPI-enabled Flask server. This example uses the [Connexion](https://github.com/zalando/connexion) library on top of Flask. ## Requirements -Python 3.5.2+ +Python 3.11+ ## Usage To run the server, please execute the following from the root directory: diff --git a/samples/server/petstore/python-flask/openapi_server/__main__.py b/samples/server/petstore/python-flask/openapi_server/__main__.py index 6045d0156f05..ff3bdf640566 100644 --- a/samples/server/petstore/python-flask/openapi_server/__main__.py +++ b/samples/server/petstore/python-flask/openapi_server/__main__.py @@ -7,7 +7,7 @@ def main(): app = connexion.App(__name__, specification_dir='./openapi/') - app.app.json_encoder = encoder.JSONEncoder + app.app.json = encoder.JSONProvider(app.app) app.add_api('openapi.yaml', arguments={'title': 'OpenAPI Petstore'}, pythonic_params=True) diff --git a/samples/server/petstore/python-flask/openapi_server/encoder.py b/samples/server/petstore/python-flask/openapi_server/encoder.py index 60f4fa67e7dc..06ef85dea50a 100644 --- a/samples/server/petstore/python-flask/openapi_server/encoder.py +++ b/samples/server/petstore/python-flask/openapi_server/encoder.py @@ -1,19 +1,22 @@ -from connexion.apps.flask_app import FlaskJSONEncoder +from connexion.apps.flask_app import FlaskJSONProvider from openapi_server.models.base_model import Model -class JSONEncoder(FlaskJSONEncoder): +def _model_to_dict(model, include_nulls): + dikt = {} + for attr in model.openapi_types: + value = getattr(model, attr) + if value is None and not include_nulls: + continue + dikt[model.attribute_map[attr]] = value + return dikt + + +class JSONProvider(FlaskJSONProvider): include_nulls = False def default(self, o): if isinstance(o, Model): - dikt = {} - for attr in o.openapi_types: - value = getattr(o, attr) - if value is None and not self.include_nulls: - continue - attr = o.attribute_map[attr] - dikt[attr] = value - return dikt - return FlaskJSONEncoder.default(self, o) + return _model_to_dict(o, self.include_nulls) + return super().default(o) diff --git a/samples/server/petstore/python-flask/openapi_server/test/__init__.py b/samples/server/petstore/python-flask/openapi_server/test/__init__.py index 364aba9fbf88..1ea070916684 100644 --- a/samples/server/petstore/python-flask/openapi_server/test/__init__.py +++ b/samples/server/petstore/python-flask/openapi_server/test/__init__.py @@ -3,7 +3,7 @@ import connexion from flask_testing import TestCase -from openapi_server.encoder import JSONEncoder +from openapi_server.encoder import JSONProvider class BaseTestCase(TestCase): @@ -11,6 +11,6 @@ class BaseTestCase(TestCase): def create_app(self): logging.getLogger('connexion.operation').setLevel('ERROR') app = connexion.App(__name__, specification_dir='../openapi/') - app.app.json_encoder = JSONEncoder + app.app.json = JSONProvider(app.app) app.add_api('openapi.yaml', pythonic_params=True) return app.app diff --git a/samples/server/petstore/python-flask/openapi_server/typing_utils.py b/samples/server/petstore/python-flask/openapi_server/typing_utils.py index 74e3c913a7db..3da5cb4fca19 100644 --- a/samples/server/petstore/python-flask/openapi_server/typing_utils.py +++ b/samples/server/petstore/python-flask/openapi_server/typing_utils.py @@ -1,30 +1,14 @@ -import sys -if sys.version_info < (3, 7): - import typing +def is_generic(klass): + """Determine whether klass is a generic class.""" + return hasattr(klass, '__origin__') - def is_generic(klass): - """ Determine whether klass is a generic class """ - return type(klass) == typing.GenericMeta - def is_dict(klass): - """ Determine whether klass is a Dict """ - return klass.__extra__ == dict +def is_dict(klass): + """Determine whether klass is a Dict.""" + return getattr(klass, '__origin__', None) is dict - def is_list(klass): - """ Determine whether klass is a List """ - return klass.__extra__ == list -else: - - def is_generic(klass): - """ Determine whether klass is a generic class """ - return hasattr(klass, '__origin__') - - def is_dict(klass): - """ Determine whether klass is a Dict """ - return klass.__origin__ == dict - - def is_list(klass): - """ Determine whether klass is a List """ - return klass.__origin__ == list +def is_list(klass): + """Determine whether klass is a List.""" + return getattr(klass, '__origin__', None) is list diff --git a/samples/server/petstore/python-flask/requirements.txt b/samples/server/petstore/python-flask/requirements.txt index 2cb06891ce9f..caf4a801cdef 100644 --- a/samples/server/petstore/python-flask/requirements.txt +++ b/samples/server/petstore/python-flask/requirements.txt @@ -1,13 +1,7 @@ -connexion[swagger-ui] >= 2.6.0; python_version>="3.6" -# 2.3 is the last version that supports python 3.4-3.5 -connexion[swagger-ui] <= 2.3.0; python_version=="3.5" or python_version=="3.4" -# prevent breaking dependencies from advent of connexion>=3.0 -connexion[swagger-ui] <= 2.14.2; python_version>"3.4" -# connexion requires werkzeug but connexion < 2.4.0 does not install werkzeug -# we must peg werkzeug versions below to fix connexion -# https://github.com/zalando/connexion/pull/1044 -werkzeug == 0.16.1; python_version=="3.5" or python_version=="3.4" -swagger-ui-bundle >= 0.0.2 -python_dateutil >= 2.6.0 -setuptools >= 21.0.0 -Flask == 2.1.1 +# Connexion 2.x +connexion[swagger-ui]>=2.15.1,<3.0.0 + +Flask>=2.3.3,<4.0.0 +python-dateutil>=2.8.2 +setuptools>=21.0.0 + diff --git a/samples/server/petstore/python-flask/setup.py b/samples/server/petstore/python-flask/setup.py index 36120e19cf67..4a698ebbfae7 100644 --- a/samples/server/petstore/python-flask/setup.py +++ b/samples/server/petstore/python-flask/setup.py @@ -1,20 +1,14 @@ -import sys + from setuptools import setup, find_packages NAME = "openapi_server" VERSION = "1.0.0" -# To install the library, run the following -# -# python setup.py install -# -# prerequisite: setuptools -# http://pypi.python.org/pypi/setuptools REQUIRES = [ - "connexion>=2.0.2,<=2.14.2", - "swagger-ui-bundle>=0.0.2", - "python_dateutil>=2.6.0" + "connexion[swagger-ui]>=2.15.1,<3.0.0", + "Flask>=2.3.3,<4.0.0", + "python-dateutil>=2.8.2", ] setup( @@ -24,12 +18,20 @@ author_email="", url="", keywords=["OpenAPI", "OpenAPI Petstore"], + python_requires=">=3.11", install_requires=REQUIRES, packages=find_packages(), - package_data={'': ['openapi/openapi.yaml']}, + package_data={ + "": [ + "openapi/openapi.yaml" + ] + }, include_package_data=True, entry_points={ - 'console_scripts': ['openapi_server=openapi_server.__main__:main']}, + "console_scripts": [ + "openapi_server=openapi_server.__main__:main" + ] + }, long_description="""\ This is a sample server Petstore server. For this sample, you can use the api key `special-key` to test the authorization filters. """ diff --git a/samples/server/petstore/python-flask/test-requirements.txt b/samples/server/petstore/python-flask/test-requirements.txt index 58f51d6a0027..e2d2b73958e6 100644 --- a/samples/server/petstore/python-flask/test-requirements.txt +++ b/samples/server/petstore/python-flask/test-requirements.txt @@ -1,4 +1,4 @@ -pytest~=7.1.0 +pytest>=7.1.0,<10.0.0 pytest-cov>=2.8.1 pytest-randomly>=1.2.3 Flask-Testing==0.8.1