Skip to content

Commit

Permalink
fix: Generating incomplete explicit examples
Browse files Browse the repository at this point in the history
Ref: #1007
  • Loading branch information
Stranger6667 committed Jan 11, 2021
1 parent 63622e7 commit 9e74ec6
Show file tree
Hide file tree
Showing 4 changed files with 105 additions and 37 deletions.
2 changes: 2 additions & 0 deletions docs/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ Changelog
- CLI crash on schemas with operation names longer than the current terminal width. `#990`_
- Handling of API operations that contain reserved characters in their paths. `#992`_
- CLI execution stops on errors during example generation. `#994`_
- Fill missing properties in incomplete explicit examples for non-body parameters. `#1007`_

**Deprecated**

Expand Down Expand Up @@ -1657,6 +1658,7 @@ Deprecated
.. _0.3.0: https://github.com/schemathesis/schemathesis/compare/v0.2.0...v0.3.0
.. _0.2.0: https://github.com/schemathesis/schemathesis/compare/v0.1.0...v0.2.0

.. _#1007: https://github.com/schemathesis/schemathesis/issues/1007
.. _#1003: https://github.com/schemathesis/schemathesis/issues/1003
.. _#994: https://github.com/schemathesis/schemathesis/issues/994
.. _#992: https://github.com/schemathesis/schemathesis/issues/992
Expand Down
75 changes: 52 additions & 23 deletions src/schemathesis/specs/openapi/_hypothesis.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import json
import re
from base64 import b64encode
from contextlib import contextmanager
from typing import Any, Callable, Dict, Generator, Optional, Tuple
from contextlib import contextmanager, suppress
from typing import Any, Callable, Dict, Generator, Iterable, Optional, Tuple, Union
from urllib.parse import quote_plus

from hypothesis import strategies as st
Expand All @@ -15,6 +15,7 @@
from ...hooks import GLOBAL_HOOK_DISPATCHER, HookContext, HookDispatcher
from ...models import APIOperation, Case
from ...schemas import BaseSchema
from ...types import NotSet
from ...utils import NOT_SET
from .constants import LOCATION_TO_CONTAINER
from .parameters import OpenAPIParameter, parameters_to_json_schema
Expand Down Expand Up @@ -79,38 +80,33 @@ def get_case_strategy( # pylint: disable=too-many-locals
operation: APIOperation,
hooks: Optional[HookDispatcher] = None,
data_generation_method: DataGenerationMethod = DataGenerationMethod.default(),
path_parameters: Any = NOT_SET,
headers: Any = NOT_SET,
cookies: Any = NOT_SET,
query: Any = NOT_SET,
path_parameters: Union[NotSet, Dict[str, Any]] = NOT_SET,
headers: Union[NotSet, Dict[str, Any]] = NOT_SET,
cookies: Union[NotSet, Dict[str, Any]] = NOT_SET,
query: Union[NotSet, Dict[str, Any]] = NOT_SET,
body: Any = NOT_SET,
) -> Any:
"""A strategy that creates `Case` instances.
Explicit `path_parameters`, `headers`, `cookies`, `query`, `body` arguments will be used in the resulting `Case`
object.
If such explicit parameters are composite (not `body`) and don't provide the whole set of parameters for that
location, then we generate what is missing and merge these two parts. Note that if parameters are optional, then
they may remain absent.
The primary purpose of this behavior is to prevent sending incomplete explicit examples by generating missing parts
as it works with `body`.
"""
to_strategy = {DataGenerationMethod.positive: make_positive_strategy}[data_generation_method]

context = HookContext(operation)

with detect_invalid_schema(operation):
if path_parameters is NOT_SET:
strategy = get_parameters_strategy(operation, to_strategy, "path")
strategy = apply_hooks(operation, context, hooks, strategy, "path")
path_parameters = draw(strategy)
if headers is NOT_SET:
strategy = get_parameters_strategy(operation, to_strategy, "header")
strategy = apply_hooks(operation, context, hooks, strategy, "header")
headers = draw(strategy)
if cookies is NOT_SET:
strategy = get_parameters_strategy(operation, to_strategy, "cookie")
strategy = apply_hooks(operation, context, hooks, strategy, "cookie")
cookies = draw(strategy)
if query is NOT_SET:
strategy = get_parameters_strategy(operation, to_strategy, "query")
strategy = apply_hooks(operation, context, hooks, strategy, "query")
query = draw(strategy)
path_parameters = get_parameters_value(path_parameters, "path", draw, operation, context, hooks, to_strategy)
headers = get_parameters_value(headers, "header", draw, operation, context, hooks, to_strategy)
cookies = get_parameters_value(cookies, "cookie", draw, operation, context, hooks, to_strategy)
query = get_parameters_value(query, "query", draw, operation, context, hooks, to_strategy)

