Skip to content

Commit

Permalink
Generate examples with jsf (#1891)
Browse files Browse the repository at this point in the history
Fixes #1719 .

Builds on work of #1718,
pulling in external library to provide the fake data from JSON schema.

Changes proposed in this pull request:

* Use JSF library to generate sample data for mocking APIs without
examples.
* Add this as a new extra dependency "mock"

See discussion on #1870

---------

Co-authored-by: Robbe Sneyders <robbe.sneyders@gmail.com>
  • Loading branch information
mjp4 and RobbeSneyders authored Mar 20, 2024
1 parent e56bc3b commit e6ceb1f
Show file tree
Hide file tree
Showing 9 changed files with 231 additions and 59 deletions.
7 changes: 6 additions & 1 deletion connexion/mock.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,4 +51,9 @@ def mock_operation(self, operation, *args, **kwargs):
resp, code = operation.example_response()
if resp is not None:
return resp, code
return "No example response was defined.", code
return (
"No example response defined in the API, and response "
"auto-generation disabled. To enable response auto-generation, "
"install connexion using the mock extra (connexion[mock])",
501,
)
28 changes: 4 additions & 24 deletions connexion/operations/openapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from connexion.datastructures import MediaTypeDict
from connexion.operations.abstract import AbstractOperation
from connexion.uri_parsing import OpenAPIURIParser
from connexion.utils import deep_get
from connexion.utils import build_example_from_schema, deep_get

logger = logging.getLogger("connexion.operations.openapi3")

Expand Down Expand Up @@ -187,31 +187,11 @@ def example_response(self, status_code=None, content_type=None):
pass

try:
return (
self._nested_example(deep_get(self._responses, schema_path)),
status_code,
)
schema = deep_get(self._responses, schema_path)
except KeyError:
return (None, status_code)
return ("No example response or response schema defined.", status_code)

def _nested_example(self, schema):
try:
return schema["example"]
except KeyError:
pass
try:
# Recurse if schema is an object
return {
key: self._nested_example(value)
for (key, value) in schema["properties"].items()
}
except KeyError:
pass
try:
# Recurse if schema is an array
return [self._nested_example(schema["items"])]
except KeyError:
raise
return (build_example_from_schema(schema), status_code)

def get_path_parameter_types(self):
types = {}
Expand Down
28 changes: 4 additions & 24 deletions connexion/operations/swagger2.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from connexion.exceptions import InvalidSpecification
from connexion.operations.abstract import AbstractOperation
from connexion.uri_parsing import Swagger2URIParser
from connexion.utils import deep_get
from connexion.utils import build_example_from_schema, deep_get

logger = logging.getLogger("connexion.operations.swagger2")

Expand Down Expand Up @@ -209,31 +209,11 @@ def example_response(self, status_code=None, *args, **kwargs):
pass

try:
return (
self._nested_example(deep_get(self._responses, schema_path)),
status_code,
)
schema = deep_get(self._responses, schema_path)
except KeyError:
return (None, status_code)
return ("No example response or response schema defined.", status_code)

def _nested_example(self, schema):
try:
return schema["example"]
except KeyError:
pass
try:
# Recurse if schema is an object
return {
key: self._nested_example(value)
for (key, value) in schema["properties"].items()
}
except KeyError:
pass
try:
# Recurse if schema is an array
return [self._nested_example(schema["items"])]
except KeyError:
raise
return (build_example_from_schema(schema), status_code)

def body_name(self, content_type: str = None) -> str:
return self.body_definition(content_type).get("name", "body")
Expand Down
32 changes: 32 additions & 0 deletions connexion/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -512,3 +512,35 @@ def sort_apis_by_basepath(apis: t.List["API"]) -> t.List["API"]:
:return: List of APIs sorted by basepath
"""
return sort_routes(apis, key=lambda api: api.base_path or "/")


def build_example_from_schema(schema):
if "example" in schema:
return schema["example"]

if "properties" in schema:
# Recurse if schema is an object
return {
key: build_example_from_schema(value)
for (key, value) in schema["properties"].items()
}

if "items" in schema:
# Recurse if schema is an array
min_item_count = schema.get("minItems", 0)
max_item_count = schema.get("maxItems")

if max_item_count is None or max_item_count >= min_item_count + 1:
item_count = min_item_count + 1
else:
item_count = min_item_count

return [build_example_from_schema(schema["items"]) for n in range(item_count)]

try:
from jsf import JSF
except ImportError:
return None

faker = JSF(schema)
return faker.generate()
5 changes: 4 additions & 1 deletion docs/cli.rst
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,10 @@ Running a mock server
---------------------

You can run a simple server which returns example responses on every request.
The example responses must be defined in the ``examples`` response property of the OpenAPI specification.

The example responses can be defined in the ``examples`` response property of
the OpenAPI specification. If no examples are specified, and you have installed connexion with the `mock` extra (`pip install connexion[mock]`), an example is generated based on the provided schema.

Your API specification file is not required to have any ``operationId``.

.. code-block:: bash
Expand Down
8 changes: 5 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -48,24 +48,26 @@ python = '^3.8'
asgiref = ">= 3.4"
httpx = ">= 0.23"
inflection = ">= 0.3.1"
jsonschema = ">= 4.0.1"
jsonschema = ">=4.17.3"
Jinja2 = ">= 3.0.0"
python-multipart = ">= 0.0.5"
PyYAML = ">= 5.1"
requests = ">= 2.27"
starlette = ">= 0.35"
typing-extensions = ">= 4"
typing-extensions = ">= 4.6.1"
werkzeug = ">= 2.2.1"

a2wsgi = { version = ">= 1.7", optional = true }
flask = { version = ">= 2.2", extras = ["async"], optional = true }
swagger-ui-bundle = { version = ">= 1.1.0", optional = true }
uvicorn = { version = ">= 0.17.6", extras = ["standard"], optional = true }
jsf = { version = ">=0.10.0", optional = true }

[tool.poetry.extras]
flask = ["a2wsgi", "flask"]
swagger-ui = ["swagger-ui-bundle"]
uvicorn = ["uvicorn"]
mock = ["jsf"]

[tool.poetry.group.tests.dependencies]
pre-commit = "~2.21.0"
Expand Down Expand Up @@ -106,4 +108,4 @@ exclude_lines = [

[[tool.mypy.overrides]]
module = "referencing.jsonschema.*"
follow_imports = "skip"
follow_imports = "skip"
13 changes: 9 additions & 4 deletions tests/test_mock.py
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,8 @@ def test_mock_resolver_no_example_nested_in_object():

response, status_code = resolver.mock_operation(operation)
assert status_code == 200
assert response == "No example response was defined."
assert isinstance(response, dict)
assert isinstance(response["foo"], str)


def test_mock_resolver_no_example_nested_in_list_openapi():
Expand Down Expand Up @@ -256,7 +257,8 @@ def test_mock_resolver_no_example_nested_in_list_openapi():

response, status_code = resolver.mock_operation(operation)
assert status_code == 202
assert response == "No example response was defined."
assert isinstance(response, list)
assert all(isinstance(c, str) for c in response)


def test_mock_resolver_no_examples():
Expand All @@ -278,7 +280,7 @@ def test_mock_resolver_no_examples():

response, status_code = resolver.mock_operation(operation)
assert status_code == 418
assert response == "No example response was defined."
assert response == "No example response or response schema defined."


def test_mock_resolver_notimplemented():
Expand Down Expand Up @@ -315,4 +317,7 @@ def test_mock_resolver_notimplemented():
)

# check if it is using the mock function
assert operation._resolution.function() == ("No example response was defined.", 418)
assert operation._resolution.function() == (
"No example response or response schema defined.",
418,
)
162 changes: 162 additions & 0 deletions tests/test_mock2.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
from datetime import datetime
from re import fullmatch

from connexion.utils import build_example_from_schema


def test_build_example_from_schema_string():
schema = {
"type": "string",
}
example = build_example_from_schema(schema)
assert isinstance(example, str)


def test_build_example_from_schema_integer():
schema = {
"type": "integer",
}
example = build_example_from_schema(schema)
assert isinstance(example, int)


def test_build_example_from_schema_number():
schema = {
"type": "number",
}
example = build_example_from_schema(schema)
assert isinstance(example, float)


def test_build_example_from_schema_boolean():
schema = {
"type": "boolean",
}
example = build_example_from_schema(schema)
assert isinstance(example, bool)


def test_build_example_from_schema_integer_minimum():
schema = {
"type": "integer",
"minimum": 4,
}
example = build_example_from_schema(schema)
assert example >= schema["minimum"] and isinstance(example, int)


def test_build_example_from_schema_integer_maximum():
schema = {
"type": "integer",
"maximum": 17,
}
example = build_example_from_schema(schema)
assert example <= schema["maximum"] and isinstance(example, int)


def test_build_example_from_schema_integer_exclusive_minimum():
schema = {
"type": "integer",
"minimum": 4,
"exclusiveMinimum": True,
}
example = build_example_from_schema(schema)
assert example > schema["minimum"] and isinstance(example, int)


def test_build_example_from_schema_integer_exclusive_maximum():
schema = {
"type": "integer",
"maximum": 17,
"exclusiveMaximum": True,
}
example = build_example_from_schema(schema)
assert example < schema["maximum"] and isinstance(example, int)


def test_build_example_from_schema_string_regular_expression():
pattern = r"^\d{3}-\d{2}-\d{4}$"
schema = {
"type": "string",
"pattern": pattern,
}
example = build_example_from_schema(schema)
assert fullmatch(pattern, example) != None and isinstance(example, str)


def test_build_example_from_schema_string_maximum():
schema = {
"type": "string",
"maxLength": 20,
}
example = build_example_from_schema(schema)
assert isinstance(example, str) and len(example) <= schema["maxLength"]


def test_build_example_from_schema_string_minimum():
schema = {
"type": "string",
"minLength": 20,
}
example = build_example_from_schema(schema)
assert isinstance(example, str) and len(example) >= schema["minLength"]


def test_build_example_from_schema_enum():
schema = {"type": "string", "enum": ["asc", "desc"]}
example = build_example_from_schema(schema)
assert isinstance(example, str)
assert example == "asc" or example == "desc"


def test_build_example_from_complex_schema():
schema = {
"type": "object",
"properties": {
"datetimeField": {"type": "string", "format": "date-time"},
"integerField": {
"type": "integer",
"minimum": 2,
"maximum": 5,
"exclusiveMinimum": True,
"multipleOf": 2,
},
"arrayOfNumbersField": {
"type": "array",
"items": {
"type": "number",
"format": "float",
"minimum": 0.1,
"maximum": 0.9,
"multipleOf": 0.1,
},
"minItems": 3,
"maxItems": 5,
},
"objectField": {
"type": "object",
"properties": {
"nestedBoolean": {"type": "boolean"},
"stringWithExample": {
"type": "string",
"example": "example-string",
},
},
},
},
}
example = build_example_from_schema(schema)

# Check that ValueError is not raised on invalid datetime.
datetime.fromisoformat(example["datetimeField"])
assert example["integerField"] == 4

assert isinstance(example["arrayOfNumbersField"], list)
assert 3 <= len(example["arrayOfNumbersField"]) <= 5
assert all(0.1 <= num <= 0.9 for num in example["arrayOfNumbersField"])

example_boolean = example["objectField"]["nestedBoolean"]
assert example_boolean is True or example_boolean is False

# Check that if an example is provided then it is used directly.
assert example["objectField"]["stringWithExample"] == "example-string"
Loading

0 comments on commit e6ceb1f

Please sign in to comment.