├── .gitignore ├── .gitlab-ci.yml ├── LICENSE ├── README.md ├── api.yaml ├── img ├── scan_result.png └── slack_notification.png ├── openapi_security_scanner.py ├── requirements.txt └── schemathesis ├── __init__.py ├── __init__.pyc ├── __pycache__ ├── __init__.cpython-38.pyc ├── _compat.cpython-38.pyc ├── _hypothesis.cpython-38.pyc ├── checks.cpython-38.pyc ├── constants.cpython-38.pyc ├── exceptions.cpython-38.pyc ├── hooks.cpython-38.pyc ├── lazy.cpython-38.pyc ├── loaders.cpython-38.pyc ├── models.cpython-38.pyc ├── parameters.cpython-38.pyc ├── schemas.cpython-38.pyc ├── serializers.cpython-38.pyc ├── stateful.cpython-38.pyc ├── targets.cpython-38.pyc ├── types.cpython-38.pyc └── utils.cpython-38.pyc ├── _compat.py ├── _hypothesis.py ├── checks.py ├── cli ├── __init__.py ├── __pycache__ │ ├── __init__.cpython-38.pyc │ ├── callbacks.cpython-38.pyc │ ├── cassettes.cpython-38.pyc │ ├── constants.cpython-38.pyc │ ├── context.cpython-38.pyc │ ├── handlers.cpython-38.pyc │ ├── junitxml.cpython-38.pyc │ └── options.cpython-38.pyc ├── callbacks.py ├── cassettes.py ├── constants.py ├── context.py ├── handlers.py ├── junitxml.py ├── options.py └── output │ ├── __init__.py │ ├── __pycache__ │ ├── __init__.cpython-38.pyc │ ├── default.cpython-38.pyc │ └── short.cpython-38.pyc │ ├── default.py │ └── short.py ├── constants.py ├── exceptions.py ├── extra ├── __init__.py ├── _aiohttp.py ├── _flask.py ├── _server.py └── pytest_plugin.py ├── fixups ├── __init__.py ├── __pycache__ │ ├── __init__.cpython-38.pyc │ └── fast_api.cpython-38.pyc └── fast_api.py ├── hooks.py ├── lazy.py ├── loaders.py ├── models.py ├── parameters.py ├── py.typed ├── runner ├── __init__.py ├── __pycache__ │ ├── __init__.cpython-38.pyc │ ├── events.cpython-38.pyc │ └── serialization.cpython-38.pyc ├── events.py ├── impl │ ├── __init__.py │ ├── __pycache__ │ │ ├── __init__.cpython-38.pyc │ │ ├── core.cpython-38.pyc │ │ ├── solo.cpython-38.pyc │ │ └── threadpool.cpython-38.pyc │ ├── core.py │ ├── solo.py │ └── threadpool.py └── serialization.py ├── schemas.py ├── serializers.py ├── specs ├── __init__.py ├── __pycache__ │ └── __init__.cpython-38.pyc ├── graphql │ ├── __init__.py │ ├── __pycache__ │ │ ├── __init__.cpython-38.pyc │ │ ├── loaders.cpython-38.pyc │ │ └── schemas.cpython-38.pyc │ ├── loaders.py │ └── schemas.py └── openapi │ ├── __init__.py │ ├── __pycache__ │ ├── __init__.cpython-38.pyc │ ├── _hypothesis.cpython-38.pyc │ ├── checks.cpython-38.pyc │ ├── constants.cpython-38.pyc │ ├── converter.cpython-38.pyc │ ├── definitions.cpython-38.pyc │ ├── examples.cpython-38.pyc │ ├── filters.cpython-38.pyc │ ├── links.cpython-38.pyc │ ├── parameters.cpython-38.pyc │ ├── references.cpython-38.pyc │ ├── schemas.cpython-38.pyc │ ├── security.cpython-38.pyc │ ├── serialization.cpython-38.pyc │ └── utils.cpython-38.pyc │ ├── _hypothesis.py │ ├── checks.py │ ├── constants.py │ ├── converter.py │ ├── definitions.py │ ├── examples.py │ ├── expressions │ ├── __init__.py │ ├── __pycache__ │ │ ├── __init__.cpython-38.pyc │ │ ├── context.cpython-38.pyc │ │ ├── errors.cpython-38.pyc │ │ ├── lexer.cpython-38.pyc │ │ ├── nodes.cpython-38.pyc │ │ ├── parser.cpython-38.pyc │ │ └── pointers.cpython-38.pyc │ ├── context.py │ ├── errors.py │ ├── lexer.py │ ├── nodes.py │ ├── parser.py │ └── pointers.py │ ├── filters.py │ ├── links.py │ ├── parameters.py │ ├── references.py │ ├── schemas.py │ ├── security.py │ ├── serialization.py │ ├── stateful │ ├── __init__.py │ ├── __pycache__ │ │ ├── __init__.cpython-38.pyc │ │ └── links.cpython-38.pyc │ └── links.py │ └── utils.py ├── stateful.py ├── targets.py ├── types.py └── utils.py /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | image: python:latest 2 | 3 | stages: 4 | - first 5 | - second 6 | - third 7 | 8 | download-old-result: 9 | except: 10 | - pushes 11 | stage: first 12 | allow_failure: true 13 | script: 14 | - 'curl --location --header "PRIVATE-TOKEN: $PRIVATE_TOKEN" --output artifacts.zip "https://gitlab.com/api/v4/projects/$CI_PROJECT_ID/jobs/artifacts/master/download?job=get-new-result"' 15 | - file artifacts.zip 16 | - unzip artifacts.zip 17 | - mv sample.html old_sample.html 18 | artifacts: 19 | paths: 20 | - old_sample.html 21 | 22 | get-new-result: 23 | except: 24 | - pushes 25 | stage: second 26 | allow_failure: true 27 | script: 28 | - pip install -r requirements.txt 29 | - python openapi_security_scanner.py 30 | artifacts: 31 | paths: 32 | - sample.html 33 | 34 | compare: 35 | except: 36 | - pushes 37 | stage: third 38 | allow_failure: true 39 | script: 40 | - ls -asl 41 | - diff sample.html old_sample.html > changes.diff 42 | - if [[ -s changes.diff ]]; then echo "file has something, integrate a way to notify yourself"; else echo "file is empty"; fi 43 | artifacts: 44 | paths: 45 | - changes.diff 46 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

OpenAPI Security Scanner

