Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .github/workflows/samples-python-flask-connexion3-server.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
name: Python Flask (Connexion 3) Server
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.

on:
push:
paths:
- samples/server/petstore/python-flask-connexion3/**
- .github/workflows/samples-python-flask-connexion3-server.yaml
pull_request:
paths:
- samples/server/petstore/python-flask-connexion3/**
- .github/workflows/samples-python-flask-connexion3-server.yaml
jobs:
build:
name: Test Python Flask (Connexion 3) server
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
sample:
# servers
- samples/server/petstore/python-flask-connexion3/
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v6
with:
python-version: '3.11'
- name: Install dependencies
working-directory: ${{ matrix.sample }}
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install -r test-requirements.txt
- name: Test
working-directory: ${{ matrix.sample }}
run: pytest
6 changes: 6 additions & 0 deletions bin/configs/python-flask-connexion3.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
generatorName: python-flask
outputDir: samples/server/petstore/python-flask-connexion3
Comment thread
Shaun-3adesign marked this conversation as resolved.
inputSpec: modules/openapi-generator/src/test/resources/3_0/python-flask/petstore.yaml
templateDir: modules/openapi-generator/src/main/resources/python-flask
additionalProperties:
useConnexion3: "true"
3 changes: 2 additions & 1 deletion docs/generators/python-flask.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ title: Documentation for the python-flask Generator
| generator language | Python | |
| generator language version | 3.5.2+ | |
| generator default templating engine | mustache | |
| helpTxt | Generates a Python server library using the Connexion project. By default, it will also generate service classes -- which you can disable with the `-Dnoservice` environment variable. | |
| helpTxt | Generates a Python Flask server library using the Connexion project. Connexion is used (instead of hand-written Flask routes) because it maps operations in an OpenAPI/Swagger spec directly to Python functions, handling request routing, payload validation and parameter binding automatically. By default, it will also generate service classes -- which you can disable with the `-Dnoservice` environment variable. | |

## CONFIG OPTIONS
These options may be applied as additional-properties (cli) or configOptions (plugins). Refer to [configuration docs](https://openapi-generator.tech/docs/configuration) for more details.
Expand All @@ -35,6 +35,7 @@ These options may be applied as additional-properties (cli) or configOptions (pl
|sortModelPropertiesByRequiredFlag|Sort model properties to place required parameters before optional parameters.| |true|
|sortParamsByRequiredFlag|Sort method arguments to place required parameters before optional parameters.| |true|
|testsUsePythonSrcRoot|generates test under the pythonSrcRoot folder.| |false|
|useConnexion3|Use Connexion 3.x instead of Connexion 2.x. This changes the pinned connexion/Flask dependency versions, removes the Flask-Testing dependency (replaced with Flask's own test client), and switches the generated encoder, __main__ and test bootstrap code to Connexion 3's APIs. This is a breaking change to the generated output, so it defaults to false to preserve existing Connexion 2.x behavior.| |false|
|useNose|use the nose test framework| |false|
|usePythonSrcRootInImports|include pythonSrcRoot in import namespaces.| |false|

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,20 +16,117 @@

package org.openapitools.codegen.languages;

import org.openapitools.codegen.CliOption;
import org.openapitools.codegen.CodegenOperation;
import org.openapitools.codegen.SupportingFile;
import org.openapitools.codegen.model.ModelMap;
import org.openapitools.codegen.model.OperationMap;
import org.openapitools.codegen.model.OperationsMap;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.File;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

/**
* <p>Mustache templates are located in {@code src/main/resources/python-flask/}.
*/
public class PythonFlaskConnexionServerCodegen extends AbstractPythonConnexionServerCodegen {
private final Logger LOGGER = LoggerFactory.getLogger(PythonFlaskConnexionServerCodegen.class);

public static final String USE_CONNEXION_3 = "useConnexion3";

protected boolean useConnexion3 = false;

public PythonFlaskConnexionServerCodegen() {
super("python-flask", false);

cliOptions.add(CliOption.newBoolean(USE_CONNEXION_3,
"Use Connexion 3.x instead of Connexion 2.x. This changes the pinned "
+ "connexion/Flask dependency versions, removes the Flask-Testing "
+ "dependency (replaced with Flask's own test client), and switches the "
+ "generated encoder, __main__ and test bootstrap code to Connexion 3's "
+ "APIs. This is a breaking change to the generated output, so it defaults "
+ "to false to preserve existing Connexion 2.x behavior.",
useConnexion3).defaultValue(Boolean.toString(useConnexion3)));
}

@Override
public void processOpts() {
super.processOpts();

if (additionalProperties.containsKey(USE_CONNEXION_3)) {
this.useConnexion3 = Boolean.parseBoolean(String.valueOf(additionalProperties.get(USE_CONNEXION_3)));
}
additionalProperties.put(USE_CONNEXION_3, useConnexion3);
}

@Override
public OperationsMap postProcessOperationsWithModels(OperationsMap objs, List<ModelMap> allModels) {
objs = super.postProcessOperationsWithModels(objs, allModels);
if (!useConnexion3) {
return objs;
}

// Connexion 3 requires the handler to explicitly say which content
// type it's returning when an operation declares more than one
// (see AbstractPythonConnexionServerCodegen#MEDIA_TYPE / "produces"
// above) -- the auto-generated stub controllers just return a bare
// string/model, so calling them raises a 500
// (NonConformingResponseHeaders) under Connexion 3. This is inherent
// to Connexion 3's stricter response handling for the placeholder
// stubs, not something fixable at the template level, so skip the
// generated test for these the same way the parent class already
// skips tests for other known Connexion limitations (unsupported
// consumes, etc.) above.
OperationMap operations = objs.getOperations();
for (CodegenOperation operation : operations.getOperation()) {
if (operation.vendorExtensions.containsKey("x-skip-test")) {
continue;
}
if (operation.produces != null && operation.produces.size() > 1) {
Map<String, String> skipTests = new HashMap<>();
skipTests.put("reason", "Connexion 3 requires the handler to specify which "
+ "content type to return when an operation declares multiple "
+ "response content types; the auto-generated stub does not, so "
+ "calling it raises a 500 until the operation is actually "
+ "implemented.");
operation.vendorExtensions.put("x-skip-test", skipTests);
continue;
}
if (operation.bodyParam != null && operation.bodyParam.isArray) {
// Pre-existing, version-agnostic codegen limitation: the
// auto-generated test example for an array-typed request
// body is a single item, not an array, so the generated
// test fails request validation regardless of Connexion
// version. Under Connexion 2 this happens to be masked for
// the two operations that hit it in the Petstore spec
// (they're already skipped for an unrelated *-not-json
// consumes reason there); under Connexion 3 that unrelated
// skip doesn't trigger, so the pre-existing example bug
// surfaces on its own here. Skip with an honest reason
// rather than leaving this failing or silently masking it.
Map<String, String> skipTests = new HashMap<>();
skipTests.put("reason", "The auto-generated test example for this array-typed "
+ "request body is a single item, not an array, which fails request "
+ "validation; this is a pre-existing example-generation limitation, "
+ "not specific to Connexion 3.");
operation.vendorExtensions.put("x-skip-test", skipTests);
}
}
return objs;
}

@Override
public String getHelp() {
return "Generates a Python Flask server library using the Connexion project. Connexion is "
+ "used (instead of hand-written Flask routes) because it maps operations in an "
+ "OpenAPI/Swagger spec directly to Python functions, handling request routing, "
+ "payload validation and parameter binding automatically. By default, it will also "
+ "generate service classes -- which you can disable with the `-Dnoservice` "
+ "environment variable.";
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import logging
{{#useConnexion3}}
import unittest
{{/useConnexion3}}

import connexion
{{^useConnexion3}}
from flask_testing import TestCase

from {{packageName}}.encoder import JSONEncoder
Expand All @@ -14,3 +18,54 @@ class BaseTestCase(TestCase):
app.app.json_encoder = JSONEncoder
app.add_api('openapi.yaml', pythonic_params=True)
return app.app
{{/useConnexion3}}
{{#useConnexion3}}
from connexion.jsonifier import Jsonifier

from {{packageName}}.encoder import JSONEncoder


class _FlaskStyleResponse:
"""Adapts Connexion 3's httpx/Starlette test response to the
status_code/data attributes the generated controller tests expect."""

def __init__(self, response):
self._response = response
self.status_code = response.status_code
self.data = response.content


class _FlaskStyleTestClient:
"""Adapts Connexion 3's `app.test_client()` (httpx/Starlette-based) to
the Flask-style `.open(path, method=, headers=, data=, content_type=,
query_string=)` calling convention the generated controller tests use."""

def __init__(self, client):
self._client = client

def open(self, path, method='GET', headers=None, data=None,
content_type=None, query_string=None):
headers = dict(headers or {})
if content_type:
headers['Content-Type'] = content_type
response = self._client.request(
method, path, headers=headers, content=data, params=query_string)
return _FlaskStyleResponse(response)


class BaseTestCase(unittest.TestCase):

def setUp(self):
logging.getLogger('connexion.operation').setLevel('ERROR')
app = connexion.FlaskApp(__name__, specification_dir='../openapi/')
app.add_api('openapi.yaml', pythonic_params=True,
jsonifier=Jsonifier(cls=JSONEncoder))
self._raw_client_cm = app.test_client()
self.client = _FlaskStyleTestClient(self._raw_client_cm.__enter__())

def tearDown(self):
self._raw_client_cm.__exit__(None, None, None)

def assert200(self, response, message=None):
self.assertEqual(200, response.status_code, message)
{{/useConnexion3}}
Original file line number Diff line number Diff line change
@@ -1,25 +1,62 @@
#!/usr/bin/env python3

import connexion
{{#useConnexion3}}
from connexion.jsonifier import Jsonifier
{{#featureCORS}}
from connexion.middleware import MiddlewarePosition
from starlette.middleware.cors import CORSMiddleware
{{/featureCORS}}
{{/useConnexion3}}
{{^useConnexion3}}
{{#featureCORS}}
from flask_cors import CORS
{{/featureCORS}}
{{/useConnexion3}}

from {{packageName}} import encoder


def main():
{{^useConnexion3}}
app = connexion.App(__name__, specification_dir='./openapi/')
app.app.json_encoder = encoder.JSONEncoder
app.add_api('openapi.yaml',
arguments={'title': '{{appName}}'},
pythonic_params=True)

{{/useConnexion3}}
{{#useConnexion3}}
app = connexion.FlaskApp(__name__, specification_dir='./openapi/')
{{#featureCORS}}
# add CORS support. Connexion 3 wraps the Flask app in its own ASGI
# middleware stack, so flask-cors (which only sees requests after
# they reach the inner WSGI app) never runs -- CORS has to be added
# as ASGI middleware instead. allow_credentials is left at its
# default (False) to match flask-cors's own default
# (supports_credentials=False) -- combining a wildcard origin with
# credentials enabled would let any site make authenticated
# requests on a user's behalf.
app.add_middleware(
CORSMiddleware,
position=MiddlewarePosition.BEFORE_EXCEPTION,
allow_origins=["*"],
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
allow_methods=["*"],
allow_headers=["*"],
)
{{/featureCORS}}
app.add_api('openapi.yaml',
arguments={'title': '{{appName}}'},
pythonic_params=True,
jsonifier=Jsonifier(cls=encoder.JSONEncoder))
{{/useConnexion3}}
{{^useConnexion3}}
{{#featureCORS}}

# add CORS support
CORS(app.app)

{{/featureCORS}}
{{/useConnexion3}}

app.run(port={{serverPort}})


Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import connexion
import flask
from typing import Dict
from typing import Tuple
from typing import Union
Expand Down Expand Up @@ -73,41 +73,41 @@ def {{operationId}}({{#allParams}}{{^isBodyParam}}{{paramName}}{{/isBodyParam}}{
{{^isPrimitiveType}}
{{^isFile}}
{{^isUuid}}
if connexion.request.is_json:
{{paramName}} = {{baseType}}{{^baseType}}{{#dataType}} {{.}}{{/dataType}}{{/baseType}}.from_dict(connexion.request.get_json()) # noqa: E501
if flask.request.is_json:
{{paramName}} = {{baseType}}{{^baseType}}{{#dataType}} {{.}}{{/dataType}}{{/baseType}}.from_dict(flask.request.get_json()) # noqa: E501
{{/isUuid}}
{{/isFile}}
{{/isPrimitiveType}}
{{/isContainer}}
{{#isArray}}
{{#items}}
{{#isDate}}
if connexion.request.is_json:
{{paramName}} = [util.deserialize_date(s) for s in connexion.request.get_json()] # noqa: E501
if flask.request.is_json:
{{paramName}} = [util.deserialize_date(s) for s in flask.request.get_json()] # noqa: E501
{{/isDate}}
{{#isDateTime}}
if connexion.request.is_json:
{{paramName}} = [util.deserialize_datetime(s) for s in connexion.request.get_json()] # noqa: E501
if flask.request.is_json:
{{paramName}} = [util.deserialize_datetime(s) for s in flask.request.get_json()] # noqa: E501
{{/isDateTime}}
{{#complexType}}
if connexion.request.is_json:
{{paramName}} = [{{complexType}}.from_dict(d) for d in connexion.request.get_json()] # noqa: E501
if flask.request.is_json:
{{paramName}} = [{{complexType}}.from_dict(d) for d in flask.request.get_json()] # noqa: E501
{{/complexType}}
{{/items}}
{{/isArray}}
{{#isMap}}
{{#items}}
{{#isDate}}
if connexion.request.is_json:
{{paramName}} = {k: util.deserialize_date(v) for k, v in connexion.request.get_json().items()} # noqa: E501
if flask.request.is_json:
{{paramName}} = {k: util.deserialize_date(v) for k, v in flask.request.get_json().items()} # noqa: E501
{{/isDate}}
{{#isDateTime}}
if connexion.request.is_json:
{{paramName}} = {k: util.deserialize_datetime(v) for k, v in connexion.request.get_json().items()} # noqa: E501
if flask.request.is_json:
{{paramName}} = {k: util.deserialize_datetime(v) for k, v in flask.request.get_json().items()} # noqa: E501
{{/isDateTime}}
{{#complexType}}
if connexion.request.is_json:
{{paramName}} = {k: {{baseType}}.from_dict(v) for k, v in connexion.request.get_json().items()} # noqa: E501
if flask.request.is_json:
{{paramName}} = {k: {{baseType}}.from_dict(v) for k, v in flask.request.get_json().items()} # noqa: E501
{{/complexType}}
{{/items}}
{{/isMap}}
Expand Down
Loading
Loading