media_type = None
if body is NOT_SET:
Expand Down Expand Up @@ -185,8 +181,35 @@ def _get_body_strategy(
return strategy


def get_parameters_value(
value: Union[NotSet, Dict[str, Any]],
location: str,
draw: Callable,
operation: APIOperation,
context: HookContext,
hooks: Optional[HookDispatcher],
to_strategy: Callable[[Dict[str, Any]], st.SearchStrategy],
) -> Dict[str, Any]:
"""Get the final value for the specified location.
If the value is not set, then generate it from the relevant strategy. Otherwise, check what is missing in it and
generate those parts.
"""
if isinstance(value, NotSet):
strategy = get_parameters_strategy(operation, to_strategy, location)
strategy = apply_hooks(operation, context, hooks, strategy, location)
return draw(strategy)
strategy = get_parameters_strategy(operation, to_strategy, location, exclude=value.keys())
strategy = apply_hooks(operation, context, hooks, strategy, location)
value.update(draw(strategy))
return value


def get_parameters_strategy(
operation: APIOperation, to_strategy: Callable[[Dict[str, Any]], st.SearchStrategy], location: str
operation: APIOperation,
to_strategy: Callable[[Dict[str, Any]], st.SearchStrategy],
location: str,
exclude: Iterable[str] = (),
) -> st.SearchStrategy:
"""Create a new strategy for the case's component from the API operation parameters."""
parameters = getattr(operation, LOCATION_TO_CONTAINER[location])
Expand All @@ -198,6 +221,12 @@ def get_parameters_strategy(
# In this case, we know that the `required` keyword should always be `True`.
schema["required"] = list(schema["properties"])
schema = operation.schema.prepare_schema(schema)
for name in exclude:
# Values from `exclude` are not necessarily valid for the schema - they come from user-defined examples
# that may be invalid
schema["properties"].pop(name, None)
with suppress(ValueError):
schema["required"].remove(name)
strategy = to_strategy(schema)
serialize = operation.get_parameter_serializer(location)
if serialize is not None:
Expand Down
31 changes: 31 additions & 0 deletions test/specs/openapi/test_examples.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
from typing import Any, Dict

import jsonschema
import pytest
import yaml
from _pytest.main import ExitCode
from hypothesis import find

import schemathesis
from schemathesis._hypothesis import get_single_example
from schemathesis.models import APIOperation
from schemathesis.specs.openapi import examples
from schemathesis.specs.openapi.parameters import parameters_to_json_schema
from schemathesis.specs.openapi.schemas import BaseOpenAPISchema


Expand Down Expand Up @@ -445,3 +448,31 @@ def test_invalid_x_examples(empty_open_api_2_schema):
schema = schemathesis.from_dict(empty_open_api_2_schema)
# Then such examples should be skipped as invalid (there should be an object)
assert schema["/test"]["POST"].get_strategies_from_examples() == []


def test_partial_examples(empty_open_api_3_schema):
# When the API schema contains multiple parameters in the same location
# And some of them don't have explicit examples and others do
empty_open_api_3_schema["paths"] = {
"/test/{foo}/{bar}/": {
"post": {
"parameters": [
{"name": "foo", "in": "path", "required": True, "schema": {"type": "string", "enum": ["A"]}},
{
"name": "bar",
"in": "path",
"required": True,
"schema": {"type": "string", "example": "bar-example"},
},
],
"responses": {"default": {"description": "OK"}},
}
}
}
schema = schemathesis.from_dict(empty_open_api_3_schema)
operation = schema["/test/{foo}/{bar}/"]["POST"]
strategy = operation.get_strategies_from_examples()[0]
# Then all generated examples should have those missing parts generated according to the API schema
example = get_single_example(strategy)
parameters_schema = parameters_to_json_schema(operation.path_parameters)
jsonschema.validate(example.path_parameters, parameters_schema)
34 changes: 20 additions & 14 deletions test/specs/openapi/test_hypothesis.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,35 +13,41 @@ def operation(make_openapi_3_schema):
"content": {"application/json": {"schema": {"type": "string"}}},
},
parameters=[
{"in": "path", "name": "p1", "required": True, "schema": {"type": "string"}},
{"in": "header", "name": "h1", "required": True, "schema": {"type": "string"}},
{"in": "cookie", "name": "c1", "required": True, "schema": {"type": "string"}},
{"in": "query", "name": "q1", "required": True, "schema": {"type": "string"}},
{"in": "path", "name": "p1", "required": True, "schema": {"type": "string", "enum": ["FOO"]}},
{"in": "header", "name": "h1", "required": True, "schema": {"type": "string", "enum": ["FOO"]}},
{"in": "cookie", "name": "c1", "required": True, "schema": {"type": "string", "enum": ["FOO"]}},
{"in": "query", "name": "q1", "required": True, "schema": {"type": "string", "enum": ["FOO"]}},
],
)
return schemathesis.from_dict(schema)["/users"]["POST"]


@pytest.mark.parametrize(
"values",
"values, expected",
(
{"body": "TEST"},
{"path_parameters": {"p1": "TEST"}},
{"headers": {"h1": "TEST"}},
{"cookies": {"c1": "TEST"}},
{"query": {"q1": "TEST"}},
({"body": "TEST"}, {"body": "TEST"}),
({"path_parameters": {"p1": "TEST"}}, {"path_parameters": {"p1": "TEST"}}),
({"path_parameters": {}}, {"path_parameters": {"p1": "FOO"}}),
({"headers": {"h1": "TEST"}}, {"headers": {"h1": "TEST"}}),
({"headers": {}}, {"headers": {"h1": "FOO"}}),
# Even if the explicit value does not match the schema, it should appear in the output
({"headers": {"invalid": "T"}}, {"headers": {"h1": "FOO", "invalid": "T"}}),
({"cookies": {"c1": "TEST"}}, {"cookies": {"c1": "TEST"}}),
({"cookies": {}}, {"cookies": {"c1": "FOO"}}),
({"query": {"q1": "TEST"}}, {"query": {"q1": "TEST"}}),
({"query": {}}, {"query": {"q1": "FOO"}}),
),
)
def test_explicit_attributes(operation, values):
def test_explicit_attributes(operation, values, expected):
# When some Case's attribute is passed explicitly to the case strategy
strategy = get_case_strategy(operation=operation, **values)

@given(strategy)
@settings(max_examples=1)
def test(case):
# Then it should be taken as is
for attr_name, expected in values.items():
# Then it should appear in the final result
for attr_name, expected_values in expected.items():
value = getattr(case, attr_name)
assert value == expected
assert value == expected_values

test()

0 comments on commit 9e74ec6

Please sign in to comment.