2 | 3 | ## About The Project 4 | 5 | Authorization security issues in modern web applications could not be easily picked up by security scanners. A few examples from [Shopify](https://hackerone.com/reports/980511), [LINE](https://hackerone.com/reports/698579), [NordVPN](https://hackerone.com/reports/751577). 6 | One of the reasons is that the security scanners mainly looking for SQLi, XSS, RCE instead of looking for authorization security bugs, and the scanners usually do not have knowledge of how the web application is supposed to behave. That's why the majority of the authorization bugs now are mainly discovered manually. 7 | OpenAPI Security Scanner is created to help to discover authorization security issues with both automation and manual review. It behaves like an external unit test tool to make sure everything is behaving the way it should be. 8 | 9 | ### Built With 10 | 11 | * [Schemathesis](https://github.com/schemathesis/schemathesis) 12 | 13 | 14 | ## Getting Started 15 | 16 | Watch this [video](https://youtu.be/K65e5QRQ1tc) to follow along or keep reading. 17 | 18 | [Fork](https://gitlab.com/ngalog-gitlab/openapi_security_scanner/-/forks/new) or clone this project and follow the instructions below to get started. 19 | 20 | The workflow to use this scanner would look something like this: 21 | 22 | - Prepare the OpenAPI yaml file of the targeted API 23 | - Define the targeted endpoints and variables to be used for the API testing 24 | - Generate multiple sets of credentials from different users to make sure a variety of edge cases are covered while performing the API tests 25 | - After the configuration, start the runner and the scanning result could be found in the artifacts 26 | - Schedule the runner to run periodically and customize the way you want to receive the notifications whenever there's a change in the scanning result 27 | 28 | ### Advantages 29 | For developers, using this tool would add an extra layer of assurance that the API is behaving as expected from both internally and externally. And the scanner could be triggered upon every code deployment by triggering the pipeline https://docs.gitlab.com/ee/ci/triggers/. 30 | 31 | For bug bounty hunters, using this tool can ensure you can always stay on top of the targeted API, because we all know, even a specific endpoint wasn't vulnerable before, it could be vulnerable in the future because of various reasons. So it's easier to have the manual part of bug hunting to be automated using this tool. 32 | 33 | ## Warning Before Use 34 | Make sure you have the permission to scan the targeted API. 35 | 36 | ### Prerequisites 37 | 38 | You'll need an OpenAPI yaml file of the web application and replace it with the content of `api.yaml` in the repository. 39 | 40 | 41 | ## Usage 42 | 43 | Replace the content of `api.yaml` by target's OpenAPI yaml file, and go to GitLab project's `Settings` -> `CI/CD` -> Expand `Variables` and enter the variables in below formats. 44 | 45 | 46 | #### OPENAPI_CREDS 47 | 48 | The credentials provided here will be passed to the API calls in the form of `Authorization: Bearer` header format. 49 | It is recommended to prepare access tokens for at least two user accounts with different permissions, and you can also create more than one access token per user account to test for correct behaviours for different scoped access tokens. 50 | 51 | ```json 52 | { 53 | "":[ 54 | { 55 | "scope":"", 56 | "access_token":"", 57 | "name":"" 58 | }, 59 | { 60 | "scope":"", 61 | "access_token":"", 62 | "name":"" 63 | } 64 | ], 65 | "":[ 66 | { 67 | "scope":"", 68 | "access_token":"", 69 | "name":"" 70 | } 71 | ] 72 | } 73 | ``` 74 | 75 | #### OPENAPI_ENDPOINTS 76 | 77 | The endpoints provided here should be `GET` based API calls, and you can also include the query in the JSON body. In below example, the endpoint is `/org/{orgs}/repos` and the query for it is `type=all`. For the endpoint `/repos/{owner}/{repo}`, there'll be no query. 78 | 79 | ```json 80 | { 81 | "/orgs/{org}/repos":{ 82 | "type":"all" 83 | }, 84 | "/repos/{owner}/{repo}":{ 85 | 86 | } 87 | } 88 | ``` 89 | 90 | #### OPENAPI_PATHS 91 | 92 | The path variables can be provided here, and the scanner will generate all combinations from values in the path variables. 93 | 94 | ```json 95 | { 96 | "org":[ 97 | "test-org" 98 | ], 99 | "owner":[ 100 | "ngalongc", 101 | "reconless" 102 | ], 103 | "repo":[ 104 | "public-repo", 105 | "private-repo" 106 | ] 107 | } 108 | ``` 109 | 110 | In this case, the following combination API calls are generated, and they will all be called by different set of credentials provided in OPENAPI_CREDS. 111 | 112 | ```http 113 | /orgs/test-org/repos?type=all 114 | /repos/ngalongc/public-repo 115 | /repos/ngalongc/private-repo 116 | /repos/reconless/public-repo 117 | /repos/reconless/private-repo 118 | ``` 119 | 120 | #### PRIVATE_TOKEN 121 | 122 | Go to https://gitlab.com/-/profile/personal_access_tokens and create an `api` access token and replace the value in here. This GitLab access token is used for downloading the scan results from GitLab runner. 123 | 124 | ``` 125 | 126 | ``` 127 | 128 | #### OPENAPI_BASE_URL 129 | 130 | This should be the value of the base url of your API server 131 | 132 | ``` 133 | https://example.com/api/v3 134 | ``` 135 | 136 | ### Demo Running simple permission tests on GitHub API 137 | 138 | *Screenshot of the slack notification when API changes are detected* 139 | 140 | 141 | 142 | *Screenshot of the scanning result in artifacts* 143 | 144 | 145 | 146 | Sample report: https://ngalog-gitlab.gitlab.io/-/openapi_security_scanner/-/jobs/966841401/artifacts/sample.html 147 | 148 | ## Limitations 149 | - It can only test for `GET` based endpoints 150 | - For some API endpoints such as `/users/{user_id}/activities`, the responses are different everytime they are called, so detecting the changes of this kind of API is not meaningful 151 | 152 | 153 | 154 | ## Contributing 155 | 156 | Contributions are what make the open source community such an amazing place to be learn, inspire, and create. Any contributions you make are **greatly appreciated**. 157 | 158 | 1. Fork the Project 159 | 2. Create your Feature Branch (`git checkout -b feature/AmazingFeature`) 160 | 3. Commit your Changes (`git commit -m 'Add some AmazingFeature'`) 161 | 4. Push to the Branch (`git push origin feature/AmazingFeature`) 162 | 5. Open a Merge Request 163 | 164 | 165 | ## License 166 | 167 | Distributed under the MIT License. See `LICENSE` for more information. 168 | 169 | 170 | ## Contact 171 | 172 | Ron Chan - [@ngalongc](https://twitter.com/ngalongc) 173 | 174 | Project Link: [https://gitlab.com/ngalog-gitlab/openapi_security_scanner](https://gitlab.com/ngalog-gitlab/openapi_security_scanner) 175 | 176 | 177 | 178 | -------------------------------------------------------------------------------- /img/scan_result.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngalongc/openapi_security_scanner/9ba2244bf0e52db6f149243de403c8c7c157216f/img/scan_result.png -------------------------------------------------------------------------------- /img/slack_notification.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngalongc/openapi_security_scanner/9ba2244bf0e52db6f149243de403c8c7c157216f/img/slack_notification.png -------------------------------------------------------------------------------- /openapi_security_scanner.py: -------------------------------------------------------------------------------- 1 | import schemathesis 2 | import json 3 | import os 4 | import requests 5 | import itertools 6 | 7 | def format_md_result(path,status_code,resp_len,scope,username,method): 8 | return path + divider + status_code + divider + resp_len + divider + scope + divider + username + divider + method + divider+"\n" 9 | 10 | 11 | def expand_paths_to_list(path_examples): 12 | keys = path_examples.keys() 13 | values = (path_examples[key] for key in keys) 14 | combinations = [dict(zip(keys, combination)) for combination in itertools.product(*values)] 15 | return combinations 16 | 17 | 18 | def main(path_examples): 19 | result_md = "" 20 | for targeted_endpoint in targeted_endpoints: 21 | for credential in credentials: # credentials = 22 | for cred_set in credentials[credential]: 23 | access_token = cred_set['access_token'] 24 | case = schema[targeted_endpoint]["GET"].make_case(path_parameters=path_examples,query=targeted_endpoints[targeted_endpoint]) 25 | response = case.call(headers={"Authorization":"Bearer " + access_token}) 26 | single_result = format_md_result( 27 | status_code=str(response.status_code), 28 | path=case.formatted_path, 29 | resp_len=str(len(response.text)), 30 | scope=cred_set['scope'], 31 | username=credential, # snapmortgage 32 | method="GET" 33 | ) 34 | result_md += single_result 35 | #targeted_endpoint = {"/orgs/{org}/repos":{"type":"all"}} 36 | return result_md 37 | 38 | 39 | ## Initiate the variables 40 | base_url = os.getenv('OPENAPI_BASE_URL') 41 | targeted_endpoints = json.loads(os.getenv('OPENAPI_ENDPOINTS')) 42 | path_examples = json.loads(os.getenv('OPENAPI_PATHS')) 43 | credentials = json.loads(os.getenv('OPENAPI_CREDS')) 44 | 45 | schema = schemathesis.from_path("api.yaml",base_url=base_url) 46 | 47 | divider = "|" 48 | md_header = "| - | Status | Length | Scope | Username | Method |\n" + "| - | - | - | - | - | -|\n" 49 | 50 | 51 | ## Make path_examples to become a list with all possible combinations 52 | path_examples = expand_paths_to_list(path_examples) 53 | 54 | 55 | for path_param in path_examples: 56 | md_header += main(path_param) 57 | 58 | result_response = requests.post(json={"text":md_header},url="https://gitlab.com/api/v4/markdown") 59 | result_json = json.loads(result_response.text) 60 | html = result_json['html'] 61 | 62 | with open('sample.html',mode='w') as f: 63 | f.write(html) 64 | 65 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | attr==0.3.1 2 | attrs==20.3.0 3 | certifi==2020.12.5 4 | chardet==4.0.0 5 | click==7.1.2 6 | curlify==2.2.1 7 | graphql-core==3.1.2 8 | hypothesis==5.43.3 9 | hypothesis-graphql==0.3.2 10 | hypothesis-jsonschema==0.18.2 11 | idna==2.10 12 | iniconfig==1.1.1 13 | jsonschema==3.2.0 14 | junit-xml==1.9 15 | multidict==5.1.0 16 | packaging==20.8 17 | pluggy==0.13.1 18 | py==1.10.0 19 | pyparsing==2.4.7 20 | pyrsistent==0.17.3 21 | pytest==6.2.1 22 | pytest-subtests==0.4.0 23 | PyYAML==5.3.1 24 | requests==2.25.1 25 | six==1.15.0 26 | sortedcontainers==2.3.0 27 | starlette==0.14.1 28 | toml==0.10.2 29 | typing-extensions==3.7.4.3 30 | urllib3==1.26.2 31 | Werkzeug==1.0.1 32 | yarl==1.6.3 33 | -------------------------------------------------------------------------------- /schemathesis/__init__.py: -------------------------------------------------------------------------------- 1 | from . import fixups, hooks, serializers, targets 2 | from .cli import register_check, register_target 3 | from .constants import DataGenerationMethod, __version__ 4 | from .loaders import from_asgi, from_dict, from_file, from_path, from_pytest_fixture, from_uri, from_wsgi 5 | from .models import Case 6 | from .specs import graphql 7 | from .specs.openapi._hypothesis import init_default_strategies, register_string_format 8 | from .stateful import Stateful 9 | from .utils import GenericResponse 10 | 11 | init_default_strategies() 12 | -------------------------------------------------------------------------------- /schemathesis/__init__.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngalongc/openapi_security_scanner/9ba2244bf0e52db6f149243de403c8c7c157216f/schemathesis/__init__.pyc -------------------------------------------------------------------------------- /schemathesis/__pycache__/__init__.cpython-38.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngalongc/openapi_security_scanner/9ba2244bf0e52db6f149243de403c8c7c157216f/schemathesis/__pycache__/__init__.cpython-38.pyc -------------------------------------------------------------------------------- /schemathesis/__pycache__/_compat.cpython-38.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngalongc/openapi_security_scanner/9ba2244bf0e52db6f149243de403c8c7c157216f/schemathesis/__pycache__/_compat.cpython-38.pyc -------------------------------------------------------------------------------- /schemathesis/__pycache__/_hypothesis.cpython-38.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngalongc/openapi_security_scanner/9ba2244bf0e52db6f149243de403c8c7c157216f/schemathesis/__pycache__/_hypothesis.cpython-38.pyc -------------------------------------------------------------------------------- /schemathesis/__pycache__/checks.cpython-38.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngalongc/openapi_security_scanner/9ba2244bf0e52db6f149243de403c8c7c157216f/schemathesis/__pycache__/checks.cpython-38.pyc -------------------------------------------------------------------------------- /schemathesis/__pycache__/constants.cpython-38.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngalongc/openapi_security_scanner/9ba2244bf0e52db6f149243de403c8c7c157216f/schemathesis/__pycache__/constants.cpython-38.pyc -------------------------------------------------------------------------------- /schemathesis/__pycache__/exceptions.cpython-38.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngalongc/openapi_security_scanner/9ba2244bf0e52db6f149243de403c8c7c157216f/schemathesis/__pycache__/exceptions.cpython-38.pyc -------------------------------------------------------------------------------- /schemathesis/__pycache__/hooks.cpython-38.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngalongc/openapi_security_scanner/9ba2244bf0e52db6f149243de403c8c7c157216f/schemathesis/__pycache__/hooks.cpython-38.pyc -------------------------------------------------------------------------------- /schemathesis/__pycache__/lazy.cpython-38.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngalongc/openapi_security_scanner/9ba2244bf0e52db6f149243de403c8c7c157216f/schemathesis/__pycache__/lazy.cpython-38.pyc -------------------------------------------------------------------------------- /schemathesis/__pycache__/loaders.cpython-38.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngalongc/openapi_security_scanner/9ba2244bf0e52db6f149243de403c8c7c157216f/schemathesis/__pycache__/loaders.cpython-38.pyc -------------------------------------------------------------------------------- /schemathesis/__pycache__/models.cpython-38.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngalongc/openapi_security_scanner/9ba2244bf0e52db6f149243de403c8c7c157216f/schemathesis/__pycache__/models.cpython-38.pyc -------------------------------------------------------------------------------- /schemathesis/__pycache__/parameters.cpython-38.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngalongc/openapi_security_scanner/9ba2244bf0e52db6f149243de403c8c7c157216f/schemathesis/__pycache__/parameters.cpython-38.pyc -------------------------------------------------------------------------------- /schemathesis/__pycache__/schemas.cpython-38.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngalongc/openapi_security_scanner/9ba2244bf0e52db6f149243de403c8c7c157216f/schemathesis/__pycache__/schemas.cpython-38.pyc -------------------------------------------------------------------------------- /schemathesis/__pycache__/serializers.cpython-38.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngalongc/openapi_security_scanner/9ba2244bf0e52db6f149243de403c8c7c157216f/schemathesis/__pycache__/serializers.cpython-38.pyc -------------------------------------------------------------------------------- /schemathesis/__pycache__/stateful.cpython-38.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngalongc/openapi_security_scanner/9ba2244bf0e52db6f149243de403c8c7c157216f/schemathesis/__pycache__/stateful.cpython-38.pyc -------------------------------------------------------------------------------- /schemathesis/__pycache__/targets.cpython-38.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngalongc/openapi_security_scanner/9ba2244bf0e52db6f149243de403c8c7c157216f/schemathesis/__pycache__/targets.cpython-38.pyc -------------------------------------------------------------------------------- /schemathesis/__pycache__/types.cpython-38.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngalongc/openapi_security_scanner/9ba2244bf0e52db6f149243de403c8c7c157216f/schemathesis/__pycache__/types.cpython-38.pyc -------------------------------------------------------------------------------- /schemathesis/__pycache__/utils.cpython-38.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngalongc/openapi_security_scanner/9ba2244bf0e52db6f149243de403c8c7c157216f/schemathesis/__pycache__/utils.cpython-38.pyc -------------------------------------------------------------------------------- /schemathesis/_compat.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=unused-import 2 | try: 3 | from importlib import metadata 4 | except ImportError: 5 | import importlib_metadata as metadata # type: ignore 6 | -------------------------------------------------------------------------------- /schemathesis/_hypothesis.py: -------------------------------------------------------------------------------- 1 | """Provide strategies for given endpoint(s) definition.""" 2 | import asyncio 3 | from typing import Any, Callable, Dict, List, Optional, Tuple, Union 4 | 5 | import hypothesis 6 | from hypothesis import strategies as st 7 | from hypothesis.strategies import SearchStrategy 8 | from hypothesis.utils.conventions import InferType 9 | 10 | from .constants import DEFAULT_DEADLINE, DataGenerationMethod 11 | from .exceptions import InvalidSchema 12 | from .hooks import GLOBAL_HOOK_DISPATCHER, HookContext, HookDispatcher 13 | from .models import Case, Endpoint 14 | from .stateful import Feedback, Stateful 15 | 16 | GivenInput = Union[SearchStrategy, InferType] 17 | 18 | 19 | def create_test( 20 | *, 21 | endpoint: Endpoint, 22 | test: Callable, 23 | settings: Optional[hypothesis.settings] = None, 24 | seed: Optional[int] = None, 25 | data_generation_method: DataGenerationMethod = DataGenerationMethod.default(), 26 | _given_args: Tuple[GivenInput, ...] = (), 27 | _given_kwargs: Optional[Dict[str, GivenInput]] = None, 28 | ) -> Callable: 29 | """Create a Hypothesis test.""" 30 | hook_dispatcher = getattr(test, "_schemathesis_hooks", None) 31 | feedback: Optional[Feedback] 32 | if endpoint.schema.stateful == Stateful.links: 33 | feedback = Feedback(endpoint.schema.stateful, endpoint) 34 | else: 35 | feedback = None 36 | strategy = endpoint.as_strategy( 37 | hooks=hook_dispatcher, feedback=feedback, data_generation_method=data_generation_method 38 | ) 39 | _given_kwargs = (_given_kwargs or {}).copy() 40 | _given_kwargs.setdefault("case", strategy) 41 | wrapped_test = hypothesis.given(*_given_args, **_given_kwargs)(test) 42 | if seed is not None: 43 | wrapped_test = hypothesis.seed(seed)(wrapped_test) 44 | if asyncio.iscoroutinefunction(test): 45 | wrapped_test.hypothesis.inner_test = make_async_test(test) # type: ignore 46 | setup_default_deadline(wrapped_test) 47 | if settings is not None: 48 | wrapped_test = settings(wrapped_test) 49 | wrapped_test._schemathesis_feedback = feedback # type: ignore 50 | return add_examples(wrapped_test, endpoint, hook_dispatcher=hook_dispatcher) 51 | 52 | 53 | def setup_default_deadline(wrapped_test: Callable) -> None: 54 | # Quite hacky, but it is the simplest way to set up the default deadline value without affecting non-Schemathesis 55 | # tests globally 56 | existing_settings = getattr(wrapped_test, "_hypothesis_internal_use_settings", None) 57 | if existing_settings is not None and existing_settings.deadline == hypothesis.settings.default.deadline: 58 | new_settings = hypothesis.settings(existing_settings, deadline=DEFAULT_DEADLINE) 59 | wrapped_test._hypothesis_internal_use_settings = new_settings # type: ignore 60 | 61 | 62 | def make_async_test(test: Callable) -> Callable: 63 | def async_run(*args: Any, **kwargs: Any) -> None: 64 | loop = asyncio.get_event_loop() 65 | coro = test(*args, **kwargs) 66 | future = asyncio.ensure_future(coro, loop=loop) 67 | loop.run_until_complete(future) 68 | 69 | return async_run 70 | 71 | 72 | def add_examples(test: Callable, endpoint: Endpoint, hook_dispatcher: Optional[HookDispatcher] = None) -> Callable: 73 | """Add examples to the Hypothesis test, if they are specified in the schema.""" 74 | try: 75 | examples: List[Case] = [get_single_example(strategy) for strategy in endpoint.get_strategies_from_examples()] 76 | except InvalidSchema: 77 | # In this case, the user didn't pass `--validate-schema=false` and see an error in the output anyway, 78 | # and no tests will be executed. For this reason, examples can be skipped 79 | return test 80 | context = HookContext(endpoint) # context should be passed here instead 81 | GLOBAL_HOOK_DISPATCHER.dispatch("before_add_examples", context, examples) 82 | endpoint.schema.hooks.dispatch("before_add_examples", context, examples) 83 | if hook_dispatcher: 84 | hook_dispatcher.dispatch("before_add_examples", context, examples) 85 | for example in examples: 86 | test = hypothesis.example(case=example)(test) 87 | return test 88 | 89 | 90 | def get_single_example(strategy: st.SearchStrategy[Case]) -> Case: 91 | @hypothesis.given(strategy) # type: ignore 92 | @hypothesis.settings( # type: ignore 93 | database=None, 94 | max_examples=1, 95 | deadline=None, 96 | verbosity=hypothesis.Verbosity.quiet, 97 | phases=(hypothesis.Phase.generate,), 98 | suppress_health_check=hypothesis.HealthCheck.all(), 99 | ) 100 | def example_generating_inner_function(ex: Case) -> None: 101 | examples.append(ex) 102 | 103 | examples: List[Case] = [] 104 | example_generating_inner_function() 105 | return examples[0] 106 | -------------------------------------------------------------------------------- /schemathesis/checks.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, Optional, Tuple 2 | 3 | from .exceptions import get_status_code_error 4 | from .specs.openapi.checks import ( 5 | content_type_conformance, 6 | response_headers_conformance, 7 | response_schema_conformance, 8 | status_code_conformance, 9 | ) 10 | from .utils import GenericResponse 11 | 12 | if TYPE_CHECKING: 13 | from .models import Case, CheckFunction 14 | 15 | 16 | def not_a_server_error(response: GenericResponse, case: "Case") -> Optional[bool]: # pylint: disable=useless-return 17 | """A check to verify that the response is not a server-side error.""" 18 | if response.status_code >= 500: 19 | exc_class = get_status_code_error(response.status_code) 20 | raise exc_class(f"Received a response with 5xx status code: {response.status_code}") 21 | return None 22 | 23 | 24 | DEFAULT_CHECKS: Tuple["CheckFunction", ...] = (not_a_server_error,) 25 | OPTIONAL_CHECKS = ( 26 | status_code_conformance, 27 | content_type_conformance, 28 | response_headers_conformance, 29 | response_schema_conformance, 30 | ) 31 | ALL_CHECKS: Tuple["CheckFunction", ...] = DEFAULT_CHECKS + OPTIONAL_CHECKS 32 | -------------------------------------------------------------------------------- /schemathesis/cli/__pycache__/__init__.cpython-38.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngalongc/openapi_security_scanner/9ba2244bf0e52db6f149243de403c8c7c157216f/schemathesis/cli/__pycache__/__init__.cpython-38.pyc -------------------------------------------------------------------------------- /schemathesis/cli/__pycache__/callbacks.cpython-38.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngalongc/openapi_security_scanner/9ba2244bf0e52db6f149243de403c8c7c157216f/schemathesis/cli/__pycache__/callbacks.cpython-38.pyc -------------------------------------------------------------------------------- /schemathesis/cli/__pycache__/cassettes.cpython-38.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngalongc/openapi_security_scanner/9ba2244bf0e52db6f149243de403c8c7c157216f/schemathesis/cli/__pycache__/cassettes.cpython-38.pyc -------------------------------------------------------------------------------- /schemathesis/cli/__pycache__/constants.cpython-38.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngalongc/openapi_security_scanner/9ba2244bf0e52db6f149243de403c8c7c157216f/schemathesis/cli/__pycache__/constants.cpython-38.pyc -------------------------------------------------------------------------------- /schemathesis/cli/__pycache__/context.cpython-38.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngalongc/openapi_security_scanner/9ba2244bf0e52db6f149243de403c8c7c157216f/schemathesis/cli/__pycache__/context.cpython-38.pyc -------------------------------------------------------------------------------- /schemathesis/cli/__pycache__/handlers.cpython-38.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngalongc/openapi_security_scanner/9ba2244bf0e52db6f149243de403c8c7c157216f/schemathesis/cli/__pycache__/handlers.cpython-38.pyc -------------------------------------------------------------------------------- /schemathesis/cli/__pycache__/junitxml.cpython-38.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngalongc/openapi_security_scanner/9ba2244bf0e52db6f149243de403c8c7c157216f/schemathesis/cli/__pycache__/junitxml.cpython-38.pyc -------------------------------------------------------------------------------- /schemathesis/cli/__pycache__/options.cpython-38.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngalongc/openapi_security_scanner/9ba2244bf0e52db6f149243de403c8c7c157216f/schemathesis/cli/__pycache__/options.cpython-38.pyc -------------------------------------------------------------------------------- /schemathesis/cli/callbacks.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | from contextlib import contextmanager 4 | from typing import Dict, Generator, Optional, Tuple, Union 5 | from urllib.parse import urlparse 6 | 7 | import click 8 | import hypothesis 9 | from requests import PreparedRequest, RequestException 10 | 11 | from .. import utils 12 | from ..stateful import Stateful 13 | from .constants import DEFAULT_WORKERS 14 | 15 | 16 | def validate_schema(ctx: click.core.Context, param: click.core.Parameter, raw_value: str) -> str: 17 | if "app" not in ctx.params: 18 | try: 19 | netloc = urlparse(raw_value).netloc 20 | except ValueError as exc: 21 | raise click.UsageError("Invalid SCHEMA, must be a valid URL or file path.") from exc 22 | if not netloc: 23 | if "\x00" in raw_value or not utils.file_exists(raw_value): 24 | raise click.UsageError("Invalid SCHEMA, must be a valid URL or file path.") 25 | if "base_url" not in ctx.params: 26 | raise click.UsageError('Missing argument, "--base-url" is required for SCHEMA specified by file.') 27 | else: 28 | _validate_url(raw_value) 29 | return raw_value 30 | 31 | 32 | def _validate_url(value: str) -> None: 33 | try: 34 | PreparedRequest().prepare_url(value, {}) # type: ignore 35 | except RequestException as exc: 36 | raise click.UsageError("Invalid SCHEMA, must be a valid URL or file path.") from exc 37 | 38 | 39 | def validate_base_url(ctx: click.core.Context, param: click.core.Parameter, raw_value: str) -> str: 40 | try: 41 | netloc = urlparse(raw_value).netloc 42 | except ValueError as exc: 43 | raise click.UsageError("Invalid base URL") from exc 44 | if raw_value and not netloc: 45 | raise click.UsageError("Invalid base URL") 46 | return raw_value 47 | 48 | 49 | APPLICATION_FORMAT_MESSAGE = ( 50 | "Can not import application from the given module!\n" 51 | "The `--app` option value should be in format:\n\n path:variable\n\n" 52 | "where `path` is an importable path to a Python module,\n" 53 | "and `variable` is a variable name inside that module." 54 | ) 55 | 56 | 57 | def validate_app(ctx: click.core.Context, param: click.core.Parameter, raw_value: Optional[str]) -> Optional[str]: 58 | if raw_value is None: 59 | return raw_value 60 | try: 61 | utils.import_app(raw_value) 62 | # String is returned instead of an app because it might be passed to a subprocess 63 | # Since most app instances are not-transferable to another process, they are passed as strings and 64 | # imported in a subprocess 65 | return raw_value 66 | except Exception as exc: 67 | show_errors_tracebacks = ctx.params["show_errors_tracebacks"] 68 | message = utils.format_exception(exc, show_errors_tracebacks).strip() 69 | click.secho(f"{APPLICATION_FORMAT_MESSAGE}\n\nException:\n\n{message}", fg="red") 70 | if not show_errors_tracebacks: 71 | click.secho( 72 | "\nAdd this option to your command line parameters to see full tracebacks: --show-errors-tracebacks", 73 | fg="red", 74 | ) 75 | raise click.exceptions.Exit(1) 76 | 77 | 78 | def validate_auth( 79 | ctx: click.core.Context, param: click.core.Parameter, raw_value: Optional[str] 80 | ) -> Optional[Tuple[str, str]]: 81 | if raw_value is not None: 82 | with reraise_format_error(raw_value): 83 | user, password = tuple(raw_value.split(":")) 84 | if not user: 85 | raise click.BadParameter("Username should not be empty") 86 | if not utils.is_latin_1_encodable(user): 87 | raise click.BadParameter("Username should be latin-1 encodable") 88 | if not utils.is_latin_1_encodable(password): 89 | raise click.BadParameter("Password should be latin-1 encodable") 90 | return user, password 91 | return None 92 | 93 | 94 | def validate_headers( 95 | ctx: click.core.Context, param: click.core.Parameter, raw_value: Tuple[str, ...] 96 | ) -> Dict[str, str]: 97 | headers = {} 98 | for header in raw_value: 99 | with reraise_format_error(header): 100 | key, value = header.split(":", maxsplit=1) 101 | value = value.lstrip() 102 | key = key.strip() 103 | if not key: 104 | raise click.BadParameter("Header name should not be empty") 105 | if not utils.is_latin_1_encodable(key): 106 | raise click.BadParameter("Header name should be latin-1 encodable") 107 | if not utils.is_latin_1_encodable(value): 108 | raise click.BadParameter("Header value should be latin-1 encodable") 109 | if utils.has_invalid_characters(key, value): 110 | raise click.BadParameter("Invalid return character or leading space in header") 111 | headers[key] = value 112 | return headers 113 | 114 | 115 | def validate_regex(ctx: click.core.Context, param: click.core.Parameter, raw_value: Tuple[str, ...]) -> Tuple[str, ...]: 116 | for value in raw_value: 117 | try: 118 | re.compile(value) 119 | except (re.error, OverflowError, RuntimeError) as exc: 120 | raise click.BadParameter(f"Invalid regex: {exc.args[0]}") 121 | return raw_value 122 | 123 | 124 | def convert_verbosity( 125 | ctx: click.core.Context, param: click.core.Parameter, value: Optional[str] 126 | ) -> Optional[hypothesis.Verbosity]: 127 | if value is None: 128 | return value 129 | return hypothesis.Verbosity[value] 130 | 131 | 132 | def convert_stateful(ctx: click.core.Context, param: click.core.Parameter, value: Optional[str]) -> Optional[Stateful]: 133 | if value is None: 134 | return value 135 | return Stateful[value] 136 | 137 | 138 | def convert_request_tls_verify(ctx: click.core.Context, param: click.core.Parameter, value: str) -> Union[str, bool]: 139 | if value.lower() in ("y", "yes", "t", "true", "on", "1"): 140 | return True 141 | if value.lower() in ("n", "no", "f", "false", "off", "0"): 142 | return False 143 | return value 144 | 145 | 146 | @contextmanager 147 | def reraise_format_error(raw_value: str) -> Generator[None, None, None]: 148 | try: 149 | yield 150 | except ValueError as exc: 151 | raise click.BadParameter(f"Should be in KEY:VALUE format. Got: {raw_value}") from exc 152 | 153 | 154 | def get_workers_count() -> int: 155 | """Detect the number of available CPUs for the current process, if possible. 156 | 157 | Use ``DEFAULT_WORKERS`` if not possible to detect. 158 | """ 159 | if hasattr(os, "sched_getaffinity"): 160 | # In contrast with `os.cpu_count` this call respects limits on CPU resources on some Unix systems 161 | return len(os.sched_getaffinity(0)) 162 | # Number of CPUs in the system, or 1 if undetermined 163 | return os.cpu_count() or DEFAULT_WORKERS 164 | 165 | 166 | def convert_workers(ctx: click.core.Context, param: click.core.Parameter, value: str) -> int: 167 | if value == "auto": 168 | return get_workers_count() 169 | return int(value) 170 | -------------------------------------------------------------------------------- /schemathesis/cli/cassettes.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import json 3 | import re 4 | import sys 5 | import threading 6 | from queue import Queue 7 | from typing import Any, Dict, Generator, Iterator, List, Optional, cast 8 | 9 | import attr 10 | import click 11 | import requests 12 | from requests.cookies import RequestsCookieJar 13 | from requests.structures import CaseInsensitiveDict 14 | 15 | from .. import constants 16 | from ..runner import events 17 | from ..runner.serialization import SerializedCheck, SerializedInteraction 18 | from .context import ExecutionContext 19 | from .handlers import EventHandler 20 | 21 | # Wait until the worker terminates 22 | WRITER_WORKER_JOIN_TIMEOUT = 1 23 | 24 | 25 | @attr.s(slots=True) # pragma: no mutate 26 | class CassetteWriter(EventHandler): 27 | """Write interactions in a YAML cassette. 28 | 29 | A low-level interface is used to write data to YAML file during the test run and reduce the delay at 30 | the end of the test run. 31 | """ 32 | 33 | file_handle: click.utils.LazyFile = attr.ib() # pragma: no mutate 34 | queue: Queue = attr.ib(factory=Queue) # pragma: no mutate 35 | worker: threading.Thread = attr.ib(init=False) # pragma: no mutate 36 | 37 | def __attrs_post_init__(self) -> None: 38 | self.worker = threading.Thread(target=worker, kwargs={"file_handle": self.file_handle, "queue": self.queue}) 39 | self.worker.start() 40 | 41 | def handle_event(self, context: ExecutionContext, event: events.ExecutionEvent) -> None: 42 | if isinstance(event, events.Initialized): 43 | # In the beginning we write metadata and start `http_interactions` list 44 | self.queue.put(Initialize()) 45 | if isinstance(event, events.AfterExecution): 46 | # Seed is always present at this point, the original Optional[int] type is there because `TestResult` 47 | # instance is created before `seed` is generated on the hypothesis side 48 | seed = cast(int, event.result.seed) 49 | self.queue.put( 50 | Process( 51 | seed=seed, 52 | interactions=event.result.interactions, 53 | ) 54 | ) 55 | if isinstance(event, events.Finished): 56 | self.shutdown() 57 | 58 | def shutdown(self) -> None: 59 | self.queue.put(Finalize()) 60 | self._stop_worker() 61 | 62 | def _stop_worker(self) -> None: 63 | self.worker.join(WRITER_WORKER_JOIN_TIMEOUT) 64 | 65 | 66 | @attr.s(slots=True) # pragma: no mutate 67 | class Initialize: 68 | """Start up, the first message to make preparations before proceeding the input data.""" 69 | 70 | 71 | @attr.s(slots=True) # pragma: no mutate 72 | class Process: 73 | """A new chunk of data should be processed.""" 74 | 75 | seed: int = attr.ib() # pragma: no mutate 76 | interactions: List[SerializedInteraction] = attr.ib() # pragma: no mutate 77 | 78 | 79 | @attr.s(slots=True) # pragma: no mutate 80 | class Finalize: 81 | """The work is done and there will be no more messages to process.""" 82 | 83 | 84 | def get_command_representation() -> str: 85 | """Get how Schemathesis was run.""" 86 | # It is supposed to be executed from Schemathesis CLI, not via Click's `command.invoke` 87 | if not sys.argv[0].endswith("schemathesis"): 88 | return "" 89 | args = " ".join(sys.argv[1:]) 90 | return f"schemathesis {args}" 91 | 92 | 93 | def worker(file_handle: click.utils.LazyFile, queue: Queue) -> None: 94 | """Write YAML to a file in an incremental manner. 95 | 96 | This implementation doesn't use `pyyaml` package and composes YAML manually as string due to the following reasons: 97 | - It is much faster. The string-based approach gives only ~2.5% time overhead when `yaml.CDumper` has ~11.2%; 98 | - Implementation complexity. We have a quite simple format where all values are strings, and it is much simpler to 99 | implement it with string composition rather than with adjusting `yaml.Serializer` to emit explicit types. 100 | Another point is that with `pyyaml` we need to emit events and handle some low-level details like providing 101 | tags, anchors to have incremental writing, with strings it is much simpler. 102 | """ 103 | current_id = 1 104 | stream = file_handle.open() 105 | 106 | def format_header_values(values: List[str]) -> str: 107 | return "\n".join(f" - {json.dumps(v)}" for v in values) 108 | 109 | def format_headers(headers: Dict[str, List[str]]) -> str: 110 | return "\n".join(f" {name}:\n{format_header_values(values)}" for name, values in headers.items()) 111 | 112 | def format_check_message(message: Optional[str]) -> str: 113 | return "~" if message is None else f"{repr(message)}" 114 | 115 | def format_checks(checks: List[SerializedCheck]) -> str: 116 | return "\n".join( 117 | f" - name: '{check.name}'\n status: '{check.value.name.upper()}'\n message: {format_check_message(check.message)}" 118 | for check in checks 119 | ) 120 | 121 | while True: 122 | item = queue.get() 123 | if isinstance(item, Initialize): 124 | stream.write( 125 | f"""command: '{get_command_representation()}' 126 | recorded_with: 'Schemathesis {constants.__version__}' 127 | http_interactions:""" 128 | ) 129 | elif isinstance(item, Process): 130 | for interaction in item.interactions: 131 | status = interaction.status.name.upper() 132 | stream.write( 133 | f"""\n- id: '{current_id}' 134 | status: '{status}' 135 | seed: '{item.seed}' 136 | elapsed: '{interaction.response.elapsed}' 137 | recorded_at: '{interaction.recorded_at}' 138 | checks: 139 | {format_checks(interaction.checks)} 140 | request: 141 | uri: '{interaction.request.uri}' 142 | method: '{interaction.request.method}' 143 | headers: 144 | {format_headers(interaction.request.headers)} 145 | body: 146 | encoding: 'utf-8' 147 | base64_string: '{interaction.request.body}' 148 | response: 149 | status: 150 | code: '{interaction.response.status_code}' 151 | message: {json.dumps(interaction.response.message)} 152 | headers: 153 | {format_headers(interaction.response.headers)} 154 | body: 155 | encoding: '{interaction.response.encoding}' 156 | base64_string: '{interaction.response.body}' 157 | http_version: '{interaction.response.http_version}'""" 158 | ) 159 | current_id += 1 160 | else: 161 | break 162 | file_handle.close() 163 | 164 | 165 | @attr.s(slots=True) # pragma: no mutate 166 | class Replayed: 167 | interaction: Dict[str, Any] = attr.ib() # pragma: no mutate 168 | response: requests.Response = attr.ib() # pragma: no mutate 169 | 170 | 171 | def replay( 172 | cassette: Dict[str, Any], 173 | id_: Optional[str] = None, 174 | status: Optional[str] = None, 175 | uri: Optional[str] = None, 176 | method: Optional[str] = None, 177 | ) -> Generator[Replayed, None, None]: 178 | """Replay saved interactions.""" 179 | session = requests.Session() 180 | for interaction in filter_cassette(cassette["http_interactions"], id_, status, uri, method): 181 | request = get_prepared_request(interaction["request"]) 182 | response = session.send(request) # type: ignore 183 | yield Replayed(interaction, response) 184 | 185 | 186 | def filter_cassette( 187 | interactions: List[Dict[str, Any]], 188 | id_: Optional[str] = None, 189 | status: Optional[str] = None, 190 | uri: Optional[str] = None, 191 | method: Optional[str] = None, 192 | ) -> Iterator[Dict[str, Any]]: 193 | 194 | filters = [] 195 | 196 | def id_filter(item: Dict[str, Any]) -> bool: 197 | return item["id"] == id_ 198 | 199 | def status_filter(item: Dict[str, Any]) -> bool: 200 | status_ = cast(str, status) 201 | return item["status"].upper() == status_.upper() 202 | 203 | def uri_filter(item: Dict[str, Any]) -> bool: 204 | uri_ = cast(str, uri) 205 | return bool(re.search(uri_, item["request"]["uri"])) 206 | 207 | def method_filter(item: Dict[str, Any]) -> bool: 208 | method_ = cast(str, method) 209 | return bool(re.search(method_, item["request"]["method"])) 210 | 211 | if id_ is not None: 212 | filters.append(id_filter) 213 | 214 | if status is not None: 215 | filters.append(status_filter) 216 | 217 | if uri is not None: 218 | filters.append(uri_filter) 219 | 220 | if method is not None: 221 | filters.append(method_filter) 222 | 223 | def is_match(interaction: Dict[str, Any]) -> bool: 224 | return all(filter_(interaction) for filter_ in filters) 225 | 226 | return filter(is_match, interactions) 227 | 228 | 229 | def get_prepared_request(data: Dict[str, Any]) -> requests.PreparedRequest: 230 | """Create a `requests.PreparedRequest` from a serialized one.""" 231 | prepared = requests.PreparedRequest() 232 | prepared.method = data["method"] 233 | prepared.url = data["uri"] 234 | prepared._cookies = RequestsCookieJar() # type: ignore 235 | encoded = data["body"]["base64_string"] 236 | if encoded: 237 | prepared.body = base64.b64decode(encoded) 238 | # There is always 1 value in a request 239 | headers = [(key, value[0]) for key, value in data["headers"].items()] 240 | prepared.headers = CaseInsensitiveDict(headers) 241 | return prepared 242 | -------------------------------------------------------------------------------- /schemathesis/cli/constants.py: -------------------------------------------------------------------------------- 1 | MIN_WORKERS = 1 2 | DEFAULT_WORKERS = MIN_WORKERS 3 | MAX_WORKERS = 64 4 | -------------------------------------------------------------------------------- /schemathesis/cli/context.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | from typing import List, Optional 4 | 5 | import attr 6 | 7 | from ..runner.serialization import SerializedTestResult 8 | 9 | 10 | @attr.s(slots=True) # pragma: no mutate 11 | class ExecutionContext: 12 | """Storage for the current context of the execution.""" 13 | 14 | hypothesis_output: List[str] = attr.ib(factory=list) # pragma: no mutate 15 | workers_num: int = attr.ib(default=1) # pragma: no mutate 16 | show_errors_tracebacks: bool = attr.ib(default=False) # pragma: no mutate 17 | endpoints_processed: int = attr.ib(default=0) # pragma: no mutate 18 | # It is set in runtime, from a `Initialized` event 19 | endpoints_count: Optional[int] = attr.ib(default=None) # pragma: no mutate 20 | current_line_length: int = attr.ib(default=0) # pragma: no mutate 21 | terminal_size: os.terminal_size = attr.ib(factory=shutil.get_terminal_size) # pragma: no mutate 22 | results: List[SerializedTestResult] = attr.ib(factory=list) # pragma: no mutate 23 | cassette_file_name: Optional[str] = attr.ib(default=None) # pragma: no mutate 24 | junit_xml_file: Optional[str] = attr.ib(default=None) # pragma: no mutate 25 | verbosity: int = attr.ib(default=0) # pragma: no mutate 26 | -------------------------------------------------------------------------------- /schemathesis/cli/handlers.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional, Set, Tuple 2 | 3 | from ..models import Status 4 | from ..runner import events 5 | from ..runner.serialization import SerializedCheck 6 | from .context import ExecutionContext 7 | 8 | 9 | class EventHandler: 10 | def handle_event(self, context: ExecutionContext, event: events.ExecutionEvent) -> None: 11 | raise NotImplementedError 12 | 13 | def shutdown(self) -> None: 14 | # Do nothing by default 15 | pass 16 | 17 | 18 | def get_unique_failures(checks: List[SerializedCheck]) -> List[SerializedCheck]: 19 | """Return only unique checks that should be displayed in the output.""" 20 | seen: Set[Tuple[str, Optional[str]]] = set() 21 | unique_checks = [] 22 | for check in reversed(checks): 23 | # There are also could be checks that didn't fail 24 | if check.example is not None and check.value == Status.failure and (check.name, check.message) not in seen: 25 | unique_checks.append(check) 26 | seen.add((check.name, check.message)) 27 | return unique_checks 28 | -------------------------------------------------------------------------------- /schemathesis/cli/junitxml.py: -------------------------------------------------------------------------------- 1 | import platform 2 | from typing import List, Optional 3 | 4 | import attr 5 | from click.utils import LazyFile 6 | from junit_xml import TestCase, TestSuite, to_xml_report_file 7 | 8 | from ..models import Status 9 | from ..runner import events 10 | from .handlers import EventHandler, ExecutionContext, get_unique_failures 11 | 12 | 13 | @attr.s(slots=True) # pragma: no mutate 14 | class JunitXMLHandler(EventHandler): 15 | file_handle: LazyFile = attr.ib() # pragma: no mutate 16 | test_cases: List = attr.ib(factory=list) # pragma: no mutate 17 | start_time: Optional[float] = attr.ib(default=None) # pragma: no mutate 18 | 19 | def handle_event(self, context: ExecutionContext, event: events.ExecutionEvent) -> None: 20 | if isinstance(event, events.Initialized): 21 | self.start_time = event.start_time 22 | if isinstance(event, events.AfterExecution): 23 | test_case = TestCase( 24 | f"{event.result.method} {event.result.path}", 25 | elapsed_sec=event.elapsed_time, 26 | allow_multiple_subelements=True, 27 | ) 28 | if event.status == Status.failure: 29 | checks = get_unique_failures(event.result.checks) 30 | for idx, check in enumerate(checks, 1): 31 | # `check.message` is always not empty for events with `failure` status 32 | test_case.add_failure_info(message=f"{idx}. {check.message}") 33 | if event.status == Status.error: 34 | test_case.add_error_info( 35 | message=event.result.errors[-1].exception, output=event.result.errors[-1].exception_with_traceback 36 | ) 37 | self.test_cases.append(test_case) 38 | if isinstance(event, events.Finished): 39 | test_suites = [TestSuite("schemathesis", test_cases=self.test_cases, hostname=platform.node())] 40 | to_xml_report_file(file_descriptor=self.file_handle, test_suites=test_suites, prettyprint=True) 41 | -------------------------------------------------------------------------------- /schemathesis/cli/options.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from typing import Any, List, Optional, Type, Union 3 | 4 | import click 5 | 6 | from ..types import NotSet 7 | 8 | 9 | class CustomHelpMessageChoice(click.Choice): 10 | """Allows you to customize how choices are displayed in the help message.""" 11 | 12 | def __init__(self, *args: Any, choices_repr: str, **kwargs: Any): 13 | super().__init__(*args, **kwargs) 14 | self.choices_repr = choices_repr 15 | 16 | def get_metavar(self, param: click.Parameter) -> str: 17 | return self.choices_repr 18 | 19 | 20 | class CSVOption(click.Choice): 21 | def __init__(self, choices: Type[Enum]): 22 | self.enum = choices 23 | super().__init__(tuple(choices.__members__)) 24 | 25 | def convert( 26 | self, value: str, param: Optional[click.core.Parameter], ctx: Optional[click.core.Context] 27 | ) -> List[Enum]: 28 | items = [item for item in value.split(",") if item] 29 | invalid_options = set(items) - set(self.choices) 30 | if not invalid_options and items: 31 | return [self.enum[item] for item in items] 32 | # Sort to keep the error output consistent with the passed values 33 | sorted_options = ", ".join(sorted(invalid_options, key=items.index)) 34 | available_options = ", ".join(self.choices) 35 | self.fail(f"invalid choice(s): {sorted_options}. Choose from {available_options}") 36 | 37 | 38 | not_set = NotSet() 39 | 40 | 41 | class OptionalInt(click.types.IntRange): 42 | def convert( # type: ignore 43 | self, value: str, param: Optional[click.core.Parameter], ctx: Optional[click.core.Context] 44 | ) -> Union[int, NotSet]: 45 | if value == "None": 46 | return not_set 47 | try: 48 | int(value) 49 | return super().convert(value, param, ctx) 50 | except ValueError: 51 | self.fail("%s is not a valid integer or None" % value, param, ctx) 52 | -------------------------------------------------------------------------------- /schemathesis/cli/output/__init__.py: -------------------------------------------------------------------------------- 1 | from . import default, short 2 | -------------------------------------------------------------------------------- /schemathesis/cli/output/__pycache__/__init__.cpython-38.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngalongc/openapi_security_scanner/9ba2244bf0e52db6f149243de403c8c7c157216f/schemathesis/cli/output/__pycache__/__init__.cpython-38.pyc -------------------------------------------------------------------------------- /schemathesis/cli/output/__pycache__/default.cpython-38.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngalongc/openapi_security_scanner/9ba2244bf0e52db6f149243de403c8c7c157216f/schemathesis/cli/output/__pycache__/default.cpython-38.pyc -------------------------------------------------------------------------------- /schemathesis/cli/output/__pycache__/short.cpython-38.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngalongc/openapi_security_scanner/9ba2244bf0e52db6f149243de403c8c7c157216f/schemathesis/cli/output/__pycache__/short.cpython-38.pyc -------------------------------------------------------------------------------- /schemathesis/cli/output/short.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | from ...runner import events 4 | from ..context import ExecutionContext 5 | from ..handlers import EventHandler 6 | from . import default 7 | 8 | 9 | def handle_before_execution(context: ExecutionContext, event: events.BeforeExecution) -> None: 10 | if event.recursion_level > 0: 11 | context.endpoints_count += 1 # type: ignore 12 | 13 | 14 | def handle_after_execution(context: ExecutionContext, event: events.AfterExecution) -> None: 15 | context.endpoints_processed += 1 16 | context.results.append(event.result) 17 | context.hypothesis_output.extend(event.hypothesis_output) 18 | default.display_execution_result(context, event) 19 | 20 | 21 | class ShortOutputStyleHandler(EventHandler): 22 | def handle_event(self, context: ExecutionContext, event: events.ExecutionEvent) -> None: 23 | """Short output style shows single symbols in the progress bar. 24 | 25 | Otherwise, identical to the default output style. 26 | """ 27 | if isinstance(event, events.Initialized): 28 | default.handle_initialized(context, event) 29 | if isinstance(event, events.BeforeExecution): 30 | handle_before_execution(context, event) 31 | if isinstance(event, events.AfterExecution): 32 | handle_after_execution(context, event) 33 | if isinstance(event, events.Finished): 34 | if context.endpoints_count == context.endpoints_processed: 35 | click.echo() 36 | default.handle_finished(context, event) 37 | if isinstance(event, events.Interrupted): 38 | default.handle_interrupted(context, event) 39 | if isinstance(event, events.InternalError): 40 | default.handle_internal_error(context, event) 41 | -------------------------------------------------------------------------------- /schemathesis/constants.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | from ._compat import metadata 4 | 5 | try: 6 | __version__ = metadata.version(__package__) 7 | except metadata.PackageNotFoundError: 8 | # Local run without installation 9 | __version__ = "dev" 10 | 11 | 12 | USER_AGENT = f"schemathesis/{__version__}" 13 | DEFAULT_DEADLINE = 500 # pragma: no mutate 14 | DEFAULT_STATEFUL_RECURSION_LIMIT = 5 # pragma: no mutate 15 | 16 | 17 | class DataGenerationMethod(str, Enum): 18 | """Defines what data Schemathesis generates for tests.""" 19 | 20 | # Generate data, that fits the API schema 21 | positive = "positive" 22 | 23 | @classmethod 24 | def default(cls) -> "DataGenerationMethod": 25 | return cls.positive 26 | 27 | def as_short_name(self) -> str: 28 | return { 29 | DataGenerationMethod.positive: "P", 30 | }[self] 31 | 32 | 33 | DEFAULT_DATA_GENERATION_METHODS = (DataGenerationMethod.default(),) 34 | -------------------------------------------------------------------------------- /schemathesis/exceptions.py: -------------------------------------------------------------------------------- 1 | from hashlib import sha1 2 | from json import JSONDecodeError 3 | from typing import Dict, Type, Union 4 | 5 | import attr 6 | from jsonschema import ValidationError 7 | 8 | from .utils import GenericResponse 9 | 10 | 11 | class CheckFailed(AssertionError): 12 | """Custom error type to distinguish from arbitrary AssertionError that may happen in the dependent libraries.""" 13 | 14 | 15 | CACHE: Dict[Union[str, int], Type[CheckFailed]] = {} 16 | 17 | 18 | def get_exception(name: str) -> Type[CheckFailed]: 19 | """Create a new exception class with provided name or fetch one from the cache.""" 20 | if name in CACHE: 21 | exception_class = CACHE[name] 22 | else: 23 | exception_class = type(name, (CheckFailed,), {}) 24 | exception_class.__qualname__ = CheckFailed.__name__ 25 | exception_class.__name__ = CheckFailed.__name__ 26 | CACHE[name] = exception_class 27 | return exception_class 28 | 29 | 30 | def _get_hashed_exception(prefix: str, message: str) -> Type[CheckFailed]: 31 | """Give different exceptions for different error messages.""" 32 | messages_digest = sha1(message.encode("utf-8")).hexdigest() 33 | name = f"{prefix}{messages_digest}" 34 | return get_exception(name) 35 | 36 | 37 | def get_grouped_exception(prefix: str, *exceptions: AssertionError) -> Type[CheckFailed]: 38 | # The prefix is needed to distinguish multiple endpoints with the same error messages 39 | # that are coming from different endpoints 40 | messages = [exception.args[0] for exception in exceptions] 41 | message = "".join(messages) 42 | return _get_hashed_exception("GroupedException", f"{prefix}{message}") 43 | 44 | 45 | def get_status_code_error(status_code: int) -> Type[CheckFailed]: 46 | """Return new exception for an unexpected status code.""" 47 | name = f"StatusCodeError{status_code}" 48 | return get_exception(name) 49 | 50 | 51 | def get_response_type_error(expected: str, received: str) -> Type[CheckFailed]: 52 | """Return new exception for an unexpected response type.""" 53 | name = f"SchemaValidationError{expected}_{received}" 54 | return get_exception(name) 55 | 56 | 57 | def get_malformed_media_type_error(media_type: str) -> Type[CheckFailed]: 58 | name = f"MalformedMediaType{media_type}" 59 | return get_exception(name) 60 | 61 | 62 | def get_missing_content_type_error() -> Type[CheckFailed]: 63 | """Return new exception for a missing Content-Type header.""" 64 | return get_exception("MissingContentTypeError") 65 | 66 | 67 | def get_schema_validation_error(exception: ValidationError) -> Type[CheckFailed]: 68 | """Return new exception for schema validation error.""" 69 | return _get_hashed_exception("SchemaValidationError", str(exception)) 70 | 71 | 72 | def get_response_parsing_error(exception: JSONDecodeError) -> Type[CheckFailed]: 73 | """Return new exception for response parsing error.""" 74 | return _get_hashed_exception("ResponseParsingError", str(exception)) 75 | 76 | 77 | def get_headers_error(message: str) -> Type[CheckFailed]: 78 | """Return new exception for missing headers.""" 79 | return _get_hashed_exception("MissingHeadersError", message) 80 | 81 | 82 | class InvalidSchema(Exception): 83 | """Schema associated with an endpoint contains an error.""" 84 | 85 | 86 | @attr.s # pragma: no mutate 87 | class HTTPError(Exception): 88 | response: GenericResponse = attr.ib() # pragma: no mutate 89 | url: str = attr.ib() # pragma: no mutate 90 | -------------------------------------------------------------------------------- /schemathesis/extra/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngalongc/openapi_security_scanner/9ba2244bf0e52db6f149243de403c8c7c157216f/schemathesis/extra/__init__.py -------------------------------------------------------------------------------- /schemathesis/extra/_aiohttp.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from typing import Optional 3 | 4 | from aiohttp import web 5 | 6 | from . import _server 7 | 8 | 9 | def _run_server(app: web.Application, port: int) -> None: 10 | """Run the given app on the given port. 11 | 12 | Intended to be called as a target for a separate thread. 13 | NOTE. `aiohttp.web.run_app` works only in the main thread and can't be used here (or maybe can we some tuning) 14 | """ 15 | # Set a loop for a new thread (there is no by default for non-main threads) 16 | loop = asyncio.new_event_loop() 17 | asyncio.set_event_loop(loop) 18 | runner = web.AppRunner(app) 19 | loop.run_until_complete(runner.setup()) 20 | site = web.TCPSite(runner, "127.0.0.1", port) 21 | loop.run_until_complete(site.start()) 22 | loop.run_forever() 23 | 24 | 25 | def run_server(app: web.Application, port: Optional[int] = None, timeout: float = 0.05) -> int: 26 | """Start a thread with the given aiohttp application.""" 27 | return _server.run(_run_server, app=app, port=port, timeout=timeout) 28 | -------------------------------------------------------------------------------- /schemathesis/extra/_flask.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from flask import Flask 4 | 5 | from . import _server 6 | 7 | 8 | def run_server(app: Flask, port: Optional[int] = None, timeout: float = 0.05) -> int: 9 | """Start a thread with the given aiohttp application.""" 10 | return _server.run(app.run, port=port, timeout=timeout) 11 | -------------------------------------------------------------------------------- /schemathesis/extra/_server.py: -------------------------------------------------------------------------------- 1 | import threading 2 | from time import sleep 3 | from typing import Any, Callable, Optional 4 | 5 | from aiohttp.test_utils import unused_port 6 | 7 | 8 | def run(target: Callable, port: Optional[int] = None, timeout: float = 0.05, **kwargs: Any) -> int: 9 | """Start a thread with the given aiohttp application.""" 10 | if port is None: 11 | port = unused_port() 12 | server_thread = threading.Thread(target=target, kwargs={"port": port, **kwargs}) 13 | server_thread.daemon = True 14 | server_thread.start() 15 | sleep(timeout) 16 | return port 17 | -------------------------------------------------------------------------------- /schemathesis/extra/pytest_plugin.py: -------------------------------------------------------------------------------- 1 | from functools import partial 2 | from typing import Any, Callable, Generator, List, Optional, Type, TypeVar, cast 3 | 4 | import pytest 5 | from _pytest import fixtures, nodes 6 | from _pytest.config import hookimpl 7 | from _pytest.fixtures import FuncFixtureInfo 8 | from _pytest.nodes import Node 9 | from _pytest.python import Class, Function, FunctionDefinition, Metafunc, Module, PyCollector 10 | from _pytest.runner import runtestprotocol 11 | from _pytest.warning_types import PytestWarning 12 | from hypothesis.errors import InvalidArgument 13 | from packaging import version 14 | 15 | from .. import DataGenerationMethod 16 | from .._hypothesis import create_test 17 | from ..models import Endpoint 18 | from ..stateful import Feedback 19 | from ..utils import is_schemathesis_test 20 | 21 | USE_FROM_PARENT = version.parse(pytest.__version__) >= version.parse("5.4.0") 22 | 23 | T = TypeVar("T", bound=Node) 24 | 25 | 26 | def create(cls: Type[T], *args: Any, **kwargs: Any) -> T: 27 | if USE_FROM_PARENT: 28 | return cls.from_parent(*args, **kwargs) # type: ignore 29 | return cls(*args, **kwargs) 30 | 31 | 32 | class SchemathesisFunction(Function): # pylint: disable=too-many-ancestors 33 | def __init__( 34 | self, 35 | *args: Any, 36 | test_func: Callable, 37 | test_name: Optional[str] = None, 38 | recursion_level: int = 0, 39 | data_generation_method: DataGenerationMethod, 40 | **kwargs: Any, 41 | ) -> None: 42 | super().__init__(*args, **kwargs) 43 | self.test_function = test_func 44 | self.test_name = test_name 45 | self.recursion_level = recursion_level 46 | self.data_generation_method = data_generation_method 47 | 48 | def _getobj(self) -> partial: 49 | """Tests defined as methods require `self` as the first argument. 50 | 51 | This method is called only for this case. 52 | """ 53 | return partial(self.obj, self.parent.obj) # type: ignore 54 | 55 | @property 56 | def feedback(self) -> Optional[Feedback]: 57 | return getattr(self.obj, "_schemathesis_feedback", None) 58 | 59 | def warn_if_stateful_responses_not_stored(self) -> None: 60 | feedback = self.feedback 61 | if feedback is not None and not feedback.stateful_tests: 62 | self.warn(PytestWarning(NOT_USED_STATEFUL_TESTING_MESSAGE)) 63 | 64 | def _get_stateful_tests(self) -> List["SchemathesisFunction"]: 65 | feedback = self.feedback 66 | recursion_level = self.recursion_level 67 | if feedback is None or recursion_level >= feedback.endpoint.schema.stateful_recursion_limit: 68 | return [] 69 | previous_test_name = self.test_name or f"{feedback.endpoint.method.upper()}:{feedback.endpoint.full_path}" 70 | 71 | def make_test( 72 | endpoint: Endpoint, 73 | test: Callable, 74 | data_generation_method: DataGenerationMethod, 75 | previous_tests: str, 76 | ) -> "SchemathesisFunction": 77 | test_name = f"{previous_tests} -> {endpoint.method.upper()}:{endpoint.full_path}" 78 | return create( 79 | self.__class__, 80 | name=f"{self.originalname}[{test_name}][{self.data_generation_method.as_short_name()}]", 81 | parent=self.parent, 82 | callspec=getattr(self, "callspec", None), 83 | callobj=test, 84 | fixtureinfo=self._fixtureinfo, 85 | keywords=self.keywords, 86 | originalname=self.originalname, 87 | test_func=self.test_function, 88 | test_name=test_name, 89 | recursion_level=recursion_level + 1, 90 | data_generation_method=data_generation_method, 91 | ) 92 | 93 | return [ 94 | make_test(endpoint, test, data_generation_method, previous_test_name) 95 | for (endpoint, data_generation_method, test) in feedback.get_stateful_tests(self.test_function, None, None) 96 | ] 97 | 98 | def add_stateful_tests(self) -> None: 99 | idx = self.session.items.index(self) + 1 100 | tests = self._get_stateful_tests() 101 | self.session.items[idx:idx] = tests 102 | self.session.testscollected += len(tests) 103 | 104 | 105 | class SchemathesisCase(PyCollector): 106 | def __init__(self, test_function: Callable, *args: Any, **kwargs: Any) -> None: 107 | self.test_function = test_function 108 | self.schemathesis_case = test_function._schemathesis_test # type: ignore 109 | self.given_args = getattr(test_function, "_schemathesis_given_args", ()) 110 | self.given_kwargs = getattr(test_function, "_schemathesis_given_kwargs", {}) 111 | super().__init__(*args, **kwargs) 112 | 113 | def _get_test_name(self, endpoint: Endpoint, data_generation_method: DataGenerationMethod) -> str: 114 | return f"{self.name}[{endpoint.method.upper()}:{endpoint.full_path}][{data_generation_method.as_short_name()}]" 115 | 116 | def _gen_items( 117 | self, endpoint: Endpoint, data_generation_method: DataGenerationMethod 118 | ) -> Generator[SchemathesisFunction, None, None]: 119 | """Generate all items for the given endpoint. 120 | 121 | Could produce more than one test item if 122 | parametrization is applied via ``pytest.mark.parametrize`` or ``pytest_generate_tests``. 123 | 124 | This implementation is based on the original one in pytest, but with slight adjustments 125 | to produce tests out of hypothesis ones. 126 | """ 127 | name = self._get_test_name(endpoint, data_generation_method) 128 | funcobj = create_test( 129 | endpoint=endpoint, 130 | test=self.test_function, 131 | _given_args=self.given_args, 132 | _given_kwargs=self.given_kwargs, 133 | data_generation_method=data_generation_method, 134 | ) 135 | 136 | cls = self._get_class_parent() 137 | definition: FunctionDefinition = create(FunctionDefinition, name=self.name, parent=self.parent, callobj=funcobj) 138 | fixturemanager = self.session._fixturemanager 139 | fixtureinfo = fixturemanager.getfixtureinfo(definition, funcobj, cls) 140 | 141 | metafunc = self._parametrize(cls, definition, fixtureinfo) 142 | 143 | if not metafunc._calls: 144 | yield create( 145 | SchemathesisFunction, 146 | name=name, 147 | parent=self.parent, 148 | callobj=funcobj, 149 | fixtureinfo=fixtureinfo, 150 | test_func=self.test_function, 151 | originalname=self.name, 152 | data_generation_method=data_generation_method, 153 | ) 154 | else: 155 | fixtures.add_funcarg_pseudo_fixture_def(self.parent, metafunc, fixturemanager) 156 | fixtureinfo.prune_dependency_tree() 157 | for callspec in metafunc._calls: 158 | subname = f"{name}[{callspec.id}]" 159 | yield create( 160 | SchemathesisFunction, 161 | name=subname, 162 | parent=self.parent, 163 | callspec=callspec, 164 | callobj=funcobj, 165 | fixtureinfo=fixtureinfo, 166 | keywords={callspec.id: True}, 167 | originalname=name, 168 | test_func=self.test_function, 169 | data_generation_method=data_generation_method, 170 | ) 171 | 172 | def _get_class_parent(self) -> Optional[Type]: 173 | clscol = self.getparent(Class) 174 | return clscol.obj if clscol else None 175 | 176 | def _parametrize( 177 | self, cls: Optional[Type], definition: FunctionDefinition, fixtureinfo: FuncFixtureInfo 178 | ) -> Metafunc: 179 | parent = self.getparent(Module) 180 | module = parent.obj if parent is not None else parent 181 | metafunc = Metafunc(definition, fixtureinfo, self.config, cls=cls, module=module) 182 | methods = [] 183 | if hasattr(module, "pytest_generate_tests"): 184 | methods.append(module.pytest_generate_tests) 185 | if hasattr(cls, "pytest_generate_tests"): 186 | cls = cast(Type, cls) 187 | methods.append(cls().pytest_generate_tests) 188 | self.ihook.pytest_generate_tests.call_extra(methods, {"metafunc": metafunc}) 189 | return metafunc 190 | 191 | def collect(self) -> List[Function]: # type: ignore 192 | """Generate different test items for all endpoints available in the given schema.""" 193 | try: 194 | return [ 195 | item 196 | for data_generation_method in self.schemathesis_case.data_generation_methods 197 | for endpoint in self.schemathesis_case.get_all_endpoints() 198 | for item in self._gen_items(endpoint, data_generation_method) 199 | ] 200 | except Exception: 201 | pytest.fail("Error during collection") 202 | 203 | 204 | NOT_USED_STATEFUL_TESTING_MESSAGE = ( 205 | "You are using stateful testing, but no responses were stored during the test! " 206 | "Please, use `case.call` or `case.store_response` in your test to enable stateful tests." 207 | ) 208 | 209 | 210 | @hookimpl(hookwrapper=True) # type:ignore # pragma: no mutate 211 | def pytest_pycollect_makeitem(collector: nodes.Collector, name: str, obj: Any) -> Generator[None, Any, None]: 212 | """Switch to a different collector if the test is parametrized marked by schemathesis.""" 213 | outcome = yield 214 | if is_schemathesis_test(obj): 215 | outcome.force_result(create(SchemathesisCase, parent=collector, test_function=obj, name=name)) 216 | else: 217 | outcome.get_result() 218 | 219 | 220 | @hookimpl(hookwrapper=True) # pragma: no mutate 221 | def pytest_pyfunc_call(pyfuncitem): # type:ignore 222 | """It is possible to have a Hypothesis exception in runtime. 223 | 224 | For example - kwargs validation is failed for some strategy. 225 | """ 226 | outcome = yield 227 | try: 228 | outcome.get_result() 229 | except InvalidArgument as exc: 230 | pytest.fail(exc.args[0]) 231 | 232 | 233 | def pytest_runtest_protocol(item: Function, nextitem: Optional[Function]) -> bool: 234 | item.ihook.pytest_runtest_logstart(nodeid=item.nodeid, location=item.location) 235 | reports = runtestprotocol(item, nextitem=nextitem) 236 | item.ihook.pytest_runtest_logfinish(nodeid=item.nodeid, location=item.location) 237 | if isinstance(item, SchemathesisFunction): 238 | for report in reports: 239 | if report.when == "call" and report.outcome == "passed": 240 | item.warn_if_stateful_responses_not_stored() 241 | item.add_stateful_tests() 242 | return True 243 | -------------------------------------------------------------------------------- /schemathesis/fixups/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import Iterable, Optional 2 | 3 | from . import fast_api 4 | 5 | ALL_FIXUPS = {"fast_api": fast_api} 6 | 7 | 8 | def install(fixups: Optional[Iterable[str]] = None) -> None: 9 | """Install fixups. 10 | 11 | Without the first argument installs all available fixups. 12 | """ 13 | fixups = fixups or list(ALL_FIXUPS.keys()) 14 | for name in fixups: 15 | ALL_FIXUPS[name].install() # type: ignore 16 | 17 | 18 | def uninstall(fixups: Optional[Iterable[str]] = None) -> None: 19 | """Uninstall fixups. 20 | 21 | Without the first argument uninstalls all available fixups. 22 | """ 23 | fixups = fixups or list(ALL_FIXUPS.keys()) 24 | for name in fixups: 25 | ALL_FIXUPS[name].uninstall() # type: ignore 26 | -------------------------------------------------------------------------------- /schemathesis/fixups/__pycache__/__init__.cpython-38.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngalongc/openapi_security_scanner/9ba2244bf0e52db6f149243de403c8c7c157216f/schemathesis/fixups/__pycache__/__init__.cpython-38.pyc -------------------------------------------------------------------------------- /schemathesis/fixups/__pycache__/fast_api.cpython-38.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngalongc/openapi_security_scanner/9ba2244bf0e52db6f149243de403c8c7c157216f/schemathesis/fixups/__pycache__/fast_api.cpython-38.pyc -------------------------------------------------------------------------------- /schemathesis/fixups/fast_api.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict 2 | 3 | from ..hooks import HookContext, register, unregister 4 | from ..utils import traverse_schema 5 | 6 | 7 | def install() -> None: 8 | register(before_load_schema) 9 | 10 | 11 | def uninstall() -> None: 12 | unregister(before_load_schema) 13 | 14 | 15 | def before_load_schema(context: HookContext, schema: Dict[str, Any]) -> None: 16 | traverse_schema(schema, _handle_boundaries) 17 | 18 | 19 | def _handle_boundaries(schema: Dict[str, Any]) -> Dict[str, Any]: 20 | """Convert Draft 7 keywords to Draft 4 compatible versions. 21 | 22 | FastAPI uses ``pydantic``, which generates Draft 7 compatible schemas. 23 | """ 24 | for boundary_name, boundary_exclusive_name in (("maximum", "exclusiveMaximum"), ("minimum", "exclusiveMinimum")): 25 | value = schema.get(boundary_exclusive_name) 26 | # `bool` check is needed, since in Python `True` is an instance of `int` 27 | if isinstance(value, (int, float)) and not isinstance(value, bool): 28 | schema[boundary_exclusive_name] = True 29 | schema[boundary_name] = value 30 | return schema 31 | -------------------------------------------------------------------------------- /schemathesis/hooks.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | from collections import defaultdict 3 | from enum import Enum, unique 4 | from typing import TYPE_CHECKING, Any, Callable, DefaultDict, Dict, List, Optional, Union, cast 5 | 6 | import attr 7 | from hypothesis import strategies as st 8 | 9 | from .types import GenericTest 10 | from .utils import GenericResponse 11 | 12 | if TYPE_CHECKING: 13 | from .models import Case, Endpoint 14 | 15 | 16 | class HookLocation(Enum): 17 | path_parameters = 1 18 | headers = 2 19 | cookies = 3 20 | query = 4 21 | body = 5 22 | 23 | 24 | @unique 25 | class HookScope(Enum): 26 | GLOBAL = 1 27 | SCHEMA = 2 28 | TEST = 3 29 | 30 | 31 | @attr.s(slots=True) # pragma: no mutate 32 | class RegisteredHook: 33 | signature: inspect.Signature = attr.ib() # pragma: no mutate 34 | scopes: List[HookScope] = attr.ib() # pragma: no mutate 35 | 36 | 37 | @attr.s(slots=True) # pragma: no mutate 38 | class HookContext: 39 | """A context that is passed to some hook functions.""" 40 | 41 | endpoint: Optional["Endpoint"] = attr.ib(default=None) # pragma: no mutate 42 | 43 | 44 | @attr.s(slots=True) # pragma: no mutate 45 | class HookDispatcher: 46 | """Generic hook dispatcher. 47 | 48 | Provides a mechanism to extend Schemathesis in registered hook points. 49 | """ 50 | 51 | scope: HookScope = attr.ib() # pragma: no mutate 52 | _hooks: DefaultDict[str, List[Callable]] = attr.ib(factory=lambda: defaultdict(list)) # pragma: no mutate 53 | _specs: Dict[str, RegisteredHook] = {} # pragma: no mutate 54 | 55 | def register(self, hook: Union[str, Callable]) -> Callable: 56 | """Register a new hook. 57 | 58 | Can be used as a decorator in two forms. 59 | Without arguments for registering hooks and autodetecting their names: 60 | 61 | @schema.hooks.register 62 | def before_generate_query(strategy, context): 63 | ... 64 | 65 | With a hook name as the first argument: 66 | 67 | @schema.hooks.register("before_generate_query") 68 | def hook(strategy, context): 69 | ... 70 | """ 71 | if isinstance(hook, str): 72 | 73 | def decorator(func: Callable) -> Callable: 74 | hook_name = cast(str, hook) 75 | return self.register_hook_with_name(func, hook_name) 76 | 77 | return decorator 78 | return self.register_hook_with_name(hook, hook.__name__) 79 | 80 | def apply(self, hook: Callable, *, name: Optional[str] = None) -> Callable[[Callable], Callable]: 81 | """Register hook to run only on one test function. 82 | 83 | Example: 84 | def before_generate_query(strategy, context): 85 | ... 86 | 87 | @schema.hooks.apply(before_generate_query) 88 | @schema.parametrize() 89 | def test_api(case): 90 | ... 91 | 92 | """ 93 | 94 | if name is None: 95 | hook_name = hook.__name__ 96 | else: 97 | hook_name = name 98 | 99 | def decorator(func: GenericTest) -> GenericTest: 100 | dispatcher = self.add_dispatcher(func) 101 | dispatcher.register_hook_with_name(hook, hook_name) 102 | return func 103 | 104 | return decorator 105 | 106 | @classmethod 107 | def add_dispatcher(cls, func: GenericTest) -> "HookDispatcher": 108 | """Attach a new dispatcher instance to the test if it is not already present.""" 109 | if not hasattr(func, "_schemathesis_hooks"): 110 | func._schemathesis_hooks = cls(scope=HookScope.TEST) # type: ignore 111 | return func._schemathesis_hooks # type: ignore 112 | 113 | def register_hook_with_name(self, hook: Callable, name: str) -> Callable: 114 | """A helper for hooks registration.""" 115 | self._validate_hook(name, hook) 116 | self._hooks[name].append(hook) 117 | return hook 118 | 119 | @classmethod 120 | def register_spec(cls, scopes: List[HookScope]) -> Callable: 121 | """Register hook specification. 122 | 123 | All hooks, registered with `register` should comply with corresponding registered specs. 124 | """ 125 | 126 | def _register_spec(spec: Callable) -> Callable: 127 | cls._specs[spec.__name__] = RegisteredHook(inspect.signature(spec), scopes) 128 | return spec 129 | 130 | return _register_spec 131 | 132 | def _validate_hook(self, name: str, hook: Callable) -> None: 133 | """Basic validation for hooks being registered.""" 134 | spec = self._specs.get(name) 135 | if spec is None: 136 | raise TypeError(f"There is no hook with name '{name}'") 137 | # Some hooks are not present on all levels. We need to avoid registering hooks on wrong levels. 138 | if self.scope not in spec.scopes: 139 | scopes = ", ".join(scope.name for scope in spec.scopes) 140 | raise ValueError( 141 | f"Cannot register hook '{name}' on {self.scope.name} scope dispatcher. " 142 | f"Use a dispatcher with {scopes} scope(s) instead" 143 | ) 144 | signature = inspect.signature(hook) 145 | if len(signature.parameters) != len(spec.signature.parameters): 146 | raise TypeError( 147 | f"Hook '{name}' takes {len(spec.signature.parameters)} arguments but {len(signature.parameters)} is defined" 148 | ) 149 | 150 | def get_all_by_name(self, name: str) -> List[Callable]: 151 | """Get a list of hooks registered for a name.""" 152 | return self._hooks.get(name, []) 153 | 154 | def dispatch(self, name: str, context: HookContext, *args: Any, **kwargs: Any) -> None: 155 | """Run all hooks for the given name.""" 156 | for hook in self.get_all_by_name(name): 157 | hook(context, *args, **kwargs) 158 | 159 | def unregister(self, hook: Callable) -> None: 160 | """Unregister a specific hook.""" 161 | # It removes this function from all places 162 | for hooks in self._hooks.values(): 163 | hooks[:] = [item for item in hooks if item is not hook] 164 | 165 | def unregister_all(self) -> None: 166 | """Remove all registered hooks. 167 | 168 | Useful in tests. 169 | """ 170 | self._hooks = defaultdict(list) 171 | 172 | 173 | all_scopes = HookDispatcher.register_spec(list(HookScope)) 174 | 175 | 176 | @all_scopes 177 | def before_generate_path_parameters(context: HookContext, strategy: st.SearchStrategy) -> st.SearchStrategy: 178 | """Called on a strategy that generates values for ``path_parameters``.""" 179 | 180 | 181 | @all_scopes 182 | def before_generate_headers(context: HookContext, strategy: st.SearchStrategy) -> st.SearchStrategy: 183 | """Called on a strategy that generates values for ``headers``.""" 184 | 185 | 186 | @all_scopes 187 | def before_generate_cookies(context: HookContext, strategy: st.SearchStrategy) -> st.SearchStrategy: 188 | """Called on a strategy that generates values for ``cookies``.""" 189 | 190 | 191 | @all_scopes 192 | def before_generate_query(context: HookContext, strategy: st.SearchStrategy) -> st.SearchStrategy: 193 | """Called on a strategy that generates values for ``query``.""" 194 | 195 | 196 | @all_scopes 197 | def before_generate_body(context: HookContext, strategy: st.SearchStrategy) -> st.SearchStrategy: 198 | """Called on a strategy that generates values for ``body``.""" 199 | 200 | 201 | @all_scopes 202 | def before_process_path(context: HookContext, path: str, methods: Dict[str, Any]) -> None: 203 | """Called before API path is processed.""" 204 | 205 | 206 | @HookDispatcher.register_spec([HookScope.GLOBAL]) 207 | def before_load_schema(context: HookContext, raw_schema: Dict[str, Any]) -> None: 208 | """Called before schema instance is created.""" 209 | 210 | 211 | @all_scopes 212 | def before_add_examples(context: HookContext, examples: List["Case"]) -> None: 213 | """Called before explicit examples are added to a test via `@example` decorator. 214 | 215 | `examples` is a list that could be extended with examples provided by the user. 216 | """ 217 | 218 | 219 | @HookDispatcher.register_spec([HookScope.GLOBAL]) 220 | def add_case(context: HookContext, case: "Case", response: GenericResponse) -> Optional["Case"]: 221 | """Creates an additional test per endpoint. If this hook returns None, no additional test created. 222 | 223 | Called with a copy of the original case object and the server's response to the original case. 224 | """ 225 | 226 | 227 | GLOBAL_HOOK_DISPATCHER = HookDispatcher(scope=HookScope.GLOBAL) 228 | dispatch = GLOBAL_HOOK_DISPATCHER.dispatch 229 | get_all_by_name = GLOBAL_HOOK_DISPATCHER.get_all_by_name 230 | register = GLOBAL_HOOK_DISPATCHER.register 231 | unregister = GLOBAL_HOOK_DISPATCHER.unregister 232 | unregister_all = GLOBAL_HOOK_DISPATCHER.unregister_all 233 | -------------------------------------------------------------------------------- /schemathesis/lazy.py: -------------------------------------------------------------------------------- 1 | from inspect import signature 2 | from typing import Any, Callable, Dict, Iterable, Optional, Union 3 | 4 | import attr 5 | from _pytest.fixtures import FixtureRequest 6 | from pytest_subtests import SubTests 7 | 8 | from .constants import DEFAULT_DATA_GENERATION_METHODS, DataGenerationMethod 9 | from .hooks import HookDispatcher, HookScope 10 | from .models import Endpoint 11 | from .schemas import BaseSchema 12 | from .types import Filter, GenericTest, NotSet 13 | from .utils import NOT_SET 14 | 15 | 16 | @attr.s(slots=True) # pragma: no mutate 17 | class LazySchema: 18 | fixture_name: str = attr.ib() # pragma: no mutate 19 | method: Optional[Filter] = attr.ib(default=NOT_SET) # pragma: no mutate 20 | endpoint: Optional[Filter] = attr.ib(default=NOT_SET) # pragma: no mutate 21 | tag: Optional[Filter] = attr.ib(default=NOT_SET) # pragma: no mutate 22 | operation_id: Optional[Filter] = attr.ib(default=NOT_SET) # pragma: no mutate 23 | hooks: HookDispatcher = attr.ib(factory=lambda: HookDispatcher(scope=HookScope.SCHEMA)) # pragma: no mutate 24 | validate_schema: bool = attr.ib(default=True) # pragma: no mutate 25 | skip_deprecated_endpoints: bool = attr.ib(default=False) # pragma: no mutate 26 | data_generation_methods: Iterable[DataGenerationMethod] = attr.ib(default=DEFAULT_DATA_GENERATION_METHODS) 27 | 28 | def parametrize( 29 | self, 30 | method: Optional[Filter] = NOT_SET, 31 | endpoint: Optional[Filter] = NOT_SET, 32 | tag: Optional[Filter] = NOT_SET, 33 | operation_id: Optional[Filter] = NOT_SET, 34 | validate_schema: Union[bool, NotSet] = NOT_SET, 35 | skip_deprecated_endpoints: Union[bool, NotSet] = NOT_SET, 36 | data_generation_methods: Union[Iterable[DataGenerationMethod], NotSet] = NOT_SET, 37 | ) -> Callable: 38 | if method is NOT_SET: 39 | method = self.method 40 | if endpoint is NOT_SET: 41 | endpoint = self.endpoint 42 | if tag is NOT_SET: 43 | tag = self.tag 44 | if operation_id is NOT_SET: 45 | operation_id = self.operation_id 46 | if data_generation_methods is NOT_SET: 47 | data_generation_methods = self.data_generation_methods 48 | 49 | def wrapper(func: Callable) -> Callable: 50 | def test(request: FixtureRequest, subtests: SubTests) -> None: 51 | """The actual test, which is executed by pytest.""" 52 | if hasattr(test, "_schemathesis_hooks"): 53 | func._schemathesis_hooks = test._schemathesis_hooks # type: ignore 54 | schema = get_schema( 55 | request=request, 56 | name=self.fixture_name, 57 | method=method, 58 | endpoint=endpoint, 59 | tag=tag, 60 | operation_id=operation_id, 61 | hooks=self.hooks, 62 | test_function=func, 63 | validate_schema=validate_schema, 64 | skip_deprecated_endpoints=skip_deprecated_endpoints, 65 | data_generation_methods=data_generation_methods, 66 | ) 67 | fixtures = get_fixtures(func, request) 68 | # Changing the node id is required for better reporting - the method and endpoint will appear there 69 | node_id = subtests.item._nodeid 70 | settings = getattr(test, "_hypothesis_internal_use_settings", None) 71 | tests = list(schema.get_all_tests(func, settings)) 72 | request.session.testscollected += len(tests) 73 | for _endpoint, data_generation_method, sub_test in tests: 74 | subtests.item._nodeid = _get_node_name(node_id, _endpoint, data_generation_method) 75 | run_subtest(_endpoint, fixtures, sub_test, subtests) 76 | subtests.item._nodeid = node_id 77 | 78 | # Needed to prevent a failure when settings are applied to the test function 79 | test.is_hypothesis_test = True # type: ignore 80 | 81 | return test 82 | 83 | return wrapper 84 | 85 | 86 | def _get_node_name(node_id: str, endpoint: Endpoint, data_generation_method: DataGenerationMethod) -> str: 87 | """Make a test node name. For example: test_api[GET:/users].""" 88 | return f"{node_id}[{endpoint.method.upper()}:{endpoint.full_path}][{data_generation_method.as_short_name()}]" 89 | 90 | 91 | def run_subtest(endpoint: Endpoint, fixtures: Dict[str, Any], sub_test: Callable, subtests: SubTests) -> None: 92 | """Run the given subtest with pytest fixtures.""" 93 | with subtests.test(method=endpoint.method.upper(), path=endpoint.path): 94 | sub_test(**fixtures) 95 | 96 | 97 | def get_schema( 98 | *, 99 | request: FixtureRequest, 100 | name: str, 101 | method: Optional[Filter] = None, 102 | endpoint: Optional[Filter] = None, 103 | tag: Optional[Filter] = None, 104 | operation_id: Optional[Filter] = None, 105 | test_function: GenericTest, 106 | hooks: HookDispatcher, 107 | validate_schema: Union[bool, NotSet] = NOT_SET, 108 | skip_deprecated_endpoints: Union[bool, NotSet] = NOT_SET, 109 | data_generation_methods: Union[Iterable[DataGenerationMethod], NotSet] = NOT_SET, 110 | ) -> BaseSchema: 111 | """Loads a schema from the fixture.""" 112 | schema = request.getfixturevalue(name) 113 | if not isinstance(schema, BaseSchema): 114 | raise ValueError(f"The given schema must be an instance of BaseSchema, got: {type(schema)}") 115 | return schema.clone( 116 | method=method, 117 | endpoint=endpoint, 118 | tag=tag, 119 | operation_id=operation_id, 120 | test_function=test_function, 121 | hooks=hooks, 122 | validate_schema=validate_schema, 123 | skip_deprecated_endpoints=skip_deprecated_endpoints, 124 | data_generation_methods=data_generation_methods, 125 | ) 126 | 127 | 128 | def get_fixtures(func: Callable, request: FixtureRequest) -> Dict[str, Any]: 129 | """Load fixtures, needed for the test function.""" 130 | sig = signature(func) 131 | return {name: request.getfixturevalue(name) for name in sig.parameters if name != "case"} 132 | -------------------------------------------------------------------------------- /schemathesis/parameters.py: -------------------------------------------------------------------------------- 1 | """API operation parameters. 2 | 3 | These are basic entities that describe what data could be sent to the API. 4 | """ 5 | from copy import deepcopy 6 | from typing import Any, Dict, Generator, Generic, List, TypeVar 7 | 8 | import attr 9 | 10 | 11 | @attr.s(slots=True) # pragma: no mutate 12 | class Parameter: 13 | """A logically separate parameter bound to a location (e.g., to "query string"). 14 | 15 | For example, if the API requires multiple headers to be present, each header is presented as a separate 16 | `Parameter` instance. 17 | """ 18 | 19 | # The parameter definition in the language acceptable by the API 20 | definition: Any = attr.ib() # pragma: no mutate 21 | 22 | def __attrs_post_init__(self) -> None: 23 | # Do not use `converter=deepcopy` on the field due to mypy not detecting type annotations 24 | self.definition = deepcopy(self.definition) 25 | 26 | @property 27 | def location(self) -> str: 28 | """Where this parameter is located. 29 | 30 | E.g. "query" or "body" 31 | """ 32 | raise NotImplementedError 33 | 34 | @property 35 | def name(self) -> str: 36 | """Parameter name.""" 37 | raise NotImplementedError 38 | 39 | @property 40 | def is_required(self) -> bool: 41 | """Whether the parameter is required for a successful API call.""" 42 | raise NotImplementedError 43 | 44 | @property 45 | def example(self) -> Any: 46 | """Parameter example.""" 47 | raise NotImplementedError 48 | 49 | 50 | P = TypeVar("P", bound=Parameter) 51 | 52 | 53 | @attr.s # pragma: no mutate 54 | class ParameterSet(Generic[P]): 55 | """A set of parameters for the same location.""" 56 | 57 | items: List[P] = attr.ib(factory=list) # pragma: no mutate 58 | 59 | def add(self, parameter: P) -> None: 60 | """Add a new parameter.""" 61 | self.items.append(parameter) 62 | 63 | @property 64 | def example(self) -> Dict[str, Any]: 65 | """Composite example gathered from individual parameters.""" 66 | return {item.name: item.example for item in self.items if item.example} 67 | 68 | def __bool__(self) -> bool: 69 | return bool(self.items) 70 | 71 | def __iter__(self) -> Generator[P, None, None]: 72 | yield from iter(self.items) 73 | 74 | def __len__(self) -> int: 75 | return len(self.items) 76 | 77 | def __getitem__(self, item: int) -> P: 78 | return self.items[item] 79 | 80 | 81 | class PayloadAlternatives(ParameterSet[P]): 82 | """A set of alternative payloads.""" 83 | 84 | @property 85 | def example(self) -> Any: 86 | """We take only the first example.""" 87 | # May be extended in the future 88 | if self.items: 89 | return self.items[0].example 90 | -------------------------------------------------------------------------------- /schemathesis/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngalongc/openapi_security_scanner/9ba2244bf0e52db6f149243de403c8c7c157216f/schemathesis/py.typed -------------------------------------------------------------------------------- /schemathesis/runner/__pycache__/__init__.cpython-38.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngalongc/openapi_security_scanner/9ba2244bf0e52db6f149243de403c8c7c157216f/schemathesis/runner/__pycache__/__init__.cpython-38.pyc -------------------------------------------------------------------------------- /schemathesis/runner/__pycache__/events.cpython-38.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngalongc/openapi_security_scanner/9ba2244bf0e52db6f149243de403c8c7c157216f/schemathesis/runner/__pycache__/events.cpython-38.pyc -------------------------------------------------------------------------------- /schemathesis/runner/__pycache__/serialization.cpython-38.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngalongc/openapi_security_scanner/9ba2244bf0e52db6f149243de403c8c7c157216f/schemathesis/runner/__pycache__/serialization.cpython-38.pyc -------------------------------------------------------------------------------- /schemathesis/runner/events.py: -------------------------------------------------------------------------------- 1 | import time 2 | from typing import Dict, List, Optional, Union 3 | 4 | import attr 5 | from requests import exceptions 6 | 7 | from ..exceptions import HTTPError 8 | from ..models import Endpoint, Status, TestResult, TestResultSet 9 | from ..schemas import BaseSchema 10 | from ..utils import format_exception 11 | from .serialization import SerializedTestResult 12 | 13 | 14 | @attr.s() # pragma: no mutate 15 | class ExecutionEvent: 16 | """Generic execution event.""" 17 | 18 | 19 | @attr.s(slots=True) # pragma: no mutate 20 | class Initialized(ExecutionEvent): 21 | """Runner is initialized, settings are prepared, requests session is ready.""" 22 | 23 | # Total number of endpoints in the schema 24 | endpoints_count: int = attr.ib() # pragma: no mutate 25 | location: Optional[str] = attr.ib() # pragma: no mutate 26 | base_url: str = attr.ib() # pragma: no mutate 27 | specification_name: str = attr.ib() # pragma: no mutate 28 | # Timestamp of test run start 29 | start_time: float = attr.ib(factory=time.monotonic) # pragma: no mutate 30 | 31 | @classmethod 32 | def from_schema(cls, *, schema: BaseSchema) -> "Initialized": 33 | """Computes all needed data from a schema instance.""" 34 | return cls( 35 | endpoints_count=schema.endpoints_count, 36 | location=schema.location, 37 | base_url=schema.get_base_url(), 38 | specification_name=schema.verbose_name, 39 | ) 40 | 41 | 42 | class CurrentPathMixin: 43 | method: str 44 | path: str 45 | 46 | @property 47 | def current_endpoint(self) -> str: 48 | return f"{self.method} {self.path}" 49 | 50 | 51 | @attr.s(slots=True) # pragma: no mutate 52 | class BeforeExecution(CurrentPathMixin, ExecutionEvent): 53 | """Happens before each examined endpoint. 54 | 55 | It happens before a single hypothesis test, that may contain many examples inside. 56 | """ 57 | 58 | method: str = attr.ib() # pragma: no mutate 59 | path: str = attr.ib() # pragma: no mutate 60 | relative_path: str = attr.ib() # pragma: no mutate 61 | recursion_level: int = attr.ib() # pragma: no mutate 62 | 63 | @classmethod 64 | def from_endpoint(cls, endpoint: Endpoint, recursion_level: int) -> "BeforeExecution": 65 | return cls( 66 | method=endpoint.method.upper(), 67 | path=endpoint.full_path, 68 | relative_path=endpoint.path, 69 | recursion_level=recursion_level, 70 | ) 71 | 72 | 73 | @attr.s(slots=True) # pragma: no mutate 74 | class AfterExecution(CurrentPathMixin, ExecutionEvent): 75 | """Happens after each examined endpoint.""" 76 | 77 | method: str = attr.ib() # pragma: no mutate 78 | path: str = attr.ib() # pragma: no mutate 79 | relative_path: str = attr.ib() # pragma: no mutate 80 | 81 | # Endpoint test status - success / failure / error 82 | status: Status = attr.ib() # pragma: no mutate 83 | result: SerializedTestResult = attr.ib() # pragma: no mutate 84 | # Test running time 85 | elapsed_time: float = attr.ib() # pragma: no mutate 86 | # Captured hypothesis stdout 87 | hypothesis_output: List[str] = attr.ib(factory=list) # pragma: no mutate 88 | 89 | @classmethod 90 | def from_result( 91 | cls, result: TestResult, status: Status, elapsed_time: float, hypothesis_output: List[str], endpoint: Endpoint 92 | ) -> "AfterExecution": 93 | return cls( 94 | method=endpoint.method.upper(), 95 | path=endpoint.full_path, 96 | relative_path=endpoint.path, 97 | result=SerializedTestResult.from_test_result(result), 98 | status=status, 99 | elapsed_time=elapsed_time, 100 | hypothesis_output=hypothesis_output, 101 | ) 102 | 103 | 104 | @attr.s(slots=True) # pragma: no mutate 105 | class Interrupted(ExecutionEvent): 106 | """If execution was interrupted by Ctrl-C, or a received SIGTERM.""" 107 | 108 | 109 | @attr.s(slots=True) # pragma: no mutate 110 | class InternalError(ExecutionEvent): 111 | """An error that happened inside the runner.""" 112 | 113 | message: str = attr.ib() # pragma: no mutate 114 | exception_type: str = attr.ib() # pragma: no mutate 115 | exception: Optional[str] = attr.ib(default=None) # pragma: no mutate 116 | exception_with_traceback: Optional[str] = attr.ib(default=None) # pragma: no mutate 117 | 118 | @classmethod 119 | def from_exc(cls, exc: Exception) -> "InternalError": 120 | exception_type = f"{exc.__class__.__module__}.{exc.__class__.__qualname__}" 121 | if isinstance(exc, HTTPError): 122 | if exc.response.status_code == 404: 123 | message = f"Schema was not found at {exc.url}" 124 | else: 125 | message = f"Failed to load schema, code {exc.response.status_code} was returned from {exc.url}" 126 | return cls(message=message, exception_type=exception_type) 127 | exception = format_exception(exc) 128 | exception_with_traceback = format_exception(exc, include_traceback=True) 129 | if isinstance(exc, exceptions.ConnectionError): 130 | message = f"Failed to load schema from {exc.request.url}" 131 | else: 132 | message = "An internal error happened during a test run" 133 | return cls( 134 | message=message, 135 | exception_type=exception_type, 136 | exception=exception, 137 | exception_with_traceback=exception_with_traceback, 138 | ) 139 | 140 | 141 | @attr.s(slots=True) # pragma: no mutate 142 | class Finished(ExecutionEvent): 143 | """The final event of the run. 144 | 145 | No more events after this point. 146 | """ 147 | 148 | passed_count: int = attr.ib() # pragma: no mutate 149 | failed_count: int = attr.ib() # pragma: no mutate 150 | errored_count: int = attr.ib() # pragma: no mutate 151 | 152 | has_failures: bool = attr.ib() # pragma: no mutate 153 | has_errors: bool = attr.ib() # pragma: no mutate 154 | has_logs: bool = attr.ib() # pragma: no mutate 155 | is_empty: bool = attr.ib() # pragma: no mutate 156 | 157 | total: Dict[str, Dict[Union[str, Status], int]] = attr.ib() # pragma: no mutate 158 | 159 | # Total test run execution time 160 | running_time: float = attr.ib() # pragma: no mutate 161 | 162 | @classmethod 163 | def from_results(cls, results: TestResultSet, running_time: float) -> "Finished": 164 | return cls( 165 | passed_count=results.passed_count, 166 | failed_count=results.failed_count, 167 | errored_count=results.errored_count, 168 | has_failures=results.has_failures, 169 | has_errors=results.has_errors, 170 | has_logs=results.has_logs, 171 | is_empty=results.is_empty, 172 | total=results.total, 173 | running_time=running_time, 174 | ) 175 | -------------------------------------------------------------------------------- /schemathesis/runner/impl/__init__.py: -------------------------------------------------------------------------------- 1 | from .core import BaseRunner 2 | from .solo import SingleThreadASGIRunner, SingleThreadRunner, SingleThreadWSGIRunner 3 | from .threadpool import ThreadPoolASGIRunner, ThreadPoolRunner, ThreadPoolWSGIRunner 4 | -------------------------------------------------------------------------------- /schemathesis/runner/impl/__pycache__/__init__.cpython-38.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngalongc/openapi_security_scanner/9ba2244bf0e52db6f149243de403c8c7c157216f/schemathesis/runner/impl/__pycache__/__init__.cpython-38.pyc -------------------------------------------------------------------------------- /schemathesis/runner/impl/__pycache__/core.cpython-38.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngalongc/openapi_security_scanner/9ba2244bf0e52db6f149243de403c8c7c157216f/schemathesis/runner/impl/__pycache__/core.cpython-38.pyc -------------------------------------------------------------------------------- /schemathesis/runner/impl/__pycache__/solo.cpython-38.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngalongc/openapi_security_scanner/9ba2244bf0e52db6f149243de403c8c7c157216f/schemathesis/runner/impl/__pycache__/solo.cpython-38.pyc -------------------------------------------------------------------------------- /schemathesis/runner/impl/__pycache__/threadpool.cpython-38.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngalongc/openapi_security_scanner/9ba2244bf0e52db6f149243de403c8c7c157216f/schemathesis/runner/impl/__pycache__/threadpool.cpython-38.pyc -------------------------------------------------------------------------------- /schemathesis/runner/impl/solo.py: -------------------------------------------------------------------------------- 1 | # weird mypy bug with imports 2 | from typing import Any, Dict, Generator, Union # pylint: disable=unused-import 3 | 4 | import attr 5 | 6 | from ...models import TestResultSet 7 | from ...utils import get_requests_auth 8 | from .. import events 9 | from .core import BaseRunner, asgi_test, get_session, network_test, wsgi_test 10 | 11 | 12 | @attr.s(slots=True) # pragma: no mutate 13 | class SingleThreadRunner(BaseRunner): 14 | """Fast runner that runs tests sequentially in the main thread.""" 15 | 16 | request_tls_verify: Union[bool, str] = attr.ib(default=True) # pragma: no mutate 17 | 18 | def _execute(self, results: TestResultSet) -> Generator[events.ExecutionEvent, None, None]: 19 | auth = get_requests_auth(self.auth, self.auth_type) 20 | with get_session(auth) as session: 21 | yield from self._run_tests( 22 | self.schema.get_all_tests, 23 | network_test, 24 | self.hypothesis_settings, 25 | self.seed, 26 | checks=self.checks, 27 | max_response_time=self.max_response_time, 28 | targets=self.targets, 29 | results=results, 30 | session=session, 31 | headers=self.headers, 32 | request_timeout=self.request_timeout, 33 | request_tls_verify=self.request_tls_verify, 34 | store_interactions=self.store_interactions, 35 | ) 36 | 37 | 38 | @attr.s(slots=True) # pragma: no mutate 39 | class SingleThreadWSGIRunner(SingleThreadRunner): 40 | def _execute(self, results: TestResultSet) -> Generator[events.ExecutionEvent, None, None]: 41 | yield from self._run_tests( 42 | self.schema.get_all_tests, 43 | wsgi_test, 44 | self.hypothesis_settings, 45 | self.seed, 46 | checks=self.checks, 47 | max_response_time=self.max_response_time, 48 | targets=self.targets, 49 | results=results, 50 | auth=self.auth, 51 | auth_type=self.auth_type, 52 | headers=self.headers, 53 | store_interactions=self.store_interactions, 54 | ) 55 | 56 | 57 | @attr.s(slots=True) # pragma: no mutate 58 | class SingleThreadASGIRunner(SingleThreadRunner): 59 | def _execute(self, results: TestResultSet) -> Generator[events.ExecutionEvent, None, None]: 60 | yield from self._run_tests( 61 | self.schema.get_all_tests, 62 | asgi_test, 63 | self.hypothesis_settings, 64 | self.seed, 65 | checks=self.checks, 66 | max_response_time=self.max_response_time, 67 | targets=self.targets, 68 | results=results, 69 | headers=self.headers, 70 | store_interactions=self.store_interactions, 71 | ) 72 | -------------------------------------------------------------------------------- /schemathesis/runner/serialization.py: -------------------------------------------------------------------------------- 1 | """Transformation from Schemathesis-specific data structures to ones that can be serialized and sent over network. 2 | 3 | They all consist of primitive types and don't have references to schemas, app, etc. 4 | """ 5 | import logging 6 | from typing import Any, Dict, List, Optional 7 | 8 | import attr 9 | 10 | from ..models import Case, Check, Interaction, Request, Response, Status, TestResult 11 | from ..utils import format_exception 12 | 13 | 14 | @attr.s(slots=True) # pragma: no mutate 15 | class SerializedCase: 16 | text_lines: List[str] = attr.ib() # pragma: no mutate 17 | requests_code: str = attr.ib() 18 | 19 | @classmethod 20 | def from_case(cls, case: Case, headers: Optional[Dict[str, Any]]) -> "SerializedCase": 21 | return cls( 22 | text_lines=case.as_text_lines(), 23 | requests_code=case.get_code_to_reproduce(headers), 24 | ) 25 | 26 | 27 | @attr.s(slots=True) # pragma: no mutate 28 | class SerializedCheck: 29 | name: str = attr.ib() # pragma: no mutate 30 | value: Status = attr.ib() # pragma: no mutate 31 | example: Optional[SerializedCase] = attr.ib(default=None) # pragma: no mutate 32 | message: Optional[str] = attr.ib(default=None) # pragma: no mutate 33 | 34 | @classmethod 35 | def from_check(cls, check: Check, headers: Optional[Dict[str, Any]]) -> "SerializedCheck": 36 | return SerializedCheck( 37 | name=check.name, 38 | value=check.value, 39 | example=SerializedCase.from_case(check.example, headers) if check.example else None, 40 | message=check.message, 41 | ) 42 | 43 | 44 | @attr.s(slots=True) # pragma: no mutate 45 | class SerializedError: 46 | exception: str = attr.ib() # pragma: no mutate 47 | exception_with_traceback: str = attr.ib() # pragma: no mutate 48 | example: Optional[SerializedCase] = attr.ib() # pragma: no mutate 49 | 50 | @classmethod 51 | def from_error( 52 | cls, exception: Exception, case: Optional[Case], headers: Optional[Dict[str, Any]] 53 | ) -> "SerializedError": 54 | return cls( 55 | exception=format_exception(exception), 56 | exception_with_traceback=format_exception(exception, True), 57 | example=SerializedCase.from_case(case, headers) if case else None, 58 | ) 59 | 60 | 61 | @attr.s(slots=True) # pragma: no mutate 62 | class SerializedInteraction: 63 | request: Request = attr.ib() # pragma: no mutate 64 | response: Response = attr.ib() # pragma: no mutate 65 | checks: List[SerializedCheck] = attr.ib() # pragma: no mutate 66 | status: Status = attr.ib() # pragma: no mutate 67 | recorded_at: str = attr.ib() # pragma: no mutate 68 | 69 | @classmethod 70 | def from_interaction(cls, interaction: Interaction, headers: Optional[Dict[str, Any]]) -> "SerializedInteraction": 71 | return cls( 72 | request=interaction.request, 73 | response=interaction.response, 74 | checks=[SerializedCheck.from_check(check, headers) for check in interaction.checks], 75 | status=interaction.status, 76 | recorded_at=interaction.recorded_at, 77 | ) 78 | 79 | 80 | @attr.s(slots=True) # pragma: no mutate 81 | class SerializedTestResult: 82 | method: str = attr.ib() # pragma: no mutate 83 | path: str = attr.ib() # pragma: no mutate 84 | has_failures: bool = attr.ib() # pragma: no mutate 85 | has_errors: bool = attr.ib() # pragma: no mutate 86 | has_logs: bool = attr.ib() # pragma: no mutate 87 | is_errored: bool = attr.ib() # pragma: no mutate 88 | seed: Optional[int] = attr.ib() # pragma: no mutate 89 | data_generation_method: str = attr.ib() # pragma: no mutate 90 | checks: List[SerializedCheck] = attr.ib() # pragma: no mutate 91 | logs: List[str] = attr.ib() # pragma: no mutate 92 | errors: List[SerializedError] = attr.ib() # pragma: no mutate 93 | interactions: List[SerializedInteraction] = attr.ib() # pragma: no mutate 94 | 95 | @classmethod 96 | def from_test_result(cls, result: TestResult) -> "SerializedTestResult": 97 | formatter = logging.Formatter("[%(asctime)s] %(levelname)s in %(module)s: %(message)s") 98 | return SerializedTestResult( 99 | method=result.endpoint.method.upper(), 100 | path=result.endpoint.full_path, 101 | has_failures=result.has_failures, 102 | has_errors=result.has_errors, 103 | has_logs=result.has_logs, 104 | is_errored=result.is_errored, 105 | seed=result.seed, 106 | data_generation_method=result.data_generation_method.as_short_name(), 107 | checks=[SerializedCheck.from_check(check, headers=result.overridden_headers) for check in result.checks], 108 | logs=[formatter.format(record) for record in result.logs], 109 | errors=[SerializedError.from_error(*error, headers=result.overridden_headers) for error in result.errors], 110 | interactions=[ 111 | SerializedInteraction.from_interaction(interaction, headers=result.overridden_headers) 112 | for interaction in result.interactions 113 | ], 114 | ) 115 | -------------------------------------------------------------------------------- /schemathesis/serializers.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, Any, Callable, Collection, Dict, Optional, Type 2 | 3 | import attr 4 | from typing_extensions import Protocol, runtime_checkable 5 | 6 | from .utils import is_json_media_type 7 | 8 | if TYPE_CHECKING: 9 | from .models import Case 10 | 11 | 12 | SERIALIZERS = {} 13 | 14 | 15 | @attr.s(slots=True) # pragma: no mutate 16 | class SerializerContext: 17 | """The context for serialization process.""" 18 | 19 | case: "Case" = attr.ib() # pragma: no mutate 20 | 21 | 22 | @runtime_checkable 23 | class Serializer(Protocol): 24 | """Transform generated data to a form supported by the transport layer. 25 | 26 | For example, to handle multipart payloads, we need to serialize them differently for 27 | `requests` and `werkzeug` transports. 28 | """ 29 | 30 | def as_requests(self, context: SerializerContext, payload: Any) -> Any: 31 | raise NotImplementedError 32 | 33 | def as_werkzeug(self, context: SerializerContext, payload: Any) -> Any: 34 | raise NotImplementedError 35 | 36 | 37 | def register(media_type: str, *, aliases: Collection[str] = ()) -> Callable[[Type[Serializer]], Type[Serializer]]: 38 | """Register a serializer for the given media type. 39 | 40 | Schemathesis uses ``requests`` for regular network calls and ``werkzeug`` for WSGI applications. Your serializer 41 | should have two methods, ``as_requests`` and ``as_werkzeug``, providing keyword arguments that Schemathesis will 42 | pass to ``requests.request`` and ``werkzeug.Client.open`` respectively. 43 | 44 | Example: 45 | @register("text/csv") 46 | class CSVSerializer: 47 | 48 | def as_requests(self, context, value): 49 | return {"data": to_csv(value)} 50 | 51 | def as_werkzeug(self, context, value): 52 | return {"data": to_csv(value)} 53 | 54 | The primary purpose of serializers is to transform data from its Python representation to the format suitable 55 | for making an API call. The generated data structure depends on your schema, but its type matches 56 | Python equivalents to the JSON Schema types. 57 | 58 | """ 59 | 60 | def wrapper(serializer: Type[Serializer]) -> Type[Serializer]: 61 | if not issubclass(serializer, Serializer): 62 | raise TypeError( 63 | f"`{serializer.__name__}` is not a valid serializer. " 64 | f"Check `schemathesis.serializers.Serializer` documentation for examples." 65 | ) 66 | SERIALIZERS[media_type] = serializer 67 | for alias in aliases: 68 | SERIALIZERS[alias] = serializer 69 | return serializer 70 | 71 | return wrapper 72 | 73 | 74 | def unregister(media_type: str) -> None: 75 | """Remove registered serializer for the given media type.""" 76 | del SERIALIZERS[media_type] 77 | 78 | 79 | def _to_json(value: Any) -> Dict[str, Any]: 80 | if value is None: 81 | # If the body is `None`, then the app expects `null`, but `None` is also the default value for the `json` 82 | # argument in `requests.request` and `werkzeug.Client.open` which makes these cases indistinguishable. 83 | # Therefore we explicitly create such payload 84 | return {"data": b"null"} 85 | return {"json": value} 86 | 87 | 88 | @register("application/json") 89 | class JSONSerializer: 90 | def as_requests(self, context: SerializerContext, value: Any) -> Any: 91 | return _to_json(value) 92 | 93 | def as_werkzeug(self, context: SerializerContext, value: Any) -> Any: 94 | return _to_json(value) 95 | 96 | 97 | def _should_coerce_to_bytes(item: Any) -> bool: 98 | """Whether the item should be converted to bytes.""" 99 | # These types are OK in forms, others should be coerced to bytes 100 | return not isinstance(item, (bytes, str, int)) 101 | 102 | 103 | def prepare_form_data(data: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]: 104 | """Make the generated data suitable for sending as multipart. 105 | 106 | If the schema is loose, Schemathesis can generate data that can't be sent as multipart. In these cases, 107 | we convert it to bytes and send it as-is, ignoring any conversion errors. 108 | 109 | NOTE. This behavior might change in the future. 110 | """ 111 | # Form data can be optional 112 | if data is not None: 113 | for name, value in data.items(): 114 | if isinstance(value, list): 115 | data[name] = [to_bytes(item) if _should_coerce_to_bytes(item) else item for item in value] 116 | elif _should_coerce_to_bytes(value): 117 | data[name] = to_bytes(value) 118 | return data 119 | 120 | 121 | def to_bytes(value: Any) -> bytes: 122 | """Convert the input value to bytes and ignore any conversion errors.""" 123 | return str(value).encode(errors="ignore") 124 | 125 | 126 | @register("multipart/form-data") 127 | class MultipartSerializer: 128 | def as_requests(self, context: SerializerContext, value: Optional[Dict[str, Any]]) -> Any: 129 | # Form data always is generated as a dictionary 130 | multipart = prepare_form_data(value) 131 | files, data = context.case.endpoint.prepare_multipart(multipart) 132 | return {"files": files, "data": data} 133 | 134 | def as_werkzeug(self, context: SerializerContext, value: Any) -> Any: 135 | return {"data": value} 136 | 137 | 138 | @register("application/x-www-form-urlencoded") 139 | class URLEncodedFormSerializer: 140 | def as_requests(self, context: SerializerContext, value: Any) -> Any: 141 | return {"data": value} 142 | 143 | def as_werkzeug(self, context: SerializerContext, value: Any) -> Any: 144 | return {"data": value} 145 | 146 | 147 | @register("text/plain") 148 | class TextSerializer: 149 | def as_requests(self, context: SerializerContext, value: Any) -> Any: 150 | return {"data": str(value).encode("utf8")} 151 | 152 | def as_werkzeug(self, context: SerializerContext, value: Any) -> Any: 153 | return {"data": str(value)} 154 | 155 | 156 | @register("application/octet-stream") 157 | class OctetStreamSerializer: 158 | def as_requests(self, context: SerializerContext, value: Any) -> Any: 159 | return {"data": value} 160 | 161 | def as_werkzeug(self, context: SerializerContext, value: Any) -> Any: 162 | return {"data": value} 163 | 164 | 165 | def get(media_type: str) -> Optional[Type[Serializer]]: 166 | """Get an appropriate serializer for the given media type.""" 167 | if is_json_media_type(media_type): 168 | media_type = "application/json" 169 | return SERIALIZERS.get(media_type) 170 | -------------------------------------------------------------------------------- /schemathesis/specs/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngalongc/openapi_security_scanner/9ba2244bf0e52db6f149243de403c8c7c157216f/schemathesis/specs/__init__.py -------------------------------------------------------------------------------- /schemathesis/specs/__pycache__/__init__.cpython-38.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngalongc/openapi_security_scanner/9ba2244bf0e52db6f149243de403c8c7c157216f/schemathesis/specs/__pycache__/__init__.cpython-38.pyc -------------------------------------------------------------------------------- /schemathesis/specs/graphql/__init__.py: -------------------------------------------------------------------------------- 1 | from .loaders import from_dict, from_url 2 | -------------------------------------------------------------------------------- /schemathesis/specs/graphql/__pycache__/__init__.cpython-38.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngalongc/openapi_security_scanner/9ba2244bf0e52db6f149243de403c8c7c157216f/schemathesis/specs/graphql/__pycache__/__init__.cpython-38.pyc -------------------------------------------------------------------------------- /schemathesis/specs/graphql/__pycache__/loaders.cpython-38.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngalongc/openapi_security_scanner/9ba2244bf0e52db6f149243de403c8c7c157216f/schemathesis/specs/graphql/__pycache__/loaders.cpython-38.pyc -------------------------------------------------------------------------------- /schemathesis/specs/graphql/__pycache__/schemas.cpython-38.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngalongc/openapi_security_scanner/9ba2244bf0e52db6f149243de403c8c7c157216f/schemathesis/specs/graphql/__pycache__/schemas.cpython-38.pyc -------------------------------------------------------------------------------- /schemathesis/specs/graphql/loaders.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, Optional 2 | 3 | import requests 4 | 5 | from ...hooks import HookContext, dispatch 6 | from .schemas import GraphQLSchema 7 | 8 | INTROSPECTION_QUERY = """ 9 | query IntrospectionQuery { 10 | __schema { 11 | queryType { name } 12 | mutationType { name } 13 | subscriptionType { name } 14 | types { 15 | ...FullType 16 | } 17 | directives { 18 | name 19 | locations 20 | args { 21 | ...InputValue 22 | } 23 | } 24 | } 25 | } 26 | fragment FullType on __Type { 27 | kind 28 | name 29 | fields(includeDeprecated: true) { 30 | name 31 | args { 32 | ...InputValue 33 | } 34 | type { 35 | ...TypeRef 36 | } 37 | isDeprecated 38 | deprecationReason 39 | } 40 | inputFields { 41 | ...InputValue 42 | } 43 | interfaces { 44 | ...TypeRef 45 | } 46 | enumValues(includeDeprecated: true) { 47 | name 48 | isDeprecated 49 | deprecationReason 50 | } 51 | possibleTypes { 52 | ...TypeRef 53 | } 54 | } 55 | fragment InputValue on __InputValue { 56 | name 57 | type { ...TypeRef } 58 | defaultValue 59 | } 60 | fragment TypeRef on __Type { 61 | kind 62 | name 63 | ofType { 64 | kind 65 | name 66 | ofType { 67 | kind 68 | name 69 | ofType { 70 | kind 71 | name 72 | ofType { 73 | kind 74 | name 75 | ofType { 76 | kind 77 | name 78 | ofType { 79 | kind 80 | name 81 | ofType { 82 | kind 83 | name 84 | } 85 | } 86 | } 87 | } 88 | } 89 | } 90 | } 91 | }""" 92 | 93 | 94 | def from_url(url: str) -> GraphQLSchema: 95 | response = requests.post(url, json={"query": INTROSPECTION_QUERY}) 96 | decoded = response.json() 97 | return from_dict(raw_schema=decoded["data"], location=url) 98 | 99 | 100 | def from_dict(raw_schema: Dict[str, Any], location: Optional[str] = None) -> GraphQLSchema: 101 | dispatch("before_load_schema", HookContext(), raw_schema) 102 | return GraphQLSchema(raw_schema, location=location) # type: ignore 103 | -------------------------------------------------------------------------------- /schemathesis/specs/graphql/schemas.py: -------------------------------------------------------------------------------- 1 | from functools import partial 2 | from typing import Any, Callable, Dict, Generator, List, Optional, Tuple, cast 3 | from urllib.parse import urlsplit 4 | 5 | import attr 6 | import graphql 7 | from hypothesis import strategies as st 8 | from hypothesis.strategies import SearchStrategy 9 | from hypothesis_graphql import strategies as gql_st 10 | 11 | from ... import DataGenerationMethod 12 | from ...checks import not_a_server_error 13 | from ...hooks import HookDispatcher 14 | from ...models import Case, CheckFunction, Endpoint 15 | from ...schemas import BaseSchema 16 | from ...stateful import Feedback 17 | from ...utils import GenericResponse 18 | 19 | 20 | @attr.s() # pragma: no mutate 21 | class GraphQLCase(Case): 22 | def as_requests_kwargs( 23 | self, base_url: Optional[str] = None, headers: Optional[Dict[str, str]] = None 24 | ) -> Dict[str, Any]: 25 | final_headers = self._get_headers(headers) 26 | base_url = self._get_base_url(base_url) 27 | return {"method": self.method, "url": base_url, "json": {"query": self.body}, "headers": final_headers} 28 | 29 | def as_werkzeug_kwargs(self, headers: Optional[Dict[str, str]] = None) -> Dict[str, Any]: 30 | final_headers = self._get_headers(headers) 31 | return { 32 | "method": self.method, 33 | "path": self.endpoint.schema.get_full_path(self.formatted_path), 34 | "headers": final_headers, 35 | "query_string": self.query, 36 | "json": {"query": self.body}, 37 | } 38 | 39 | def validate_response( 40 | self, 41 | response: GenericResponse, 42 | checks: Tuple[CheckFunction, ...] = (), 43 | additional_checks: Tuple[CheckFunction, ...] = (), 44 | ) -> None: 45 | checks = checks or (not_a_server_error,) 46 | checks += additional_checks 47 | return super().validate_response(response, checks) 48 | 49 | 50 | @attr.s() # pragma: no mutate 51 | class GraphQLSchema(BaseSchema): 52 | def get_full_path(self, path: str) -> str: 53 | return self.base_path 54 | 55 | @property # pragma: no mutate 56 | def verbose_name(self) -> str: 57 | return "GraphQL" 58 | 59 | @property 60 | def base_path(self) -> str: 61 | if self.base_url: 62 | return urlsplit(self.base_url).path 63 | return self._get_base_path() 64 | 65 | def _get_base_path(self) -> str: 66 | return cast(str, urlsplit(self.location).path) 67 | 68 | def get_all_endpoints(self) -> Generator[Endpoint, None, None]: 69 | yield Endpoint( 70 | base_url=self.location, path=self.base_path, method="POST", schema=self, definition=None # type: ignore 71 | ) 72 | 73 | def get_case_strategy( 74 | self, 75 | endpoint: Endpoint, 76 | hooks: Optional[HookDispatcher] = None, 77 | feedback: Optional[Feedback] = None, 78 | data_generation_method: DataGenerationMethod = DataGenerationMethod.default(), 79 | ) -> SearchStrategy: 80 | constructor = partial(GraphQLCase, endpoint=endpoint) 81 | schema = graphql.build_client_schema(self.raw_schema) 82 | return st.builds(constructor, body=gql_st.query(schema)) 83 | 84 | def get_strategies_from_examples(self, endpoint: Endpoint) -> List[SearchStrategy[Case]]: 85 | return [] 86 | 87 | def get_parameter_serializer(self, endpoint: Endpoint, location: str) -> Optional[Callable]: 88 | return None 89 | -------------------------------------------------------------------------------- /schemathesis/specs/openapi/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngalongc/openapi_security_scanner/9ba2244bf0e52db6f149243de403c8c7c157216f/schemathesis/specs/openapi/__init__.py -------------------------------------------------------------------------------- /schemathesis/specs/openapi/__pycache__/__init__.cpython-38.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngalongc/openapi_security_scanner/9ba2244bf0e52db6f149243de403c8c7c157216f/schemathesis/specs/openapi/__pycache__/__init__.cpython-38.pyc -------------------------------------------------------------------------------- /schemathesis/specs/openapi/__pycache__/_hypothesis.cpython-38.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngalongc/openapi_security_scanner/9ba2244bf0e52db6f149243de403c8c7c157216f/schemathesis/specs/openapi/__pycache__/_hypothesis.cpython-38.pyc -------------------------------------------------------------------------------- /schemathesis/specs/openapi/__pycache__/checks.cpython-38.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngalongc/openapi_security_scanner/9ba2244bf0e52db6f149243de403c8c7c157216f/schemathesis/specs/openapi/__pycache__/checks.cpython-38.pyc -------------------------------------------------------------------------------- /schemathesis/specs/openapi/__pycache__/constants.cpython-38.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngalongc/openapi_security_scanner/9ba2244bf0e52db6f149243de403c8c7c157216f/schemathesis/specs/openapi/__pycache__/constants.cpython-38.pyc -------------------------------------------------------------------------------- /schemathesis/specs/openapi/__pycache__/converter.cpython-38.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngalongc/openapi_security_scanner/9ba2244bf0e52db6f149243de403c8c7c157216f/schemathesis/specs/openapi/__pycache__/converter.cpython-38.pyc -------------------------------------------------------------------------------- /schemathesis/specs/openapi/__pycache__/definitions.cpython-38.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngalongc/openapi_security_scanner/9ba2244bf0e52db6f149243de403c8c7c157216f/schemathesis/specs/openapi/__pycache__/definitions.cpython-38.pyc -------------------------------------------------------------------------------- /schemathesis/specs/openapi/__pycache__/examples.cpython-38.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngalongc/openapi_security_scanner/9ba2244bf0e52db6f149243de403c8c7c157216f/schemathesis/specs/openapi/__pycache__/examples.cpython-38.pyc -------------------------------------------------------------------------------- /schemathesis/specs/openapi/__pycache__/filters.cpython-38.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngalongc/openapi_security_scanner/9ba2244bf0e52db6f149243de403c8c7c157216f/schemathesis/specs/openapi/__pycache__/filters.cpython-38.pyc -------------------------------------------------------------------------------- /schemathesis/specs/openapi/__pycache__/links.cpython-38.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngalongc/openapi_security_scanner/9ba2244bf0e52db6f149243de403c8c7c157216f/schemathesis/specs/openapi/__pycache__/links.cpython-38.pyc -------------------------------------------------------------------------------- /schemathesis/specs/openapi/__pycache__/parameters.cpython-38.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngalongc/openapi_security_scanner/9ba2244bf0e52db6f149243de403c8c7c157216f/schemathesis/specs/openapi/__pycache__/parameters.cpython-38.pyc -------------------------------------------------------------------------------- /schemathesis/specs/openapi/__pycache__/references.cpython-38.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngalongc/openapi_security_scanner/9ba2244bf0e52db6f149243de403c8c7c157216f/schemathesis/specs/openapi/__pycache__/references.cpython-38.pyc -------------------------------------------------------------------------------- /schemathesis/specs/openapi/__pycache__/schemas.cpython-38.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngalongc/openapi_security_scanner/9ba2244bf0e52db6f149243de403c8c7c157216f/schemathesis/specs/openapi/__pycache__/schemas.cpython-38.pyc -------------------------------------------------------------------------------- /schemathesis/specs/openapi/__pycache__/security.cpython-38.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngalongc/openapi_security_scanner/9ba2244bf0e52db6f149243de403c8c7c157216f/schemathesis/specs/openapi/__pycache__/security.cpython-38.pyc -------------------------------------------------------------------------------- /schemathesis/specs/openapi/__pycache__/serialization.cpython-38.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngalongc/openapi_security_scanner/9ba2244bf0e52db6f149243de403c8c7c157216f/schemathesis/specs/openapi/__pycache__/serialization.cpython-38.pyc -------------------------------------------------------------------------------- /schemathesis/specs/openapi/__pycache__/utils.cpython-38.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngalongc/openapi_security_scanner/9ba2244bf0e52db6f149243de403c8c7c157216f/schemathesis/specs/openapi/__pycache__/utils.cpython-38.pyc -------------------------------------------------------------------------------- /schemathesis/specs/openapi/_hypothesis.py: -------------------------------------------------------------------------------- 1 | import re 2 | from base64 import b64encode 3 | from typing import Any, Callable, Dict, Optional, Tuple 4 | from urllib.parse import quote_plus 5 | 6 | from hypothesis import strategies as st 7 | from hypothesis_jsonschema import from_schema 8 | from requests.auth import _basic_auth_str 9 | 10 | from ... import utils 11 | from ...constants import DataGenerationMethod 12 | from ...exceptions import InvalidSchema 13 | from ...hooks import GLOBAL_HOOK_DISPATCHER, HookContext, HookDispatcher 14 | from ...models import Case, Endpoint 15 | from ...stateful import Feedback 16 | from ...utils import NOT_SET 17 | from .constants import LOCATION_TO_CONTAINER 18 | from .parameters import OpenAPIParameter, parameters_to_json_schema 19 | 20 | PARAMETERS = frozenset(("path_parameters", "headers", "cookies", "query", "body")) 21 | SLASH = "/" 22 | STRING_FORMATS = {} 23 | 24 | 25 | def register_string_format(name: str, strategy: st.SearchStrategy) -> None: 26 | """Register a new strategy for generating data for specific string "format".""" 27 | if not isinstance(name, str): 28 | raise TypeError(f"name must be of type {str}, not {type(name)}") 29 | if not isinstance(strategy, st.SearchStrategy): 30 | raise TypeError(f"strategy must be of type {st.SearchStrategy}, not {type(strategy)}") 31 | 32 | STRING_FORMATS[name] = strategy 33 | 34 | 35 | def init_default_strategies() -> None: 36 | """Register all default "format" strategies.""" 37 | register_string_format("binary", st.binary()) 38 | register_string_format("byte", st.binary().map(lambda x: b64encode(x).decode())) 39 | 40 | def make_basic_auth_str(item: Tuple[str, str]) -> str: 41 | return _basic_auth_str(*item) 42 | 43 | latin1_text = st.text(alphabet=st.characters(min_codepoint=0, max_codepoint=255)) 44 | 45 | register_string_format("_basic_auth", st.tuples(latin1_text, latin1_text).map(make_basic_auth_str)) # type: ignore 46 | register_string_format("_bearer_auth", st.text().map("Bearer {}".format)) 47 | 48 | 49 | def is_valid_header(headers: Dict[str, Any]) -> bool: 50 | """Verify if the generated headers are valid.""" 51 | for name, value in headers.items(): 52 | if not utils.is_latin_1_encodable(value): 53 | return False 54 | if utils.has_invalid_characters(name, value): 55 | return False 56 | return True 57 | 58 | 59 | def is_illegal_surrogate(item: Any) -> bool: 60 | return isinstance(item, str) and bool(re.search(r"[\ud800-\udfff]", item)) 61 | 62 | 63 | def is_valid_query(query: Dict[str, Any]) -> bool: 64 | """Surrogates are not allowed in a query string. 65 | 66 | `requests` and `werkzeug` will fail to send it to the application. 67 | """ 68 | for name, value in query.items(): 69 | if is_illegal_surrogate(name) or is_illegal_surrogate(value): 70 | return False 71 | return True 72 | 73 | 74 | @st.composite # type: ignore 75 | def get_case_strategy( # pylint: disable=too-many-locals 76 | draw: Callable, 77 | endpoint: Endpoint, 78 | hooks: Optional[HookDispatcher] = None, 79 | feedback: Optional[Feedback] = None, 80 | data_generation_method: DataGenerationMethod = DataGenerationMethod.default(), 81 | path_parameters: Any = NOT_SET, 82 | headers: Any = NOT_SET, 83 | cookies: Any = NOT_SET, 84 | query: Any = NOT_SET, 85 | body: Any = NOT_SET, 86 | ) -> Any: 87 | """A strategy that creates `Case` instances. 88 | 89 | Explicit `path_parameters`, `headers`, `cookies`, `query`, `body` arguments will be used in the resulting `Case` 90 | object. 91 | """ 92 | to_strategy = {DataGenerationMethod.positive: make_positive_strategy}[data_generation_method] 93 | 94 | context = HookContext(endpoint) 95 | 96 | if path_parameters is NOT_SET: 97 | strategy = get_parameters_strategy(endpoint, to_strategy, "path") 98 | strategy = apply_hooks(endpoint, context, hooks, strategy, "path") 99 | path_parameters = draw(strategy) 100 | if headers is NOT_SET: 101 | strategy = get_parameters_strategy(endpoint, to_strategy, "header") 102 | strategy = apply_hooks(endpoint, context, hooks, strategy, "header") 103 | headers = draw(strategy) 104 | if cookies is NOT_SET: 105 | strategy = get_parameters_strategy(endpoint, to_strategy, "cookie") 106 | strategy = apply_hooks(endpoint, context, hooks, strategy, "cookie") 107 | cookies = draw(strategy) 108 | if query is NOT_SET: 109 | strategy = get_parameters_strategy(endpoint, to_strategy, "query") 110 | strategy = apply_hooks(endpoint, context, hooks, strategy, "query") 111 | query = draw(strategy) 112 | 113 | media_type = None 114 | if body is NOT_SET: 115 | if endpoint.body: 116 | parameter = draw(st.sampled_from(endpoint.body.items)) 117 | strategy = _get_body_strategy(parameter, to_strategy) 118 | media_type = parameter.media_type 119 | body = draw(strategy) 120 | else: 121 | media_types = endpoint.get_request_payload_content_types() or ["application/json"] 122 | # Take the first available media type. 123 | # POSSIBLE IMPROVEMENT: 124 | # - Test examples for each available media type on Open API 2.0; 125 | # - On Open API 3.0, media types are explicit, and each example has it. We can pass `OpenAPIBody.media_type` 126 | # here from the examples handling code. 127 | media_type = media_types[0] 128 | if endpoint.schema.validate_schema and endpoint.method.upper() == "GET" and endpoint.body: 129 | raise InvalidSchema("Body parameters are defined for GET request.") 130 | return Case( 131 | endpoint=endpoint, 132 | feedback=feedback, 133 | media_type=media_type, 134 | path_parameters=path_parameters, 135 | headers=headers, 136 | cookies=cookies, 137 | query=query, 138 | body=body, 139 | ) 140 | 141 | 142 | def _get_body_strategy( 143 | parameter: OpenAPIParameter, to_strategy: Callable[[Dict[str, Any]], st.SearchStrategy] 144 | ) -> st.SearchStrategy: 145 | schema = parameter.as_json_schema() 146 | strategy = to_strategy(schema) 147 | if not parameter.is_required: 148 | strategy |= st.just(NOT_SET) 149 | return strategy 150 | 151 | 152 | def get_parameters_strategy( 153 | endpoint: Endpoint, to_strategy: Callable[[Dict[str, Any]], st.SearchStrategy], location: str 154 | ) -> st.SearchStrategy: 155 | """Create a new strategy for the case's component from the endpoint parameters.""" 156 | parameters = getattr(endpoint, LOCATION_TO_CONTAINER[location]) 157 | if parameters: 158 | schema = parameters_to_json_schema(parameters) 159 | strategy = to_strategy(schema) 160 | serialize = endpoint.get_parameter_serializer(location) 161 | if serialize is not None: 162 | strategy = strategy.map(serialize) 163 | filter_func = { 164 | "path": is_valid_path, 165 | "header": is_valid_header, 166 | "cookie": is_valid_header, 167 | "query": is_valid_query, 168 | }[location] 169 | strategy = strategy.filter(filter_func) 170 | map_func = {"path": quote_all}.get(location) 171 | if map_func: 172 | strategy = strategy.map(map_func) 173 | return strategy 174 | # No parameters defined for this location 175 | return st.none() 176 | 177 | 178 | def make_positive_strategy(schema: Dict[str, Any]) -> st.SearchStrategy: 179 | return from_schema(schema, custom_formats=STRING_FORMATS) 180 | 181 | 182 | def is_valid_path(parameters: Dict[str, Any]) -> bool: 183 | """Single "." chars and empty strings "" are excluded from path by urllib3. 184 | 185 | A path containing to "/" or "%2F" will lead to ambiguous path resolution in 186 | many frameworks and libraries, such behaviour have been observed in both 187 | WSGI and ASGI applications. 188 | 189 | In this case one variable in the path template will be empty, which will lead to 404 in most of the cases. 190 | Because of it this case doesn't bring much value and might lead to false positives results of Schemathesis runs. 191 | """ 192 | 193 | path_parameter_blacklist = (".", SLASH, "") 194 | 195 | return not any( 196 | (value in path_parameter_blacklist or is_illegal_surrogate(value) or isinstance(value, str) and SLASH in value) 197 | for value in parameters.values() 198 | ) 199 | 200 | 201 | def quote_all(parameters: Dict[str, Any]) -> Dict[str, Any]: 202 | """Apply URL quotation for all values in a dictionary.""" 203 | return {key: quote_plus(value) if isinstance(value, str) else value for key, value in parameters.items()} 204 | 205 | 206 | def apply_hooks( 207 | endpoint: Endpoint, 208 | context: HookContext, 209 | hooks: Optional[HookDispatcher], 210 | strategy: st.SearchStrategy[Case], 211 | location: str, 212 | ) -> st.SearchStrategy[Case]: 213 | """Apply all `before_generate_` hooks related to the given location.""" 214 | strategy = _apply_hooks(context, GLOBAL_HOOK_DISPATCHER, strategy, location) 215 | strategy = _apply_hooks(context, endpoint.schema.hooks, strategy, location) 216 | if hooks is not None: 217 | strategy = _apply_hooks(context, hooks, strategy, location) 218 | return strategy 219 | 220 | 221 | def _apply_hooks( 222 | context: HookContext, hooks: HookDispatcher, strategy: st.SearchStrategy[Case], location: str 223 | ) -> st.SearchStrategy[Case]: 224 | """Apply all `before_generate_` hooks related to the given location & dispatcher.""" 225 | container = LOCATION_TO_CONTAINER[location] 226 | for hook in hooks.get_all_by_name(f"before_generate_{container}"): 227 | strategy = hook(context, strategy) 228 | return strategy 229 | -------------------------------------------------------------------------------- /schemathesis/specs/openapi/checks.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, Any, Dict, Generator, Optional, Union 2 | 3 | from ...exceptions import ( 4 | get_headers_error, 5 | get_malformed_media_type_error, 6 | get_missing_content_type_error, 7 | get_response_type_error, 8 | get_status_code_error, 9 | ) 10 | from ...utils import GenericResponse, are_content_types_equal, parse_content_type 11 | from .schemas import BaseOpenAPISchema 12 | from .utils import expand_status_code 13 | 14 | if TYPE_CHECKING: 15 | from ...models import Case 16 | 17 | 18 | def status_code_conformance(response: GenericResponse, case: "Case") -> Optional[bool]: 19 | if not isinstance(case.endpoint.schema, BaseOpenAPISchema): 20 | raise TypeError("This check can be used only with Open API schemas") 21 | responses = case.endpoint.definition.raw.get("responses", {}) 22 | # "default" can be used as the default response object for all HTTP codes that are not covered individually 23 | if "default" in responses: 24 | return None 25 | allowed_response_statuses = list(_expand_responses(responses)) 26 | if response.status_code not in allowed_response_statuses: 27 | responses_list = ", ".join(map(str, responses)) 28 | message = ( 29 | f"Received a response with a status code, which is not defined in the schema: " 30 | f"{response.status_code}\n\nDeclared status codes: {responses_list}" 31 | ) 32 | exc_class = get_status_code_error(response.status_code) 33 | raise exc_class(message) 34 | return None # explicitly return None for mypy 35 | 36 | 37 | def _expand_responses(responses: Dict[Union[str, int], Any]) -> Generator[int, None, None]: 38 | for code in responses: 39 | yield from expand_status_code(code) 40 | 41 | 42 | def content_type_conformance(response: GenericResponse, case: "Case") -> Optional[bool]: 43 | if not isinstance(case.endpoint.schema, BaseOpenAPISchema): 44 | raise TypeError("This check can be used only with Open API schemas") 45 | content_types = case.endpoint.schema.get_content_types(case.endpoint, response) 46 | if not content_types: 47 | return None 48 | content_type = response.headers.get("Content-Type") 49 | if not content_type: 50 | raise get_missing_content_type_error()("Response is missing the `Content-Type` header") 51 | for option in content_types: 52 | try: 53 | if are_content_types_equal(option, content_type): 54 | return None 55 | except ValueError as exc: 56 | raise get_malformed_media_type_error(str(exc))(str(exc)) from exc 57 | expected_main, expected_sub = parse_content_type(option) 58 | received_main, received_sub = parse_content_type(content_type) 59 | exc_class = get_response_type_error(f"{expected_main}_{expected_sub}", f"{received_main}_{received_sub}") 60 | raise exc_class( 61 | f"Received a response with '{content_type}' Content-Type, " 62 | f"but it is not declared in the schema.\n\n" 63 | f"Defined content types: {', '.join(content_types)}" 64 | ) 65 | 66 | 67 | def response_headers_conformance(response: GenericResponse, case: "Case") -> Optional[bool]: 68 | if not isinstance(case.endpoint.schema, BaseOpenAPISchema): 69 | raise TypeError("This check can be used only with Open API schemas") 70 | defined_headers = case.endpoint.schema.get_headers(case.endpoint, response) 71 | if not defined_headers: 72 | return None 73 | 74 | missing_headers = [header for header in defined_headers if header not in response.headers] 75 | if not missing_headers: 76 | return None 77 | message = ",".join(missing_headers) 78 | exc_class = get_headers_error(message) 79 | raise exc_class(f"Received a response with missing headers: {message}") 80 | 81 | 82 | def response_schema_conformance(response: GenericResponse, case: "Case") -> None: 83 | if not isinstance(case.endpoint.schema, BaseOpenAPISchema): 84 | raise TypeError("This check can be used only with Open API schemas") 85 | return case.endpoint.validate_response(response) 86 | -------------------------------------------------------------------------------- /schemathesis/specs/openapi/constants.py: -------------------------------------------------------------------------------- 1 | LOCATION_TO_CONTAINER = { 2 | "path": "path_parameters", 3 | "query": "query", 4 | "header": "headers", 5 | "cookie": "cookies", 6 | "body": "body", 7 | } 8 | -------------------------------------------------------------------------------- /schemathesis/specs/openapi/converter.py: -------------------------------------------------------------------------------- 1 | from copy import deepcopy 2 | from typing import Any, Dict 3 | 4 | from ...utils import traverse_schema 5 | 6 | 7 | def to_json_schema(schema: Dict[str, Any], nullable_name: str) -> Dict[str, Any]: 8 | """Convert Open API parameters to JSON Schema. 9 | 10 | NOTE. This function is applied to all keywords (including nested) during a schema resolving, thus it is not recursive. 11 | See a recursive version below. 12 | """ 13 | schema = deepcopy(schema) 14 | if schema.get(nullable_name) is True: 15 | del schema[nullable_name] 16 | schema = {"anyOf": [schema, {"type": "null"}]} 17 | if schema.get("type") == "file": 18 | schema["type"] = "string" 19 | schema["format"] = "binary" 20 | return schema 21 | 22 | 23 | def to_json_schema_recursive(schema: Dict[str, Any], nullable_name: str) -> Dict[str, Any]: 24 | return traverse_schema(schema, to_json_schema, nullable_name) 25 | -------------------------------------------------------------------------------- /schemathesis/specs/openapi/examples.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, List 2 | 3 | from hypothesis.strategies import SearchStrategy 4 | 5 | from ...models import Case, Endpoint 6 | from ._hypothesis import PARAMETERS, get_case_strategy 7 | from .constants import LOCATION_TO_CONTAINER 8 | 9 | 10 | def get_object_example_from_properties(object_schema: Dict[str, Any]) -> Dict[str, Any]: 11 | return { 12 | prop_name: prop["example"] 13 | for prop_name, prop in object_schema.get("properties", {}).items() 14 | if "example" in prop 15 | } 16 | 17 | 18 | def get_parameter_examples(endpoint_def: Dict[str, Any], examples_field: str) -> List[Dict[str, Any]]: 19 | """Gets parameter examples from OAS3 `examples` keyword or `x-examples` for Swagger 2.""" 20 | return [ 21 | { 22 | "type": LOCATION_TO_CONTAINER.get(parameter["in"]), 23 | "name": parameter["name"], 24 | "examples": [example["value"] for example in parameter[examples_field].values() if "value" in example], 25 | } 26 | for parameter in endpoint_def.get("parameters", []) 27 | if examples_field in parameter 28 | ] 29 | 30 | 31 | def get_parameter_example_from_properties(endpoint_def: Dict[str, Any]) -> Dict[str, Any]: 32 | static_parameters: Dict[str, Any] = {} 33 | for parameter in endpoint_def.get("parameters", []): 34 | parameter_schema = parameter["schema"] if "schema" in parameter else parameter 35 | example = get_object_example_from_properties(parameter_schema) 36 | if example: 37 | parameter_type = LOCATION_TO_CONTAINER[parameter["in"]] 38 | if parameter_type not in ("body", "form_data"): 39 | if parameter_type not in static_parameters: 40 | static_parameters[parameter_type] = {} 41 | static_parameters[parameter_type][parameter["name"]] = example 42 | else: 43 | # swagger 2 body and formData parameters should not include parameter names 44 | static_parameters[parameter_type] = example 45 | return static_parameters 46 | 47 | 48 | def get_request_body_examples(endpoint_def: Dict[str, Any], examples_field: str) -> Dict[str, Any]: 49 | """Gets request body examples from OAS3 `examples` keyword or `x-examples` for Swagger 2.""" 50 | request_bodies_items = endpoint_def.get("requestBody", {}).get("content", {}).items() 51 | if not request_bodies_items: 52 | return {} 53 | # first element in tuple in media type, second element is dict 54 | media_type, schema = next(iter(request_bodies_items)) 55 | parameter_type = "body" if media_type != "multipart/form-data" else "form_data" 56 | return { 57 | "type": parameter_type, 58 | "examples": [example["value"] for example in schema.get(examples_field, {}).values() if "value" in example], 59 | } 60 | 61 | 62 | def get_request_body_example_from_properties(endpoint_def: Dict[str, Any]) -> Dict[str, Any]: 63 | static_parameters: Dict[str, Any] = {} 64 | request_bodies_items = endpoint_def.get("requestBody", {}).get("content", {}).items() 65 | if request_bodies_items: 66 | media_type, request_body_schema = next(iter(request_bodies_items)) 67 | example = get_object_example_from_properties(request_body_schema.get("schema", {})) 68 | if example: 69 | request_body_type = "body" if media_type != "multipart/form-data" else "form_data" 70 | static_parameters[request_body_type] = example 71 | 72 | return static_parameters 73 | 74 | 75 | def get_static_parameters_from_example(endpoint: Endpoint) -> Dict[str, Any]: 76 | static_parameters = {} 77 | for name in PARAMETERS: 78 | parameters = getattr(endpoint, name) 79 | example = parameters.example 80 | if example: 81 | static_parameters[name] = example 82 | return static_parameters 83 | 84 | 85 | def get_static_parameters_from_examples(endpoint: Endpoint, examples_field: str) -> List[Dict[str, Any]]: 86 | """Get static parameters from OpenAPI examples keyword.""" 87 | endpoint_def = endpoint.definition.resolved 88 | return merge_examples( 89 | get_parameter_examples(endpoint_def, examples_field), get_request_body_examples(endpoint_def, examples_field) 90 | ) 91 | 92 | 93 | def get_static_parameters_from_properties(endpoint: Endpoint) -> Dict[str, Any]: 94 | endpoint_def = endpoint.definition.resolved 95 | return { 96 | **get_parameter_example_from_properties(endpoint_def), 97 | **get_request_body_example_from_properties(endpoint_def), 98 | } 99 | 100 | 101 | def get_strategies_from_examples(endpoint: Endpoint, examples_field: str = "examples") -> List[SearchStrategy[Case]]: 102 | strategies = [ 103 | get_case_strategy(endpoint=endpoint, **static_parameters) 104 | for static_parameters in get_static_parameters_from_examples(endpoint, examples_field) 105 | if static_parameters 106 | ] 107 | for static_parameters in static_parameters_union( 108 | get_static_parameters_from_example(endpoint), get_static_parameters_from_properties(endpoint) 109 | ): 110 | strategies.append(get_case_strategy(endpoint=endpoint, **static_parameters)) 111 | return strategies 112 | 113 | 114 | def merge_examples( 115 | parameter_examples: List[Dict[str, Any]], request_body_examples: Dict[str, Any] 116 | ) -> List[Dict[str, Any]]: 117 | """Create list of static parameter objects from the parameter and request body examples.""" 118 | static_parameter_list = [] 119 | for idx in range(num_examples(parameter_examples, request_body_examples)): 120 | static_parameters: Dict[str, Any] = {} 121 | for parameter in parameter_examples: 122 | if parameter["type"] not in static_parameters: 123 | static_parameters[parameter["type"]] = {} 124 | static_parameters[parameter["type"]][parameter["name"]] = parameter["examples"][ 125 | min(idx, len(parameter["examples"]) - 1) 126 | ] 127 | if "examples" in request_body_examples and request_body_examples["examples"]: 128 | static_parameters[request_body_examples["type"]] = request_body_examples["examples"][ 129 | min(idx, len(request_body_examples["examples"]) - 1) 130 | ] 131 | static_parameter_list.append(static_parameters) 132 | return static_parameter_list 133 | 134 | 135 | def static_parameters_union(sp_1: Dict[str, Any], sp_2: Dict[str, Any]) -> List[Dict[str, Any]]: 136 | """Fill missing parameters in each static parameter dict with parameters provided in the other dict.""" 137 | full_static_parameters = (_static_parameters_union(sp_1, sp_2), _static_parameters_union(sp_2, sp_1)) 138 | return [static_parameter for static_parameter in full_static_parameters if static_parameter] 139 | 140 | 141 | def _static_parameters_union(base_obj: Dict[str, Any], fill_obj: Dict[str, Any]) -> Dict[str, Any]: 142 | """Fill base_obj with parameter examples in fill_obj that were not in base_obj.""" 143 | if not base_obj: 144 | return {} 145 | 146 | full_static_parameters: Dict[str, Any] = {**base_obj} 147 | 148 | for parameter_type, examples in fill_obj.items(): 149 | if parameter_type not in full_static_parameters: 150 | full_static_parameters[parameter_type] = examples 151 | elif parameter_type not in ("body", "form_data"): 152 | # copy individual parameter names. 153 | # body and form_data are unnamed, single examples, so we only do this for named parameters. 154 | for parameter_name, example in examples.items(): 155 | if parameter_name not in full_static_parameters[parameter_type]: 156 | full_static_parameters[parameter_type][parameter_name] = example 157 | return full_static_parameters 158 | 159 | 160 | def num_examples(parameter_examples: List[Dict[str, Any]], request_body_examples: Dict[str, Any]) -> int: 161 | max_parameter_examples = ( 162 | max(len(parameter["examples"]) for parameter in parameter_examples) if parameter_examples else 0 163 | ) 164 | num_request_body_examples = len(request_body_examples["examples"]) if "examples" in request_body_examples else 0 165 | return max(max_parameter_examples, num_request_body_examples) 166 | -------------------------------------------------------------------------------- /schemathesis/specs/openapi/expressions/__init__.py: -------------------------------------------------------------------------------- 1 | """Runtime expressions support. 2 | 3 | https://swagger.io/docs/specification/links/#runtime-expressions 4 | """ 5 | from typing import Any 6 | 7 | from . import lexer, nodes, parser 8 | from .context import ExpressionContext 9 | 10 | 11 | def evaluate(expr: Any, context: ExpressionContext) -> str: 12 | """Evaluate runtime expression in context.""" 13 | if not isinstance(expr, str): 14 | # Can be a non-string constant 15 | return expr 16 | parts = [node.evaluate(context) for node in parser.parse(expr)] 17 | if len(parts) == 1: 18 | return parts[0] # keep the return type the same as the internal value type 19 | # otherwise, concatenate into a string 20 | return "".join(map(str, parts)) 21 | -------------------------------------------------------------------------------- /schemathesis/specs/openapi/expressions/__pycache__/__init__.cpython-38.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngalongc/openapi_security_scanner/9ba2244bf0e52db6f149243de403c8c7c157216f/schemathesis/specs/openapi/expressions/__pycache__/__init__.cpython-38.pyc -------------------------------------------------------------------------------- /schemathesis/specs/openapi/expressions/__pycache__/context.cpython-38.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngalongc/openapi_security_scanner/9ba2244bf0e52db6f149243de403c8c7c157216f/schemathesis/specs/openapi/expressions/__pycache__/context.cpython-38.pyc -------------------------------------------------------------------------------- /schemathesis/specs/openapi/expressions/__pycache__/errors.cpython-38.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngalongc/openapi_security_scanner/9ba2244bf0e52db6f149243de403c8c7c157216f/schemathesis/specs/openapi/expressions/__pycache__/errors.cpython-38.pyc -------------------------------------------------------------------------------- /schemathesis/specs/openapi/expressions/__pycache__/lexer.cpython-38.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngalongc/openapi_security_scanner/9ba2244bf0e52db6f149243de403c8c7c157216f/schemathesis/specs/openapi/expressions/__pycache__/lexer.cpython-38.pyc -------------------------------------------------------------------------------- /schemathesis/specs/openapi/expressions/__pycache__/nodes.cpython-38.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngalongc/openapi_security_scanner/9ba2244bf0e52db6f149243de403c8c7c157216f/schemathesis/specs/openapi/expressions/__pycache__/nodes.cpython-38.pyc -------------------------------------------------------------------------------- /schemathesis/specs/openapi/expressions/__pycache__/parser.cpython-38.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngalongc/openapi_security_scanner/9ba2244bf0e52db6f149243de403c8c7c157216f/schemathesis/specs/openapi/expressions/__pycache__/parser.cpython-38.pyc -------------------------------------------------------------------------------- /schemathesis/specs/openapi/expressions/__pycache__/pointers.cpython-38.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngalongc/openapi_security_scanner/9ba2244bf0e52db6f149243de403c8c7c157216f/schemathesis/specs/openapi/expressions/__pycache__/pointers.cpython-38.pyc -------------------------------------------------------------------------------- /schemathesis/specs/openapi/expressions/context.py: -------------------------------------------------------------------------------- 1 | import attr 2 | 3 | from ....models import Case 4 | from ....utils import GenericResponse 5 | 6 | 7 | @attr.s(slots=True) # pragma: no mutate 8 | class ExpressionContext: 9 | """Context in what an expression are evaluated.""" 10 | 11 | response: GenericResponse = attr.ib() # pragma: no mutate 12 | case: Case = attr.ib() # pragma: no mutate 13 | -------------------------------------------------------------------------------- /schemathesis/specs/openapi/expressions/errors.py: -------------------------------------------------------------------------------- 1 | class RuntimeExpressionError(ValueError): 2 | """Generic error that happened during evaluation of a runtime expression.""" 3 | 4 | 5 | class UnknownToken(RuntimeExpressionError): 6 | """Don't know how to handle a token value.""" 7 | -------------------------------------------------------------------------------- /schemathesis/specs/openapi/expressions/lexer.py: -------------------------------------------------------------------------------- 1 | """Lexical analysis of runtime expressions.""" 2 | from enum import Enum, unique 3 | from typing import Callable, Generator 4 | 5 | import attr 6 | 7 | 8 | @unique # pragma: no mutate 9 | class TokenType(Enum): 10 | VARIABLE = 1 # pragma: no mutate 11 | STRING = 2 # pragma: no mutate 12 | POINTER = 3 # pragma: no mutate 13 | DOT = 4 # pragma: no mutate 14 | LBRACKET = 5 # pragma: no mutate 15 | RBRACKET = 6 # pragma: no mutate 16 | 17 | 18 | @attr.s(slots=True) # pragma: no mutate 19 | class Token: 20 | """Lexical token that may occur in a runtime expression.""" 21 | 22 | value: str = attr.ib() # pragma: no mutate 23 | type_: TokenType = attr.ib() # pragma: no mutate 24 | 25 | # Helpers for cleaner instantiation 26 | 27 | @classmethod 28 | def variable(cls, value: str) -> "Token": 29 | return cls(value, TokenType.VARIABLE) 30 | 31 | @classmethod 32 | def string(cls, value: str) -> "Token": 33 | return cls(value, TokenType.STRING) 34 | 35 | @classmethod 36 | def pointer(cls, value: str) -> "Token": 37 | return cls(value, TokenType.POINTER) 38 | 39 | @classmethod 40 | def lbracket(cls) -> "Token": 41 | return cls("{", TokenType.LBRACKET) 42 | 43 | @classmethod 44 | def rbracket(cls) -> "Token": 45 | return cls("}", TokenType.RBRACKET) 46 | 47 | @classmethod 48 | def dot(cls) -> "Token": 49 | return cls(".", TokenType.DOT) 50 | 51 | # Helpers for simpler type comparison 52 | 53 | @property 54 | def is_string(self) -> bool: 55 | return self.type_ == TokenType.STRING 56 | 57 | @property 58 | def is_variable(self) -> bool: 59 | return self.type_ == TokenType.VARIABLE 60 | 61 | @property 62 | def is_dot(self) -> bool: 63 | return self.type_ == TokenType.DOT 64 | 65 | @property 66 | def is_pointer(self) -> bool: 67 | return self.type_ == TokenType.POINTER 68 | 69 | @property 70 | def is_left_bracket(self) -> bool: 71 | return self.type_ == TokenType.LBRACKET 72 | 73 | @property 74 | def is_right_bracket(self) -> bool: 75 | return self.type_ == TokenType.RBRACKET 76 | 77 | 78 | TokenGenerator = Generator[Token, None, None] 79 | 80 | 81 | def tokenize(expression: str) -> TokenGenerator: 82 | """Do lexical analysis of the expression and return a list of tokens.""" 83 | cursor = 0 84 | 85 | def is_eol() -> bool: 86 | return cursor == len(expression) 87 | 88 | def current_symbol() -> str: 89 | return expression[cursor] 90 | 91 | def move() -> None: 92 | nonlocal cursor 93 | cursor += 1 94 | 95 | def move_until(predicate: Callable[[], bool]) -> None: 96 | move() 97 | while not predicate(): 98 | move() 99 | 100 | stop_symbols = {"$", ".", "{", "}", "#"} 101 | 102 | while not is_eol(): 103 | if current_symbol() == "$": 104 | start = cursor 105 | move_until(lambda: is_eol() or current_symbol() in stop_symbols) 106 | yield Token.variable(expression[start:cursor]) 107 | elif current_symbol() == ".": 108 | yield Token.dot() 109 | move() 110 | elif current_symbol() == "{": 111 | yield Token.lbracket() 112 | move() 113 | elif current_symbol() == "}": 114 | yield Token.rbracket() 115 | move() 116 | elif current_symbol() == "#": 117 | start = cursor 118 | # Symbol '}' is valid inside a JSON pointer, but also denotes closing of an embedded runtime expression 119 | # This is an ambiguous situation, for example: 120 | # Expression: `ID_{$request.body#/foo}}` 121 | # Body: `{"foo}": 1, "foo": 2}` 122 | # It could be evaluated differently: 123 | # - `ID_1` if we take the last bracket as the closing one 124 | # - `ID_2}` if we take the first bracket as the closing one 125 | # In this situation we take the second approach, to support cases like this: 126 | # `ID_{$response.body#/foo}_{$response.body#/bar}` 127 | # Which is much easier if we treat `}` as a closing bracket of an embedded runtime expression 128 | move_until(lambda: is_eol() or current_symbol() == "}") 129 | yield Token.pointer(expression[start:cursor]) 130 | else: 131 | start = cursor 132 | move_until(lambda: is_eol() or current_symbol() in stop_symbols) 133 | yield Token.string(expression[start:cursor]) 134 | -------------------------------------------------------------------------------- /schemathesis/specs/openapi/expressions/nodes.py: -------------------------------------------------------------------------------- 1 | """Expression nodes description and evaluation logic.""" 2 | import json 3 | from enum import Enum, unique 4 | from typing import Any, Dict, Optional, Union 5 | 6 | import attr 7 | from requests.structures import CaseInsensitiveDict 8 | 9 | from ....utils import WSGIResponse 10 | from . import pointers 11 | from .context import ExpressionContext 12 | from .errors import RuntimeExpressionError 13 | 14 | 15 | @attr.s(slots=True) # pragma: no mutate 16 | class Node: 17 | """Generic expression node.""" 18 | 19 | def evaluate(self, context: ExpressionContext) -> str: 20 | raise NotImplementedError 21 | 22 | 23 | @unique 24 | class NodeType(Enum): 25 | URL = "$url" 26 | METHOD = "$method" 27 | STATUS_CODE = "$statusCode" 28 | REQUEST = "$request" 29 | RESPONSE = "$response" 30 | 31 | 32 | @attr.s(slots=True) # pragma: no mutate 33 | class String(Node): 34 | """A simple string that is not evaluated somehow specifically.""" 35 | 36 | value: str = attr.ib() # pragma: no mutate 37 | 38 | def evaluate(self, context: ExpressionContext) -> str: 39 | """String tokens are passed as they are. 40 | 41 | ``foo{$request.path.id}`` 42 | 43 | "foo" is String token there. 44 | """ 45 | return self.value 46 | 47 | 48 | @attr.s(slots=True) # pragma: no mutate 49 | class URL(Node): 50 | """A node for `$url` expression.""" 51 | 52 | def evaluate(self, context: ExpressionContext) -> str: 53 | return context.case.get_full_url() 54 | 55 | 56 | @attr.s(slots=True) # pragma: no mutate 57 | class Method(Node): 58 | """A node for `$method` expression.""" 59 | 60 | def evaluate(self, context: ExpressionContext) -> str: 61 | return context.case.endpoint.method.upper() 62 | 63 | 64 | @attr.s(slots=True) # pragma: no mutate 65 | class StatusCode(Node): 66 | """A node for `$statusCode` expression.""" 67 | 68 | def evaluate(self, context: ExpressionContext) -> str: 69 | return str(context.response.status_code) 70 | 71 | 72 | @attr.s(slots=True) # pragma: no mutate 73 | class NonBodyRequest(Node): 74 | """A node for `$request` expressions where location is not `body`.""" 75 | 76 | location: str = attr.ib() # pragma: no mutate 77 | parameter: str = attr.ib() # pragma: no mutate 78 | 79 | def evaluate(self, context: ExpressionContext) -> str: 80 | container: Union[Dict, CaseInsensitiveDict] = { 81 | "query": context.case.query, 82 | "path": context.case.path_parameters, 83 | "header": context.case.headers, 84 | }[self.location] or {} 85 | if self.location == "header": 86 | container = CaseInsensitiveDict(container) 87 | return str(container[self.parameter]) 88 | 89 | 90 | @attr.s(slots=True) # pragma: no mutate 91 | class BodyRequest(Node): 92 | """A node for `$request` expressions where location is `body`.""" 93 | 94 | pointer: Optional[str] = attr.ib(default=None) # pragma: no mutate 95 | 96 | def evaluate(self, context: ExpressionContext) -> Any: 97 | if self.pointer is None: 98 | try: 99 | return json.dumps(context.case.body) 100 | except TypeError as exc: 101 | raise RuntimeExpressionError("The request body is not JSON-serializable") from exc 102 | document = context.case.body 103 | return pointers.resolve(document, self.pointer[1:]) 104 | 105 | 106 | @attr.s(slots=True) # pragma: no mutate 107 | class HeaderResponse(Node): 108 | """A node for `$response.header` expressions.""" 109 | 110 | parameter: str = attr.ib() # pragma: no mutate 111 | 112 | def evaluate(self, context: ExpressionContext) -> str: 113 | return context.response.headers[self.parameter] 114 | 115 | 116 | @attr.s(slots=True) # pragma: no mutate 117 | class BodyResponse(Node): 118 | """A node for `$response.body` expressions.""" 119 | 120 | pointer: Optional[str] = attr.ib(default=None) # pragma: no mutate 121 | 122 | def evaluate(self, context: ExpressionContext) -> Any: 123 | if self.pointer is None: 124 | return context.response.text 125 | if isinstance(context.response, WSGIResponse): 126 | document = context.response.json 127 | else: 128 | document = context.response.json() 129 | return pointers.resolve(document, self.pointer[1:]) 130 | -------------------------------------------------------------------------------- /schemathesis/specs/openapi/expressions/parser.py: -------------------------------------------------------------------------------- 1 | from functools import lru_cache 2 | from typing import Generator, List, Union 3 | 4 | from . import lexer, nodes 5 | from .errors import RuntimeExpressionError, UnknownToken 6 | 7 | 8 | @lru_cache() # pragma: no mutate 9 | def parse(expr: str) -> List[nodes.Node]: 10 | """Parse lexical tokens into concrete expression nodes.""" 11 | return list(_parse(expr)) 12 | 13 | 14 | def _parse(expr: str) -> Generator[nodes.Node, None, None]: 15 | tokens = lexer.tokenize(expr) 16 | brackets_stack: List[str] = [] 17 | for token in tokens: 18 | if token.is_string: 19 | yield nodes.String(token.value) 20 | elif token.is_variable: 21 | yield from _parse_variable(tokens, token, expr) 22 | elif token.is_left_bracket: 23 | if brackets_stack: 24 | raise RuntimeExpressionError("Nested embedded expressions are not allowed") 25 | brackets_stack.append("{") 26 | elif token.is_right_bracket: 27 | if not brackets_stack: 28 | raise RuntimeExpressionError("Unmatched bracket") 29 | brackets_stack.pop() 30 | if brackets_stack: 31 | raise RuntimeExpressionError("Unmatched bracket") 32 | 33 | 34 | def _parse_variable(tokens: lexer.TokenGenerator, token: lexer.Token, expr: str) -> Generator[nodes.Node, None, None]: 35 | if token.value == nodes.NodeType.URL.value: 36 | yield nodes.URL() 37 | elif token.value == nodes.NodeType.METHOD.value: 38 | yield nodes.Method() 39 | elif token.value == nodes.NodeType.STATUS_CODE.value: 40 | yield nodes.StatusCode() 41 | elif token.value == nodes.NodeType.REQUEST.value: 42 | yield _parse_request(tokens, expr) 43 | elif token.value == nodes.NodeType.RESPONSE.value: 44 | yield _parse_response(tokens, expr) 45 | else: 46 | raise UnknownToken(token.value) 47 | 48 | 49 | def _parse_request(tokens: lexer.TokenGenerator, expr: str) -> Union[nodes.BodyRequest, nodes.NonBodyRequest]: 50 | skip_dot(tokens, "$request") 51 | location = next(tokens) 52 | if location.value in ("query", "path", "header"): 53 | skip_dot(tokens, f"$request.{location.value}") 54 | parameter = take_string(tokens, expr) 55 | return nodes.NonBodyRequest(location.value, parameter) 56 | if location.value == "body": 57 | try: 58 | token = next(tokens) 59 | if token.is_pointer: 60 | return nodes.BodyRequest(token.value) 61 | except StopIteration: 62 | return nodes.BodyRequest() 63 | raise RuntimeExpressionError(f"Invalid expression: {expr}") 64 | 65 | 66 | def _parse_response(tokens: lexer.TokenGenerator, expr: str) -> Union[nodes.HeaderResponse, nodes.BodyResponse]: 67 | skip_dot(tokens, "$response") 68 | location = next(tokens) 69 | if location.value == "header": 70 | skip_dot(tokens, f"$response.{location.value}") 71 | parameter = take_string(tokens, expr) 72 | return nodes.HeaderResponse(parameter) 73 | if location.value == "body": 74 | try: 75 | token = next(tokens) 76 | if token.is_pointer: 77 | return nodes.BodyResponse(token.value) 78 | except StopIteration: 79 | return nodes.BodyResponse() 80 | raise RuntimeExpressionError(f"Invalid expression: {expr}") 81 | 82 | 83 | def skip_dot(tokens: lexer.TokenGenerator, name: str) -> None: 84 | token = next(tokens) 85 | if not token.is_dot: 86 | raise RuntimeExpressionError(f"`{name}` expression should be followed by a dot (`.`). Got: {token.value}") 87 | 88 | 89 | def take_string(tokens: lexer.TokenGenerator, expr: str) -> str: 90 | parameter = next(tokens) 91 | if not parameter.is_string: 92 | raise RuntimeExpressionError(f"Invalid expression: {expr}") 93 | return parameter.value 94 | -------------------------------------------------------------------------------- /schemathesis/specs/openapi/expressions/pointers.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, List, Optional, Union 2 | 3 | 4 | def resolve(document: Any, pointer: str) -> Optional[Union[Dict, List, str, int, float]]: 5 | """Implementation is adapted from Rust's `serde-json` crate. 6 | 7 | Ref: https://github.com/serde-rs/json/blob/master/src/value/mod.rs#L751 8 | """ 9 | if not pointer: 10 | return document 11 | if not pointer.startswith("/"): 12 | return None 13 | 14 | def replace(value: str) -> str: 15 | return value.replace("~1", "/").replace("~0", "~") 16 | 17 | tokens = map(replace, pointer.split("/")[1:]) 18 | target = document 19 | for token in tokens: 20 | if isinstance(target, dict): 21 | target = target.get(token) 22 | elif isinstance(target, list): 23 | try: 24 | target = target[int(token)] 25 | except IndexError: 26 | return None 27 | else: 28 | return None 29 | return target 30 | -------------------------------------------------------------------------------- /schemathesis/specs/openapi/filters.py: -------------------------------------------------------------------------------- 1 | import re 2 | from typing import List, Optional 3 | 4 | from ...types import Filter 5 | from ...utils import force_tuple 6 | 7 | 8 | def should_skip_method(method: str, pattern: Optional[Filter]) -> bool: 9 | if pattern is None: 10 | return False 11 | patterns = force_tuple(pattern) 12 | return method.upper() not in map(str.upper, patterns) 13 | 14 | 15 | def should_skip_endpoint(endpoint: str, pattern: Optional[Filter]) -> bool: 16 | if pattern is None: 17 | return False 18 | return not _match_any_pattern(endpoint, pattern) 19 | 20 | 21 | def should_skip_by_tag(tags: Optional[List[str]], pattern: Optional[Filter]) -> bool: 22 | if pattern is None: 23 | return False 24 | if not tags: 25 | return True 26 | patterns = force_tuple(pattern) 27 | return not any(re.search(item, tag) for item in patterns for tag in tags) 28 | 29 | 30 | def should_skip_by_operation_id(operation_id: Optional[str], pattern: Optional[Filter]) -> bool: 31 | if pattern is None: 32 | return False 33 | if not operation_id: 34 | return True 35 | return not _match_any_pattern(operation_id, pattern) 36 | 37 | 38 | def should_skip_deprecated(is_deprecated: bool, skip_deprecated_endpoints: bool) -> bool: 39 | return skip_deprecated_endpoints and is_deprecated 40 | 41 | 42 | def _match_any_pattern(target: str, pattern: Filter) -> bool: 43 | patterns = force_tuple(pattern) 44 | return any(re.search(item, target) for item in patterns) 45 | -------------------------------------------------------------------------------- /schemathesis/specs/openapi/references.py: -------------------------------------------------------------------------------- 1 | from copy import deepcopy 2 | from functools import lru_cache 3 | from typing import Any, Callable, Dict, List, Tuple, Union, overload 4 | from urllib.request import urlopen 5 | 6 | import jsonschema 7 | import requests 8 | import yaml 9 | 10 | from ...utils import StringDatesYAMLLoader, traverse_schema 11 | from .converter import to_json_schema 12 | 13 | # Reference resolving will stop after this depth 14 | RECURSION_DEPTH_LIMIT = 100 15 | 16 | 17 | def load_file_impl(location: str, opener: Callable) -> Dict[str, Any]: 18 | """Load a schema from the given file.""" 19 | with opener(location) as fd: 20 | return yaml.load(fd, StringDatesYAMLLoader) 21 | 22 | 23 | @lru_cache() 24 | def load_file(location: str) -> Dict[str, Any]: 25 | """Load a schema from the given file.""" 26 | return load_file_impl(location, open) 27 | 28 | 29 | @lru_cache() 30 | def load_file_uri(location: str) -> Dict[str, Any]: 31 | """Load a schema from the given file uri.""" 32 | return load_file_impl(location, urlopen) 33 | 34 | 35 | def load_remote_uri(uri: str) -> Any: 36 | """Load the resource and parse it as YAML / JSON.""" 37 | response = requests.get(uri) 38 | return yaml.load(response.content, StringDatesYAMLLoader) 39 | 40 | 41 | class ConvertingResolver(jsonschema.RefResolver): 42 | """A custom resolver converts resolved OpenAPI schemas to JSON Schema. 43 | 44 | When recursive schemas are validated we need to have resolved documents properly converted. 45 | This approach is the simplest one, since this logic isolated in a single place. 46 | """ 47 | 48 | def __init__(self, *args: Any, nullable_name: Any, **kwargs: Any) -> None: 49 | kwargs.setdefault( 50 | "handlers", {"file": load_file_uri, "": load_file, "http": load_remote_uri, "https": load_remote_uri} 51 | ) 52 | super().__init__(*args, **kwargs) 53 | self.nullable_name = nullable_name 54 | 55 | def resolve(self, ref: str) -> Tuple[str, Any]: 56 | url, document = super().resolve(ref) 57 | document = traverse_schema(document, to_json_schema, nullable_name=self.nullable_name) 58 | return url, document 59 | 60 | @overload # pragma: no mutate 61 | def resolve_all( 62 | self, item: Dict[str, Any], recursion_level: int = 0 63 | ) -> Dict[str, Any]: # pylint: disable=function-redefined 64 | pass 65 | 66 | @overload # pragma: no mutate 67 | def resolve_all(self, item: List, recursion_level: int = 0) -> List: # pylint: disable=function-redefined 68 | pass 69 | 70 | # pylint: disable=function-redefined 71 | def resolve_all(self, item: Union[Dict[str, Any], List], recursion_level: int = 0) -> Union[Dict[str, Any], List]: 72 | """Recursively resolve all references in the given object.""" 73 | if recursion_level > RECURSION_DEPTH_LIMIT: 74 | return item 75 | item = deepcopy(item) 76 | if isinstance(item, dict): 77 | if "$ref" in item and isinstance(item["$ref"], str): 78 | with self.resolving(item["$ref"]) as resolved: 79 | return self.resolve_all(resolved, recursion_level + 1) 80 | for key, sub_item in item.items(): 81 | item[key] = self.resolve_all(sub_item, recursion_level) 82 | elif isinstance(item, list): 83 | for idx, sub_item in enumerate(item): 84 | item[idx] = self.resolve_all(sub_item, recursion_level) 85 | return item 86 | 87 | def resolve_in_scope(self, definition: Dict[str, Any], scope: str) -> Tuple[List[str], Dict[str, Any]]: 88 | scopes = [scope] 89 | # if there is `$ref` then we have a scope change that should be used during validation later to 90 | # resolve nested references correctly 91 | if "$ref" in definition: 92 | with self.in_scope(scope): 93 | new_scope, definition = deepcopy(self.resolve(definition["$ref"])) 94 | scopes.append(new_scope) 95 | return scopes, definition 96 | -------------------------------------------------------------------------------- /schemathesis/specs/openapi/security.py: -------------------------------------------------------------------------------- 1 | """Processing of ``securityDefinitions`` or ``securitySchemes`` keywords.""" 2 | from typing import Any, ClassVar, Dict, Generator, List, Tuple, Type 3 | 4 | import attr 5 | from jsonschema import RefResolver 6 | 7 | from ...models import Endpoint 8 | from .parameters import OpenAPI20Parameter, OpenAPI30Parameter, OpenAPIParameter 9 | 10 | 11 | @attr.s(slots=True) # pragma: no mutate 12 | class BaseSecurityProcessor: 13 | api_key_locations: Tuple[str, ...] = ("header", "query") 14 | http_security_name = "basic" 15 | parameter_cls: ClassVar[Type[OpenAPIParameter]] = OpenAPI20Parameter 16 | 17 | def process_definitions(self, schema: Dict[str, Any], endpoint: Endpoint, resolver: RefResolver) -> None: 18 | """Add relevant security parameters to data generation.""" 19 | for definition in self._get_active_definitions(schema, endpoint, resolver): 20 | if definition["type"] == "apiKey": 21 | self.process_api_key_security_definition(definition, endpoint) 22 | self.process_http_security_definition(definition, endpoint) 23 | 24 | def _get_active_definitions( 25 | self, schema: Dict[str, Any], endpoint: Endpoint, resolver: RefResolver 26 | ) -> Generator[Dict[str, Any], None, None]: 27 | """Get only security definitions active for the given endpoint.""" 28 | definitions = self.get_security_definitions(schema, resolver) 29 | requirements = get_security_requirements(schema, endpoint) 30 | for name, definition in definitions.items(): 31 | if name in requirements: 32 | yield definition 33 | 34 | def get_security_definitions(self, schema: Dict[str, Any], resolver: RefResolver) -> Dict[str, Any]: 35 | return schema.get("securityDefinitions", {}) 36 | 37 | def get_security_definitions_as_parameters( 38 | self, schema: Dict[str, Any], endpoint: Endpoint, resolver: RefResolver, location: str 39 | ) -> List[Dict[str, Any]]: 40 | """Security definitions converted to OAS parameters. 41 | 42 | We need it to get proper serialization that will be applied on generated values. For this case it is only 43 | coercing to a string. 44 | """ 45 | return [ 46 | self._to_parameter(definition) 47 | for definition in self._get_active_definitions(schema, endpoint, resolver) 48 | if self._is_match(definition, location) 49 | ] 50 | 51 | def process_api_key_security_definition(self, definition: Dict[str, Any], endpoint: Endpoint) -> None: 52 | parameter = self.parameter_cls(self._make_api_key_parameter(definition)) 53 | endpoint.add_parameter(parameter) 54 | 55 | def process_http_security_definition(self, definition: Dict[str, Any], endpoint: Endpoint) -> None: 56 | if definition["type"] == self.http_security_name: 57 | parameter = self.parameter_cls(self._make_http_auth_parameter(definition)) 58 | endpoint.add_parameter(parameter) 59 | 60 | def _is_match(self, definition: Dict[str, Any], location: str) -> bool: 61 | return (definition["type"] == "apiKey" and location in self.api_key_locations) or ( 62 | definition["type"] == self.http_security_name and location == "header" 63 | ) 64 | 65 | def _to_parameter(self, definition: Dict[str, Any]) -> Dict[str, Any]: 66 | func = { 67 | "apiKey": self._make_api_key_parameter, 68 | self.http_security_name: self._make_http_auth_parameter, 69 | }[definition["type"]] 70 | return func(definition) 71 | 72 | def _make_http_auth_parameter(self, definition: Dict[str, Any]) -> Dict[str, Any]: 73 | schema = make_auth_header_schema(definition) 74 | return make_auth_header(**schema) 75 | 76 | def _make_api_key_parameter(self, definition: Dict[str, Any]) -> Dict[str, Any]: 77 | return make_api_key_schema(definition, type="string") 78 | 79 | 80 | def make_auth_header_schema(definition: Dict[str, Any]) -> Dict[str, str]: 81 | schema = definition.get("scheme", "basic").lower() 82 | return {"type": "string", "format": f"_{schema}_auth"} 83 | 84 | 85 | def make_auth_header(**kwargs: Any) -> Dict[str, Any]: 86 | return {"name": "Authorization", "in": "header", "required": True, **kwargs} 87 | 88 | 89 | def make_api_key_schema(definition: Dict[str, Any], **kwargs: Any) -> Dict[str, Any]: 90 | return {"name": definition["name"], "required": True, "in": definition["in"], **kwargs} 91 | 92 | 93 | SwaggerSecurityProcessor = BaseSecurityProcessor 94 | 95 | 96 | @attr.s(slots=True) # pragma: no mutate 97 | class OpenAPISecurityProcessor(BaseSecurityProcessor): 98 | api_key_locations = ("header", "cookie", "query") 99 | http_security_name = "http" 100 | parameter_cls: ClassVar[Type[OpenAPIParameter]] = OpenAPI30Parameter 101 | 102 | def get_security_definitions(self, schema: Dict[str, Any], resolver: RefResolver) -> Dict[str, Any]: 103 | """In Open API 3 security definitions are located in ``components`` and may have references inside.""" 104 | components = schema.get("components", {}) 105 | security_schemes = components.get("securitySchemes", {}) 106 | if "$ref" in security_schemes: 107 | return resolver.resolve(security_schemes["$ref"])[1] 108 | return security_schemes 109 | 110 | def _make_http_auth_parameter(self, definition: Dict[str, Any]) -> Dict[str, Any]: 111 | schema = make_auth_header_schema(definition) 112 | return make_auth_header(schema=schema) 113 | 114 | def _make_api_key_parameter(self, definition: Dict[str, Any]) -> Dict[str, Any]: 115 | return make_api_key_schema(definition, schema={"type": "string"}) 116 | 117 | 118 | def get_security_requirements(schema: Dict[str, Any], endpoint: Endpoint) -> List[str]: 119 | """Get applied security requirements for the given endpoint.""" 120 | # https://github.com/OAI/OpenAPI-Specification/blob/master/versions/2.0.md#operation-object 121 | # > This definition overrides any declared top-level security. 122 | # > To remove a top-level security declaration, an empty array can be used. 123 | global_requirements = schema.get("security", []) 124 | local_requirements = endpoint.definition.raw.get("security", None) 125 | if local_requirements is not None: 126 | requirements = local_requirements 127 | else: 128 | requirements = global_requirements 129 | return [key for requirement in requirements for key in requirement] 130 | -------------------------------------------------------------------------------- /schemathesis/specs/openapi/serialization.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import json 3 | from typing import Any, Callable, Dict, Generator, List, Optional 4 | 5 | Generated = Dict[str, Any] 6 | Definition = Dict[str, Any] 7 | DefinitionList = List[Definition] 8 | MapFunction = Callable[[Generated], Generated] 9 | 10 | 11 | def compose(*functions: Callable) -> Callable: 12 | """Compose multiple functions into a single one.""" 13 | 14 | def noop(x: Any) -> Any: 15 | return x 16 | 17 | return functools.reduce(lambda f, g: lambda x: f(g(x)), functions, noop) 18 | 19 | 20 | def make_serializer( 21 | func: Callable[[DefinitionList], Generator[Optional[Callable], None, None]] 22 | ) -> Callable[[DefinitionList], Optional[Callable]]: 23 | """A maker function to avoid code duplication.""" 24 | 25 | def _wrapper(definitions: DefinitionList) -> Optional[Callable]: 26 | conversions = list(func(definitions)) 27 | if conversions: 28 | return compose(*[conv for conv in conversions if conv is not None]) 29 | return None 30 | 31 | return _wrapper 32 | 33 | 34 | def _serialize_openapi3(definitions: DefinitionList) -> Generator[Optional[Callable], None, None]: 35 | """Different collection styles for Open API 3.0.""" 36 | for definition in definitions: 37 | name = definition["name"] 38 | if "content" in definition: 39 | # https://swagger.io/docs/specification/describing-parameters/#schema-vs-content 40 | options = iter(definition["content"].keys()) 41 | media_type = next(options, None) 42 | if media_type == "application/json": 43 | yield to_json(name) 44 | else: 45 | # Simple serialization 46 | style = definition.get("style") 47 | explode = definition.get("explode") 48 | type_ = definition.get("schema", {}).get("type") 49 | if definition["in"] == "path": 50 | yield from _serialize_path_openapi3(name, type_, style, explode) 51 | elif definition["in"] == "query": 52 | yield from _serialize_query_openapi3(name, type_, style, explode) 53 | elif definition["in"] == "header": 54 | yield from _serialize_header_openapi3(name, type_, explode) 55 | elif definition["in"] == "cookie": 56 | yield from _serialize_cookie_openapi3(name, type_, explode) 57 | 58 | 59 | def _serialize_path_openapi3( 60 | name: str, type_: str, style: Optional[str], explode: Optional[bool] 61 | ) -> Generator[Optional[Callable], None, None]: 62 | # pylint: disable=too-many-branches 63 | if style == "simple": 64 | if type_ == "object": 65 | if explode is False: 66 | yield comma_delimited_object(name) 67 | if explode is True: 68 | yield delimited_object(name) 69 | if type_ == "array": 70 | yield delimited(name, delimiter=",") 71 | if style == "label": 72 | if type_ == "object": 73 | yield label_object(name, explode=explode) 74 | elif type_ == "array": 75 | yield label_array(name, explode=explode) 76 | else: 77 | yield label_primitive(name) 78 | if style == "matrix": 79 | if type_ == "object": 80 | yield matrix_object(name, explode=explode) 81 | elif type_ == "array": 82 | yield matrix_array(name, explode=explode) 83 | else: 84 | yield matrix_primitive(name) 85 | 86 | 87 | def _serialize_query_openapi3( 88 | name: str, type_: str, style: Optional[str], explode: Optional[bool] 89 | ) -> Generator[Optional[Callable], None, None]: 90 | if type_ == "object": 91 | if style == "deepObject": 92 | yield deep_object(name) 93 | if style is None or style == "form": 94 | if explode is False: 95 | yield comma_delimited_object(name) 96 | if explode is True: 97 | yield extracted_object(name) 98 | elif type_ == "array" and explode is False: 99 | if style == "pipeDelimited": 100 | yield delimited(name, delimiter="|") 101 | if style == "spaceDelimited": 102 | yield delimited(name, delimiter=" ") 103 | if style is None or style == "form": # "form" is the default style 104 | yield delimited(name, delimiter=",") 105 | 106 | 107 | def _serialize_header_openapi3( 108 | name: str, type_: str, explode: Optional[bool] 109 | ) -> Generator[Optional[Callable], None, None]: 110 | # Headers should be coerced to a string so we can check it for validity later 111 | yield to_string(name) 112 | # Header parameters always use the "simple" style, that is, comma-separated values 113 | if type_ == "array": 114 | yield delimited(name, delimiter=",") 115 | if type_ == "object": 116 | if explode is False: 117 | yield comma_delimited_object(name) 118 | if explode is True: 119 | yield delimited_object(name) 120 | 121 | 122 | def _serialize_cookie_openapi3( 123 | name: str, type_: str, explode: Optional[bool] 124 | ) -> Generator[Optional[Callable], None, None]: 125 | # Cookies should be coerced to a string so we can check it for validity later 126 | yield to_string(name) 127 | # Cookie parameters always use the "form" style 128 | if explode and type_ in ("array", "object"): 129 | # `explode=true` doesn't make sense 130 | # I.e. we can't create multiple values for the same cookie 131 | # We use the same behavior as in the examples - https://swagger.io/docs/specification/serialization/ 132 | # The item is removed 133 | yield nothing(name) 134 | if explode is False: 135 | if type_ == "array": 136 | yield delimited(name, delimiter=",") 137 | if type_ == "object": 138 | yield comma_delimited_object(name) 139 | 140 | 141 | def _serialize_swagger2(definitions: DefinitionList) -> Generator[Optional[Callable], None, None]: 142 | """Different collection formats for Open API 2.0.""" 143 | for definition in definitions: 144 | name = definition["name"] 145 | collection_format = definition.get("collectionFormat", "csv") 146 | type_ = definition.get("type") 147 | if definition["in"] == "header": 148 | # Headers should be coerced to a string so we can check it for validity later 149 | yield to_string(name) 150 | if definition["in"] != "body": 151 | if type_ in ("array", "object"): 152 | if collection_format == "csv": 153 | yield delimited(name, delimiter=",") 154 | if collection_format == "ssv": 155 | yield delimited(name, delimiter=" ") 156 | if collection_format == "tsv": 157 | yield delimited(name, delimiter="\t") 158 | if collection_format == "pipes": 159 | yield delimited(name, delimiter="|") 160 | 161 | 162 | serialize_openapi3_parameters = make_serializer(_serialize_openapi3) 163 | serialize_swagger2_parameters = make_serializer(_serialize_swagger2) 164 | 165 | 166 | def conversion(func: Callable[..., None]) -> Callable: 167 | def _wrapper(name: str, **kwargs: Any) -> MapFunction: 168 | def _map(item: Generated) -> Generated: 169 | if name in item: 170 | func(item, name, **kwargs) 171 | return item 172 | 173 | return _map 174 | 175 | return _wrapper 176 | 177 | 178 | def make_delimited(data: Dict[str, Any], delimiter: str = ",") -> str: 179 | return delimiter.join(f"{key}={value}" for key, value in data.items()) 180 | 181 | 182 | @conversion 183 | def to_json(item: Generated, name: str) -> None: 184 | """Serialize an item to JSON.""" 185 | item[name] = json.dumps(item[name]) 186 | 187 | 188 | @conversion 189 | def delimited(item: Generated, name: str, delimiter: str) -> None: 190 | item[name] = delimiter.join(map(str, item[name])) 191 | 192 | 193 | @conversion 194 | def deep_object(item: Generated, name: str) -> None: 195 | """Serialize an object with `deepObject` style. 196 | 197 | id={"role": "admin", "firstName": "Alex"} => id[role]=admin&id[firstName]=Alex 198 | """ 199 | generated = item.pop(name) 200 | item.update({f"{name}[{key}]": value for key, value in generated.items()}) 201 | 202 | 203 | @conversion 204 | def comma_delimited_object(item: Generated, name: str) -> None: 205 | item[name] = ",".join(map(str, sum(item[name].items(), ()))) 206 | 207 | 208 | @conversion 209 | def delimited_object(item: Generated, name: str) -> None: 210 | item[name] = make_delimited(item[name]) 211 | 212 | 213 | @conversion 214 | def extracted_object(item: Generated, name: str) -> None: 215 | """Merge a child node to the parent one.""" 216 | generated = item.pop(name) 217 | item.update(generated) 218 | 219 | 220 | @conversion 221 | def label_primitive(item: Generated, name: str) -> None: 222 | """Serialize a primitive value with the `label` style. 223 | 224 | 5 => ".5" 225 | """ 226 | item[name] = f".{item[name]}" 227 | 228 | 229 | @conversion 230 | def label_array(item: Generated, name: str, explode: Optional[bool]) -> None: 231 | """Serialize an array with the `label` style. 232 | 233 | Explode=True 234 | 235 | id=[3, 4, 5] => ".3.4.5" 236 | 237 | Explode=False 238 | 239 | id=[3, 4, 5] => ".3,4,5" 240 | """ 241 | if explode: 242 | delimiter = "." 243 | else: 244 | delimiter = "," 245 | item[name] = f".{delimiter.join(map(str, item[name]))}" 246 | 247 | 248 | @conversion 249 | def label_object(item: Generated, name: str, explode: Optional[bool]) -> None: 250 | """Serialize an object with the `label` style. 251 | 252 | Explode=True 253 | 254 | id={"role": "admin", "firstName": "Alex"} => ".role=admin.firstName=Alex" 255 | 256 | Explode=False 257 | 258 | id={"role": "admin", "firstName": "Alex"} => ".role=admin,firstName,Alex" 259 | """ 260 | if explode: 261 | new = make_delimited(item[name], ".") 262 | else: 263 | object_items = map(str, sum(item[name].items(), ())) 264 | new = ",".join(object_items) 265 | item[name] = f".{new}" 266 | 267 | 268 | @conversion 269 | def matrix_primitive(item: Generated, name: str) -> None: 270 | """Serialize a primitive value with the `matrix` style. 271 | 272 | 5 => ";id=5" 273 | """ 274 | item[name] = f";{name}={item[name]}" 275 | 276 | 277 | @conversion 278 | def matrix_array(item: Generated, name: str, explode: Optional[bool]) -> None: 279 | """Serialize an array with the `matrix` style. 280 | 281 | Explode=True 282 | 283 | id=[3, 4, 5] => ";id=3;id=4;id=5" 284 | 285 | Explode=False 286 | 287 | id=[3, 4, 5] => ";id=3,4,5" 288 | """ 289 | if explode: 290 | new = ";".join(f"{name}={value}" for value in item[name]) 291 | else: 292 | new = ",".join(map(str, item[name])) 293 | item[name] = f";{new}" 294 | 295 | 296 | @conversion 297 | def matrix_object(item: Generated, name: str, explode: Optional[bool]) -> None: 298 | """Serialize an object with the `matrix` style. 299 | 300 | Explode=True 301 | 302 | id={"role": "admin", "firstName": "Alex"} => ";role=admin;firstName=Alex" 303 | 304 | Explode=False 305 | 306 | id={"role": "admin", "firstName": "Alex"} => ";role=admin,firstName,Alex" 307 | """ 308 | if explode: 309 | new = make_delimited(item[name], ";") 310 | else: 311 | object_items = map(str, sum(item[name].items(), ())) 312 | new = ",".join(object_items) 313 | item[name] = f";{new}" 314 | 315 | 316 | @conversion 317 | def nothing(item: Generated, name: str) -> None: 318 | """Remove a key from an item.""" 319 | item.pop(name, None) 320 | 321 | 322 | @conversion 323 | def to_string(item: Generated, name: str) -> None: 324 | """Convert the value to a string.""" 325 | item[name] = str(item[name]) 326 | -------------------------------------------------------------------------------- /schemathesis/specs/openapi/stateful/__init__.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import operator 3 | from collections import defaultdict 4 | from typing import TYPE_CHECKING, Any, Dict, List, Tuple, Type 5 | 6 | from hypothesis.stateful import Bundle, Rule, rule 7 | from hypothesis.strategies import SearchStrategy, none 8 | from requests.structures import CaseInsensitiveDict 9 | 10 | from ....stateful import APIStateMachine, Direction, StepResult 11 | from .. import expressions 12 | from ..links import OpenAPILink 13 | from . import links 14 | 15 | if TYPE_CHECKING: 16 | from ....models import Case, Endpoint 17 | from ..schemas import BaseOpenAPISchema 18 | 19 | 20 | EndpointConnections = Dict[str, List[SearchStrategy[Tuple[StepResult, OpenAPILink]]]] 21 | 22 | 23 | class OpenAPIStateMachine(APIStateMachine): 24 | def transform(self, result: StepResult, direction: Direction, case: "Case") -> "Case": 25 | context = expressions.ExpressionContext(case=result.case, response=result.response) 26 | direction.set_data(case, context=context) 27 | return case 28 | 29 | 30 | def create_state_machine(schema: "BaseOpenAPISchema") -> Type[APIStateMachine]: 31 | """Create a state machine class. 32 | 33 | This state machine will contain transitions that connect some endpoints' outputs with other endpoints' inputs. 34 | """ 35 | bundles = init_bundles(schema) 36 | connections: EndpointConnections = defaultdict(list) 37 | for endpoint in schema.get_all_endpoints(): 38 | links.apply(endpoint, bundles, connections) 39 | 40 | rules = make_all_rules(schema, bundles, connections) 41 | 42 | kwargs: Dict[str, Any] = {"bundles": bundles, "schema": schema} 43 | return type("APIWorkflow", (OpenAPIStateMachine,), {**kwargs, **rules}) 44 | 45 | 46 | def init_bundles(schema: "BaseOpenAPISchema") -> Dict[str, CaseInsensitiveDict]: 47 | """Create bundles for all endpoints in the given schema. 48 | 49 | Each endpoint has a bundle that stores all responses from that endpoint. 50 | We need to create bundles first, so they can be referred when building connections between endpoints. 51 | """ 52 | output: Dict[str, CaseInsensitiveDict] = {} 53 | for endpoint in schema.get_all_endpoints(): 54 | output.setdefault(endpoint.path, CaseInsensitiveDict()) 55 | output[endpoint.path][endpoint.method.upper()] = Bundle(endpoint.verbose_name) # type: ignore 56 | return output 57 | 58 | 59 | def make_all_rules( 60 | schema: "BaseOpenAPISchema", bundles: Dict[str, CaseInsensitiveDict], connections: EndpointConnections 61 | ) -> Dict[str, Rule]: 62 | """Create rules for all endpoints, based on the provided connections.""" 63 | return { 64 | f"rule {endpoint.verbose_name}": make_rule( 65 | endpoint, bundles[endpoint.path][endpoint.method.upper()], connections 66 | ) 67 | for endpoint in schema.get_all_endpoints() 68 | } 69 | 70 | 71 | def make_rule(endpoint: "Endpoint", bundle: Bundle, connections: EndpointConnections) -> Rule: 72 | """Create a rule for an endpoint.""" 73 | previous_strategies = connections.get(endpoint.verbose_name) 74 | if previous_strategies is not None: 75 | previous = _combine_strategies(previous_strategies) 76 | else: 77 | previous = none() 78 | return rule(target=bundle, previous=previous, case=endpoint.as_strategy())(APIStateMachine.step) # type: ignore 79 | 80 | 81 | def _combine_strategies(strategies: List[SearchStrategy]) -> SearchStrategy: 82 | """Combine a list of strategies into a single one. 83 | 84 | If the input is `[a, b, c]`, then the result is equivalent to `a | b | c`. 85 | """ 86 | return functools.reduce(operator.or_, strategies[1:], strategies[0]) 87 | -------------------------------------------------------------------------------- /schemathesis/specs/openapi/stateful/__pycache__/__init__.cpython-38.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngalongc/openapi_security_scanner/9ba2244bf0e52db6f149243de403c8c7c157216f/schemathesis/specs/openapi/stateful/__pycache__/__init__.cpython-38.pyc -------------------------------------------------------------------------------- /schemathesis/specs/openapi/stateful/__pycache__/links.cpython-38.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngalongc/openapi_security_scanner/9ba2244bf0e52db6f149243de403c8c7c157216f/schemathesis/specs/openapi/stateful/__pycache__/links.cpython-38.pyc -------------------------------------------------------------------------------- /schemathesis/specs/openapi/stateful/links.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, Callable, Dict, List, Tuple 2 | 3 | import hypothesis.strategies as st 4 | from requests.structures import CaseInsensitiveDict 5 | 6 | from ....stateful import StepResult 7 | from ..links import OpenAPILink, get_all_links 8 | from ..utils import expand_status_code 9 | 10 | if TYPE_CHECKING: 11 | from ....models import Endpoint 12 | 13 | FilterFunction = Callable[[StepResult], bool] 14 | 15 | 16 | def apply( 17 | endpoint: "Endpoint", 18 | bundles: Dict[str, CaseInsensitiveDict], 19 | connections: Dict[str, List[st.SearchStrategy[Tuple[StepResult, OpenAPILink]]]], 20 | ) -> None: 21 | """Gather all connections based on Open API links definitions.""" 22 | all_status_codes = list(endpoint.definition.resolved["responses"]) 23 | for status_code, link in get_all_links(endpoint): 24 | target_endpoint = link.get_target_endpoint() 25 | strategy = bundles[endpoint.path][endpoint.method.upper()].filter( 26 | make_response_filter(status_code, all_status_codes) 27 | ) 28 | connections[target_endpoint.verbose_name].append(_convert_strategy(strategy, link)) 29 | 30 | 31 | def _convert_strategy( 32 | strategy: st.SearchStrategy[StepResult], link: OpenAPILink 33 | ) -> st.SearchStrategy[Tuple[StepResult, OpenAPILink]]: 34 | # This function is required to capture values properly (it won't work properly when lambda is defined in a loop) 35 | return strategy.map(lambda out: (out, link)) 36 | 37 | 38 | def make_response_filter(status_code: str, all_status_codes: List[str]) -> FilterFunction: 39 | """Create a filter for stored responses. 40 | 41 | This filter will decide whether some response is suitable to use as a source for requesting some endpoint. 42 | """ 43 | if status_code == "default": 44 | return default_status_code(all_status_codes) 45 | return match_status_code(status_code) 46 | 47 | 48 | def match_status_code(status_code: str) -> FilterFunction: 49 | """Create a filter function that matches all responses with the given status code. 50 | 51 | Note that the status code can contain "X", which means any digit. 52 | For example, 50X will match all status codes from 500 to 509. 53 | """ 54 | status_codes = set(expand_status_code(status_code)) 55 | 56 | def compare(result: StepResult) -> bool: 57 | return result.response.status_code in status_codes 58 | 59 | # This name is displayed in the resulting strategy representation. For example, if you run your tests with 60 | # `--hypothesis-show-statistics`, then you can see `Bundle(name='GET /users/{user_id}').filter(match_200_response)` 61 | # which gives you information about the particularly used filter. 62 | compare.__name__ = f"match_{status_code}_response" 63 | 64 | return compare 65 | 66 | 67 | def default_status_code(status_codes: List[str]) -> FilterFunction: 68 | """Create a filter that matches all "default" responses. 69 | 70 | In Open API, the "default" response is the one that is used if no other options were matched. 71 | Therefore we need to match only responses that were not matched by other listed status codes. 72 | """ 73 | expanded_status_codes = { 74 | status_code for value in status_codes if value != "default" for status_code in expand_status_code(value) 75 | } 76 | 77 | def match_default_response(result: StepResult) -> bool: 78 | return result.response.status_code not in expanded_status_codes 79 | 80 | return match_default_response 81 | -------------------------------------------------------------------------------- /schemathesis/specs/openapi/utils.py: -------------------------------------------------------------------------------- 1 | import string 2 | from itertools import product 3 | from typing import Generator, Union 4 | 5 | 6 | def expand_status_code(status_code: Union[str, int]) -> Generator[int, None, None]: 7 | chars = [list(string.digits) if digit == "X" else [digit] for digit in str(status_code).upper()] 8 | for expanded in product(*chars): 9 | yield int("".join(expanded)) 10 | -------------------------------------------------------------------------------- /schemathesis/targets.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, Callable, Tuple 2 | 3 | import attr 4 | 5 | from .utils import GenericResponse 6 | 7 | if TYPE_CHECKING: 8 | from .models import Case 9 | 10 | 11 | @attr.s(slots=True) # pragma: no mutate 12 | class TargetContext: 13 | case: "Case" = attr.ib() # pragma: no mutate 14 | response: GenericResponse = attr.ib() # pragma: no mutate 15 | response_time: float = attr.ib() # pragma: no mutate 16 | 17 | 18 | def response_time(context: TargetContext) -> float: 19 | return context.response_time 20 | 21 | 22 | Target = Callable[[TargetContext], float] 23 | DEFAULT_TARGETS = () 24 | OPTIONAL_TARGETS = (response_time,) 25 | ALL_TARGETS: Tuple[Target, ...] = DEFAULT_TARGETS + OPTIONAL_TARGETS 26 | -------------------------------------------------------------------------------- /schemathesis/types.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import TYPE_CHECKING, Any, Callable, Dict, List, Set, Tuple, Union 3 | 4 | from hypothesis.strategies import SearchStrategy 5 | 6 | if TYPE_CHECKING: 7 | from .hooks import HookContext 8 | 9 | PathLike = Union[Path, str] # pragma: no mutate 10 | 11 | Query = Dict[str, Any] # pragma: no mutate 12 | # Body can be of any Python type that corresponds to JSON Schema types + `bytes` 13 | Body = Union[List, Dict[str, Any], str, int, float, bool, bytes] # pragma: no mutate 14 | PathParameters = Dict[str, Any] # pragma: no mutate 15 | Headers = Dict[str, Any] # pragma: no mutate 16 | Cookies = Dict[str, Any] # pragma: no mutate 17 | FormData = Dict[str, Any] # pragma: no mutate 18 | 19 | 20 | class NotSet: 21 | pass 22 | 23 | 24 | # A filter for endpoint / method 25 | Filter = Union[str, List[str], Tuple[str], Set[str], NotSet] # pragma: no mutate 26 | 27 | Hook = Union[ 28 | Callable[[SearchStrategy], SearchStrategy], Callable[[SearchStrategy, "HookContext"], SearchStrategy] 29 | ] # pragma: no mutate 30 | 31 | RawAuth = Tuple[str, str] # pragma: no mutate 32 | # Generic test with any arguments and no return 33 | GenericTest = Callable[..., None] # pragma: no mutate 34 | -------------------------------------------------------------------------------- /schemathesis/utils.py: -------------------------------------------------------------------------------- 1 | import cgi 2 | import pathlib 3 | import re 4 | import sys 5 | import traceback 6 | from contextlib import contextmanager 7 | from json import JSONDecodeError 8 | from typing import Any, Callable, Dict, Generator, List, NoReturn, Optional, Set, Tuple, Type, Union, overload 9 | 10 | import requests 11 | import yaml 12 | from hypothesis.reporting import with_reporter 13 | from requests.auth import HTTPDigestAuth 14 | from requests.exceptions import InvalidHeader # type: ignore 15 | from requests.utils import check_header_validity # type: ignore 16 | from werkzeug.wrappers import Response as BaseResponse 17 | from werkzeug.wrappers.json import JSONMixin 18 | 19 | from .types import Filter, NotSet, RawAuth 20 | 21 | try: 22 | from yaml import CSafeLoader as SafeLoader 23 | except ImportError: 24 | # pylint: disable=unused-import 25 | from yaml import SafeLoader # type: ignore 26 | 27 | 28 | NOT_SET = NotSet() 29 | 30 | 31 | def file_exists(path: str) -> bool: 32 | try: 33 | return pathlib.Path(path).is_file() 34 | except OSError: 35 | # For example, path could be too long 36 | return False 37 | 38 | 39 | def is_latin_1_encodable(value: str) -> bool: 40 | """Header values are encoded to latin-1 before sending.""" 41 | try: 42 | value.encode("latin-1") 43 | return True 44 | except UnicodeEncodeError: 45 | return False 46 | 47 | 48 | # Adapted from http.client._is_illegal_header_value 49 | INVALID_HEADER_RE = re.compile(r"\n(?![ \t])|\r(?![ \t\n])") # pragma: no mutate 50 | 51 | 52 | def has_invalid_characters(name: str, value: str) -> bool: 53 | try: 54 | check_header_validity((name, value)) 55 | return bool(INVALID_HEADER_RE.search(value)) 56 | except InvalidHeader: 57 | return True 58 | 59 | 60 | def is_schemathesis_test(func: Callable) -> bool: 61 | """Check whether test is parametrized with schemathesis.""" 62 | try: 63 | from .schemas import BaseSchema # pylint: disable=import-outside-toplevel 64 | 65 | item = getattr(func, "_schemathesis_test", None) 66 | # Comparison is needed to avoid false-positives when mocks are collected by pytest 67 | return isinstance(item, BaseSchema) 68 | except Exception: 69 | return False 70 | 71 | 72 | def force_tuple(item: Filter) -> Union[List, Set, Tuple]: 73 | if not isinstance(item, (list, set, tuple)): 74 | return (item,) 75 | return item 76 | 77 | 78 | def dict_true_values(**kwargs: Any) -> Dict[str, Any]: 79 | """Create a dict with given kwargs while skipping items where bool(value) evaluates to False.""" 80 | return {key: value for key, value in kwargs.items() if bool(value)} 81 | 82 | 83 | def dict_not_none_values(**kwargs: Any) -> Dict[str, Any]: 84 | return {key: value for key, value in kwargs.items() if value is not None} 85 | 86 | 87 | IGNORED_PATTERNS = ( 88 | "Falsifying example: ", 89 | "You can add @seed", 90 | "Failed to reproduce exception. Expected:", 91 | "Flaky example!", 92 | "Traceback (most recent call last):", 93 | ) 94 | 95 | 96 | @contextmanager 97 | def capture_hypothesis_output() -> Generator[List[str], None, None]: 98 | """Capture all output of Hypothesis into a list of strings. 99 | 100 | It allows us to have more granular control over Schemathesis output. 101 | 102 | Usage:: 103 | 104 | @given(i=st.integers()) 105 | def test(i): 106 | assert 0 107 | 108 | with capture_hypothesis_output() as output: 109 | test() # hypothesis test 110 | # output == ["Falsifying example: test(i=0)"] 111 | """ 112 | output = [] 113 | 114 | def get_output(value: str) -> None: 115 | # Drop messages that could be confusing in the Schemathesis context 116 | if value.startswith(IGNORED_PATTERNS): 117 | return 118 | output.append(value) 119 | 120 | # the following context manager is untyped 121 | with with_reporter(get_output): # type: ignore 122 | yield output 123 | 124 | 125 | def format_exception(error: Exception, include_traceback: bool = False) -> str: 126 | if include_traceback: 127 | return "".join(traceback.format_exception(type(error), error, error.__traceback__)) 128 | return "".join(traceback.format_exception_only(type(error), error)) 129 | 130 | 131 | def parse_content_type(content_type: str) -> Tuple[str, str]: 132 | """Parse Content Type and return main type and subtype.""" 133 | try: 134 | content_type, _ = cgi.parse_header(content_type) 135 | main_type, sub_type = content_type.split("/", 1) 136 | except ValueError as exc: 137 | raise ValueError(f"Malformed media type: `{content_type}`") from exc 138 | return main_type.lower(), sub_type.lower() 139 | 140 | 141 | def is_json_media_type(value: str) -> bool: 142 | """Detect whether the content type is JSON-compatible. 143 | 144 | For example - `application/problem+json` matches. 145 | """ 146 | main, sub = parse_content_type(value) 147 | return main == "application" and (sub == "json" or sub.endswith("+json")) 148 | 149 | 150 | def are_content_types_equal(source: str, target: str) -> bool: 151 | """Check if two content types are the same excluding options.""" 152 | return parse_content_type(source) == parse_content_type(target) 153 | 154 | 155 | def make_loader(*tags_to_remove: str) -> Type[yaml.SafeLoader]: 156 | """Create a YAML loader, that doesn't parse specific tokens into Python objects.""" 157 | cls: Type[yaml.SafeLoader] = type("YAMLLoader", (SafeLoader,), {}) 158 | cls.yaml_implicit_resolvers = { 159 | key: [(tag, regexp) for tag, regexp in mapping if tag not in tags_to_remove] 160 | for key, mapping in cls.yaml_implicit_resolvers.copy().items() 161 | } 162 | 163 | # Fix pyyaml scientific notation parse bug 164 | # See PR: https://github.com/yaml/pyyaml/pull/174 for upstream fix 165 | cls.add_implicit_resolver( # type: ignore 166 | "tag:yaml.org,2002:float", 167 | re.compile( 168 | r"""^(?:[-+]?(?:[0-9][0-9_]*)\.[0-9_]*(?:[eE][-+]?[0-9]+)? 169 | |[-+]?(?:[0-9][0-9_]*)(?:[eE][-+]?[0-9]+) 170 | |\.[0-9_]+(?:[eE][-+]?[0-9]+)? 171 | |[-+]?[0-9][0-9_]*(?::[0-5]?[0-9])+\.[0-9_]* 172 | |[-+]?\.(?:inf|Inf|INF) 173 | |\.(?:nan|NaN|NAN))$""", 174 | re.X, 175 | ), 176 | list("-+0123456789."), 177 | ) 178 | 179 | return cls 180 | 181 | 182 | StringDatesYAMLLoader = make_loader("tag:yaml.org,2002:timestamp") 183 | 184 | 185 | class WSGIResponse(BaseResponse, JSONMixin): # pylint: disable=too-many-ancestors 186 | # We store "requests" request to build a reproduction code 187 | request: requests.PreparedRequest 188 | 189 | def on_json_loading_failed(self, e: JSONDecodeError) -> NoReturn: 190 | # We don't need a werkzeug-specific exception when JSON parsing error happens 191 | raise e 192 | 193 | 194 | def get_requests_auth(auth: Optional[RawAuth], auth_type: Optional[str]) -> Optional[Union[HTTPDigestAuth, RawAuth]]: 195 | if auth and auth_type == "digest": 196 | return HTTPDigestAuth(*auth) 197 | return auth 198 | 199 | 200 | GenericResponse = Union[requests.Response, WSGIResponse] # pragma: no mutate 201 | 202 | 203 | def get_response_payload(response: GenericResponse) -> str: 204 | if isinstance(response, requests.Response): 205 | return response.text 206 | return response.get_data(as_text=True) 207 | 208 | 209 | def import_app(path: str) -> Any: 210 | """Import an application from a string.""" 211 | path, name = (re.split(r":(?![\\/])", path, 1) + [""])[:2] 212 | __import__(path) 213 | # accessing the module from sys.modules returns a proper module, while `__import__` 214 | # may return a parent module (system dependent) 215 | module = sys.modules[path] 216 | return getattr(module, name) 217 | 218 | 219 | Schema = Union[Dict[str, Any], List, str, float, int] 220 | 221 | 222 | @overload 223 | def traverse_schema(schema: Dict[str, Any], callback: Callable, *args: Any, **kwargs: Any) -> Dict[str, Any]: 224 | pass 225 | 226 | 227 | @overload 228 | def traverse_schema(schema: List, callback: Callable, *args: Any, **kwargs: Any) -> List: 229 | pass 230 | 231 | 232 | @overload 233 | def traverse_schema(schema: str, callback: Callable, *args: Any, **kwargs: Any) -> str: 234 | pass 235 | 236 | 237 | @overload 238 | def traverse_schema(schema: float, callback: Callable, *args: Any, **kwargs: Any) -> float: 239 | pass 240 | 241 | 242 | def traverse_schema(schema: Schema, callback: Callable[..., Dict[str, Any]], *args: Any, **kwargs: Any) -> Schema: 243 | """Apply callback recursively to the given schema.""" 244 | if isinstance(schema, dict): 245 | schema = callback(schema, *args, **kwargs) 246 | for key, sub_item in schema.items(): 247 | schema[key] = traverse_schema(sub_item, callback, *args, **kwargs) 248 | elif isinstance(schema, list): 249 | for idx, sub_item in enumerate(schema): 250 | schema[idx] = traverse_schema(sub_item, callback, *args, **kwargs) 251 | return schema 252 | --------------------------------------------------------------------------------