rest-api-mocker 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- rest_api_mocker-0.1.0/.github/workflows/ci.yml +56 -0
- rest_api_mocker-0.1.0/.github/workflows/publish.yml +39 -0
- rest_api_mocker-0.1.0/.gitignore +218 -0
- rest_api_mocker-0.1.0/LICENSE +21 -0
- rest_api_mocker-0.1.0/PKG-INFO +175 -0
- rest_api_mocker-0.1.0/README.md +121 -0
- rest_api_mocker-0.1.0/pyproject.toml +58 -0
- rest_api_mocker-0.1.0/src/rest_api_mocker/__init__.py +17 -0
- rest_api_mocker-0.1.0/src/rest_api_mocker/client.py +164 -0
- rest_api_mocker-0.1.0/src/rest_api_mocker/exceptions.py +21 -0
- rest_api_mocker-0.1.0/src/rest_api_mocker/models.py +66 -0
- rest_api_mocker-0.1.0/tests/test_client.py +180 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
lint:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
steps:
|
|
12
|
+
- uses: actions/checkout@v4
|
|
13
|
+
- uses: actions/setup-python@v5
|
|
14
|
+
with:
|
|
15
|
+
python-version: "3.12"
|
|
16
|
+
- run: python -m pip install --upgrade pip
|
|
17
|
+
- run: pip install -e ".[dev]"
|
|
18
|
+
- name: Ruff (lint)
|
|
19
|
+
run: ruff check src tests
|
|
20
|
+
- name: Ruff (format)
|
|
21
|
+
run: ruff format --check src tests
|
|
22
|
+
- name: Mypy
|
|
23
|
+
run: mypy src
|
|
24
|
+
|
|
25
|
+
test:
|
|
26
|
+
runs-on: ubuntu-latest
|
|
27
|
+
strategy:
|
|
28
|
+
fail-fast: false
|
|
29
|
+
matrix:
|
|
30
|
+
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"]
|
|
31
|
+
steps:
|
|
32
|
+
- uses: actions/checkout@v4
|
|
33
|
+
- uses: actions/setup-python@v5
|
|
34
|
+
with:
|
|
35
|
+
python-version: ${{ matrix.python-version }}
|
|
36
|
+
- run: python -m pip install --upgrade pip
|
|
37
|
+
- run: pip install -e ".[test]"
|
|
38
|
+
- name: Pytest
|
|
39
|
+
run: pytest
|
|
40
|
+
|
|
41
|
+
build:
|
|
42
|
+
runs-on: ubuntu-latest
|
|
43
|
+
steps:
|
|
44
|
+
- uses: actions/checkout@v4
|
|
45
|
+
- uses: actions/setup-python@v5
|
|
46
|
+
with:
|
|
47
|
+
python-version: "3.12"
|
|
48
|
+
- run: python -m pip install --upgrade pip build twine
|
|
49
|
+
- name: Build distribution
|
|
50
|
+
run: python -m build
|
|
51
|
+
- name: Check distribution
|
|
52
|
+
run: twine check dist/*
|
|
53
|
+
- uses: actions/upload-artifact@v4
|
|
54
|
+
with:
|
|
55
|
+
name: dist
|
|
56
|
+
path: dist/
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
# Publishes when you cut a GitHub Release. Uses PyPI Trusted Publishing
|
|
4
|
+
# (OIDC) — no API tokens or secrets to manage. See README for the one-time
|
|
5
|
+
# PyPI setup this requires.
|
|
6
|
+
|
|
7
|
+
on:
|
|
8
|
+
release:
|
|
9
|
+
types: [published]
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
build:
|
|
13
|
+
runs-on: ubuntu-latest
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v4
|
|
16
|
+
- uses: actions/setup-python@v5
|
|
17
|
+
with:
|
|
18
|
+
python-version: "3.12"
|
|
19
|
+
- run: python -m pip install --upgrade pip build twine
|
|
20
|
+
- run: python -m build
|
|
21
|
+
- run: twine check dist/*
|
|
22
|
+
- uses: actions/upload-artifact@v4
|
|
23
|
+
with:
|
|
24
|
+
name: dist
|
|
25
|
+
path: dist/
|
|
26
|
+
|
|
27
|
+
publish:
|
|
28
|
+
needs: build
|
|
29
|
+
runs-on: ubuntu-latest
|
|
30
|
+
environment: pypi
|
|
31
|
+
permissions:
|
|
32
|
+
id-token: write # required for trusted publishing
|
|
33
|
+
steps:
|
|
34
|
+
- uses: actions/download-artifact@v4
|
|
35
|
+
with:
|
|
36
|
+
name: dist
|
|
37
|
+
path: dist/
|
|
38
|
+
- name: Publish to PyPI
|
|
39
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
# Byte-compiled / optimized / DLL files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[codz]
|
|
4
|
+
*$py.class
|
|
5
|
+
|
|
6
|
+
# C extensions
|
|
7
|
+
*.so
|
|
8
|
+
|
|
9
|
+
# Distribution / packaging
|
|
10
|
+
.Python
|
|
11
|
+
build/
|
|
12
|
+
develop-eggs/
|
|
13
|
+
dist/
|
|
14
|
+
downloads/
|
|
15
|
+
eggs/
|
|
16
|
+
.eggs/
|
|
17
|
+
lib/
|
|
18
|
+
lib64/
|
|
19
|
+
parts/
|
|
20
|
+
sdist/
|
|
21
|
+
var/
|
|
22
|
+
wheels/
|
|
23
|
+
share/python-wheels/
|
|
24
|
+
*.egg-info/
|
|
25
|
+
.installed.cfg
|
|
26
|
+
*.egg
|
|
27
|
+
MANIFEST
|
|
28
|
+
|
|
29
|
+
# PyInstaller
|
|
30
|
+
# Usually these files are written by a python script from a template
|
|
31
|
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
32
|
+
*.manifest
|
|
33
|
+
*.spec
|
|
34
|
+
|
|
35
|
+
# Installer logs
|
|
36
|
+
pip-log.txt
|
|
37
|
+
pip-delete-this-directory.txt
|
|
38
|
+
|
|
39
|
+
# Unit test / coverage reports
|
|
40
|
+
htmlcov/
|
|
41
|
+
.tox/
|
|
42
|
+
.nox/
|
|
43
|
+
.coverage
|
|
44
|
+
.coverage.*
|
|
45
|
+
.cache
|
|
46
|
+
nosetests.xml
|
|
47
|
+
coverage.xml
|
|
48
|
+
*.cover
|
|
49
|
+
*.py.cover
|
|
50
|
+
.hypothesis/
|
|
51
|
+
.pytest_cache/
|
|
52
|
+
cover/
|
|
53
|
+
|
|
54
|
+
# Translations
|
|
55
|
+
*.mo
|
|
56
|
+
*.pot
|
|
57
|
+
|
|
58
|
+
# Django stuff:
|
|
59
|
+
*.log
|
|
60
|
+
local_settings.py
|
|
61
|
+
db.sqlite3
|
|
62
|
+
db.sqlite3-journal
|
|
63
|
+
|
|
64
|
+
# Flask stuff:
|
|
65
|
+
instance/
|
|
66
|
+
.webassets-cache
|
|
67
|
+
|
|
68
|
+
# Scrapy stuff:
|
|
69
|
+
.scrapy
|
|
70
|
+
|
|
71
|
+
# Sphinx documentation
|
|
72
|
+
docs/_build/
|
|
73
|
+
|
|
74
|
+
# PyBuilder
|
|
75
|
+
.pybuilder/
|
|
76
|
+
target/
|
|
77
|
+
|
|
78
|
+
# Jupyter Notebook
|
|
79
|
+
.ipynb_checkpoints
|
|
80
|
+
|
|
81
|
+
# IPython
|
|
82
|
+
profile_default/
|
|
83
|
+
ipython_config.py
|
|
84
|
+
|
|
85
|
+
# pyenv
|
|
86
|
+
# For a library or package, you might want to ignore these files since the code is
|
|
87
|
+
# intended to run in multiple environments; otherwise, check them in:
|
|
88
|
+
# .python-version
|
|
89
|
+
|
|
90
|
+
# pipenv
|
|
91
|
+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
|
92
|
+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
|
93
|
+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
|
94
|
+
# install all needed dependencies.
|
|
95
|
+
# Pipfile.lock
|
|
96
|
+
|
|
97
|
+
# UV
|
|
98
|
+
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
|
99
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
100
|
+
# commonly ignored for libraries.
|
|
101
|
+
# uv.lock
|
|
102
|
+
|
|
103
|
+
# poetry
|
|
104
|
+
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
|
105
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
106
|
+
# commonly ignored for libraries.
|
|
107
|
+
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
|
108
|
+
# poetry.lock
|
|
109
|
+
# poetry.toml
|
|
110
|
+
|
|
111
|
+
# pdm
|
|
112
|
+
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
|
113
|
+
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
|
|
114
|
+
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
|
|
115
|
+
# pdm.lock
|
|
116
|
+
# pdm.toml
|
|
117
|
+
.pdm-python
|
|
118
|
+
.pdm-build/
|
|
119
|
+
|
|
120
|
+
# pixi
|
|
121
|
+
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
|
|
122
|
+
# pixi.lock
|
|
123
|
+
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
|
|
124
|
+
# in the .venv directory. It is recommended not to include this directory in version control.
|
|
125
|
+
.pixi
|
|
126
|
+
|
|
127
|
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
|
128
|
+
__pypackages__/
|
|
129
|
+
|
|
130
|
+
# Celery stuff
|
|
131
|
+
celerybeat-schedule
|
|
132
|
+
celerybeat.pid
|
|
133
|
+
|
|
134
|
+
# Redis
|
|
135
|
+
*.rdb
|
|
136
|
+
*.aof
|
|
137
|
+
*.pid
|
|
138
|
+
|
|
139
|
+
# RabbitMQ
|
|
140
|
+
mnesia/
|
|
141
|
+
rabbitmq/
|
|
142
|
+
rabbitmq-data/
|
|
143
|
+
|
|
144
|
+
# ActiveMQ
|
|
145
|
+
activemq-data/
|
|
146
|
+
|
|
147
|
+
# SageMath parsed files
|
|
148
|
+
*.sage.py
|
|
149
|
+
|
|
150
|
+
# Environments
|
|
151
|
+
.env
|
|
152
|
+
.envrc
|
|
153
|
+
.venv
|
|
154
|
+
env/
|
|
155
|
+
venv/
|
|
156
|
+
ENV/
|
|
157
|
+
env.bak/
|
|
158
|
+
venv.bak/
|
|
159
|
+
|
|
160
|
+
# Spyder project settings
|
|
161
|
+
.spyderproject
|
|
162
|
+
.spyproject
|
|
163
|
+
|
|
164
|
+
# Rope project settings
|
|
165
|
+
.ropeproject
|
|
166
|
+
|
|
167
|
+
# mkdocs documentation
|
|
168
|
+
/site
|
|
169
|
+
|
|
170
|
+
# mypy
|
|
171
|
+
.mypy_cache/
|
|
172
|
+
.dmypy.json
|
|
173
|
+
dmypy.json
|
|
174
|
+
|
|
175
|
+
# Pyre type checker
|
|
176
|
+
.pyre/
|
|
177
|
+
|
|
178
|
+
# pytype static type analyzer
|
|
179
|
+
.pytype/
|
|
180
|
+
|
|
181
|
+
# Cython debug symbols
|
|
182
|
+
cython_debug/
|
|
183
|
+
|
|
184
|
+
# PyCharm
|
|
185
|
+
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
|
186
|
+
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
|
187
|
+
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
|
188
|
+
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
|
189
|
+
# .idea/
|
|
190
|
+
|
|
191
|
+
# Abstra
|
|
192
|
+
# Abstra is an AI-powered process automation framework.
|
|
193
|
+
# Ignore directories containing user credentials, local state, and settings.
|
|
194
|
+
# Learn more at https://abstra.io/docs
|
|
195
|
+
.abstra/
|
|
196
|
+
|
|
197
|
+
# Visual Studio Code
|
|
198
|
+
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
|
|
199
|
+
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
|
|
200
|
+
# and can be added to the global gitignore or merged into this file. However, if you prefer,
|
|
201
|
+
# you could uncomment the following to ignore the entire vscode folder
|
|
202
|
+
# .vscode/
|
|
203
|
+
# Temporary file for partial code execution
|
|
204
|
+
tempCodeRunnerFile.py
|
|
205
|
+
|
|
206
|
+
# Ruff stuff:
|
|
207
|
+
.ruff_cache/
|
|
208
|
+
|
|
209
|
+
# PyPI configuration file
|
|
210
|
+
.pypirc
|
|
211
|
+
|
|
212
|
+
# Marimo
|
|
213
|
+
marimo/_static/
|
|
214
|
+
marimo/_lsp/
|
|
215
|
+
__marimo__/
|
|
216
|
+
|
|
217
|
+
# Streamlit
|
|
218
|
+
.streamlit/secrets.toml
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 julien lopez
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: rest-api-mocker
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A small Python wrapper for RestApiMocker.
|
|
5
|
+
Project-URL: Homepage, https://github.com/julienlopez/RestApiMockerPythonAPI
|
|
6
|
+
Project-URL: Repository, https://github.com/julienlopez/RestApiMockerPythonAPI
|
|
7
|
+
Author-email: Julien Lopez <julien.lopez51@gmail.com>
|
|
8
|
+
License: MIT License
|
|
9
|
+
|
|
10
|
+
Copyright (c) 2026 julien lopez
|
|
11
|
+
|
|
12
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
13
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
14
|
+
in the Software without restriction, including without limitation the rights
|
|
15
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
16
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
17
|
+
furnished to do so, subject to the following conditions:
|
|
18
|
+
|
|
19
|
+
The above copyright notice and this permission notice shall be included in all
|
|
20
|
+
copies or substantial portions of the Software.
|
|
21
|
+
|
|
22
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
23
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
24
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
25
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
26
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
27
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
28
|
+
SOFTWARE.
|
|
29
|
+
License-File: LICENSE
|
|
30
|
+
Keywords: api,http,mock,rest,testing
|
|
31
|
+
Classifier: Development Status :: 3 - Alpha
|
|
32
|
+
Classifier: Intended Audience :: Developers
|
|
33
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
34
|
+
Classifier: Programming Language :: Python :: 3
|
|
35
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
36
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
37
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
38
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
39
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
40
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
41
|
+
Classifier: Topic :: Software Development :: Testing :: Mocking
|
|
42
|
+
Requires-Python: >=3.8
|
|
43
|
+
Requires-Dist: requests>=2.20
|
|
44
|
+
Provides-Extra: dev
|
|
45
|
+
Requires-Dist: mypy>=1.8; extra == 'dev'
|
|
46
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
47
|
+
Requires-Dist: responses>=0.23; extra == 'dev'
|
|
48
|
+
Requires-Dist: ruff>=0.4; extra == 'dev'
|
|
49
|
+
Requires-Dist: types-requests; extra == 'dev'
|
|
50
|
+
Provides-Extra: test
|
|
51
|
+
Requires-Dist: pytest>=7; extra == 'test'
|
|
52
|
+
Requires-Dist: responses>=0.23; extra == 'test'
|
|
53
|
+
Description-Content-Type: text/markdown
|
|
54
|
+
|
|
55
|
+
# rest-api-mocker
|
|
56
|
+
|
|
57
|
+
A small Python wrapper for [RestApiMocker](https://github.com/julienlopez/RestApiMockerPythonAPI).
|
|
58
|
+
|
|
59
|
+
## Installation
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
pip install rest-api-mocker
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Until it's published, install from a checkout:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
pip install -e ".[test]"
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Usage
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
from rest_api_mocker import RestApiMocker
|
|
75
|
+
|
|
76
|
+
mocker = RestApiMocker("http://localhost", 8080)
|
|
77
|
+
mocker.add_mock(
|
|
78
|
+
method="GET",
|
|
79
|
+
path_pattern="/users/.*",
|
|
80
|
+
status=200,
|
|
81
|
+
body={"id": 1, "name": "Ada"},
|
|
82
|
+
)
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
`RestApiMocker` can also be used as a context manager so the underlying HTTP
|
|
86
|
+
session is closed for you:
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
with RestApiMocker("http://localhost", 8080) as mocker:
|
|
90
|
+
mocker.add_mock("GET", "/health", 200, {"ok": True})
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### API
|
|
94
|
+
|
|
95
|
+
The client mirrors the server's `/internal` control plane:
|
|
96
|
+
|
|
97
|
+
| Method | Description |
|
|
98
|
+
| --- | --- |
|
|
99
|
+
| `add_mock(method, path_pattern, status, body, conditions=None)` | Register a mock response. |
|
|
100
|
+
| `get_mocks() -> list[MockConfig]` | List all configured mocks. |
|
|
101
|
+
| `delete_mock(index)` | Delete a mock by its 0-based index. |
|
|
102
|
+
| `delete_all_mocks()` | Delete every configured mock. |
|
|
103
|
+
| `delete_mocks_by_pattern(path_pattern)` | Delete all mocks matching a path pattern. |
|
|
104
|
+
| `get_config() -> ServerConfig` | Get the server's public/private ports. |
|
|
105
|
+
| `get_history() -> list[RequestRecord]` | Get the recorded request history. |
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
mocker.add_mock("GET", "/users/.*", 200, {"id": 1})
|
|
109
|
+
|
|
110
|
+
config = mocker.get_config() # ServerConfig(public_port=9090, private_port=80)
|
|
111
|
+
mocks = mocker.get_mocks() # [MockConfig(...)]
|
|
112
|
+
history = mocker.get_history() # [RequestRecord(method=..., path=..., timestamp=...)]
|
|
113
|
+
|
|
114
|
+
mocker.delete_mocks_by_pattern("/users/.*")
|
|
115
|
+
mocker.delete_all_mocks()
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
The `MockConfig`, `ServerConfig` and `RequestRecord` dataclasses are importable
|
|
119
|
+
from the top-level package.
|
|
120
|
+
|
|
121
|
+
### Errors
|
|
122
|
+
|
|
123
|
+
A non-success response from the mocker server raises `MockRequestError`
|
|
124
|
+
(a subclass of `RestApiMockerError`):
|
|
125
|
+
|
|
126
|
+
```python
|
|
127
|
+
from rest_api_mocker import MockRequestError
|
|
128
|
+
|
|
129
|
+
try:
|
|
130
|
+
mocker.add_mock("GET", "/x", 200, {})
|
|
131
|
+
except MockRequestError as exc:
|
|
132
|
+
print(exc.status_code, exc.response_text)
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## Development
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
pip install -e ".[dev]"
|
|
139
|
+
pytest # tests
|
|
140
|
+
ruff check . # lint
|
|
141
|
+
ruff format . # format
|
|
142
|
+
mypy src # type-check
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
CI (`.github/workflows/ci.yml`) runs the tests on Python 3.8–3.13 plus lint,
|
|
146
|
+
format, and type checks on every push and pull request.
|
|
147
|
+
|
|
148
|
+
## Releasing to PyPI
|
|
149
|
+
|
|
150
|
+
Publishing is automated by `.github/workflows/publish.yml`, which runs when you
|
|
151
|
+
publish a GitHub Release. It uses PyPI **Trusted Publishing** (OIDC), so there
|
|
152
|
+
are no API tokens or secrets to store.
|
|
153
|
+
|
|
154
|
+
One-time setup:
|
|
155
|
+
|
|
156
|
+
1. Create an account at <https://pypi.org/account/register/>.
|
|
157
|
+
2. On PyPI, go to your account → *Publishing* → *Add a pending publisher* and
|
|
158
|
+
register this repository as a trusted publisher:
|
|
159
|
+
- PyPI Project Name: `rest-api-mocker`
|
|
160
|
+
- Owner / Repository: your GitHub `owner` / `RestApiMockerPythonAPI`
|
|
161
|
+
- Workflow name: `publish.yml`
|
|
162
|
+
- Environment name: `pypi`
|
|
163
|
+
3. (Recommended) In the GitHub repo settings, create an Environment named
|
|
164
|
+
`pypi` to gate releases.
|
|
165
|
+
|
|
166
|
+
To cut a release:
|
|
167
|
+
|
|
168
|
+
1. Bump `version` in `pyproject.toml` (and `__version__` in
|
|
169
|
+
`src/rest_api_mocker/__init__.py`).
|
|
170
|
+
2. Tag and push, then publish a GitHub Release for that tag. The workflow
|
|
171
|
+
builds the package and uploads it to PyPI.
|
|
172
|
+
|
|
173
|
+
> Tip: to rehearse without affecting the real index, register the same trusted
|
|
174
|
+
> publisher on <https://test.pypi.org> and point the publish step at it with
|
|
175
|
+
> `with: { repository-url: https://test.pypi.org/legacy/ }`.
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# rest-api-mocker
|
|
2
|
+
|
|
3
|
+
A small Python wrapper for [RestApiMocker](https://github.com/julienlopez/RestApiMockerPythonAPI).
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install rest-api-mocker
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Until it's published, install from a checkout:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pip install -e ".[test]"
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Usage
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
from rest_api_mocker import RestApiMocker
|
|
21
|
+
|
|
22
|
+
mocker = RestApiMocker("http://localhost", 8080)
|
|
23
|
+
mocker.add_mock(
|
|
24
|
+
method="GET",
|
|
25
|
+
path_pattern="/users/.*",
|
|
26
|
+
status=200,
|
|
27
|
+
body={"id": 1, "name": "Ada"},
|
|
28
|
+
)
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
`RestApiMocker` can also be used as a context manager so the underlying HTTP
|
|
32
|
+
session is closed for you:
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
with RestApiMocker("http://localhost", 8080) as mocker:
|
|
36
|
+
mocker.add_mock("GET", "/health", 200, {"ok": True})
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### API
|
|
40
|
+
|
|
41
|
+
The client mirrors the server's `/internal` control plane:
|
|
42
|
+
|
|
43
|
+
| Method | Description |
|
|
44
|
+
| --- | --- |
|
|
45
|
+
| `add_mock(method, path_pattern, status, body, conditions=None)` | Register a mock response. |
|
|
46
|
+
| `get_mocks() -> list[MockConfig]` | List all configured mocks. |
|
|
47
|
+
| `delete_mock(index)` | Delete a mock by its 0-based index. |
|
|
48
|
+
| `delete_all_mocks()` | Delete every configured mock. |
|
|
49
|
+
| `delete_mocks_by_pattern(path_pattern)` | Delete all mocks matching a path pattern. |
|
|
50
|
+
| `get_config() -> ServerConfig` | Get the server's public/private ports. |
|
|
51
|
+
| `get_history() -> list[RequestRecord]` | Get the recorded request history. |
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
mocker.add_mock("GET", "/users/.*", 200, {"id": 1})
|
|
55
|
+
|
|
56
|
+
config = mocker.get_config() # ServerConfig(public_port=9090, private_port=80)
|
|
57
|
+
mocks = mocker.get_mocks() # [MockConfig(...)]
|
|
58
|
+
history = mocker.get_history() # [RequestRecord(method=..., path=..., timestamp=...)]
|
|
59
|
+
|
|
60
|
+
mocker.delete_mocks_by_pattern("/users/.*")
|
|
61
|
+
mocker.delete_all_mocks()
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
The `MockConfig`, `ServerConfig` and `RequestRecord` dataclasses are importable
|
|
65
|
+
from the top-level package.
|
|
66
|
+
|
|
67
|
+
### Errors
|
|
68
|
+
|
|
69
|
+
A non-success response from the mocker server raises `MockRequestError`
|
|
70
|
+
(a subclass of `RestApiMockerError`):
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
from rest_api_mocker import MockRequestError
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
mocker.add_mock("GET", "/x", 200, {})
|
|
77
|
+
except MockRequestError as exc:
|
|
78
|
+
print(exc.status_code, exc.response_text)
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Development
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
pip install -e ".[dev]"
|
|
85
|
+
pytest # tests
|
|
86
|
+
ruff check . # lint
|
|
87
|
+
ruff format . # format
|
|
88
|
+
mypy src # type-check
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
CI (`.github/workflows/ci.yml`) runs the tests on Python 3.8–3.13 plus lint,
|
|
92
|
+
format, and type checks on every push and pull request.
|
|
93
|
+
|
|
94
|
+
## Releasing to PyPI
|
|
95
|
+
|
|
96
|
+
Publishing is automated by `.github/workflows/publish.yml`, which runs when you
|
|
97
|
+
publish a GitHub Release. It uses PyPI **Trusted Publishing** (OIDC), so there
|
|
98
|
+
are no API tokens or secrets to store.
|
|
99
|
+
|
|
100
|
+
One-time setup:
|
|
101
|
+
|
|
102
|
+
1. Create an account at <https://pypi.org/account/register/>.
|
|
103
|
+
2. On PyPI, go to your account → *Publishing* → *Add a pending publisher* and
|
|
104
|
+
register this repository as a trusted publisher:
|
|
105
|
+
- PyPI Project Name: `rest-api-mocker`
|
|
106
|
+
- Owner / Repository: your GitHub `owner` / `RestApiMockerPythonAPI`
|
|
107
|
+
- Workflow name: `publish.yml`
|
|
108
|
+
- Environment name: `pypi`
|
|
109
|
+
3. (Recommended) In the GitHub repo settings, create an Environment named
|
|
110
|
+
`pypi` to gate releases.
|
|
111
|
+
|
|
112
|
+
To cut a release:
|
|
113
|
+
|
|
114
|
+
1. Bump `version` in `pyproject.toml` (and `__version__` in
|
|
115
|
+
`src/rest_api_mocker/__init__.py`).
|
|
116
|
+
2. Tag and push, then publish a GitHub Release for that tag. The workflow
|
|
117
|
+
builds the package and uploads it to PyPI.
|
|
118
|
+
|
|
119
|
+
> Tip: to rehearse without affecting the real index, register the same trusted
|
|
120
|
+
> publisher on <https://test.pypi.org> and point the publish step at it with
|
|
121
|
+
> `with: { repository-url: https://test.pypi.org/legacy/ }`.
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "rest-api-mocker"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "A small Python wrapper for RestApiMocker."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
license = { file = "LICENSE" }
|
|
12
|
+
authors = [{ name = "Julien Lopez", email = "julien.lopez51@gmail.com" }]
|
|
13
|
+
keywords = ["mock", "rest", "api", "testing", "http"]
|
|
14
|
+
dependencies = ["requests>=2.20"]
|
|
15
|
+
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 3 - Alpha",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"License :: OSI Approved :: MIT License",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.8",
|
|
22
|
+
"Programming Language :: Python :: 3.9",
|
|
23
|
+
"Programming Language :: Python :: 3.10",
|
|
24
|
+
"Programming Language :: Python :: 3.11",
|
|
25
|
+
"Programming Language :: Python :: 3.12",
|
|
26
|
+
"Programming Language :: Python :: 3.13",
|
|
27
|
+
"Topic :: Software Development :: Testing :: Mocking",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.optional-dependencies]
|
|
31
|
+
test = ["pytest>=7", "responses>=0.23"]
|
|
32
|
+
dev = [
|
|
33
|
+
"pytest>=7",
|
|
34
|
+
"responses>=0.23",
|
|
35
|
+
"ruff>=0.4",
|
|
36
|
+
"mypy>=1.8",
|
|
37
|
+
"types-requests",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
[project.urls]
|
|
41
|
+
Homepage = "https://github.com/julienlopez/RestApiMockerPythonAPI"
|
|
42
|
+
Repository = "https://github.com/julienlopez/RestApiMockerPythonAPI"
|
|
43
|
+
|
|
44
|
+
[tool.hatch.build.targets.wheel]
|
|
45
|
+
packages = ["src/rest_api_mocker"]
|
|
46
|
+
|
|
47
|
+
[tool.pytest.ini_options]
|
|
48
|
+
testpaths = ["tests"]
|
|
49
|
+
|
|
50
|
+
[tool.ruff]
|
|
51
|
+
target-version = "py38"
|
|
52
|
+
line-length = 88
|
|
53
|
+
|
|
54
|
+
[tool.ruff.lint]
|
|
55
|
+
select = ["E", "F", "I", "UP", "B"]
|
|
56
|
+
|
|
57
|
+
[tool.mypy]
|
|
58
|
+
strict = true
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""rest_api_mocker — a small Python wrapper for RestApiMocker."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .client import RestApiMocker
|
|
6
|
+
from .exceptions import MockRequestError, RestApiMockerError
|
|
7
|
+
from .models import MockConfig, RequestRecord, ServerConfig
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"RestApiMocker",
|
|
11
|
+
"RestApiMockerError",
|
|
12
|
+
"MockRequestError",
|
|
13
|
+
"ServerConfig",
|
|
14
|
+
"RequestRecord",
|
|
15
|
+
"MockConfig",
|
|
16
|
+
]
|
|
17
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Client for talking to a RestApiMocker server."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any, Sequence
|
|
7
|
+
|
|
8
|
+
import requests
|
|
9
|
+
|
|
10
|
+
from .exceptions import MockRequestError
|
|
11
|
+
from .models import MockConfig, RequestRecord, ServerConfig
|
|
12
|
+
|
|
13
|
+
DEFAULT_TIMEOUT = 30.0
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class RestApiMocker:
|
|
17
|
+
"""A thin wrapper around the RestApiMocker HTTP control API.
|
|
18
|
+
|
|
19
|
+
All methods talk to the server's ``/internal`` control plane.
|
|
20
|
+
|
|
21
|
+
Example:
|
|
22
|
+
>>> mocker = RestApiMocker("http://localhost", 8080)
|
|
23
|
+
>>> mocker.add_mock(
|
|
24
|
+
... method="GET",
|
|
25
|
+
... path_pattern="/users/.*",
|
|
26
|
+
... status=200,
|
|
27
|
+
... body={"id": 1, "name": "Ada"},
|
|
28
|
+
... )
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
url: str,
|
|
34
|
+
port: int,
|
|
35
|
+
*,
|
|
36
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
37
|
+
session: requests.Session | None = None,
|
|
38
|
+
) -> None:
|
|
39
|
+
"""Create a client.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
url: Base URL of the mocker server, e.g. ``"http://localhost"``.
|
|
43
|
+
port: Port the mocker server's internal API listens on.
|
|
44
|
+
timeout: Per-request timeout in seconds.
|
|
45
|
+
session: Optional pre-configured :class:`requests.Session`. When
|
|
46
|
+
omitted, a new session is created and owned by this instance.
|
|
47
|
+
"""
|
|
48
|
+
self.url = url.rstrip("/")
|
|
49
|
+
self.port = port
|
|
50
|
+
self.timeout = timeout
|
|
51
|
+
self._session = session or requests.Session()
|
|
52
|
+
self._owns_session = session is None
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def base_url(self) -> str:
|
|
56
|
+
"""The fully-qualified base URL, including port."""
|
|
57
|
+
return f"{self.url}:{self.port}"
|
|
58
|
+
|
|
59
|
+
# -- low-level helpers ------------------------------------------------
|
|
60
|
+
|
|
61
|
+
def _request(self, method: str, path: str, **kwargs: Any) -> requests.Response:
|
|
62
|
+
"""Send a request to ``/internal`` and raise on a non-success status.
|
|
63
|
+
|
|
64
|
+
Raises:
|
|
65
|
+
MockRequestError: If the server returns a 4xx/5xx response.
|
|
66
|
+
"""
|
|
67
|
+
response = self._session.request(
|
|
68
|
+
method,
|
|
69
|
+
f"{self.base_url}/internal{path}",
|
|
70
|
+
timeout=self.timeout,
|
|
71
|
+
**kwargs,
|
|
72
|
+
)
|
|
73
|
+
if not response.ok:
|
|
74
|
+
raise MockRequestError(response.status_code, response.text)
|
|
75
|
+
return response
|
|
76
|
+
|
|
77
|
+
# -- mocks ------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
def add_mock(
|
|
80
|
+
self,
|
|
81
|
+
method: str,
|
|
82
|
+
path_pattern: str,
|
|
83
|
+
status: int,
|
|
84
|
+
body: Any,
|
|
85
|
+
conditions: Sequence[dict[str, Any]] | None = None,
|
|
86
|
+
) -> None:
|
|
87
|
+
"""Register a mock response on the server (``POST /internal/mock``).
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
method: HTTP method to match, e.g. ``"GET"``.
|
|
91
|
+
path_pattern: Path (or pattern) the mock should match.
|
|
92
|
+
status: HTTP status code the mock should return.
|
|
93
|
+
body: Response body. Serialized to a JSON string before sending.
|
|
94
|
+
conditions: Optional list of matching conditions.
|
|
95
|
+
|
|
96
|
+
Raises:
|
|
97
|
+
MockRequestError: If the server returns a non-success response.
|
|
98
|
+
"""
|
|
99
|
+
mock_definition = {
|
|
100
|
+
"method": method,
|
|
101
|
+
"path_pattern": path_pattern,
|
|
102
|
+
"status": status,
|
|
103
|
+
"body": json.dumps(body),
|
|
104
|
+
"conditions": list(conditions) if conditions is not None else [],
|
|
105
|
+
}
|
|
106
|
+
self._request("POST", "/mock", json=mock_definition)
|
|
107
|
+
|
|
108
|
+
def get_mocks(self) -> list[MockConfig]:
|
|
109
|
+
"""Return all configured mocks (``GET /internal/mocks``)."""
|
|
110
|
+
response = self._request("GET", "/mocks")
|
|
111
|
+
return [MockConfig.from_dict(item) for item in response.json()]
|
|
112
|
+
|
|
113
|
+
def delete_mock(self, index: int) -> None:
|
|
114
|
+
"""Delete a mock by its 0-based index (``DELETE /internal/mock/<index>``).
|
|
115
|
+
|
|
116
|
+
Raises:
|
|
117
|
+
MockRequestError: If the index is out of range (404) or the request
|
|
118
|
+
otherwise fails.
|
|
119
|
+
"""
|
|
120
|
+
self._request("DELETE", f"/mock/{index}")
|
|
121
|
+
|
|
122
|
+
def delete_all_mocks(self) -> None:
|
|
123
|
+
"""Delete every configured mock (``DELETE /internal/mocks``)."""
|
|
124
|
+
self._request("DELETE", "/mocks")
|
|
125
|
+
|
|
126
|
+
def delete_mocks_by_pattern(self, path_pattern: str) -> None:
|
|
127
|
+
"""Delete all mocks whose path pattern matches ``path_pattern``.
|
|
128
|
+
|
|
129
|
+
Maps to ``DELETE /internal/mocks/by-pattern?path_pattern=...``.
|
|
130
|
+
|
|
131
|
+
Raises:
|
|
132
|
+
MockRequestError: If no mock matches the pattern (404) or the
|
|
133
|
+
request otherwise fails.
|
|
134
|
+
"""
|
|
135
|
+
self._request(
|
|
136
|
+
"DELETE",
|
|
137
|
+
"/mocks/by-pattern",
|
|
138
|
+
params={"path_pattern": path_pattern},
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
# -- introspection ----------------------------------------------------
|
|
142
|
+
|
|
143
|
+
def get_config(self) -> ServerConfig:
|
|
144
|
+
"""Return the server's port configuration (``GET /internal/config``)."""
|
|
145
|
+
response = self._request("GET", "/config")
|
|
146
|
+
return ServerConfig.from_dict(response.json())
|
|
147
|
+
|
|
148
|
+
def get_history(self) -> list[RequestRecord]:
|
|
149
|
+
"""Return the recorded request history (``GET /internal/history``)."""
|
|
150
|
+
response = self._request("GET", "/history")
|
|
151
|
+
return [RequestRecord.from_dict(item) for item in response.json()]
|
|
152
|
+
|
|
153
|
+
# -- lifecycle --------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
def close(self) -> None:
|
|
156
|
+
"""Close the underlying session, if this instance owns it."""
|
|
157
|
+
if self._owns_session:
|
|
158
|
+
self._session.close()
|
|
159
|
+
|
|
160
|
+
def __enter__(self) -> RestApiMocker:
|
|
161
|
+
return self
|
|
162
|
+
|
|
163
|
+
def __exit__(self, *exc_info: object) -> None:
|
|
164
|
+
self.close()
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Exceptions raised by :mod:`rest_api_mocker`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class RestApiMockerError(Exception):
|
|
7
|
+
"""Base class for all errors raised by this library."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class MockRequestError(RestApiMockerError):
|
|
11
|
+
"""Raised when the mocker server returns a non-success response.
|
|
12
|
+
|
|
13
|
+
Attributes:
|
|
14
|
+
status_code: HTTP status code returned by the server.
|
|
15
|
+
response_text: Raw response body returned by the server.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(self, status_code: int, response_text: str) -> None:
|
|
19
|
+
self.status_code = status_code
|
|
20
|
+
self.response_text = response_text
|
|
21
|
+
super().__init__(f"Mocker server returned {status_code}: {response_text}")
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Dataclasses mirroring the RestApiMocker server's JSON types."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class ServerConfig:
|
|
11
|
+
"""Server configuration, as returned by ``GET /internal/config``."""
|
|
12
|
+
|
|
13
|
+
public_port: int
|
|
14
|
+
private_port: int
|
|
15
|
+
|
|
16
|
+
@classmethod
|
|
17
|
+
def from_dict(cls, data: dict[str, Any]) -> ServerConfig:
|
|
18
|
+
return cls(
|
|
19
|
+
public_port=data["public_port"],
|
|
20
|
+
private_port=data["private_port"],
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class RequestRecord:
|
|
26
|
+
"""A single request recorded by the public server.
|
|
27
|
+
|
|
28
|
+
Returned by ``GET /internal/history``.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
method: str
|
|
32
|
+
path: str
|
|
33
|
+
timestamp: int
|
|
34
|
+
|
|
35
|
+
@classmethod
|
|
36
|
+
def from_dict(cls, data: dict[str, Any]) -> RequestRecord:
|
|
37
|
+
return cls(
|
|
38
|
+
method=data["method"],
|
|
39
|
+
path=data["path"],
|
|
40
|
+
timestamp=data["timestamp"],
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class MockConfig:
|
|
46
|
+
"""A configured mock response.
|
|
47
|
+
|
|
48
|
+
Returned by ``GET /internal/mocks``. Note that ``body`` is the JSON string
|
|
49
|
+
the server stores, not a decoded object.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
method: str
|
|
53
|
+
path_pattern: str
|
|
54
|
+
status: int
|
|
55
|
+
body: str
|
|
56
|
+
conditions: list[Any] = field(default_factory=list)
|
|
57
|
+
|
|
58
|
+
@classmethod
|
|
59
|
+
def from_dict(cls, data: dict[str, Any]) -> MockConfig:
|
|
60
|
+
return cls(
|
|
61
|
+
method=data["method"],
|
|
62
|
+
path_pattern=data["path_pattern"],
|
|
63
|
+
status=data["status"],
|
|
64
|
+
body=data.get("body", ""),
|
|
65
|
+
conditions=list(data.get("conditions") or []),
|
|
66
|
+
)
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
import responses
|
|
5
|
+
|
|
6
|
+
from rest_api_mocker import (
|
|
7
|
+
MockConfig,
|
|
8
|
+
MockRequestError,
|
|
9
|
+
RequestRecord,
|
|
10
|
+
RestApiMocker,
|
|
11
|
+
ServerConfig,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
BASE = "http://localhost:8080/internal"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def make_mocker():
|
|
18
|
+
return RestApiMocker("http://localhost", 8080)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@responses.activate
|
|
22
|
+
def test_add_mock_posts_expected_payload():
|
|
23
|
+
responses.add(responses.POST, f"{BASE}/mock", status=200)
|
|
24
|
+
|
|
25
|
+
make_mocker().add_mock(
|
|
26
|
+
method="GET",
|
|
27
|
+
path_pattern="/users/.*",
|
|
28
|
+
status=200,
|
|
29
|
+
body={"id": 1, "name": "Ada"},
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
assert len(responses.calls) == 1
|
|
33
|
+
sent = json.loads(responses.calls[0].request.body)
|
|
34
|
+
assert sent == {
|
|
35
|
+
"method": "GET",
|
|
36
|
+
"path_pattern": "/users/.*",
|
|
37
|
+
"status": 200,
|
|
38
|
+
"body": json.dumps({"id": 1, "name": "Ada"}),
|
|
39
|
+
"conditions": [],
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@responses.activate
|
|
44
|
+
def test_add_mock_raises_on_non_200():
|
|
45
|
+
responses.add(responses.POST, f"{BASE}/mock", status=500, body="boom")
|
|
46
|
+
|
|
47
|
+
with pytest.raises(MockRequestError) as exc_info:
|
|
48
|
+
make_mocker().add_mock("GET", "/x", 200, {})
|
|
49
|
+
|
|
50
|
+
assert exc_info.value.status_code == 500
|
|
51
|
+
assert exc_info.value.response_text == "boom"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_base_url_strips_trailing_slash():
|
|
55
|
+
mocker = RestApiMocker("http://localhost/", 8080)
|
|
56
|
+
assert mocker.base_url == "http://localhost:8080"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@responses.activate
|
|
60
|
+
def test_conditions_default_is_not_shared():
|
|
61
|
+
responses.add(responses.POST, f"{BASE}/mock", status=200)
|
|
62
|
+
mocker = make_mocker()
|
|
63
|
+
|
|
64
|
+
mocker.add_mock("GET", "/a", 200, {})
|
|
65
|
+
mocker.add_mock("GET", "/b", 200, {})
|
|
66
|
+
|
|
67
|
+
first = json.loads(responses.calls[0].request.body)["conditions"]
|
|
68
|
+
second = json.loads(responses.calls[1].request.body)["conditions"]
|
|
69
|
+
assert first == [] and second == []
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@responses.activate
|
|
73
|
+
def test_get_config():
|
|
74
|
+
responses.add(
|
|
75
|
+
responses.GET,
|
|
76
|
+
f"{BASE}/config",
|
|
77
|
+
json={"public_port": 9090, "private_port": 80},
|
|
78
|
+
status=200,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
config = make_mocker().get_config()
|
|
82
|
+
assert config == ServerConfig(public_port=9090, private_port=80)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@responses.activate
|
|
86
|
+
def test_get_history():
|
|
87
|
+
responses.add(
|
|
88
|
+
responses.GET,
|
|
89
|
+
f"{BASE}/history",
|
|
90
|
+
json=[{"method": "GET", "path": "/users/1", "timestamp": 1700000000}],
|
|
91
|
+
status=200,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
history = make_mocker().get_history()
|
|
95
|
+
assert history == [
|
|
96
|
+
RequestRecord(method="GET", path="/users/1", timestamp=1700000000)
|
|
97
|
+
]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@responses.activate
|
|
101
|
+
def test_get_mocks():
|
|
102
|
+
responses.add(
|
|
103
|
+
responses.GET,
|
|
104
|
+
f"{BASE}/mocks",
|
|
105
|
+
json=[
|
|
106
|
+
{
|
|
107
|
+
"method": "GET",
|
|
108
|
+
"path_pattern": "/users/.*",
|
|
109
|
+
"status": 200,
|
|
110
|
+
"body": "{}",
|
|
111
|
+
"conditions": [],
|
|
112
|
+
}
|
|
113
|
+
],
|
|
114
|
+
status=200,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
mocks = make_mocker().get_mocks()
|
|
118
|
+
assert mocks == [
|
|
119
|
+
MockConfig(
|
|
120
|
+
method="GET",
|
|
121
|
+
path_pattern="/users/.*",
|
|
122
|
+
status=200,
|
|
123
|
+
body="{}",
|
|
124
|
+
conditions=[],
|
|
125
|
+
)
|
|
126
|
+
]
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@responses.activate
|
|
130
|
+
def test_delete_mock_by_index():
|
|
131
|
+
responses.add(responses.DELETE, f"{BASE}/mock/0", status=200, body="Mock deleted")
|
|
132
|
+
|
|
133
|
+
make_mocker().delete_mock(0)
|
|
134
|
+
assert responses.calls[0].request.url == f"{BASE}/mock/0"
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@responses.activate
|
|
138
|
+
def test_delete_mock_out_of_range_raises():
|
|
139
|
+
responses.add(responses.DELETE, f"{BASE}/mock/99", status=404, body="out of range")
|
|
140
|
+
|
|
141
|
+
with pytest.raises(MockRequestError) as exc_info:
|
|
142
|
+
make_mocker().delete_mock(99)
|
|
143
|
+
assert exc_info.value.status_code == 404
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@responses.activate
|
|
147
|
+
def test_delete_all_mocks():
|
|
148
|
+
responses.add(
|
|
149
|
+
responses.DELETE, f"{BASE}/mocks", status=200, body="All mocks deleted"
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
make_mocker().delete_all_mocks()
|
|
153
|
+
assert len(responses.calls) == 1
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@responses.activate
|
|
157
|
+
def test_delete_mocks_by_pattern():
|
|
158
|
+
responses.add(
|
|
159
|
+
responses.DELETE,
|
|
160
|
+
f"{BASE}/mocks/by-pattern",
|
|
161
|
+
status=200,
|
|
162
|
+
body="1 mock(s) deleted",
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
make_mocker().delete_mocks_by_pattern("/users/:id")
|
|
166
|
+
|
|
167
|
+
assert responses.calls[0].request.params == {"path_pattern": "/users/:id"}
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@responses.activate
|
|
171
|
+
def test_delete_mocks_by_pattern_no_match_raises():
|
|
172
|
+
responses.add(
|
|
173
|
+
responses.DELETE,
|
|
174
|
+
f"{BASE}/mocks/by-pattern",
|
|
175
|
+
status=404,
|
|
176
|
+
body="No mocks found",
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
with pytest.raises(MockRequestError):
|
|
180
|
+
make_mocker().delete_mocks_by_pattern("/nope")
|