diff --git a/docs/README.md b/docs/README.md index 79366da..cf61123 100644 --- a/docs/README.md +++ b/docs/README.md @@ -67,6 +67,12 @@ Validates OK as OpenAPI 3.0.2! You'll have to change the url or file reference to the location of the openapi document for your API. +> Note: if you encounter a `Recursion reached limit ...` error there is a circular +reference somewhere in your OpenAPI document. +Although recursion is technically allowed under the OAS, tool support is limited +and changing the API to not use recursion is recommended. +At present OpenApiLibCore does not support recursion in the OpenAPI document. + If the openapi document passes this validation, the next step is trying to do a test run with a minimal test suite. The example below can be used, with `source`, `origin` and 'endpoint' altered to diff --git a/docs/openapi_libcore.html b/docs/openapi_libcore.html index 6d91570..9d3115e 100644 --- a/docs/openapi_libcore.html +++ b/docs/openapi_libcore.html @@ -1192,7 +1192,7 @@ jQuery.extend({highlight:function(e,t,n,r){if(e.nodeType===3){var i=e.data.match(t);if(i){var s=document.createElement(n||"span");s.className=r||"highlight";var o=e.splitText(i.index);o.splitText(i[0].length);var u=o.cloneNode(true);s.appendChild(u);o.parentNode.replaceChild(s,o);return 1}}else if(e.nodeType===1&&e.childNodes&&!/(script|style)/i.test(e.tagName)&&!(e.tagName===n.toUpperCase()&&e.className===r)){for(var a=0;a diff --git a/pyproject.toml b/pyproject.toml index a3f12e5..b764908 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name="robotframework-openapi-libcore" -version = "1.1.2" +version = "1.2.0" description = "A Robot Framework library to facilitate library development for OpenAPI / Swagger APIs." license = "Apache-2.0" authors = ["Robin Mackaij"] diff --git a/src/OpenApiLibCore/openapi_libcore.libspec b/src/OpenApiLibCore/openapi_libcore.libspec index e8a82a3..e63317c 100644 --- a/src/OpenApiLibCore/openapi_libcore.libspec +++ b/src/OpenApiLibCore/openapi_libcore.libspec @@ -1,12 +1,12 @@ - -1.1.2 + +1.2.0 <p>Main class providing the keywords and core logic to interact with an OpenAPI server.</p> <p>Visit the <a href="https://github.com/MarketSquare/robotframework-openapi-libcore">library page</a> for an introduction.</p> - + source @@ -85,7 +85,7 @@ - + url @@ -118,7 +118,7 @@ <p>&gt; Note: provided username / password or auth objects take precedence over token based security</p> Perform a request using the security token or authentication set in the library. - + url @@ -132,7 +132,7 @@ <p>Ensure that the (right-most) <span class="name">id</span> of the resource referenced by the <span class="name">url</span> is used by the resource defined by the <span class="name">resource_relation</span>.</p> Ensure that the (right-most) `id` of the resource referenced by the `url` is used by the resource defined by the `resource_relation`. - + url @@ -142,7 +142,7 @@ <p>Perform a GET request on the <span class="name">url</span> and return the list of resource <span class="name">ids</span> from the response.</p> Perform a GET request on the `url` and return the list of resource `ids` from the response. - + url @@ -165,7 +165,7 @@ <p>&gt; Note: applicable UniquePropertyValueConstraint and IdReference Relations are considered before changes to <span class="name">json_data</span> are made.</p> Return `json_data` based on the `dto` on the `request_data` that will cause the provided `status_code` for the `method` operation on the `url`. - + status_code @@ -179,7 +179,7 @@ <p>Returns a version of <span class="name">params, headers</span> as present on <span class="name">request_data</span> that has been modified to cause the provided <span class="name">status_code</span>.</p> Returns a version of `params, headers` as present on `request_data` that has been modified to cause the provided `status_code`. - + valid_url @@ -190,7 +190,7 @@ <p>Raises ValueError if the valid_url cannot be invalidated.</p> Return an url with all the path parameters in the `valid_url` replaced by a random UUID. - + schema @@ -209,7 +209,7 @@ <p>Generate a valid (json-compatible) dict for all the <span class="name">dto_class</span> properties.</p> Generate a valid (json-compatible) dict for all the `dto_class` properties. - + url @@ -231,7 +231,7 @@ <p>Return <span class="name">json_data</span> based on the <span class="name">UniquePropertyValueConstraint</span> that must be returned by the <span class="name">get_relations</span> implementation on the <span class="name">dto</span> for the given <span class="name">conflict_status_code</span>.</p> Return `json_data` based on the `UniquePropertyValueConstraint` that must be returned by the `get_relations` implementation on the `dto` for the given `conflict_status_code`. - + url @@ -241,7 +241,7 @@ <p>Return the endpoint as found in the <span class="name">paths</span> section based on the given <span class="name">url</span>.</p> Return the endpoint as found in the `paths` section based on the given `url`. - + endpoint @@ -255,7 +255,7 @@ <p>Return an object with valid request data for body, headers and query params.</p> Return an object with valid request data for body, headers and query params. - + endpoint @@ -270,7 +270,7 @@ <p>To prevent resource conflicts with other test cases, a new resource is created (POST) if possible.</p> Support keyword that returns the `id` for an existing resource at `endpoint`. - + endpoint diff --git a/src/OpenApiLibCore/openapi_libcore.py b/src/OpenApiLibCore/openapi_libcore.py index 76f4c6b..4e38675 100644 --- a/src/OpenApiLibCore/openapi_libcore.py +++ b/src/OpenApiLibCore/openapi_libcore.py @@ -66,6 +66,12 @@ You'll have to change the url or file reference to the location of the openapi document for your API. +> Note: if you encounter a `Recursion reached limit ...` error there is a circular +reference somewhere in your OpenAPI document. +Although recursion is technically allowed under the OAS, tool support is limited +and changing the API to not use recursion is recommended. +At present OpenApiLibCore does not support recursion in the OpenAPI document. + If the openapi document passes this validation, the next step is trying to do a test run with a minimal test suite. The example below can be used, with `source`, `origin` and 'endpoint' altered to @@ -271,6 +277,8 @@ def headers_that_can_be_invalidated(self) -> Set[str]: "exclusiveMaximum", "minLength", "maxLength", + "minItems", + "maxItems", } ): result.add(header["name"]) @@ -807,7 +815,6 @@ def get_invalidated_url(self, valid_url: str) -> Optional[str]: valid_url_parts.reverse() invalid_url = "/".join(valid_url_parts) return invalid_url - # TODO: add support for query parameters that can be invalidated raise ValueError(f"{parameterized_endpoint} could not be invalidated.") @keyword diff --git a/src/OpenApiLibCore/value_utils.py b/src/OpenApiLibCore/value_utils.py index ef5bd47..93b0323 100644 --- a/src/OpenApiLibCore/value_utils.py +++ b/src/OpenApiLibCore/value_utils.py @@ -24,6 +24,8 @@ def get_valid_value(value_schema: Dict[str, Any]) -> Any: return get_random_float(value_schema=value_schema) if value_type == "string": return get_random_string(value_schema=value_schema) + if value_type == "array": + return get_random_array(value_schema=value_schema) raise NotImplementedError(f"Type '{value_type}' is currently not supported") @@ -136,6 +138,20 @@ def get_random_string(value_schema: Dict[str, Any]) -> str: return value +def get_random_array(value_schema: Dict[str, Any]) -> List[Any]: + """Generate a list with random elements as specified by the schema.""" + minimum = value_schema.get("minItems", 0) + maximum = value_schema.get("maxItems", 1) + if minimum > maximum: + maximum = minimum + items_schema = value_schema["items"] + value = [] + for _ in range(maximum): + item_value = get_valid_value(items_schema) + value.append(item_value) + return value + + def get_invalid_value_from_constraint( values_from_constraint: List[Any], value_type: str ) -> Any: @@ -218,6 +234,14 @@ def get_value_out_of_bounds(value_schema: Dict[str, Any], current_value: Any) -> return exclusive_minimum if exclusive_maximum := value_schema.get("exclusiveMaximum"): return exclusive_maximum + if value_type == "array": + if minimum := value_schema.get("minItems", 0) > 0: + return current_value[0 : minimum - 1] + if maximum := value_schema.get("maxItems"): + invalid_value = current_value if current_value else ["x"] + while len(invalid_value) <= maximum: + invalid_value.append(choice(invalid_value)) + return invalid_value if value_type == "string": # if there is a minimum length, send 1 character less if minimum := value_schema.get("minLength", 0): diff --git a/tasks.py b/tasks.py index 81b1fe0..6d9380d 100644 --- a/tasks.py +++ b/tasks.py @@ -1,4 +1,4 @@ -# pylint: disable=missing-function-docstring +# pylint: disable=missing-function-docstring, unused-argument import pathlib import subprocess from importlib.metadata import version @@ -13,8 +13,18 @@ @task def testserver(context): - testserver_path = f"{ROOT}/tests/server/testserver.py" - subprocess.run(f"python {testserver_path}", shell=True, check=False) + cmd = [ + "python", + "-m", + "uvicorn", + "testserver:app", + f"--app-dir {ROOT}/tests/server", + "--host 0.0.0.0", + "--port 8000", + "--reload", + f"--reload-dir {ROOT}/tests/server", + ] + subprocess.run(" ".join(cmd), shell=True, check=False) @task diff --git a/tests/server/testserver.py b/tests/server/testserver.py index 7bf0021..c1deaeb 100644 --- a/tests/server/testserver.py +++ b/tests/server/testserver.py @@ -1,10 +1,10 @@ +# pylint: disable="missing-class-docstring", "missing-function-docstring" import datetime from enum import Enum from sys import float_info from typing import Callable, Dict, List, Optional from uuid import uuid4 -import uvicorn from fastapi import FastAPI, Header, HTTPException, Path, Query, Request, Response from pydantic import BaseModel, confloat, conint, constr @@ -149,7 +149,14 @@ def get_message( # deliberate trailing / @app.get("/events/", status_code=200, response_model=List[Event]) -def get_events() -> List[Event]: +def get_events( + search_strings: Optional[List[str]] = Query(None), +) -> List[Event]: + if search_strings: + result: List[Event] = [] + for search_string in search_strings: + result.extend([e for e in EVENTS if search_string in e.message.message]) + return result return EVENTS @@ -338,11 +345,3 @@ def get_available_employees(weekday: WeekDay = Query(...)) -> List[EmployeeDetai return [ e for e in EMPLOYEES.values() if getattr(e, "parttime_day", None) != weekday ] - - -def main(): - uvicorn.run(app, host="0.0.0.0", port=8000) - - -if __name__ == "__main__": - main() diff --git a/tests/unittests/test_value_utils.py b/tests/unittests/test_value_utils.py index 7bc8346..ce139f6 100644 --- a/tests/unittests/test_value_utils.py +++ b/tests/unittests/test_value_utils.py @@ -1,10 +1,11 @@ +# pylint: disable="missing-class-docstring", "missing-function-docstring" import unittest from sys import float_info -EPSILON = float_info.epsilon - from OpenApiLibCore import IGNORE, value_utils +EPSILON = float_info.epsilon + class TestRandomInteger(unittest.TestCase): def test_default_min_max(self): @@ -149,6 +150,30 @@ def test_min_max(self): self.assertEqual(len(value), 42) +class TestRandomArray(unittest.TestCase): + def test_default_min_max(self): + schema = {"items": {"type": "string"}} + value = value_utils.get_random_array(schema) + self.assertEqual(len(value), 1) + + schema = {"maxItems": 0, "items": {"type": "string"}} + value = value_utils.get_random_array(schema) + self.assertEqual(value, []) + + def test_min_max(self): + schema = {"maxItems": 3, "items": {"type": "string"}} + value = value_utils.get_random_array(schema) + self.assertEqual(len(value), 3) + + schema = {"minItems": 5, "items": {"type": "string"}} + value = value_utils.get_random_array(schema) + self.assertEqual(len(value), 5) + + schema = {"minItems": 7, "maxItems": 5, "items": {"type": "string"}} + value = value_utils.get_random_array(schema) + self.assertEqual(len(value), 7) + + class TestGetValidValue(unittest.TestCase): def test_enum(self): schema = {"enum": ["foo", "bar"]} @@ -175,8 +200,32 @@ def test_string(self): value = value_utils.get_valid_value(schema) self.assertIsInstance(value, str) + def test_bool_array(self): + schema = {"type": "array", "items": {"type": "boolean"}} + value = value_utils.get_valid_value(schema) + self.assertIsInstance(value, list) + self.assertIsInstance(value[0], bool) + + def test_int_array(self): + schema = {"type": "array", "items": {"type": "integer"}} + value = value_utils.get_valid_value(schema) + self.assertIsInstance(value, list) + self.assertIsInstance(value[0], int) + + def test_number_array(self): + schema = {"type": "array", "items": {"type": "number"}} + value = value_utils.get_valid_value(schema) + self.assertIsInstance(value, list) + self.assertIsInstance(value[0], float) + + def test_string_array(self): + schema = {"type": "array", "items": {"type": "string"}} + value = value_utils.get_valid_value(schema) + self.assertIsInstance(value, list) + self.assertIsInstance(value[0], str) + def test_raises(self): - schema = {"type": "array"} + schema = {"type": "object"} self.assertRaises(NotImplementedError, value_utils.get_valid_value, schema) @@ -536,7 +585,7 @@ def test_maximum_length(self): self.assertGreater(len(value), maximum) self.assertIsInstance(value, str) - def test_minimum_zero(self): + def test_minimum_length_zero(self): minimum = 0 value_schema = {"type": "string", "minLength": minimum} current_value = "irrelevant" @@ -546,7 +595,7 @@ def test_minimum_zero(self): ) self.assertEqual(value, None) - def test_maximum_zero(self): + def test_maximum_length_zero(self): maximum = 0 value_schema = {"type": "string", "maxLength": maximum} current_value = "irrelevant" @@ -556,6 +605,50 @@ def test_maximum_zero(self): ) self.assertEqual(value, None) + def test_min_items(self): + minimum = 1 + value_schema = { + "type": "array", + "minItems": minimum, + "items": {"type": "string"}, + } + current_value = ["irrelevant"] + value = value_utils.get_value_out_of_bounds( + value_schema=value_schema, + current_value=current_value, + ) + self.assertLess(len(value), minimum) + self.assertIsInstance(value, list) + + def test_max_items(self): + maximum = 3 + value_schema = { + "type": "array", + "maxItems": maximum, + "items": {"type": "boolean"}, + } + current_value = [True, False] + value = value_utils.get_value_out_of_bounds( + value_schema=value_schema, + current_value=current_value, + ) + self.assertGreater(len(value), maximum) + self.assertIsInstance(value, list) + + def test_min_items_zero(self): + minimum = 0 + value_schema = { + "type": "array", + "minItems": minimum, + "items": {"type": "number"}, + } + current_value = [42] + value = value_utils.get_value_out_of_bounds( + value_schema=value_schema, + current_value=current_value, + ) + self.assertEqual(value, None) + def test_unbound(self): value_schema = {"type": "integer"} current_value = "irrelvant"