Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
d4193db
fix(python-aiohttp, python-blueplanet, python-flask): read header par…
khayashi4337 Oct 22, 2026
910dcae
fix(python-aiohttp, python-blueplanet, python-flask): handle cookie p…
khayashi4337 Oct 22, 2026
ec1eeee
fix(python-aiohttp, python-blueplanet, python-flask): format array de…
khayashi4337 Oct 22, 2026
cf71763
fix(python-aiohttp, python-blueplanet, python-flask): guard None date…
khayashi4337 Oct 23, 2026
ad058d0
Merge remote-tracking branch 'upstream/master' into fix/python-connex…
khayashi4337 Oct 26, 2026
9e43103
fix(python-aiohttp, python-blueplanet, python-flask): don't call obje…
khayashi4337 Oct 26, 2026
cd3fe31
Merge remote-tracking branch 'upstream/master' into fix/python-connex…
khayashi4337 Oct 26, 2026
a82ad55
feat(python-flask, python-aiohttp): deserialize object-typed query an…
khayashi4337 Oct 26, 2026
3b371f1
Merge remote-tracking branch 'upstream/master' into feat/python-conne…
khayashi4337 Oct 28, 2026
28a56b3
fix(python-flask, python-aiohttp): address review of object-typed par…
khayashi4337 Oct 28, 2026
129c4fd
Merge remote-tracking branch 'upstream/master' into feat/python-conne…
khayashi4337 Oct 28, 2026
2217b4b
Merge remote-tracking branch 'upstream/master' into feat/python-conne…
khayashi4337 Nov 2, 2026
3224b5e
Carry serialized query pairs into x-test-query-string
khayashi4337 Nov 2, 2026
2ed95e1
Accept type-less and singleton-array object schemas
khayashi4337 Nov 2, 2026
d033425
Emit no pairs for objects with no serializable scalar properties
khayashi4337 Nov 2, 2026
fe34c94
Drop unencodable array-of-object header param and guard nested props
khayashi4337 Nov 2, 2026
0fc610e
Keep strict object-type gate, restore X-Obj-List, skip unencodable tests
khayashi4337 Nov 2, 2026
9e316da
Render test query-pair names unescaped in flask and aiohttp tests
khayashi4337 Nov 2, 2026
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

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -268,4 +268,10 @@ public String apiFileFolder() {
public String generatorLanguageVersion() {
return "3.5.2+";
}

@Override
protected boolean supportsObjectParameterParsing() {
// Connexion 1.x (Swagger 2.0) has no object-typed query/header parameters.
return false;
}
}
Original file line number Diff line number Diff line change
@@ -1,19 +1,24 @@
import os
import connexion
{{#connexionObjectParams}}
from {{packageName}} import param_parsing
{{/connexionObjectParams}}
{{#featureCORS}}
import aiohttp_cors
{{/featureCORS}}

def main():
options = {
"swagger_ui": True
"swagger_ui": True{{#connexionObjectParams}},
"uri_parser_class": param_parsing.ObjectParameterURIParser{{/connexionObjectParams}}
}
specification_dir = os.path.join(os.path.dirname(__file__), 'openapi')
app = connexion.AioHttpApp(__name__, specification_dir=specification_dir, options=options)
app.add_api('openapi.yaml',
arguments={'title': '{{appName}}'},
pythonic_params=True,
pass_context_arg_name='request')
pass_context_arg_name='request'{{#connexionObjectParams}},
validator_map={'parameter': param_parsing.ObjectParameterValidator}{{/connexionObjectParams}})

{{#featureCORS}}
# Enable CORS for all origins.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,17 @@ import pytest
import os

import connexion
{{#connexionObjectParams}}
from {{packageName}} import param_parsing
{{/connexionObjectParams}}


@pytest.fixture
async def client(aiohttp_client):
logging.getLogger('connexion.operation').setLevel('ERROR')
options = {
"swagger_ui": True
"swagger_ui": True{{#connexionObjectParams}},
"uri_parser_class": param_parsing.ObjectParameterURIParser{{/connexionObjectParams}}
}
specification_dir = os.path.join(os.path.dirname(__file__), '..',
{{#lambda.forwardslash}}
Expand All @@ -22,5 +26,6 @@ async def client(aiohttp_client):
app = connexion.AioHttpApp(__name__, specification_dir=specification_dir,
options=options)
app.add_api('openapi.yaml', pythonic_params=True,
pass_context_arg_name='request')
pass_context_arg_name='request'{{#connexionObjectParams}},
validator_map={'parameter': param_parsing.ObjectParameterValidator}{{/connexionObjectParams}})
return await aiohttp_client(app.app)
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ from aiohttp import web

{{#imports}}{{import}}
{{/imports}}
{{#hasObjectHeaderParam}}
from {{packageName}} import param_parsing
{{/hasObjectHeaderParam}}
from {{packageName}} import util
{{#operations}}
{{#operation}}
Expand Down Expand Up @@ -61,10 +64,13 @@ async def {{operationId}}(request: web.Request, {{#vendorExtensions.x-handler-pa
"""
{{#allParams}}
{{#isHeaderParam}}
{{paramName}} = request.headers.get('{{baseName}}'{{#defaultValue}}, {{{defaultValue}}}{{/defaultValue}})
{{paramName}} = request.headers.get('{{{vendorExtensions.x-python-connexion-quoted-name}}}'{{#defaultValue}}, {{{defaultValue}}}{{/defaultValue}})
{{#vendorExtensions.x-python-connexion-object-style}}
{{paramName}} = param_parsing.parse_simple_object({{paramName}}, explode={{#vendorExtensions.x-python-connexion-object-explode}}True{{/vendorExtensions.x-python-connexion-object-explode}}{{^vendorExtensions.x-python-connexion-object-explode}}False{{/vendorExtensions.x-python-connexion-object-explode}}, model={{baseType}}{{^baseType}}{{#dataType}}{{.}}{{/dataType}}{{/baseType}})
{{/vendorExtensions.x-python-connexion-object-style}}
{{/isHeaderParam}}
{{#isCookieParam}}
{{paramName}} = request.cookies.get('{{baseName}}'{{#defaultValue}}, {{{defaultValue}}}{{/defaultValue}})
{{paramName}} = request.cookies.get('{{{vendorExtensions.x-python-connexion-quoted-name}}}'{{#defaultValue}}, {{{defaultValue}}}{{/defaultValue}})
{{/isCookieParam}}
{{#vendorExtensions.x-python-connexion-cast-int}}
if {{paramName}} is not None:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,12 @@ async def test_{{operationId}}(client):
{{paramName}} = {{{example}}}
{{/bodyParam}}
{{#vendorExtensions.x-test-query-string}}
{{#-first}}params = [{{/-first}}{{^-first}} {{/-first}}('{{name}}', {{{example}}}){{^-last}},{{/-last}}{{#-last}}]{{/-last}}
{{#-first}}params = [{{/-first}}{{^-first}} {{/-first}}('{{{name}}}', {{{example}}}){{^-last}},{{/-last}}{{#-last}}]{{/-last}}
{{/vendorExtensions.x-test-query-string}}
headers = { {{#vendorExtensions.x-preferred-produce}}
'Accept': '{{{mediaType}}}',{{/vendorExtensions.x-preferred-produce}}{{#vendorExtensions.x-preferred-consume}}
'Content-Type': '{{{mediaType}}}',{{/vendorExtensions.x-preferred-consume}}{{#headerParams}}
'{{baseName}}': {{{example}}},{{/headerParams}}{{#authMethods}}
'{{{vendorExtensions.x-python-connexion-quoted-name}}}': {{{example}}},{{/headerParams}}{{#authMethods}}
{{#isOAuth}}'Authorization': 'Bearer special-key',{{/isOAuth}}{{#isApiKey}}{{#isKeyInHeader}}'{{keyParamName}}': 'special-key',{{/isKeyInHeader}}{{/isApiKey}}{{#isBasicBasic}}'Authorization': 'BasicZm9vOmJhcg==',{{/isBasicBasic}}{{#isBasicBearer}}'Authorization': 'Bearer special-key',{{/isBasicBearer}}{{/authMethods}}{{#vendorExtensions.x-test-apikey-cookie}}
'Cookie': '{{{vendorExtensions.x-test-apikey-cookie}}}',{{/vendorExtensions.x-test-apikey-cookie}}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
"""Deserialize object-typed query and header parameters.

Connexion only turns ``style: deepObject`` query parameters into objects. A
``style: form`` query parameter (``?name=a&size=3``) or a ``style: simple``
header (``X-Trace: id,abc``) whose schema is an object reaches validation as
missing or as a plain string, so valid requests are rejected with 400. The
classes below rebuild those objects before Connexion validates them, following
https://spec.openapis.org/oas/v3.0.3#style-values.
"""
try: # Connexion 3
from connexion.uri_parsing import OpenAPIURIParser
from connexion.validators.parameter import ParameterValidator
except ImportError: # Connexion 2
from connexion.decorators.uri_parsing import OpenAPIURIParser
from connexion.decorators.validation import ParameterValidator


_SCALAR_TYPES = ('string', 'integer', 'number', 'boolean')


def _is_object(param):
"""An object schema whose properties are all scalars; these are the only objects that
``style: form``/``simple`` can carry. Must match the generator's isScalarObjectSchema()."""
schema = param.get('schema', {})
properties = schema.get('properties')
if (not properties or schema.get('type') != 'object'
or any(key in schema for key in ('allOf', 'oneOf', 'anyOf'))):
return False
return all(prop.get('type') in _SCALAR_TYPES for prop in properties.values())


def _cast(value, target):
"""Cast a serialized scalar to ``target`` (int, float or bool); leave it unchanged otherwise."""
if not isinstance(value, str):
return value
if target is bool:
lowered = value.lower()
return lowered == 'true' if lowered in ('true', 'false') else value
if target in (int, float):
try:
return target(value)
except ValueError:
return value
return value


_SCHEMA_TYPES = {'integer': int, 'number': float, 'boolean': bool}
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Resolved


def _cast_by_schema(obj, schema):
properties = schema.get('properties', {})
return {key: _cast(value, _SCHEMA_TYPES.get(properties.get(key, {}).get('type')))
for key, value in obj.items()}


def parse_simple_object(value, explode=False, model=None):
"""Parse a ``style: simple`` (or non-exploded ``style: form``) object.

``'k1,v1,k2,v2'`` when ``explode`` is false, ``'k1=v1,k2=v2'`` when true.
When ``model`` is given, values are cast to the types of its attributes.
"""
if not isinstance(value, str):
return value
parts = value.split(',') if value else []
# A malformed value is returned unchanged, so that validation rejects it instead of
# silently dropping part of the input.
if explode:
if any('=' not in part for part in parts):
return value
obj = dict(part.split('=', 1) for part in parts)
else:
if len(parts) % 2:
return value
obj = dict(zip(parts[0::2], parts[1::2]))
if model is not None:
instance = model() # openapi_types/attribute_map are set per instance
types = {instance.attribute_map[attr]: t for attr, t in instance.openapi_types.items()}
obj = {key: _cast(item, types.get(key)) for key, item in obj.items()}
return obj


class ObjectParameterURIParser(OpenAPIURIParser):
"""Collects object-typed ``style: form`` query parameters into a single value."""

def resolve_query(self, query_data):
original = dict(query_data)
query_data = dict(query_data)
consumed = set()
for name, param in self.param_defns.items():
if param.get('in') != 'query' or not _is_object(param) or param.get('style', 'form') != 'form':
continue
if param.get('explode', True):
# ?name=abc&size=3 -> {'name': 'abc', 'size': '3'}; a property may share the parameter's name.
properties = param.get('schema', {}).get('properties', {})
picked = {key: original[key][-1] for key in properties if key in original}
if picked:
query_data[name] = [picked]
consumed.update(picked)
elif name in original:
query_data[name] = [parse_simple_object(original[name][-1])]
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Resolved
# Drop the property keys we consumed so strict_validation does not report them as unknown,
# but keep any that are parameters of their own.
for key in consumed:
if key not in self.param_defns:
query_data.pop(key, None)
return super().resolve_query(query_data)


class ObjectParameterValidator(ParameterValidator):
"""Parses object-typed ``style: simple`` headers before validating them."""

def validate_header_parameter(self, param, request):
value = request.headers.get(param['name'])
if value is not None and _is_object(param):
# Connexion 3 validates header values as they are (no type coercion), so cast the leaves here;
# Connexion 2 would coerce them itself, and casting twice is harmless.
value = parse_simple_object(value, param.get('explode', False))
if isinstance(value, dict): # a malformed value stays a string and fails validation
value = _cast_by_schema(value, param.get('schema', {}))
return self.validate_parameter('header', value, param)
Original file line number Diff line number Diff line change
Expand Up @@ -8,21 +8,29 @@ import connexion
from flask_testing import TestCase

from {{packageName}}.encoder import JSONEncoder
{{#connexionObjectParams}}
from {{packageName}} import param_parsing
{{/connexionObjectParams}}


class BaseTestCase(TestCase):

def create_app(self):
logging.getLogger('connexion.operation').setLevel('ERROR')
app = connexion.App(__name__, specification_dir='../openapi/')
app = connexion.App(__name__, specification_dir='../openapi/'{{#connexionObjectParams}},
options={'uri_parser_class': param_parsing.ObjectParameterURIParser}{{/connexionObjectParams}})
app.app.json_encoder = JSONEncoder
app.add_api('openapi.yaml', pythonic_params=True)
app.add_api('openapi.yaml', pythonic_params=True{{#connexionObjectParams}},
validator_map={'parameter': param_parsing.ObjectParameterValidator}{{/connexionObjectParams}})
return app.app
{{/useConnexion3}}
{{#useConnexion3}}
from connexion.jsonifier import Jsonifier

from {{packageName}}.encoder import JSONEncoder
{{#connexionObjectParams}}
from {{packageName}} import param_parsing
{{/connexionObjectParams}}


class _FlaskStyleResponse:
Expand Down Expand Up @@ -59,7 +67,9 @@ class BaseTestCase(unittest.TestCase):
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))
jsonifier=Jsonifier(cls=JSONEncoder){{#connexionObjectParams}},
uri_parser_class=param_parsing.ObjectParameterURIParser,
validator_map={'parameter': param_parsing.ObjectParameterValidator}{{/connexionObjectParams}})
self._raw_client_cm = app.test_client()
self.client = _FlaskStyleTestClient(self._raw_client_cm.__enter__())

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,20 @@ from flask_cors import CORS
{{/useConnexion3}}

from {{packageName}} import encoder
{{#connexionObjectParams}}
from {{packageName}} import param_parsing
{{/connexionObjectParams}}


def main():
{{^useConnexion3}}
app = connexion.App(__name__, specification_dir='./openapi/')
app = connexion.App(__name__, specification_dir='./openapi/'{{#connexionObjectParams}},
options={'uri_parser_class': param_parsing.ObjectParameterURIParser}{{/connexionObjectParams}})
app.app.json_encoder = encoder.JSONEncoder
app.add_api('openapi.yaml',
arguments={'title': '{{appName}}'},
pythonic_params=True)
pythonic_params=True{{#connexionObjectParams}},
validator_map={'parameter': param_parsing.ObjectParameterValidator}{{/connexionObjectParams}})
{{/useConnexion3}}
{{#useConnexion3}}
app = connexion.FlaskApp(__name__, specification_dir='./openapi/')
Expand All @@ -47,7 +52,9 @@ def main():
app.add_api('openapi.yaml',
arguments={'title': '{{appName}}'},
pythonic_params=True,
jsonifier=Jsonifier(cls=encoder.JSONEncoder))
jsonifier=Jsonifier(cls=encoder.JSONEncoder){{#connexionObjectParams}},
uri_parser_class=param_parsing.ObjectParameterURIParser,
validator_map={'parameter': param_parsing.ObjectParameterValidator}{{/connexionObjectParams}})
{{/useConnexion3}}
{{^useConnexion3}}
{{#featureCORS}}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ from typing import Union

{{#imports}}{{import}} # noqa: E501
{{/imports}}
{{#hasObjectHeaderParam}}
from {{packageName}} import param_parsing
{{/hasObjectHeaderParam}}
from {{packageName}} import util
{{#operations}}
{{#operation}}
Expand Down Expand Up @@ -64,10 +67,13 @@ def {{operationId}}({{#vendorExtensions.x-handler-params}}{{^isBodyParam}}{{para
"""
{{#allParams}}
{{#isHeaderParam}}
{{paramName}} = flask.request.headers.get('{{baseName}}'{{#defaultValue}}, {{{defaultValue}}}{{/defaultValue}})
{{paramName}} = flask.request.headers.get('{{{vendorExtensions.x-python-connexion-quoted-name}}}'{{#defaultValue}}, {{{defaultValue}}}{{/defaultValue}})
{{#vendorExtensions.x-python-connexion-object-style}}
{{paramName}} = param_parsing.parse_simple_object({{paramName}}, explode={{#vendorExtensions.x-python-connexion-object-explode}}True{{/vendorExtensions.x-python-connexion-object-explode}}{{^vendorExtensions.x-python-connexion-object-explode}}False{{/vendorExtensions.x-python-connexion-object-explode}}, model={{baseType}}{{^baseType}}{{#dataType}}{{.}}{{/dataType}}{{/baseType}})
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Resolved
{{/vendorExtensions.x-python-connexion-object-style}}
{{/isHeaderParam}}
{{#isCookieParam}}
{{paramName}} = flask.request.cookies.get('{{baseName}}'{{#defaultValue}}, {{{defaultValue}}}{{/defaultValue}})
{{paramName}} = flask.request.cookies.get('{{{vendorExtensions.x-python-connexion-quoted-name}}}'{{#defaultValue}}, {{{defaultValue}}}{{/defaultValue}})
{{/isCookieParam}}
{{#vendorExtensions.x-python-connexion-cast-int}}
if {{paramName}} is not None:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,12 @@ class {{#operations}}Test{{classname}}(BaseTestCase):
{{paramName}} = {{{example}}}
{{/bodyParam}}
{{#vendorExtensions.x-test-query-string}}
{{#-first}}query_string = [{{/-first}}{{^-first}} {{/-first}}('{{name}}', {{{example}}}){{^-last}},{{/-last}}{{#-last}}]{{/-last}}
{{#-first}}query_string = [{{/-first}}{{^-first}} {{/-first}}('{{{name}}}', {{{example}}}){{^-last}},{{/-last}}{{#-last}}]{{/-last}}
{{/vendorExtensions.x-test-query-string}}
headers = { {{#vendorExtensions.x-preferred-produce}}
'Accept': '{{{mediaType}}}',{{/vendorExtensions.x-preferred-produce}}{{#vendorExtensions.x-preferred-consume}}
'Content-Type': '{{{mediaType}}}',{{/vendorExtensions.x-preferred-consume}}{{#headerParams}}
'{{baseName}}': {{{example}}},{{/headerParams}}{{#authMethods}}
'{{{vendorExtensions.x-python-connexion-quoted-name}}}': {{{example}}},{{/headerParams}}{{#authMethods}}
{{#isOAuth}}'Authorization': 'Bearer special-key',{{/isOAuth}}{{#isApiKey}}{{#isKeyInHeader}}'{{keyParamName}}': 'special-key',{{/isKeyInHeader}}{{/isApiKey}}{{#isBasicBasic}}'Authorization': 'Basic Zm9vOmJhcg==',{{/isBasicBasic}}{{#isBasicBearer}}'Authorization': 'Bearer special-key',{{/isBasicBearer}}{{/authMethods}}{{#vendorExtensions.x-test-apikey-cookie}}
'Cookie': '{{{vendorExtensions.x-test-apikey-cookie}}}',{{/vendorExtensions.x-test-apikey-cookie}}
}
Expand Down
Loading
Loading