mirror of
https://github.com/kennethreitz/responder.git
synced 2026-06-05 23:00:17 +00:00
Compare commits
401 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 02aa338970 | |||
| 882250bd86 | |||
| 3809eda2f2 | |||
| b32eda70d2 | |||
| f1b2f46a10 | |||
| cf82dac4ad | |||
| a0913e3f63 | |||
| f90955a9b9 | |||
| 3736c9229d | |||
| a802853367 | |||
| 96ca88fe88 | |||
| a57570210a | |||
| 7682e94b35 | |||
| 8bbebe113c | |||
| 7c921f827b | |||
| 4cc055f93a | |||
| e596a8b457 | |||
| fd2da55880 | |||
| 975e9b5643 | |||
| c0036e0474 | |||
| 103816e27a | |||
| b7c1684ab4 | |||
| 16bd6ca266 | |||
| 20bae4712b | |||
| a7aa80c690 | |||
| df89d1d58b | |||
| 477cddd29c | |||
| 9b8cf3a1b1 | |||
| 2871a3c07f | |||
| 13763296dd | |||
| 783b22ab1c | |||
| 109937adf4 | |||
| 63ea9cc4e0 | |||
| ec40a0c4c3 | |||
| 0855d1a378 | |||
| 77fe17d350 | |||
| 0b8a031ccb | |||
| 0678daa880 | |||
| 6761e3bdd8 | |||
| ead213a506 | |||
| 75b5782eee | |||
| a80df809e4 | |||
| 7f3177f662 | |||
| 906cd2fbbf | |||
| 9d0129da56 | |||
| aedcf12d99 | |||
| 86361523e2 | |||
| a7110ef441 | |||
| d3e4968546 | |||
| 03e34d56ab | |||
| b470d10416 | |||
| b8aea89039 | |||
| 4a1e89af1b | |||
| 81fbc94d36 | |||
| 6487671559 | |||
| 838c7f29b5 | |||
| df85a4c214 | |||
| 1bdbea238e | |||
| 97dbef92d9 | |||
| 85c1c0036c | |||
| 0653ee2c6b | |||
| d1db913c7d | |||
| d24b921cdc | |||
| b31b742787 | |||
| d820f0277f | |||
| 7219856177 | |||
| e6d302aabb | |||
| d73243ab60 | |||
| 8101e7d7b0 | |||
| 784c7e72ae | |||
| 93156fd2f1 | |||
| e4f6898498 | |||
| ef330135f9 | |||
| 54cbbdface | |||
| f3c9320837 | |||
| 0529629ac8 | |||
| 22af42ead6 | |||
| bd2efb68e1 | |||
| 37ba3d2efc | |||
| ed8afeaa87 | |||
| ee6efe5aa4 | |||
| 3a8113d8b0 | |||
| 7afce42943 | |||
| 67a6c25256 | |||
| 38dea8311c | |||
| 555e1f7924 | |||
| 8b87f63609 | |||
| f01b1d493f | |||
| a802245bf0 | |||
| 6dbbad158a | |||
| 877fe144b4 | |||
| 70e6bc0466 | |||
| 63f2e833eb | |||
| fb71abe534 | |||
| 8ccb39560e | |||
| e6b880be62 | |||
| d0016ac7c9 | |||
| 05035e0171 | |||
| 78b5bef879 | |||
| a6955b5db5 | |||
| a1a0a1b71e | |||
| 0bdde6d5fe | |||
| cf5447d5bd | |||
| b2dd2c205d | |||
| e52c9277c8 | |||
| 712ec2410d | |||
| dea2ca41d2 | |||
| ca0f32c02b | |||
| f21b296fba | |||
| 3224479b99 | |||
| f95950eedc | |||
| 4467376d0a | |||
| ac65dc5361 | |||
| 4957793c80 | |||
| ff7f4b502d | |||
| 816cb7188b | |||
| 6456d435eb | |||
| 63e338ed6f | |||
| 00211c8f03 | |||
| ebed9fe3aa | |||
| 734b5e7303 | |||
| 1696d501e2 | |||
| e65d2f8c50 | |||
| 9ea705b2ea | |||
| 5a5a811dca | |||
| df7b9419c2 | |||
| 37318f1106 | |||
| 19e9f6ac5d | |||
| 658b51a449 | |||
| 485303c0f2 | |||
| 885d902b7d | |||
| a35f02fb64 | |||
| 28d1f16ad5 | |||
| a04d7c3a9a | |||
| b876f8484c | |||
| 854c6d3d65 | |||
| f9a850a8fe | |||
| e808662fe7 | |||
| 7bbb02126e | |||
| aa101059a7 | |||
| d1f7fe02e4 | |||
| 3e26dc1373 | |||
| 0a9d819555 | |||
| b31dfeefb7 | |||
| fc640ec331 | |||
| 3382723457 | |||
| 1fc0722ad6 | |||
| b21e308357 | |||
| 738105314b | |||
| f3cdc99b29 | |||
| eb70376438 | |||
| dae1a4fa35 | |||
| 2ad351197e | |||
| 3d9235c4bc | |||
| 2cd5596def | |||
| d4191030d9 | |||
| 447630a051 | |||
| f7b53a4895 | |||
| 21896aa171 | |||
| e8a15697d2 | |||
| 0030993631 | |||
| 13ba2f72f5 | |||
| 9f39917895 | |||
| 1b0859fdbb | |||
| acd1561b1b | |||
| 9f2182949d | |||
| 6e5b3a4bf9 | |||
| d2ec323888 | |||
| 8b9645cf2d | |||
| 4ecfef0ddf | |||
| 84fb7bd622 | |||
| 0b261252e1 | |||
| d60b5ee39e | |||
| e2f887ec5f | |||
| 97da6a6694 | |||
| c0e9a6778d | |||
| 5c327a2e0b | |||
| 5ed45634cb | |||
| a50a373e84 | |||
| 86705d0c2f | |||
| b9581444f9 | |||
| 2a60b094b8 | |||
| 1ec567cabf | |||
| 4fd898b239 | |||
| 03d6b72a00 | |||
| 4d0382d580 | |||
| a0dd7481ec | |||
| 1c91480b0c | |||
| 85e5ec0a9a | |||
| 4ac04b0abc | |||
| d7e64a6e39 | |||
| 17d526632e | |||
| 43da481df7 | |||
| 5f5402833b | |||
| d59c4333f2 | |||
| 49114f36ce | |||
| b2039d99f3 | |||
| 94fd86fee0 | |||
| d70fdd3301 | |||
| 05b75efb43 | |||
| be56e92d65 | |||
| 69eb843604 | |||
| 84a7f0e90b | |||
| d1e105a29a | |||
| 9f0a568fa3 | |||
| 05b46cbe34 | |||
| c045586997 | |||
| 8f0707f697 | |||
| 36929b265c | |||
| 734ba64965 | |||
| 148e6742df | |||
| bcb7e8f4f3 | |||
| f678112099 | |||
| 60b0c5f256 | |||
| c8627939de | |||
| 9144f0158a | |||
| d541aca80f | |||
| c73b2b8d34 | |||
| e2493b489d | |||
| 8dee28ac7c | |||
| cdd3885a0c | |||
| 1a28d528d0 | |||
| 3ba12b8cee | |||
| 5a29ab6917 | |||
| 694144a0c8 | |||
| 8bed8e8741 | |||
| a81a348bce | |||
| fd9e8c5cbc | |||
| 8030b1919d | |||
| 72c789fdd7 | |||
| 1113a9aa0d | |||
| a5532614a2 | |||
| 122023fb70 | |||
| b8fa923ec9 | |||
| ae06b3e01a | |||
| 5599ec2809 | |||
| e795cbddb6 | |||
| 0cb087c37b | |||
| 983cbcc711 | |||
| 6d154b0c78 | |||
| f3f36e28c4 | |||
| fdf4797726 | |||
| 67d8a3be98 | |||
| 4001a60f6c | |||
| d94db41271 | |||
| 8abb78bb58 | |||
| a80db99aa3 | |||
| 69a300f21a | |||
| 1b024b8092 | |||
| a622689597 | |||
| 9943e66c49 | |||
| 7233c08281 | |||
| 0845d92fda | |||
| 1cc02e5a83 | |||
| aa4cd7a144 | |||
| b42ae0dfd7 | |||
| a6bd179726 | |||
| aac7b5117b | |||
| 8e8e99ed2e | |||
| 078ac23b20 | |||
| 8e61df6b6a | |||
| cf7fb56653 | |||
| da20d13c49 | |||
| 7a250aa8fc | |||
| af28ecb82d | |||
| 39a0e52a2a | |||
| a4f5a111c7 | |||
| c65e585493 | |||
| c9e94561aa | |||
| 1daa4c202b | |||
| 83ff361672 | |||
| 2c686f107d | |||
| ac8ec3d5ef | |||
| 21e70ef913 | |||
| c4f5b0e7c2 | |||
| 45d6c1389d | |||
| 0c9bc5a3af | |||
| 5b67d5a04a | |||
| f3396a5573 | |||
| e9f48788a3 | |||
| 6993a1ea46 | |||
| 8bcfb4585b | |||
| db45251f7f | |||
| 5582667b4f | |||
| 2c898aaf23 | |||
| e0999ffcdd | |||
| 03811768bb | |||
| fbef577c9f | |||
| 9434510ce9 | |||
| 354130c151 | |||
| 3e3cba016a | |||
| f75e120bef | |||
| 1d0294e430 | |||
| f786dd8254 | |||
| cd9d09fd53 | |||
| 7471bbcd4e | |||
| 43b04eccbd | |||
| 6a5d0b5e9f | |||
| 359d366de4 | |||
| 556d9f3a7b | |||
| 2cab2dcec0 | |||
| 99d4e78dc9 | |||
| 9aa99869ae | |||
| 08e0d87347 | |||
| 3f9e4057d3 | |||
| a29e40353c | |||
| 778cb2dd0f | |||
| f7d5514b94 | |||
| 954637f7b3 | |||
| 1ab46104c8 | |||
| 815776d473 | |||
| 8db1a7be90 | |||
| 7b11fa24dd | |||
| 1f0f2318d5 | |||
| 029b3e2a52 | |||
| 4fff823def | |||
| cab78275f4 | |||
| 5f60e4fedb | |||
| 96971a33a7 | |||
| 9a7409f521 | |||
| 80aa7e305b | |||
| 27d513cb01 | |||
| 9bf5cc8c03 | |||
| 7994b210cd | |||
| 46555bbe3f | |||
| 4d15dbc465 | |||
| 855d3c4320 | |||
| 4564862acc | |||
| 176dd70073 | |||
| a5e6f0c196 | |||
| 083bb5a96c | |||
| 04522281be | |||
| 0e8bb49b59 | |||
| 9abf6eea16 | |||
| 1d7a04ce7b | |||
| 49fb5792c3 | |||
| 5eebba09c5 | |||
| b86974688e | |||
| 74afe2ed13 | |||
| ed53a0b624 | |||
| 23e15d6459 | |||
| 71ea19d1c1 | |||
| fa621d076d | |||
| 4902f1328a | |||
| 2ee8ff484d | |||
| c872fe3c78 | |||
| a08b275463 | |||
| 9717208dd4 | |||
| c9a233f5e5 | |||
| 7389350ff9 | |||
| f46ac08cff | |||
| 296d5e7974 | |||
| fe0bea686c | |||
| 838d172512 | |||
| 2c02c51c37 | |||
| 67a4cbca2c | |||
| a2f97e727f | |||
| 462506113e | |||
| 5f2a72203f | |||
| d6febe2d02 | |||
| c2bd1e989a | |||
| f886c2c050 | |||
| ae770e603a | |||
| 7b79472d65 | |||
| 090a3a571b | |||
| f9d55fc425 | |||
| 4f57e8a5d1 | |||
| 1e6c9d935a | |||
| 00cfde169b | |||
| 02733ac718 | |||
| 55b55e62da | |||
| 5fccedd4c4 | |||
| b9ad78ec79 | |||
| 64ac6bcd1f | |||
| 45e4d80c4d | |||
| a5b1652d15 | |||
| f954eb7d88 | |||
| 53216813e5 | |||
| 1618203930 | |||
| 237a2ed426 | |||
| d33289503a | |||
| f5ff4c9725 | |||
| 62f932dcfc | |||
| b66112d0ca | |||
| b98354e63a | |||
| 94b3625718 | |||
| f7ee720281 | |||
| 4ab523bf01 | |||
| 2d4f1bfd02 | |||
| 38426c9143 | |||
| bdf151e0a7 | |||
| 9768b7888d | |||
| 740a48566f | |||
| 475cd1a106 | |||
| 38e7c39d69 | |||
| 774db6bead | |||
| a1a3e0412a | |||
| 0b39c89e60 | |||
| 53be4d8954 | |||
| 6b93125ff2 | |||
| 43faef4569 |
@@ -0,0 +1,23 @@
|
||||
name: Lint
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: 3.7
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install pipenv
|
||||
pipenv install --dev --system
|
||||
|
||||
- name: Lint
|
||||
run: pre-commit run --all-files --show-diff-on-failure
|
||||
@@ -0,0 +1,30 @@
|
||||
name: Test
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: [3.6, 3.7, 3.8, 3.9]
|
||||
os: [ubuntu-latest, macos-latest]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install pipenv
|
||||
pipenv install --dev --system
|
||||
|
||||
- name: Tests
|
||||
run: |
|
||||
python --version
|
||||
pytest
|
||||
@@ -0,0 +1,13 @@
|
||||
repos:
|
||||
- repo: https://github.com/psf/black
|
||||
rev: 20.8b1
|
||||
hooks:
|
||||
- id: black
|
||||
types: [python]
|
||||
|
||||
- repo: https://github.com/pre-commit/mirrors-prettier
|
||||
rev: v2.2.1
|
||||
hooks:
|
||||
- id: prettier
|
||||
args: [--prose-wrap=always, --print-width=88]
|
||||
exclude: ^docs/source/_static/
|
||||
-12
@@ -1,12 +0,0 @@
|
||||
language: python
|
||||
python:
|
||||
- "3.6"
|
||||
|
||||
# command to install dependencies
|
||||
install:
|
||||
- "pip install pipenv --upgrade-strategy=only-if-needed"
|
||||
- "pipenv install --dev"
|
||||
|
||||
# command to run the dependencies
|
||||
script:
|
||||
- "pytest"
|
||||
+339
-32
@@ -1,74 +1,381 @@
|
||||
# v0.3.1
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and
|
||||
this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [v2.0.5] - 2019-12-15
|
||||
|
||||
### Added
|
||||
|
||||
- Update requirements to support python 3.8
|
||||
|
||||
## [v2.0.4] - 2019-11-19
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix static app resolving
|
||||
|
||||
## [v2.0.3] - 2019-09-20
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix template conflicts
|
||||
|
||||
## [v2.0.2] - 2019-09-20
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix template conflicts
|
||||
|
||||
## [v2.0.1] - 2019-09-20
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix template import
|
||||
|
||||
## [v2.0.0] - 2019-09-19
|
||||
|
||||
### Changed
|
||||
|
||||
- Refactor Router and Schema
|
||||
|
||||
## [v1.3.2] - 2019-08-15
|
||||
|
||||
### Added
|
||||
|
||||
- ASGI 3 support
|
||||
- CI tests for python 3.8-dev
|
||||
- Now requests have `state` a mapping object
|
||||
|
||||
### Deprecated
|
||||
|
||||
- ASGI 2
|
||||
|
||||
## [v1.3.1] - 2019-04-28
|
||||
|
||||
### Added
|
||||
|
||||
- Route params Converters
|
||||
- Add search for documentation pages
|
||||
|
||||
### Changed
|
||||
|
||||
- Bump dependencies
|
||||
|
||||
## [v1.3.0] - 2019-02-22
|
||||
|
||||
### Fixed
|
||||
|
||||
- Versioning issue
|
||||
- Multiple cookies.
|
||||
- Whitenoise returns not found.
|
||||
- Other bugfixes.
|
||||
|
||||
### Added
|
||||
|
||||
- Stream support via `resp.stream`.
|
||||
- Cookie directives via `resp.set_cookie`.
|
||||
- Add `resp.html` to send HTML.
|
||||
- Other improvements.
|
||||
|
||||
## [v1.1.3] - 2019-01-12
|
||||
|
||||
### Changed
|
||||
|
||||
- Refactor `_route_for`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Resolve startup/shutdwown events
|
||||
|
||||
## [v1.2.0] - 2018-12-29
|
||||
|
||||
### Added
|
||||
|
||||
- Documentations
|
||||
|
||||
### Changed
|
||||
|
||||
- Use Starlette's LifeSpan middleware
|
||||
- Update denpendencies
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix route.is_class_based
|
||||
- Fix test_500
|
||||
- Typos
|
||||
|
||||
## [v1.1.2] - 2018-11-11
|
||||
|
||||
### Fixed
|
||||
|
||||
- Minor fixes for Open API
|
||||
- Typos
|
||||
|
||||
## [v1.1.1] - 2018-10-29
|
||||
|
||||
### Changed
|
||||
|
||||
- Run sync views in a threadpoolexecutor.
|
||||
|
||||
## [v1.1.0] - 2018-10-27
|
||||
|
||||
### Added
|
||||
|
||||
- Support for `before_request`.
|
||||
|
||||
## [v1.0.5]- 2018-10-27
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix sessions.
|
||||
|
||||
## [v1.0.4] - 2018-10-27
|
||||
|
||||
### Fixed
|
||||
|
||||
- Potential bufix for cookies.
|
||||
|
||||
## [v1.0.3] - 2018-10-27
|
||||
|
||||
### Fixed
|
||||
|
||||
- Bugfix for redirects.
|
||||
|
||||
## [v1.0.2] - 2018-10-27
|
||||
|
||||
### Changed
|
||||
|
||||
- Improvement for static file hosting.
|
||||
|
||||
## [v1.0.1] - 2018-10-26
|
||||
|
||||
### Changed
|
||||
|
||||
- Improve cors configuration settings.
|
||||
|
||||
## [v1.0.0] - 2018-10-26
|
||||
|
||||
### Changed
|
||||
|
||||
- Move GraphQL support into a built-in plugin.
|
||||
|
||||
## [v0.3.3] - 2018-10-25
|
||||
|
||||
### Added
|
||||
|
||||
- CORS support
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved exceptions.
|
||||
|
||||
## [v0.3.2] - 2018-10-25
|
||||
|
||||
### Changed
|
||||
|
||||
- Subtle improvements.
|
||||
|
||||
## [v0.3.1] - 2018-10-24
|
||||
|
||||
### Fixed
|
||||
|
||||
- Packaging fix.
|
||||
|
||||
# v0.3.0
|
||||
## [v0.3.0] - 2018-10-24
|
||||
|
||||
### Changed
|
||||
|
||||
- Interactive Documentation endpoint.
|
||||
- Minor improvements.
|
||||
|
||||
# v0.2.3
|
||||
## [v0.2.3] - 2018-10-24
|
||||
|
||||
### Changed
|
||||
|
||||
- Overall improvements.
|
||||
|
||||
# v0.2.2
|
||||
## [v0.2.2] - 2018-10-23
|
||||
|
||||
### Added
|
||||
|
||||
- Show traceback info when background tasks raise exceptions.
|
||||
|
||||
# v0.2.1
|
||||
## [v0.2.1] - 2018-10-23
|
||||
|
||||
### Added
|
||||
|
||||
- api.requests.
|
||||
|
||||
# v0.2.0
|
||||
## [v0.2.0] - 2018-10-22
|
||||
|
||||
### Added
|
||||
|
||||
- WebSocket support.
|
||||
|
||||
# v0.1.6
|
||||
## [v0.1.6] - 2018-10-20
|
||||
|
||||
### Added
|
||||
|
||||
- 500 support.
|
||||
|
||||
# v0.1.5
|
||||
- Improvements to sequential media reading.
|
||||
- File upload support.
|
||||
## [v0.1.5] - 2018-10-20
|
||||
|
||||
### Added
|
||||
|
||||
- File upload support
|
||||
|
||||
### Changed
|
||||
|
||||
- Improvements to sequential media reading.
|
||||
|
||||
## [v0.1.4] - 2018-10-19
|
||||
|
||||
### Fixed
|
||||
|
||||
# v0.1.4
|
||||
- Stability.
|
||||
|
||||
# v0.1.3
|
||||
## [v0.1.3] - 2018-10-18
|
||||
|
||||
### Added
|
||||
|
||||
- Sessions support.
|
||||
|
||||
# v0.1.2
|
||||
## [v0.1.2] - 2018-10-18
|
||||
|
||||
### Added
|
||||
|
||||
- Cookies support.
|
||||
|
||||
# v0.1.1
|
||||
## [v0.1.1] - 2018-10-17
|
||||
|
||||
### Changed
|
||||
|
||||
- Default routes.
|
||||
|
||||
# v0.1.0
|
||||
## [v0.1.0] - 2018-10-17
|
||||
|
||||
### Added
|
||||
|
||||
- Prototype of static application support.
|
||||
|
||||
# v0.0.10
|
||||
## [v0.0.10] - 2018-10-17
|
||||
|
||||
### Fixed
|
||||
|
||||
- Bugfix for async class-based views.
|
||||
|
||||
# v0.0.9
|
||||
## [v0.0.9] - 2018-10-17
|
||||
|
||||
### Fixed
|
||||
|
||||
- Bugfix for async class-based views.
|
||||
|
||||
# v0.0.8
|
||||
## [v0.0.8] - 2018-10-17
|
||||
|
||||
### Added
|
||||
|
||||
- GraphiQL Support.
|
||||
|
||||
### Changed
|
||||
|
||||
- Improvement to route selection.
|
||||
|
||||
# v0.0.7
|
||||
- Immutable Request object.
|
||||
## [v0.0.7] - 2018-10-16
|
||||
|
||||
# v0.0.6:
|
||||
- Ability to mount WSGI apps.
|
||||
- Supply content-type when serving up the schema.
|
||||
### Changed
|
||||
|
||||
# v0.0.5:
|
||||
- OpenAPI Schema support.
|
||||
- Safe load/dump yaml.
|
||||
- Immutable Request object.
|
||||
|
||||
# v0.0.4:
|
||||
- Asynchronous support for data uploads.
|
||||
- Bug fixes.
|
||||
## [v0.0.6] - 2018-10-16
|
||||
|
||||
### Added
|
||||
|
||||
- Ability to mount WSGI apps.
|
||||
- Supply content-type when serving up the schema.
|
||||
|
||||
## [v0.0.5] - 2018-10-15
|
||||
|
||||
### Added
|
||||
|
||||
- OpenAPI Schema support.
|
||||
- Safe load/dump yaml.
|
||||
|
||||
## [v0.0.4] - 2018-10-15
|
||||
|
||||
### Added
|
||||
|
||||
- Asynchronous support for data uploads.
|
||||
|
||||
### Fixed
|
||||
|
||||
# v0.0.3:
|
||||
- Bug fixes.
|
||||
|
||||
# v0.0.2
|
||||
## [v0.0.3] - 2018-10-13
|
||||
|
||||
### Fixed
|
||||
|
||||
- Bug fixes.
|
||||
|
||||
## [v0.0.2] - 2018-10-13
|
||||
|
||||
### Changed
|
||||
|
||||
- Switch to ASGI/Starlette.
|
||||
|
||||
# v0.0.1
|
||||
## [v0.0.1] - 2018-10-12
|
||||
|
||||
### Added
|
||||
|
||||
- Conception!
|
||||
|
||||
[unreleased]: https://github.com/taoufik07/responder/compare/v2.0.5..HEAD
|
||||
[v2.0.5]: https://github.com/taoufik07/responder/compare/v2.0.4..v2.0.5
|
||||
[v2.0.4]: https://github.com/taoufik07/responder/compare/v2.0.3..v2.0.4
|
||||
[v2.0.3]: https://github.com/taoufik07/responder/compare/v2.0.2..v2.0.3
|
||||
[v2.0.2]: https://github.com/taoufik07/responder/compare/v2.0.1..v2.0.2
|
||||
[v2.0.1]: https://github.com/taoufik07/responder/compare/v2.0.0..v2.0.1
|
||||
[v2.0.0]: https://github.com/taoufik07/responder/compare/v1.3.2..v2.0.0
|
||||
[v1.3.2]: https://github.com/taoufik07/responder/compare/v1.3.1..v1.3.2
|
||||
[v1.3.1]: https://github.com/taoufik07/responder/compare/v1.3.0..v1.3.1
|
||||
[v1.3.0]: https://github.com/taoufik07/responder/compare/v1.2.0..v1.3.0
|
||||
[v1.2.0]: https://github.com/taoufik07/responder/compare/v1.1.3..v1.2.0
|
||||
[v1.1.3]: https://github.com/taoufik07/responder/compare/v1.1.2..v1.1.3
|
||||
[v1.1.2]: https://github.com/taoufik07/responder/compare/v1.1.1..v1.1.2
|
||||
[v1.1.1]: https://github.com/taoufik07/responder/compare/v1.1.0..v1.1.1
|
||||
[v1.1.0]: https://github.com/taoufik07/responder/compare/v1.0.5..v1.1.0
|
||||
[v1.0.5]: https://github.com/taoufik07/responder/compare/v1.0.4..v1.0.5
|
||||
[v1.0.4]: https://github.com/taoufik07/responder/compare/v1.0.3..v1.0.4
|
||||
[v1.0.3]: https://github.com/taoufik07/responder/compare/v1.0.2..v1.0.3
|
||||
[v1.0.2]: https://github.com/taoufik07/responder/compare/v1.0.1..v1.0.2
|
||||
[v1.0.1]: https://github.com/taoufik07/responder/compare/v1.0.0..v1.0.1
|
||||
[v1.0.0]: https://github.com/taoufik07/responder/compare/v0.3.3..v1.0.0
|
||||
[v0.3.3]: https://github.com/taoufik07/responder/compare/v0.3.2..v0.3.3
|
||||
[v0.3.2]: https://github.com/taoufik07/responder/compare/v0.3.1..v0.3.2
|
||||
[v0.3.1]: https://github.com/taoufik07/responder/compare/v0.3.0..v0.3.1
|
||||
[v0.3.0]: https://github.com/taoufik07/responder/compare/v0.2.3..v0.3.0
|
||||
[v0.2.3]: https://github.com/taoufik07/responder/compare/v0.2.2..v0.2.3
|
||||
[v0.2.2]: https://github.com/taoufik07/responder/compare/v0.2.1..v0.2.2
|
||||
[v0.2.1]: https://github.com/taoufik07/responder/compare/v0.2.0..v0.2.1
|
||||
[v0.2.0]: https://github.com/taoufik07/responder/compare/v0.1.6..v0.2.0
|
||||
[v0.1.6]: https://github.com/taoufik07/responder/compare/v0.1.5..v0.1.6
|
||||
[v0.1.5]: https://github.com/taoufik07/responder/compare/v0.1.4..v0.1.5
|
||||
[v0.1.4]: https://github.com/taoufik07/responder/compare/v0.1.3..v0.1.4
|
||||
[v0.1.3]: https://github.com/taoufik07/responder/compare/v0.1.2..v0.1.3
|
||||
[v0.1.2]: https://github.com/taoufik07/responder/compare/v0.1.1..v0.1.2
|
||||
[v0.1.1]: https://github.com/taoufik07/responder/compare/v0.1.0..v0.1.1
|
||||
[v0.1.0]: https://github.com/taoufik07/responder/compare/v0.0.10..v0.1.0
|
||||
[v0.0.10]: https://github.com/taoufik07/responder/compare/v0.0.9..v0.0.10
|
||||
[v0.0.9]: https://github.com/taoufik07/responder/compare/v0.0.8..v0.0.9
|
||||
[v0.0.8]: https://github.com/taoufik07/responder/compare/v0.0.7..v0.0.8
|
||||
[v0.0.7]: https://github.com/taoufik07/responder/compare/v0.0.6..v0.0.7
|
||||
[v0.0.6]: https://github.com/taoufik07/responder/compare/v0.0.5..v0.0.6
|
||||
[v0.0.5]: https://github.com/taoufik07/responder/compare/v0.0.4..v0.0.5
|
||||
[v0.0.4]: https://github.com/taoufik07/responder/compare/v0.0.3..v0.0.4
|
||||
[v0.0.3]: https://github.com/taoufik07/responder/compare/v0.0.2..v0.0.3
|
||||
[v0.0.2]: https://github.com/taoufik07/responder/compare/v0.0.1..v0.0.2
|
||||
[v0.0.1]: https://github.com/taoufik07/responder/compare/v0.0.0..v0.0.1
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
include LICENSE
|
||||
@@ -5,19 +5,17 @@ name = "pypi"
|
||||
|
||||
[packages]
|
||||
responder = {editable = true, path = "."}
|
||||
pre-commit = "*"
|
||||
|
||||
[dev-packages]
|
||||
pytest = "*"
|
||||
"flake8" = "*"
|
||||
black = "*"
|
||||
black = "==20.8b1"
|
||||
twine = "*"
|
||||
flask = "*"
|
||||
sphinx = "*"
|
||||
marshmallow = "*"
|
||||
pytest-cov = "*"
|
||||
|
||||
[requires]
|
||||
python_version = "3.7"
|
||||
|
||||
[pipenv]
|
||||
allow_prereleases = true
|
||||
|
||||
Generated
+580
-292
File diff suppressed because it is too large
Load Diff
@@ -1,170 +1,85 @@
|
||||
# Responder: a familiar HTTP Service Framework for Python
|
||||
|
||||
[](https://travis-ci.org/kennethreitz/responder)
|
||||
[](https://travis-ci.org/taoufik07/responder)
|
||||
[](https://responder.readthedocs.io/en/latest/)
|
||||
[](https://pypi.org/project/responder/)
|
||||
[](https://pypi.org/project/responder/)
|
||||
[](https://pypi.org/project/responder/)
|
||||
[](https://github.com/kennethreitz/responder/graphs/contributors)
|
||||
[](https://github.com/taoufik07/responder/graphs/contributors)
|
||||
|
||||
[](http://python-responder.org/)
|
||||
[](https://responder.readthedocs.io)
|
||||
|
||||
The Python world certainly doesn't need more web frameworks. But, it does need more creativity, so I thought I'd spread some [Hacktoberfest](https://hacktoberfest.digitalocean.com/) spirit around, bring some of my ideas to the table, and see what I could come up with.
|
||||
|
||||
```python
|
||||
import responder
|
||||
|
||||
api = responder.API()
|
||||
|
||||
@api.route("/{greeting}")
|
||||
async def greet_world(req, resp, *, greeting):
|
||||
resp.text = f"{greeting}, world!"
|
||||
|
||||
if __name__ == '__main__':
|
||||
api.run()
|
||||
```
|
||||
|
||||
That `async` declaration is optional. [View documentation](http://python-responder.org).
|
||||
|
||||
This gets you a ASGI app, with a production static files server pre-installed, jinja2 templating (without additional imports), and a production webserver based on uvloop, serving up requests with gzip compression automatically.
|
||||
Powered by [Starlette](https://www.starlette.io/). That `async` declaration is optional.
|
||||
[View documentation](https://responder.readthedocs.io).
|
||||
|
||||
This gets you a ASGI app, with a production static files server pre-installed, jinja2
|
||||
templating (without additional imports), and a production webserver based on uvloop,
|
||||
serving up requests with gzip compression automatically.
|
||||
|
||||
## Testimonials
|
||||
|
||||
> "Pleasantly very taken with python-responder. [@kennethreitz](https://twitter.com/kennethreitz) at his absolute best." —Rudraksh M.K.
|
||||
> "Pleasantly very taken with python-responder.
|
||||
> [@kennethreitz](https://twitter.com/kennethreitz) at his absolute best." —Rudraksh
|
||||
> M.K.
|
||||
|
||||
> "ASGI is going to enable all sorts of new high-performance web services. It's awesome to see Responder starting to take advantage of that." — Tom Christie author of [Django REST Framework](https://www.django-rest-framework.org/)
|
||||
> "ASGI is going to enable all sorts of new high-performance web services. It's awesome
|
||||
> to see Responder starting to take advantage of that." — Tom Christie author of
|
||||
> [Django REST Framework](https://www.django-rest-framework.org/)
|
||||
|
||||
> "I love that you are exploring new patterns. Go go go!" — Danny Greenfield, author of [Two Scoops of Django]()
|
||||
|
||||
> "Love what I have seen while it's in progress! Many features of Responder are from my wishlist for Flask, and it's even faster and even easier than Flask!" — Luna C.
|
||||
> "I love that you are exploring new patterns. Go go go!" — Danny Greenfield, author of
|
||||
> [Two Scoops of Django]()
|
||||
|
||||
## More Examples
|
||||
|
||||
Class-based views (and setting some headers and stuff):
|
||||
|
||||
```python
|
||||
@api.route("/{greeting}")
|
||||
class GreetingResource:
|
||||
def on_request(req, resp, *, greeting): # or on_get...
|
||||
resp.text = f"{greeting}, world!"
|
||||
resp.headers.update({'X-Life': '42'})
|
||||
resp.status_code = api.status_codes.HTTP_416
|
||||
```
|
||||
|
||||
Render a template, with arguments:
|
||||
|
||||
```python
|
||||
@api.route("/{greeting}")
|
||||
def greet_world(req, resp, *, greeting):
|
||||
resp.content = api.template("index.html", greeting=greeting)
|
||||
```
|
||||
|
||||
The `api` instance is available as an object during template rendering.
|
||||
|
||||
Here, you can spawn off a background thread to run any function, out-of-request:
|
||||
|
||||
```python
|
||||
@api.route("/")
|
||||
def hello(req, resp):
|
||||
|
||||
@api.background.task
|
||||
def sleep(s=10):
|
||||
time.sleep(s)
|
||||
print("slept!")
|
||||
|
||||
sleep()
|
||||
resp.content = "processing"
|
||||
```
|
||||
|
||||
And even serve a GraphQL API:
|
||||
|
||||
```python
|
||||
import graphene
|
||||
|
||||
class Query(graphene.ObjectType):
|
||||
hello = graphene.String(name=graphene.String(default_value="stranger"))
|
||||
|
||||
def resolve_hello(self, info, name):
|
||||
return f"Hello {name}"
|
||||
|
||||
api.add_route("/graph", graphene.Schema(query=Query))
|
||||
```
|
||||
|
||||
We can then send a query to our service:
|
||||
|
||||
```pycon
|
||||
>>> requests = api.session()
|
||||
>>> r = requests.get("http://;/graph", params={"query": "{ hello }"})
|
||||
>>> r.json()
|
||||
{'data': {'hello': 'Hello stranger'}}
|
||||
```
|
||||
|
||||
Or, request YAML back:
|
||||
|
||||
```pycon
|
||||
>>> r = requests.get("http://;/graph", params={"query": "{ hello(name:\"john\") }"}, headers={"Accept": "application/x-yaml"})
|
||||
>>> print(r.text)
|
||||
data: {hello: Hello john}
|
||||
|
||||
```
|
||||
|
||||
Want HSTS?
|
||||
|
||||
```
|
||||
api = responder.API(enable_hsts=True)
|
||||
```
|
||||
|
||||
Boom.
|
||||
|
||||
See
|
||||
[the documentation's feature tour](https://responder.readthedocs.io/en/latest/tour.html)
|
||||
for more details on features available in Responder.
|
||||
|
||||
# Installing Responder
|
||||
|
||||
Install the latest release:
|
||||
Install the stable release:
|
||||
|
||||
|
||||
$ pipenv install responder --pre
|
||||
$ pipenv install responder
|
||||
✨🍰✨
|
||||
|
||||
|
||||
Or, install from the development branch:
|
||||
|
||||
$ pipenv install -e git+https://github.com/kennethreitz/responder.git#egg=responder
|
||||
$ pipenv install -e git+https://github.com/taoufik07/responder.git#egg=responder
|
||||
|
||||
Only **Python 3.6+** is supported.
|
||||
|
||||
|
||||
# The Basic Idea
|
||||
|
||||
The primary concept here is to bring the niceties that are brought forth from both Flask and Falcon and unify them into a single framework, along with some new ideas I have. I also wanted to take some of the API primitives that are instilled in the Requests library and put them into a web framework. So, you'll find a lot of parallels here with Requests.
|
||||
The primary concept here is to bring the niceties that are brought forth from both Flask
|
||||
and Falcon and unify them into a single framework, along with some new ideas I have. I
|
||||
also wanted to take some of the API primitives that are instilled in the Requests
|
||||
library and put them into a web framework. So, you'll find a lot of parallels here with
|
||||
Requests.
|
||||
|
||||
- Setting `resp.text` sends back unicode, while setting `resp.content` sends back bytes.
|
||||
- Setting `resp.media` sends back JSON/YAML (`.text`/`.content` override this).
|
||||
- Setting `resp.content` sends back bytes.
|
||||
- Setting `resp.text` sends back unicode, while setting `resp.html` sends back HTML.
|
||||
- Setting `resp.media` sends back JSON/YAML (`.text`/`.html`/`.content` override this).
|
||||
- Case-insensitive `req.headers` dict (from Requests directly).
|
||||
- `resp.status_code`, `req.method`, `req.url`, and other familiar friends.
|
||||
|
||||
## Ideas
|
||||
|
||||
- Flask-style route expression, with new capabilities -- all while using Python 3.6+'s new f-string syntax.
|
||||
- I love Falcon's "every request and response is passed into to each view and mutated" methodology, especially `response.media`, and have used it here. In addition to supporting JSON, I have decided to support YAML as well, as Kubernetes is slowly taking over the world, and it uses YAML for all the things. Content-negotiation and all that.
|
||||
- Flask-style route expression, with new capabilities -- all while using Python 3.6+'s
|
||||
new f-string syntax.
|
||||
- I love Falcon's "every request and response is passed into to each view and mutated"
|
||||
methodology, especially `response.media`, and have used it here. In addition to
|
||||
supporting JSON, I have decided to support YAML as well, as Kubernetes is slowly
|
||||
taking over the world, and it uses YAML for all the things. Content-negotiation and
|
||||
all that.
|
||||
- **A built in testing client that uses the actual Requests you know and love**.
|
||||
- The ability to mount other WSGI apps easily.
|
||||
- Automatic gzipped-responses.
|
||||
- In addition to Falcon's `on_get`, `on_post`, etc methods, Responder features an `on_request` method, which gets called on every type of request, much like Requests.
|
||||
- In addition to Falcon's `on_get`, `on_post`, etc methods, Responder features an
|
||||
`on_request` method, which gets called on every type of request, much like Requests.
|
||||
- A production static file server is built-in.
|
||||
- Uvicorn built-in as a production web server. I would have chosen Gunicorn, but it doesn't run on Windows. Plus, Uvicorn serves well to protect against slowloris attacks, making nginx unnecessary in production.
|
||||
- GraphQL support, via Graphene. The goal here is to have any GraphQL query exposable at any route, magically.
|
||||
|
||||
## Future Ideas
|
||||
|
||||
- Cookie-based sessions are currently an afterthought, as this is an API framework, but websites are APIs too.
|
||||
- If frontend websites are supported, provide an official way to run webpack.
|
||||
|
||||
# The Goal
|
||||
|
||||
The primary goal here is to learn, not to get adoption. Though, who knows how these things will pan out.
|
||||
|
||||
|
||||
----------
|
||||
|
||||
[](https://hacktoberfest.digitalocean.com/)
|
||||
- Uvicorn built-in as a production web server. I would have chosen Gunicorn, but it
|
||||
doesn't run on Windows. Plus, Uvicorn serves well to protect against slowloris
|
||||
attacks, making nginx unnecessary in production.
|
||||
- GraphQL support, via Graphene. The goal here is to have any GraphQL query exposable at
|
||||
any route, magically.
|
||||
- Provide an official way to run webpack.
|
||||
|
||||
@@ -4,7 +4,7 @@ atomicwrites==1.2.1
|
||||
attrs==18.2.0
|
||||
babel==2.6.0
|
||||
black==18.9b0
|
||||
bleach==3.0.2
|
||||
bleach==3.1.4
|
||||
certifi==2018.8.24
|
||||
cffi==1.11.5
|
||||
chardet==3.0.4
|
||||
@@ -45,4 +45,4 @@ tqdm==4.26.0
|
||||
twine==1.12.1
|
||||
urllib3==1.23
|
||||
webencodings==0.5.1
|
||||
werkzeug==0.14.1
|
||||
werkzeug==0.15.5
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* Hide module name and default value for environment variable section */
|
||||
div[id$='environment-variables'] code.descclassname {
|
||||
display: none;
|
||||
div[id$="environment-variables"] code.descclassname {
|
||||
display: none;
|
||||
}
|
||||
div[id$='environment-variables'] em.property {
|
||||
display: none;
|
||||
div[id$="environment-variables"] em.property {
|
||||
display: none;
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+142
-132
@@ -10,142 +10,152 @@
|
||||
*/
|
||||
|
||||
var Konami = function (callback) {
|
||||
var konami = {
|
||||
addEvent: function (obj, type, fn, ref_obj) {
|
||||
if (obj.addEventListener)
|
||||
obj.addEventListener(type, fn, false);
|
||||
else if (obj.attachEvent) {
|
||||
// IE
|
||||
obj["e" + type + fn] = fn;
|
||||
obj[type + fn] = function () {
|
||||
obj["e" + type + fn](window.event, ref_obj);
|
||||
}
|
||||
obj.attachEvent("on" + type, obj[type + fn]);
|
||||
}
|
||||
},
|
||||
removeEvent: function (obj, eventName, eventCallback) {
|
||||
if (obj.removeEventListener) {
|
||||
obj.removeEventListener(eventName, eventCallback);
|
||||
} else if (obj.attachEvent) {
|
||||
obj.detachEvent(eventName);
|
||||
}
|
||||
},
|
||||
input: "",
|
||||
pattern: "38384040373937396665",
|
||||
keydownHandler: function (e, ref_obj) {
|
||||
if (ref_obj) {
|
||||
konami = ref_obj;
|
||||
} // IE
|
||||
konami.input += e ? e.keyCode : event.keyCode;
|
||||
if (konami.input.length > konami.pattern.length) {
|
||||
konami.input = konami.input.substr((konami.input.length - konami.pattern.length));
|
||||
}
|
||||
if (konami.input === konami.pattern) {
|
||||
konami.code(konami._currentLink);
|
||||
konami.input = '';
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}
|
||||
},
|
||||
load: function (link) {
|
||||
this._currentLink = link;
|
||||
this.addEvent(document, "keydown", this.keydownHandler, this);
|
||||
this.iphone.load(link);
|
||||
},
|
||||
unload: function () {
|
||||
this.removeEvent(document, 'keydown', this.keydownHandler);
|
||||
this.iphone.unload();
|
||||
},
|
||||
code: function (link) {
|
||||
window.location = link
|
||||
},
|
||||
iphone: {
|
||||
start_x: 0,
|
||||
start_y: 0,
|
||||
stop_x: 0,
|
||||
stop_y: 0,
|
||||
tap: false,
|
||||
capture: false,
|
||||
orig_keys: "",
|
||||
keys: ["UP", "UP", "DOWN", "DOWN", "LEFT", "RIGHT", "LEFT", "RIGHT", "TAP", "TAP"],
|
||||
input: [],
|
||||
code: function (link) {
|
||||
konami.code(link);
|
||||
},
|
||||
touchmoveHandler: function (e) {
|
||||
if (e.touches.length === 1 && konami.iphone.capture === true) {
|
||||
var touch = e.touches[0];
|
||||
konami.iphone.stop_x = touch.pageX;
|
||||
konami.iphone.stop_y = touch.pageY;
|
||||
konami.iphone.tap = false;
|
||||
konami.iphone.capture = false;
|
||||
konami.iphone.check_direction();
|
||||
}
|
||||
},
|
||||
touchendHandler: function () {
|
||||
konami.iphone.input.push(konami.iphone.check_direction());
|
||||
|
||||
if (konami.iphone.input.length > konami.iphone.keys.length) konami.iphone.input.shift();
|
||||
|
||||
if (konami.iphone.input.length === konami.iphone.keys.length) {
|
||||
var match = true;
|
||||
for (var i = 0; i < konami.iphone.keys.length; i++) {
|
||||
if (konami.iphone.input[i] !== konami.iphone.keys[i]) {
|
||||
match = false;
|
||||
}
|
||||
}
|
||||
if (match) {
|
||||
konami.iphone.code(konami._currentLink);
|
||||
}
|
||||
}
|
||||
},
|
||||
touchstartHandler: function (e) {
|
||||
konami.iphone.start_x = e.changedTouches[0].pageX;
|
||||
konami.iphone.start_y = e.changedTouches[0].pageY;
|
||||
konami.iphone.tap = true;
|
||||
konami.iphone.capture = true;
|
||||
},
|
||||
load: function (link) {
|
||||
this.orig_keys = this.keys;
|
||||
konami.addEvent(document, "touchmove", this.touchmoveHandler);
|
||||
konami.addEvent(document, "touchend", this.touchendHandler, false);
|
||||
konami.addEvent(document, "touchstart", this.touchstartHandler);
|
||||
},
|
||||
unload: function () {
|
||||
konami.removeEvent(document, 'touchmove', this.touchmoveHandler);
|
||||
konami.removeEvent(document, 'touchend', this.touchendHandler);
|
||||
konami.removeEvent(document, 'touchstart', this.touchstartHandler);
|
||||
},
|
||||
check_direction: function () {
|
||||
x_magnitude = Math.abs(this.start_x - this.stop_x);
|
||||
y_magnitude = Math.abs(this.start_y - this.stop_y);
|
||||
x = ((this.start_x - this.stop_x) < 0) ? "RIGHT" : "LEFT";
|
||||
y = ((this.start_y - this.stop_y) < 0) ? "DOWN" : "UP";
|
||||
result = (x_magnitude > y_magnitude) ? x : y;
|
||||
result = (this.tap === true) ? "TAP" : result;
|
||||
return result;
|
||||
}
|
||||
var konami = {
|
||||
addEvent: function (obj, type, fn, ref_obj) {
|
||||
if (obj.addEventListener) obj.addEventListener(type, fn, false);
|
||||
else if (obj.attachEvent) {
|
||||
// IE
|
||||
obj["e" + type + fn] = fn;
|
||||
obj[type + fn] = function () {
|
||||
obj["e" + type + fn](window.event, ref_obj);
|
||||
};
|
||||
obj.attachEvent("on" + type, obj[type + fn]);
|
||||
}
|
||||
},
|
||||
removeEvent: function (obj, eventName, eventCallback) {
|
||||
if (obj.removeEventListener) {
|
||||
obj.removeEventListener(eventName, eventCallback);
|
||||
} else if (obj.attachEvent) {
|
||||
obj.detachEvent(eventName);
|
||||
}
|
||||
},
|
||||
input: "",
|
||||
pattern: "38384040373937396665",
|
||||
keydownHandler: function (e, ref_obj) {
|
||||
if (ref_obj) {
|
||||
konami = ref_obj;
|
||||
} // IE
|
||||
konami.input += e ? e.keyCode : event.keyCode;
|
||||
if (konami.input.length > konami.pattern.length) {
|
||||
konami.input = konami.input.substr(konami.input.length - konami.pattern.length);
|
||||
}
|
||||
if (konami.input === konami.pattern) {
|
||||
konami.code(konami._currentLink);
|
||||
konami.input = "";
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}
|
||||
},
|
||||
load: function (link) {
|
||||
this._currentLink = link;
|
||||
this.addEvent(document, "keydown", this.keydownHandler, this);
|
||||
this.iphone.load(link);
|
||||
},
|
||||
unload: function () {
|
||||
this.removeEvent(document, "keydown", this.keydownHandler);
|
||||
this.iphone.unload();
|
||||
},
|
||||
code: function (link) {
|
||||
window.location = link;
|
||||
},
|
||||
iphone: {
|
||||
start_x: 0,
|
||||
start_y: 0,
|
||||
stop_x: 0,
|
||||
stop_y: 0,
|
||||
tap: false,
|
||||
capture: false,
|
||||
orig_keys: "",
|
||||
keys: [
|
||||
"UP",
|
||||
"UP",
|
||||
"DOWN",
|
||||
"DOWN",
|
||||
"LEFT",
|
||||
"RIGHT",
|
||||
"LEFT",
|
||||
"RIGHT",
|
||||
"TAP",
|
||||
"TAP",
|
||||
],
|
||||
input: [],
|
||||
code: function (link) {
|
||||
konami.code(link);
|
||||
},
|
||||
touchmoveHandler: function (e) {
|
||||
if (e.touches.length === 1 && konami.iphone.capture === true) {
|
||||
var touch = e.touches[0];
|
||||
konami.iphone.stop_x = touch.pageX;
|
||||
konami.iphone.stop_y = touch.pageY;
|
||||
konami.iphone.tap = false;
|
||||
konami.iphone.capture = false;
|
||||
konami.iphone.check_direction();
|
||||
}
|
||||
}
|
||||
},
|
||||
touchendHandler: function () {
|
||||
konami.iphone.input.push(konami.iphone.check_direction());
|
||||
|
||||
typeof callback === "string" && konami.load(callback);
|
||||
if (typeof callback === "function") {
|
||||
konami.code = callback;
|
||||
konami.load();
|
||||
}
|
||||
if (konami.iphone.input.length > konami.iphone.keys.length)
|
||||
konami.iphone.input.shift();
|
||||
|
||||
return konami;
|
||||
if (konami.iphone.input.length === konami.iphone.keys.length) {
|
||||
var match = true;
|
||||
for (var i = 0; i < konami.iphone.keys.length; i++) {
|
||||
if (konami.iphone.input[i] !== konami.iphone.keys[i]) {
|
||||
match = false;
|
||||
}
|
||||
}
|
||||
if (match) {
|
||||
konami.iphone.code(konami._currentLink);
|
||||
}
|
||||
}
|
||||
},
|
||||
touchstartHandler: function (e) {
|
||||
konami.iphone.start_x = e.changedTouches[0].pageX;
|
||||
konami.iphone.start_y = e.changedTouches[0].pageY;
|
||||
konami.iphone.tap = true;
|
||||
konami.iphone.capture = true;
|
||||
},
|
||||
load: function (link) {
|
||||
this.orig_keys = this.keys;
|
||||
konami.addEvent(document, "touchmove", this.touchmoveHandler);
|
||||
konami.addEvent(document, "touchend", this.touchendHandler, false);
|
||||
konami.addEvent(document, "touchstart", this.touchstartHandler);
|
||||
},
|
||||
unload: function () {
|
||||
konami.removeEvent(document, "touchmove", this.touchmoveHandler);
|
||||
konami.removeEvent(document, "touchend", this.touchendHandler);
|
||||
konami.removeEvent(document, "touchstart", this.touchstartHandler);
|
||||
},
|
||||
check_direction: function () {
|
||||
x_magnitude = Math.abs(this.start_x - this.stop_x);
|
||||
y_magnitude = Math.abs(this.start_y - this.stop_y);
|
||||
x = this.start_x - this.stop_x < 0 ? "RIGHT" : "LEFT";
|
||||
y = this.start_y - this.stop_y < 0 ? "DOWN" : "UP";
|
||||
result = x_magnitude > y_magnitude ? x : y;
|
||||
result = this.tap === true ? "TAP" : result;
|
||||
return result;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
typeof callback === "string" && konami.load(callback);
|
||||
if (typeof callback === "function") {
|
||||
konami.code = callback;
|
||||
konami.load();
|
||||
}
|
||||
|
||||
return konami;
|
||||
};
|
||||
|
||||
|
||||
if (typeof module !== 'undefined' && typeof module.exports !== 'undefined') {
|
||||
module.exports = Konami;
|
||||
if (typeof module !== "undefined" && typeof module.exports !== "undefined") {
|
||||
module.exports = Konami;
|
||||
} else {
|
||||
if (typeof define === 'function' && define.amd) {
|
||||
define([], function() {
|
||||
return Konami;
|
||||
});
|
||||
} else {
|
||||
window.Konami = Konami;
|
||||
}
|
||||
if (typeof define === "function" && define.amd) {
|
||||
define([], function () {
|
||||
return Konami;
|
||||
});
|
||||
} else {
|
||||
window.Konami = Konami;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
<link rel="stylesheet" type="text/css" href="https://cloud.typography.com/7584432/7586812/css/fonts.css" />
|
||||
<script type="text/javascript">$('#searchbox').hide(0);</script>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
type="text/css"
|
||||
href="https://cloud.typography.com/7584432/7586812/css/fonts.css"
|
||||
/>
|
||||
<script type="text/javascript">
|
||||
$("#searchbox").hide(0);
|
||||
</script>
|
||||
<!--Alabaster (krTheme++) Hacks -->
|
||||
|
||||
<!-- CSS Adjustments (I'm very picky.) -->
|
||||
@@ -26,7 +32,7 @@
|
||||
.class em,
|
||||
.descname,
|
||||
.method em {
|
||||
font-family: "Operator Mono SSm A", "Operator Mono SSm B" !important;
|
||||
font-family: "Operator Mono SSm A", "Operator Mono SSm B", monospace !important;
|
||||
font-weight: 400 !important;
|
||||
}
|
||||
|
||||
@@ -39,9 +45,7 @@
|
||||
}
|
||||
|
||||
.method {
|
||||
|
||||
margin-bottom: 2em;
|
||||
|
||||
}
|
||||
|
||||
.si,
|
||||
@@ -80,8 +84,6 @@
|
||||
margin-top: -1em;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* "Quick Search" should be not be shown for now. */
|
||||
div#searchbox h3 {
|
||||
display: none;
|
||||
@@ -118,10 +120,12 @@
|
||||
<script async src="https://www.googletagmanager.com/gtag/js?id=UA-127383416-1"></script>
|
||||
<script>
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag() { dataLayer.push(arguments); }
|
||||
gtag('js', new Date());
|
||||
function gtag() {
|
||||
dataLayer.push(arguments);
|
||||
}
|
||||
gtag("js", new Date());
|
||||
|
||||
gtag('config', 'UA-127383416-1');
|
||||
gtag("config", "UA-127383416-1");
|
||||
</script>
|
||||
|
||||
<!-- There are no more hacks. -->
|
||||
@@ -130,7 +134,10 @@
|
||||
|
||||
<script src="{{ pathto('_static/', 1) }}/konami.js"></script>
|
||||
<script>
|
||||
var easter_egg = new Konami('https://www.myfortunecookie.co.uk/fortunes/' + (Math.floor(Math.random() * 152) + 1));
|
||||
var easter_egg = new Konami(
|
||||
"https://www.myfortunecookie.co.uk/fortunes/" +
|
||||
(Math.floor(Math.random() * 152) + 1)
|
||||
);
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@@ -140,67 +147,94 @@
|
||||
</style>
|
||||
|
||||
<!-- GitHub Logo -->
|
||||
<a href="https://github.com/kennethreitz/responder" class="github-corner" aria-label="View source on GitHub">
|
||||
<svg width="80" height="80" viewBox="0 0 250 250" style="fill:#151513; color:#fff; position: absolute; top: 0; border: 0; right: 0;"
|
||||
aria-hidden="true">
|
||||
<a
|
||||
href="https://github.com/kennethreitz/responder"
|
||||
class="github-corner"
|
||||
aria-label="View source on GitHub"
|
||||
>
|
||||
<svg
|
||||
width="80"
|
||||
height="80"
|
||||
viewBox="0 0 250 250"
|
||||
style="fill: #151513; color: #fff; position: absolute; top: 0; border: 0; right: 0"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M0,0 L115,115 L130,115 L142,142 L250,250 L250,0 Z"></path>
|
||||
<path d="M128.3,109.0 C113.8,99.7 119.0,89.6 119.0,89.6 C122.0,82.7 120.5,78.6 120.5,78.6 C119.2,72.0 123.4,76.3 123.4,76.3 C127.3,80.9 125.5,87.3 125.5,87.3 C122.9,97.6 130.6,101.9 134.4,103.2"
|
||||
fill="currentColor" style="transform-origin: 130px 106px;" class="octo-arm"></path>
|
||||
<path d="M115.0,115.0 C114.9,115.1 118.7,116.5 119.8,115.4 L133.7,101.6 C136.9,99.2 139.9,98.4 142.2,98.6 C133.8,88.0 127.5,74.4 143.8,58.0 C148.5,53.4 154.0,51.2 159.7,51.0 C160.3,49.4 163.2,43.6 171.4,40.1 C171.4,40.1 176.1,42.5 178.8,56.2 C183.1,58.6 187.2,61.8 190.9,65.4 C194.5,69.0 197.7,73.2 200.1,77.6 C213.8,80.2 216.3,84.9 216.3,84.9 C212.7,93.1 206.9,96.0 205.4,96.6 C205.1,102.4 203.0,107.8 198.3,112.5 C181.9,128.9 168.3,122.5 157.7,114.1 C157.9,116.9 156.7,120.9 152.7,124.9 L141.0,136.5 C139.8,137.7 141.6,141.9 141.8,141.8 Z"
|
||||
fill="currentColor" class="octo-body"></path>
|
||||
<path
|
||||
d="M128.3,109.0 C113.8,99.7 119.0,89.6 119.0,89.6 C122.0,82.7 120.5,78.6 120.5,78.6 C119.2,72.0 123.4,76.3 123.4,76.3 C127.3,80.9 125.5,87.3 125.5,87.3 C122.9,97.6 130.6,101.9 134.4,103.2"
|
||||
fill="currentColor"
|
||||
style="transform-origin: 130px 106px"
|
||||
class="octo-arm"
|
||||
></path>
|
||||
<path
|
||||
d="M115.0,115.0 C114.9,115.1 118.7,116.5 119.8,115.4 L133.7,101.6 C136.9,99.2 139.9,98.4 142.2,98.6 C133.8,88.0 127.5,74.4 143.8,58.0 C148.5,53.4 154.0,51.2 159.7,51.0 C160.3,49.4 163.2,43.6 171.4,40.1 C171.4,40.1 176.1,42.5 178.8,56.2 C183.1,58.6 187.2,61.8 190.9,65.4 C194.5,69.0 197.7,73.2 200.1,77.6 C213.8,80.2 216.3,84.9 216.3,84.9 C212.7,93.1 206.9,96.0 205.4,96.6 C205.1,102.4 203.0,107.8 198.3,112.5 C181.9,128.9 168.3,122.5 157.7,114.1 C157.9,116.9 156.7,120.9 152.7,124.9 L141.0,136.5 C139.8,137.7 141.6,141.9 141.8,141.8 Z"
|
||||
fill="currentColor"
|
||||
class="octo-body"
|
||||
></path>
|
||||
</svg>
|
||||
</a>
|
||||
<style>
|
||||
.github-corner:hover .octo-arm {
|
||||
animation: octocat-wave 560ms ease-in-out
|
||||
animation: octocat-wave 560ms ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes octocat-wave {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(0)
|
||||
transform: rotate(0);
|
||||
}
|
||||
|
||||
20%,
|
||||
60% {
|
||||
transform: rotate(-25deg)
|
||||
transform: rotate(-25deg);
|
||||
}
|
||||
|
||||
40%,
|
||||
80% {
|
||||
transform: rotate(10deg)
|
||||
transform: rotate(10deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width:500px) {
|
||||
@media (max-width: 500px) {
|
||||
.github-corner:hover .octo-arm {
|
||||
animation: none
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.github-corner .octo-arm {
|
||||
animation: octocat-wave 560ms ease-in-out
|
||||
animation: octocat-wave 560ms ease-in-out;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
<!-- That was not a hack. That was art.
|
||||
|
||||
<!-- UserVoice JavaScript SDK (only needed once on a page) -->
|
||||
<script>(function () { var uv = document.createElement('script'); uv.type = 'text/javascript'; uv.async = true; uv.src = '//widget.uservoice.com/f4AQraEfwInlMzkexfRLg.js'; var s = document.getElementsByTagName('script')[0]; s.parentNode.insertBefore(uv, s) })()</script>
|
||||
<script>
|
||||
(function () {
|
||||
var uv = document.createElement("script");
|
||||
uv.type = "text/javascript";
|
||||
uv.async = true;
|
||||
uv.src = "//widget.uservoice.com/f4AQraEfwInlMzkexfRLg.js";
|
||||
var s = document.getElementsByTagName("script")[0];
|
||||
s.parentNode.insertBefore(uv, s);
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- A tab to launch the Classic Widget -->
|
||||
<script>
|
||||
UserVoice = window.UserVoice || [];
|
||||
UserVoice.push(['showTab', 'classic_widget', {
|
||||
mode: 'feedback',
|
||||
primary_color: '#fa8c28',
|
||||
link_color: '#0a8cc6',
|
||||
forum_id: 913660,
|
||||
tab_label: 'Got feedback?',
|
||||
tab_color: '#00994f',
|
||||
tab_position: 'bottom-left',
|
||||
tab_inverted: true
|
||||
}]);
|
||||
UserVoice.push([
|
||||
"showTab",
|
||||
"classic_widget",
|
||||
{
|
||||
mode: "feedback",
|
||||
primary_color: "#fa8c28",
|
||||
link_color: "#0a8cc6",
|
||||
forum_id: 913660,
|
||||
tab_label: "Got feedback?",
|
||||
tab_color: "#00994f",
|
||||
tab_position: "bottom-left",
|
||||
tab_inverted: true,
|
||||
},
|
||||
]);
|
||||
</script>
|
||||
|
||||
@@ -1,33 +1,93 @@
|
||||
<p class="logo">
|
||||
<a href="{{ pathto(master_doc) }}">
|
||||
<img class="logo" src="{{ pathto('_static/responder.png', 1) }}" title="https://kennethreitz.org/tattoos" />
|
||||
<img
|
||||
class="logo"
|
||||
src="{{ pathto('_static/responder.png', 1) }}"
|
||||
title="https://kennethreitz.org/tattoos"
|
||||
/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<iframe src="https://ghbtns.com/github-btn.html?user=kennethreitz&repo=responder&type=watch&count=true&size=large"
|
||||
allowtransparency="true" frameborder="0" scrolling="0" width="200px" height="35px"></iframe>
|
||||
<iframe
|
||||
src="https://ghbtns.com/github-btn.html?user=kennethreitz&repo=responder&type=watch&count=true&size=large"
|
||||
allowtransparency="true"
|
||||
frameborder="0"
|
||||
scrolling="0"
|
||||
width="200px"
|
||||
height="35px"
|
||||
></iframe>
|
||||
</p>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/npm/docsearch.js@2/dist/cdn/docsearch.min.css"
|
||||
/>
|
||||
<style>
|
||||
.algolia-autocomplete {
|
||||
width: 100%;
|
||||
height: 1.5em;
|
||||
}
|
||||
.algolia-autocomplete a {
|
||||
border-bottom: none !important;
|
||||
}
|
||||
#doc_search {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
<input id="doc_search" placeholder="Search the doc" autofocus />
|
||||
<script
|
||||
type="text/javascript"
|
||||
src="https://cdn.jsdelivr.net/npm/docsearch.js@2/dist/cdn/docsearch.min.js"
|
||||
onload="docsearch({
|
||||
apiKey: 'ac965312db252e0496283c75c6f76f0b',
|
||||
indexName: 'python-responder',
|
||||
inputSelector: '#doc_search',
|
||||
debug: false // Set debug to true if you want to inspect the dropdown
|
||||
})"
|
||||
async
|
||||
></script>
|
||||
|
||||
<p>
|
||||
<strong>Responder</strong> is a web service framework, written for human beings.
|
||||
</p>
|
||||
<p><strong>Responder</strong> is a web service framework, written for human beings.</p>
|
||||
|
||||
<h3>Stay Informed</h3>
|
||||
<p>Receive updates on new releases and upcoming projects.</p>
|
||||
|
||||
<p><iframe src="https://ghbtns.com/github-btn.html?user=kennethreitz&type=follow&count=true" allowtransparency="true"
|
||||
frameborder="0" scrolling="0" width="200" height="20"></iframe></p>
|
||||
|
||||
<p><a href="https://twitter.com/kennethreitz" class="twitter-follow-button" data-show-count="false">Follow
|
||||
@kennethreitz</a>
|
||||
<script>!function (d, s, id) { var js, fjs = d.getElementsByTagName(s)[0], p = /^http:/.test(d.location) ? 'http' : 'https'; if (!d.getElementById(id)) { js = d.createElement(s); js.id = id; js.src = p + '://platform.twitter.com/widgets.js'; fjs.parentNode.insertBefore(js, fjs); } }(document, 'script', 'twitter-wjs');</script>
|
||||
<p>
|
||||
<iframe
|
||||
src="https://ghbtns.com/github-btn.html?user=kennethreitz&type=follow&count=true"
|
||||
allowtransparency="true"
|
||||
frameborder="0"
|
||||
scrolling="0"
|
||||
width="200"
|
||||
height="20"
|
||||
></iframe>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<a
|
||||
href="https://twitter.com/kennethreitz"
|
||||
class="twitter-follow-button"
|
||||
data-show-count="false"
|
||||
>Follow @kennethreitz</a
|
||||
>
|
||||
<script>
|
||||
!(function (d, s, id) {
|
||||
var js,
|
||||
fjs = d.getElementsByTagName(s)[0],
|
||||
p = /^http:/.test(d.location) ? "http" : "https";
|
||||
if (!d.getElementById(id)) {
|
||||
js = d.createElement(s);
|
||||
js.id = id;
|
||||
js.src = p + "://platform.twitter.com/widgets.js";
|
||||
fjs.parentNode.insertBefore(js, fjs);
|
||||
}
|
||||
})(document, "script", "twitter-wjs");
|
||||
</script>
|
||||
</p>
|
||||
|
||||
<h3>Useful Links</h3>
|
||||
<ul>
|
||||
|
||||
<li><a href="http://github.com/kennethreitz/responder">Responder @ GitHub</a></li>
|
||||
<li><a href="http://pypi.python.org/pypi/responder">Responder @ PyPI</a></li>
|
||||
<li><a href="http://github.com/kennethreitz/responder/issues">Issue Tracker</a></li>
|
||||
|
||||
@@ -1,33 +1,93 @@
|
||||
<p class="logo">
|
||||
<a href="{{ pathto(master_doc) }}">
|
||||
<img class="logo" src="{{ pathto('_static/responder.png', 1) }}" title="https://kennethreitz.org/tattoos" />
|
||||
<img
|
||||
class="logo"
|
||||
src="{{ pathto('_static/responder.png', 1) }}"
|
||||
title="https://kennethreitz.org/tattoos"
|
||||
/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<iframe src="https://ghbtns.com/github-btn.html?user=kennethreitz&repo=responder&type=watch&count=true&size=large"
|
||||
allowtransparency="true" frameborder="0" scrolling="0" width="200px" height="35px"></iframe>
|
||||
<iframe
|
||||
src="https://ghbtns.com/github-btn.html?user=kennethreitz&repo=responder&type=watch&count=true&size=large"
|
||||
allowtransparency="true"
|
||||
frameborder="0"
|
||||
scrolling="0"
|
||||
width="200px"
|
||||
height="35px"
|
||||
></iframe>
|
||||
</p>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/npm/docsearch.js@2/dist/cdn/docsearch.min.css"
|
||||
/>
|
||||
<style>
|
||||
.algolia-autocomplete {
|
||||
width: 100%;
|
||||
height: 1.5em;
|
||||
}
|
||||
.algolia-autocomplete a {
|
||||
border-bottom: none !important;
|
||||
}
|
||||
#doc_search {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
<input id="doc_search" placeholder="Search the doc" autofocus />
|
||||
<script
|
||||
type="text/javascript"
|
||||
src="https://cdn.jsdelivr.net/npm/docsearch.js@2/dist/cdn/docsearch.min.js"
|
||||
onload="docsearch({
|
||||
apiKey: 'ac965312db252e0496283c75c6f76f0b',
|
||||
indexName: 'python-responder',
|
||||
inputSelector: '#doc_search',
|
||||
debug: false // Set debug to true if you want to inspect the dropdown
|
||||
})"
|
||||
async
|
||||
></script>
|
||||
|
||||
<p>
|
||||
<strong>Responder</strong> is a web service framework, written for human beings.
|
||||
</p>
|
||||
<p><strong>Responder</strong> is a web service framework, written for human beings.</p>
|
||||
|
||||
<h3>Stay Informed</h3>
|
||||
<p>Receive updates on new releases and upcoming projects.</p>
|
||||
|
||||
<p><iframe src="https://ghbtns.com/github-btn.html?user=kennethreitz&type=follow&count=true" allowtransparency="true"
|
||||
frameborder="0" scrolling="0" width="200" height="20"></iframe></p>
|
||||
|
||||
<p><a href="https://twitter.com/kennethreitz" class="twitter-follow-button" data-show-count="false">Follow
|
||||
@kennethreitz</a>
|
||||
<script>!function (d, s, id) { var js, fjs = d.getElementsByTagName(s)[0], p = /^http:/.test(d.location) ? 'http' : 'https'; if (!d.getElementById(id)) { js = d.createElement(s); js.id = id; js.src = p + '://platform.twitter.com/widgets.js'; fjs.parentNode.insertBefore(js, fjs); } }(document, 'script', 'twitter-wjs');</script>
|
||||
<p>
|
||||
<iframe
|
||||
src="https://ghbtns.com/github-btn.html?user=kennethreitz&type=follow&count=true"
|
||||
allowtransparency="true"
|
||||
frameborder="0"
|
||||
scrolling="0"
|
||||
width="200"
|
||||
height="20"
|
||||
></iframe>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<a
|
||||
href="https://twitter.com/kennethreitz"
|
||||
class="twitter-follow-button"
|
||||
data-show-count="false"
|
||||
>Follow @kennethreitz</a
|
||||
>
|
||||
<script>
|
||||
!(function (d, s, id) {
|
||||
var js,
|
||||
fjs = d.getElementsByTagName(s)[0],
|
||||
p = /^http:/.test(d.location) ? "http" : "https";
|
||||
if (!d.getElementById(id)) {
|
||||
js = d.createElement(s);
|
||||
js.id = id;
|
||||
js.src = p + "://platform.twitter.com/widgets.js";
|
||||
fjs.parentNode.insertBefore(js, fjs);
|
||||
}
|
||||
})(document, "script", "twitter-wjs");
|
||||
</script>
|
||||
</p>
|
||||
|
||||
<h3>Useful Links</h3>
|
||||
<ul>
|
||||
|
||||
<li><a href="http://github.com/kennethreitz/responder">Responder @ GitHub</a></li>
|
||||
<li><a href="http://pypi.python.org/pypi/responder">Responder @ PyPI</a></li>
|
||||
<li><a href="http://github.com/kennethreitz/responder/issues">Issue Tracker</a></li>
|
||||
|
||||
@@ -10,10 +10,11 @@ Assuming existing ``api.py`` and ``Pipfile.lock`` containing ``responder``.
|
||||
|
||||
``Dockerfile``::
|
||||
|
||||
from kennethreitz/pipenv
|
||||
|
||||
FROM kennethreitz/pipenv
|
||||
ENV PORT '80'
|
||||
COPY . /app
|
||||
CMD python3 api.py
|
||||
EXPOSE 80
|
||||
|
||||
That's it!
|
||||
|
||||
|
||||
+15
-33
@@ -21,9 +21,6 @@ A familiar HTTP Service Framework
|
||||
.. |image5| image:: https://img.shields.io/badge/Say%20Thanks-!-1EAEDB.svg
|
||||
:target: https://saythanks.io/to/kennethreitz
|
||||
|
||||
The Python world certainly doesn't need more web frameworks. But, it does need more creativity, so I thought I'd
|
||||
spread some `Hacktoberfest <https://hacktoberfest.digitalocean.com/>`_ spirit around, bring some of my ideas to the table, and see what I could come up with.
|
||||
|
||||
.. code:: python
|
||||
|
||||
import responder
|
||||
@@ -37,21 +34,23 @@ spread some `Hacktoberfest <https://hacktoberfest.digitalocean.com/>`_ spirit ar
|
||||
if __name__ == '__main__':
|
||||
api.run()
|
||||
|
||||
That ``async`` declaration is optional.
|
||||
Powered by `Starlette <https://www.starlette.io/>`_. That ``async`` declaration is optional.
|
||||
|
||||
This gets you a ASGI app, with a production static files server
|
||||
(`WhiteNoise <http://whitenoise.evans.io/en/stable/>`_)
|
||||
pre-installed, jinja2 templating (without additional imports), and a
|
||||
production webserver based on uvloop, serving up requests with gzip
|
||||
compression automatically.
|
||||
production webserver based on uvloop, serving up requests with
|
||||
automatic gzip compression.
|
||||
|
||||
Features
|
||||
--------
|
||||
|
||||
- A pleasant API, with a single import statement.
|
||||
- Class-based views without inheritence.
|
||||
- ASGI framework, the future of Python web services.
|
||||
- Class-based views without inheritance.
|
||||
- `ASGI <https://asgi.readthedocs.io>`_ framework, the future of Python web services.
|
||||
- WebSocket support!
|
||||
- The ability to mount any ASGI / WSGI app at a subroute.
|
||||
- *f-string syntax* route declaration.
|
||||
- `f-string syntax <https://docs.python.org/3/whatsnew/3.6.html#pep-498-formatted-string-literals>`_ route declaration.
|
||||
- Mutable response object, passed into each view. No need to return anything.
|
||||
- Background tasks, spawned off in a ``ThreadPoolExecutor``.
|
||||
- GraphQL (with *GraphiQL*) support!
|
||||
@@ -83,18 +82,8 @@ Testimonials
|
||||
— Danny Greenfield, author of `Two Scoops of Django`_
|
||||
|
||||
|
||||
..
|
||||
|
||||
|
||||
“The most ambitious crossover event in history.”
|
||||
|
||||
—Pablo Cabezas, `on Tom Christie joining the project`_
|
||||
|
||||
|
||||
.. _APIStar: https://github.com/encode/apistar
|
||||
.. _Django REST Framework: https://www.django-rest-framework.org/
|
||||
.. _Two Scoops of Django:
|
||||
.. _on Tom Christie joining the project: https://twitter.com/pabloteleco/status/1050841098321620992?s=20
|
||||
.. _Two Scoops of Django: https://www.twoscoopspress.com/products/two-scoops-of-django-1-11
|
||||
|
||||
User Guides
|
||||
-----------
|
||||
@@ -114,7 +103,7 @@ Installing Responder
|
||||
|
||||
.. code-block:: shell
|
||||
|
||||
$ pipenv install responder --pre
|
||||
$ pipenv install responder
|
||||
✨🍰✨
|
||||
|
||||
Only **Python 3.6+** is supported.
|
||||
@@ -125,8 +114,9 @@ The Basic Idea
|
||||
|
||||
The primary concept here is to bring the niceties that are brought forth from both Flask and Falcon and unify them into a single framework, along with some new ideas I have. I also wanted to take some of the API primitives that are instilled in the Requests library and put them into a web framework. So, you'll find a lot of parallels here with Requests.
|
||||
|
||||
- Setting ``resp.text`` sends back unicode, while setting ``resp.content`` sends back bytes.
|
||||
- Setting ``resp.media`` sends back JSON/YAML (``.text``/``.content`` override this).
|
||||
- Setting ``resp.content`` sends back bytes.
|
||||
- Setting ``resp.text`` sends back unicode, while setting ``resp.html`` sends back HTML.
|
||||
- Setting ``resp.media`` sends back JSON/YAML (``.text``/``.html``/``.content`` override this).
|
||||
- Case-insensitive ``req.headers`` dict (from Requests directly).
|
||||
- ``resp.status_code``, ``req.method``, ``req.url``, and other familiar friends.
|
||||
|
||||
@@ -134,24 +124,16 @@ Ideas
|
||||
-----
|
||||
|
||||
- Flask-style route expression, with new capabilities -- all while using Python 3.6+'s new f-string syntax.
|
||||
- I love Falcon's "every request and response is passed into to each view and mutated" methodology, especially ``response.media``, and have used it here. In addition to supporting JSON, I have decided to support YAML as well, as Kubernetes is slowly taking over the world, and it uses YAML for all the things. Content-negotiation and all that.
|
||||
- I love Falcon's "every request and response is passed into each view and mutated" methodology, especially ``response.media``, and have used it here. In addition to supporting JSON, I have decided to support YAML as well, as Kubernetes is slowly taking over the world, and it uses YAML for all the things. Content-negotiation and all that.
|
||||
- **A built in testing client that uses the actual Requests you know and love**.
|
||||
- The ability to mount other WSGI apps easily.
|
||||
- Automatic gzipped-responses.
|
||||
- In addition to Falcon's ``on_get``, ``on_post``, etc methods, Responder features an ``on_request`` method, which gets called on every type of request, much like Requests.
|
||||
- A production static files server is built-in.
|
||||
- Uvicorn built-in as a production web server. I would have chosen Gunicorn, but it doesn't run on Windows. Plus, Uvicorn serves well to protect against slowloris attacks, making nginx unneccessary in production.
|
||||
- `Uvicorn <https://www.uvicorn.org/>`_ is built-in as a production web server. I would have chosen Gunicorn, but it doesn't run on Windows. Plus, Uvicorn serves well to protect against `slowloris <https://en.wikipedia.org/wiki/Slowloris_(computer_security)>`_ attacks, making nginx unnecessary in production.
|
||||
- GraphQL support, via Graphene. The goal here is to have any GraphQL query exposable at any route, magically.
|
||||
|
||||
|
||||
Future Ideas
|
||||
------------
|
||||
|
||||
- Cookie-based sessions are currently an afterthought, as this is an API framework, but websites are APIs too.
|
||||
- If frontend websites are supported, provide an official way to run webpack.
|
||||
|
||||
|
||||
|
||||
Indices and tables
|
||||
==================
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ Next, we can run our web service easily, with ``api.run()``::
|
||||
|
||||
This will spin up a production web server on port ``5042``, ready for incoming HTTP requests.
|
||||
|
||||
Note: you can pass ``port=5000`` if you want to customize the port. The ``PORT`` environment variable for established web service providers (e.g. Heroku) will automatically be honored.
|
||||
Note: you can pass ``port=5000`` if you want to customize the port. The ``PORT`` environment variable for established web service providers (e.g. Heroku) will automatically be honored and will set the listening address to ``0.0.0.0`` automatically (also configurable through the ``address`` keyword argument).
|
||||
|
||||
|
||||
Accept Route Arguments
|
||||
@@ -48,6 +48,14 @@ If you want dynamic URLs, you can use Python's familiar *f-string syntax* to dec
|
||||
|
||||
A ``GET`` request to ``/hello/brettcannon`` will result in a response of ``hello, brettcannon!``.
|
||||
|
||||
Type convertors are also available::
|
||||
|
||||
@api.route("/add/{a:int}/{b:int}")
|
||||
async def add(req, resp, *, a, b):
|
||||
resp.text = f"{a} + {b} = {a + b}"
|
||||
|
||||
Supported types: ``str``, ``int`` and ``float``.
|
||||
|
||||
Returning JSON / YAML
|
||||
---------------------
|
||||
|
||||
@@ -65,13 +73,30 @@ If the client requests YAML instead (with a header of ``Accept: application/x-ya
|
||||
Rendering a Template
|
||||
--------------------
|
||||
|
||||
If you want to render a template, simply use ``api.template``. No need for additional imports::
|
||||
Responder provides a built-in light `jinja2 <http://jinja.pocoo.org/docs/>`_ wrapper ``templates.Templates``
|
||||
|
||||
Usage::
|
||||
|
||||
from responder.templates import Templates
|
||||
|
||||
templates = Templates()
|
||||
|
||||
@api.route("/hello/{name}/html")
|
||||
def hello(req, resp, name):
|
||||
resp.html = templates.render("hello.html", name=name)
|
||||
|
||||
|
||||
Also a ``render_async`` is available::
|
||||
|
||||
templates = Templates(enable_async=True)
|
||||
resp.html = await templates.render_async("hello.html", who=who)
|
||||
|
||||
You can also use the existing ``api.template(filename, *args, **kwargs)`` to render templates::
|
||||
|
||||
@api.route("/hello/{who}/html")
|
||||
def hello_html(req, resp, *, who):
|
||||
resp.content = api.template('hello.html', who=who)
|
||||
resp.html = api.template('hello.html', who=who)
|
||||
|
||||
The ``api`` instance is available as an object during template rendering.
|
||||
|
||||
Setting Response Status Code
|
||||
----------------------------
|
||||
@@ -90,7 +115,7 @@ If you want to set a response header, like ``X-Pizza: 42``, simply modify the ``
|
||||
|
||||
@api.route("/pizza")
|
||||
def pizza_pizza(req, resp):
|
||||
resp.headers['X-Pizza'] = 42
|
||||
resp.headers['X-Pizza'] = '42'
|
||||
|
||||
That's it!
|
||||
|
||||
@@ -124,3 +149,29 @@ Here, we'll process our data in the background, while responding immediately to
|
||||
resp.media = {'success': True}
|
||||
|
||||
A ``POST`` request to ``/incoming`` will result in an immediate response of ``{'success': true}``.
|
||||
|
||||
|
||||
Here's a sample code to post a file with background::
|
||||
|
||||
@api.route("/")
|
||||
async def upload_file(req, resp):
|
||||
|
||||
@api.background.task
|
||||
def process_data(data):
|
||||
f = open('./{}'.format(data['file']['filename']), 'w')
|
||||
f.write(data['file']['content'].decode('utf-8'))
|
||||
f.close()
|
||||
|
||||
data = await req.media(format='files')
|
||||
process_data(data)
|
||||
|
||||
resp.media = {'success': 'ok'}
|
||||
|
||||
You can send a file easily with requests::
|
||||
|
||||
import requests
|
||||
|
||||
data = {'file': ('hello.txt', 'hello, world!', "text/plain")}
|
||||
r = requests.post('http://127.0.0.1:8210/file', files=data)
|
||||
|
||||
print(r.text)
|
||||
|
||||
+239
-18
@@ -9,7 +9,7 @@ Class-based views (and setting some headers and stuff)::
|
||||
|
||||
@api.route("/{greeting}")
|
||||
class GreetingResource:
|
||||
def on_request(req, resp, *, greeting): # or on_get...
|
||||
def on_request(self, req, resp, *, greeting): # or on_get...
|
||||
resp.text = f"{greeting}, world!"
|
||||
resp.headers.update({'X-Life': '42'})
|
||||
resp.status_code = api.status_codes.HTTP_416
|
||||
@@ -45,23 +45,51 @@ Serve a GraphQL API::
|
||||
def resolve_hello(self, info, name):
|
||||
return f"Hello {name}"
|
||||
|
||||
api.add_route("/graph", graphene.Schema(query=Query))
|
||||
schema = graphene.Schema(query=Query)
|
||||
view = responder.ext.GraphQLView(api=api, schema=schema)
|
||||
|
||||
api.add_route("/graph", view)
|
||||
|
||||
Visiting the endpoint will render a *GraphiQL* instance, in the browser.
|
||||
|
||||
You can make use of Responder's Request and Response objects in your GraphQL resolvers through ``info.context['request']`` and ``info.context['response']``.
|
||||
|
||||
|
||||
OpenAPI Schema Support
|
||||
----------------------
|
||||
|
||||
Responder comes with built-in support for OpenAPI / marshmallow::
|
||||
Responder comes with built-in support for OpenAPI / marshmallow
|
||||
|
||||
New in Responder `1.4.0`::
|
||||
|
||||
import responder
|
||||
from responder.ext.schema import Schema as OpenAPISchema
|
||||
from marshmallow import Schema, fields
|
||||
|
||||
api = responder.API(title="Web Service", version="1.0", openapi="3.0")
|
||||
contact = {
|
||||
"name": "API Support",
|
||||
"url": "http://www.example.com/support",
|
||||
"email": "support@example.com",
|
||||
}
|
||||
license = {
|
||||
"name": "Apache 2.0",
|
||||
"url": "https://www.apache.org/licenses/LICENSE-2.0.html",
|
||||
}
|
||||
|
||||
api = responder.API()
|
||||
|
||||
schema = OpenAPISchema(
|
||||
app=api,
|
||||
title="Web Service",
|
||||
version="1.0",
|
||||
openapi="3.0.2",
|
||||
description="A simple pet store",
|
||||
terms_of_service="http://example.com/terms/",
|
||||
contact=contact,
|
||||
license=license,
|
||||
)
|
||||
|
||||
@api.schema("Pet")
|
||||
@schema.schema("Pet")
|
||||
class PetSchema(Schema):
|
||||
name = fields.Str()
|
||||
|
||||
@@ -75,46 +103,134 @@ Responder comes with built-in support for OpenAPI / marshmallow::
|
||||
responses:
|
||||
200:
|
||||
description: A pet to be returned
|
||||
schema:
|
||||
$ref = "#/components/schemas/Pet"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Pet'
|
||||
"""
|
||||
resp.media = PetSchema().dump({"name": "little orange"})
|
||||
|
||||
|
||||
Old way *It's recommended to use the code above* ::
|
||||
|
||||
import responder
|
||||
from marshmallow import Schema, fields
|
||||
|
||||
contact = {
|
||||
"name": "API Support",
|
||||
"url": "http://www.example.com/support",
|
||||
"email": "support@example.com",
|
||||
}
|
||||
license = {
|
||||
"name": "Apache 2.0",
|
||||
"url": "https://www.apache.org/licenses/LICENSE-2.0.html",
|
||||
}
|
||||
|
||||
api = responder.API(
|
||||
title="Web Service",
|
||||
version="1.0",
|
||||
openapi="3.0.2",
|
||||
description="A simple pet store",
|
||||
terms_of_service="http://example.com/terms/",
|
||||
contact=contact,
|
||||
license=license,
|
||||
)
|
||||
|
||||
@api.schema("Pet")
|
||||
class PetSchema(Schema):
|
||||
name = fields.Str()
|
||||
|
||||
@api.route("/")
|
||||
def route(req, resp):
|
||||
"""A cute furry animal endpoint.
|
||||
---
|
||||
get:
|
||||
description: Get a random pet
|
||||
responses:
|
||||
200:
|
||||
description: A pet to be returned
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Pet'
|
||||
"""
|
||||
resp.media = PetSchema().dump({"name": "little orange"})
|
||||
|
||||
::
|
||||
|
||||
>>> r = api.session().get("http://;/schema.yml")
|
||||
|
||||
>>> print(r.text)
|
||||
components:
|
||||
parameters: {}
|
||||
schemas:
|
||||
parameters: {}
|
||||
responses: {}
|
||||
schemas:
|
||||
Pet:
|
||||
properties:
|
||||
properties:
|
||||
name: {type: string}
|
||||
type: object
|
||||
info: {title: Web Service, version: 1.0}
|
||||
openapi: '3.0'
|
||||
type: object
|
||||
securitySchemes: {}
|
||||
info:
|
||||
contact: {email: support@example.com, name: API Support, url: 'http://www.example.com/support'}
|
||||
description: This is a sample server for a pet store.
|
||||
license: {name: Apache 2.0, url: 'https://www.apache.org/licenses/LICENSE-2.0.html'}
|
||||
termsOfService: http://example.com/terms/
|
||||
title: Web Service
|
||||
version: 1.0
|
||||
openapi: 3.0.2
|
||||
paths:
|
||||
/:
|
||||
get:
|
||||
description: Get a random pet
|
||||
responses:
|
||||
200: {description: A pet to be returned, schema: $ref = "#/components/schemas/Pet"}
|
||||
200: {description: A pet to be returned, schema: $ref: "#/components/schemas/Pet"}
|
||||
tags: []
|
||||
|
||||
|
||||
Interactive Documentation
|
||||
-------------------------
|
||||
|
||||
Responder can automatically supply API Documentation for you. Using the example above::
|
||||
Responder can automatically supply API Documentation for you. Using the example above
|
||||
|
||||
api = responder.API(title="Web Service", version="1.0", openapi="3.0", docs_route="/docs")
|
||||
The new and recommended way::
|
||||
|
||||
...
|
||||
from responder.ext.schema import Schema
|
||||
...
|
||||
api = responder.API()
|
||||
|
||||
schema = Schema(
|
||||
app=api,
|
||||
title="Web Service",
|
||||
version="1.0",
|
||||
openapi="3.0.2",
|
||||
...
|
||||
docs_route='/docs',
|
||||
...
|
||||
description=description,
|
||||
terms_of_service=terms_of_service,
|
||||
contact=contact,
|
||||
license=license,
|
||||
)
|
||||
|
||||
|
||||
The old way ::
|
||||
|
||||
api = responder.API(
|
||||
title="Web Service",
|
||||
version="1.0",
|
||||
openapi="3.0.2",
|
||||
docs_route='/docs',
|
||||
description=description,
|
||||
terms_of_service=terms_of_service,
|
||||
contact=contact,
|
||||
license=license,
|
||||
)
|
||||
|
||||
This will make ``/docs`` render interactive documentation for your API.
|
||||
|
||||
Mount a WSGI App (e.g. Flask)
|
||||
-----------------------------
|
||||
Mount a WSGI / ASGI Apps (e.g. Flask, Starlette,...)
|
||||
----------------------------------------------------
|
||||
|
||||
Responder gives you the ability to mount another ASGI / WSGI app at a subroute::
|
||||
|
||||
@@ -152,6 +268,24 @@ Responder makes it very easy to interact with cookies from a Request, or add som
|
||||
{"hello": "world"}
|
||||
|
||||
|
||||
To set cookies directives, you should use `resp.set_cookie`::
|
||||
|
||||
>>> resp.set_cookie("hello", value="world", max_age=60)
|
||||
|
||||
Supported directives:
|
||||
|
||||
* ``key`` - **Required**
|
||||
* ``value`` - [OPTIONAL] - Defaults to ``""``.
|
||||
* ``expires`` - Defaults to ``None``.
|
||||
* ``max_age`` - Defaults to ``None``.
|
||||
* ``domain`` - Defaults to ``None``.
|
||||
* ``path`` - Defaults to ``"/"``.
|
||||
* ``secure`` - Defaults to ``False``.
|
||||
* ``httponly`` - Defaults to ``True``.
|
||||
|
||||
For more information see `directives <https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#Directives>`_
|
||||
|
||||
|
||||
Using Cookie-Based Sessions
|
||||
---------------------------
|
||||
|
||||
@@ -170,6 +304,52 @@ You can easily read a Request's session data, that can be trusted to have origin
|
||||
|
||||
api = responder.API(secret_key=os.environ['SECRET_KEY'])
|
||||
|
||||
Using ``before_request``
|
||||
------------------------
|
||||
|
||||
If you'd like a view to be executed before every request, simply do the following::
|
||||
|
||||
@api.route(before_request=True)
|
||||
def prepare_response(req, resp):
|
||||
resp.headers["X-Pizza"] = "42"
|
||||
|
||||
Now all requests to your HTTP Service will include an ``X-Pizza`` header.
|
||||
|
||||
For ``websockets``::
|
||||
|
||||
@api.route(before_request=True, websocket=True)
|
||||
def prepare_response(ws):
|
||||
await ws.accept()
|
||||
|
||||
|
||||
WebSocket Support
|
||||
-----------------
|
||||
|
||||
Responder supports WebSockets::
|
||||
|
||||
@api.route('/ws', websocket=True)
|
||||
async def websocket(ws):
|
||||
await ws.accept()
|
||||
while True:
|
||||
name = await ws.receive_text()
|
||||
await ws.send_text(f"Hello {name}!")
|
||||
await ws.close()
|
||||
|
||||
Accepting the connection::
|
||||
|
||||
await websocket.accept()
|
||||
|
||||
Sending and receiving data::
|
||||
|
||||
await websocket.send_{format}(data)
|
||||
await websocket.receive_{format}(data)
|
||||
|
||||
Supported formats: ``text``, ``json``, ``bytes``.
|
||||
|
||||
Closing the connection::
|
||||
|
||||
await websocket.close()
|
||||
|
||||
Using Requests Test Client
|
||||
--------------------------
|
||||
|
||||
@@ -204,3 +384,44 @@ Want HSTS (to redirect all traffic to HTTPS)?
|
||||
|
||||
|
||||
Boom.
|
||||
|
||||
CORS
|
||||
----
|
||||
|
||||
Want `CORS <https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS/>`_ ?
|
||||
|
||||
::
|
||||
|
||||
api = responder.API(cors=True)
|
||||
|
||||
|
||||
The default parameters used by **Responder** are restrictive by default, so you'll need to explicitly enable particular origins, methods, or headers, in order for browsers to be permitted to use them in a Cross-Domain context.
|
||||
|
||||
In order to set custom parameters, you need to set the ``cors_params`` argument of ``api``, a dictionary containing the following entries:
|
||||
|
||||
* ``allow_origins`` - A list of origins that should be permitted to make cross-origin requests. eg. ``['https://example.org', 'https://www.example.org']``. You can use ``['*']`` to allow any origin.
|
||||
* ``allow_origin_regex`` - A regex string to match against origins that should be permitted to make cross-origin requests. eg. ``'https://.*\.example\.org'``.
|
||||
* ``allow_methods`` - A list of HTTP methods that should be allowed for cross-origin requests. Defaults to `['GET']`. You can use ``['*']`` to allow all standard methods.
|
||||
* ``allow_headers`` - A list of HTTP request headers that should be supported for cross-origin requests. Defaults to ``[]``. You can use ``['*']`` to allow all headers. The ``Accept``, ``Accept-Language``, ``Content-Language`` and ``Content-Type`` headers are always allowed for CORS requests.
|
||||
* ``allow_credentials`` - Indicate that cookies should be supported for cross-origin requests. Defaults to ``False``.
|
||||
* ``expose_headers`` - Indicate any response headers that should be made accessible to the browser. Defaults to ``[]``.
|
||||
* ``max_age`` - Sets a maximum time in seconds for browsers to cache CORS responses. Defaults to ``60``.
|
||||
|
||||
Trusted Hosts
|
||||
-------------
|
||||
|
||||
Make sure that all the incoming requests headers have a valid ``host``, that matches one of the provided patterns in the ``allowed_hosts`` attribute, in order to prevent HTTP Host Header attacks.
|
||||
|
||||
A 400 response will be raised, if a request does not match any of the provided patterns in the ``allowed_hosts`` attribute.
|
||||
|
||||
::
|
||||
|
||||
api = responder.API(allowed_hosts=['example.com', 'tenant.example.com'])
|
||||
|
||||
* ``allowed_hosts`` - A list of allowed hostnames.
|
||||
|
||||
Note:
|
||||
|
||||
* By default, all hostnames are allowed.
|
||||
* Wildcard domains such as ``*.example.com`` are supported.
|
||||
* To allow any hostname use ``allowed_hosts=["*"]``.
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
[build-system]
|
||||
requires = ["setuptools", "wheel"]
|
||||
build-backend = "setuptools.build_meta:__legacy__"
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
build:
|
||||
image: latest
|
||||
image: latest
|
||||
|
||||
python:
|
||||
version: 3.6
|
||||
version: 3.6
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
from .core import *
|
||||
from . import ext
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "0.3.1"
|
||||
__version__ = "2.0.7"
|
||||
|
||||
+171
-405
@@ -1,40 +1,39 @@
|
||||
import os
|
||||
import json
|
||||
from functools import partial
|
||||
import os
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import uvicorn
|
||||
import apistar
|
||||
import yaml
|
||||
import jinja2
|
||||
import itsdangerous
|
||||
from graphql_server import encode_execution_results, json_encode, default_format_error
|
||||
from starlette.websockets import WebSocket
|
||||
from starlette.debug import DebugMiddleware
|
||||
from starlette.testclient import TestClient
|
||||
import uvicorn
|
||||
from starlette.exceptions import ExceptionMiddleware
|
||||
from starlette.middleware.wsgi import WSGIMiddleware
|
||||
from starlette.middleware.errors import ServerErrorMiddleware
|
||||
from starlette.middleware.cors import CORSMiddleware
|
||||
from starlette.middleware.gzip import GZipMiddleware
|
||||
from starlette.middleware.httpsredirect import HTTPSRedirectMiddleware
|
||||
from apispec import APISpec
|
||||
from apispec.ext.marshmallow import MarshmallowPlugin
|
||||
from apispec import yaml_utils
|
||||
from asgiref.wsgi import WsgiToAsgi
|
||||
from whitenoise import WhiteNoise
|
||||
from starlette.middleware.trustedhost import TrustedHostMiddleware
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
from starlette.staticfiles import StaticFiles
|
||||
from starlette.testclient import TestClient
|
||||
from starlette.websockets import WebSocket
|
||||
|
||||
from . import models
|
||||
from . import status_codes
|
||||
from .routes import Route
|
||||
from .formats import get_formats
|
||||
from . import models, status_codes
|
||||
from .background import BackgroundQueue
|
||||
from .templates import GRAPHIQL
|
||||
from .formats import get_formats
|
||||
from .routes import Router
|
||||
from .statics import DEFAULT_API_THEME, DEFAULT_CORS_PARAMS, DEFAULT_SECRET_KEY
|
||||
from .ext.schema import Schema as OpenAPISchema
|
||||
from .staticfiles import StaticFiles
|
||||
from .templates import Templates
|
||||
|
||||
|
||||
# TODO: consider moving status codes here
|
||||
class API:
|
||||
"""The primary web-service class.
|
||||
|
||||
:param static_dir: The directory to use for static files. Will be created for you if it doesn't already exist.
|
||||
:param templates_dir: The directory to use for templates. Will be created for you if it doesn't already exist.
|
||||
:param auto_escape: If ``True``, HTML and XML templates will automatically be escaped.
|
||||
:param enable_hsts: If ``True``, send all responses to HTTPS URLs.
|
||||
:param static_dir: The directory to use for static files. Will be created for you if it doesn't already exist.
|
||||
:param templates_dir: The directory to use for templates. Will be created for you if it doesn't already exist.
|
||||
:param auto_escape: If ``True``, HTML and XML templates will automatically be escaped.
|
||||
:param enable_hsts: If ``True``, send all responses to HTTPS URLs.
|
||||
"""
|
||||
|
||||
status_codes = status_codes
|
||||
@@ -45,170 +44,124 @@ class API:
|
||||
debug=False,
|
||||
title=None,
|
||||
version=None,
|
||||
description=None,
|
||||
terms_of_service=None,
|
||||
contact=None,
|
||||
license=None,
|
||||
openapi=None,
|
||||
openapi_route="/schema.yml",
|
||||
static_dir="static",
|
||||
static_route="/static",
|
||||
templates_dir="templates",
|
||||
auto_escape=True,
|
||||
secret_key="NOTASECRET",
|
||||
secret_key=DEFAULT_SECRET_KEY,
|
||||
enable_hsts=False,
|
||||
docs_route=None,
|
||||
cors=False,
|
||||
cors_params=DEFAULT_CORS_PARAMS,
|
||||
allowed_hosts=None,
|
||||
):
|
||||
self.background = BackgroundQueue()
|
||||
|
||||
self.secret_key = secret_key
|
||||
self.title = title
|
||||
self.version = version
|
||||
self.openapi_version = openapi
|
||||
self.static_dir = Path(os.path.abspath(static_dir))
|
||||
|
||||
self.router = Router()
|
||||
|
||||
if static_dir is not None:
|
||||
if static_route is None:
|
||||
static_route = static_dir
|
||||
static_dir = Path(os.path.abspath(static_dir))
|
||||
|
||||
self.static_dir = static_dir
|
||||
self.static_route = static_route
|
||||
self.templates_dir = Path(os.path.abspath(templates_dir))
|
||||
self.built_in_templates_dir = Path(
|
||||
os.path.abspath(os.path.dirname(__file__) + "/templates")
|
||||
)
|
||||
self.routes = {}
|
||||
self.docs_theme = "swaggerui"
|
||||
self.docs_route = docs_route
|
||||
self.schemas = {}
|
||||
self.session_cookie = "Responder-Session"
|
||||
|
||||
self.hsts_enabled = enable_hsts
|
||||
self.cors = cors
|
||||
self.cors_params = cors_params
|
||||
self.debug = debug
|
||||
|
||||
self.whitenoise = WhiteNoise(
|
||||
application=self._default_wsgi_app, index_file=True
|
||||
)
|
||||
self.whitenoise.add_files(str(self.static_dir))
|
||||
import apistar
|
||||
if not allowed_hosts:
|
||||
# if not debug:
|
||||
# raise RuntimeError(
|
||||
# "You need to specify `allowed_hosts` when debug is set to False"
|
||||
# )
|
||||
allowed_hosts = ["*"]
|
||||
self.allowed_hosts = allowed_hosts
|
||||
|
||||
self.whitenoise.add_files(
|
||||
(
|
||||
Path(apistar.__file__).parent / "themes" / self.docs_theme / "static"
|
||||
).resolve()
|
||||
)
|
||||
if self.static_dir is not None:
|
||||
os.makedirs(self.static_dir, exist_ok=True)
|
||||
|
||||
self.apps = {}
|
||||
self.mount(self.static_route, self.whitenoise)
|
||||
if self.static_dir is not None:
|
||||
self.mount(self.static_route, self.static_app)
|
||||
|
||||
self.formats = get_formats()
|
||||
|
||||
# Make the static/templates directory if they don't exist.
|
||||
for _dir in (self.static_dir, self.templates_dir):
|
||||
os.makedirs(_dir, exist_ok=True)
|
||||
|
||||
# Cached requests session.
|
||||
self._session = None
|
||||
self.background = BackgroundQueue()
|
||||
|
||||
if self.openapi_version:
|
||||
self.add_route(openapi_route, self.schema_response)
|
||||
|
||||
if self.docs_route:
|
||||
self.add_route(self.docs_route, self.docs_response)
|
||||
|
||||
self.default_endpoint = None
|
||||
self.app = self.dispatch
|
||||
self.app = ExceptionMiddleware(self.router, debug=debug)
|
||||
self.add_middleware(GZipMiddleware)
|
||||
if debug:
|
||||
self.add_middleware(DebugMiddleware)
|
||||
|
||||
if self.hsts_enabled:
|
||||
self.add_middleware(HTTPSRedirectMiddleware)
|
||||
|
||||
# Jinja enviroment
|
||||
self.jinja_env = jinja2.Environment(
|
||||
loader=jinja2.FileSystemLoader(
|
||||
[str(self.templates_dir), str(self.built_in_templates_dir)],
|
||||
followlinks=True,
|
||||
),
|
||||
autoescape=jinja2.select_autoescape(["html", "xml"] if auto_escape else []),
|
||||
)
|
||||
self.jinja_values_base = {"api": self} # Give reference to self.
|
||||
self.add_middleware(TrustedHostMiddleware, allowed_hosts=self.allowed_hosts)
|
||||
|
||||
if self.cors:
|
||||
self.add_middleware(CORSMiddleware, **self.cors_params)
|
||||
self.add_middleware(ServerErrorMiddleware, debug=debug)
|
||||
self.add_middleware(SessionMiddleware, secret_key=self.secret_key)
|
||||
|
||||
if openapi or docs_route:
|
||||
self.openapi = OpenAPISchema(
|
||||
app=self,
|
||||
title=title,
|
||||
version=version,
|
||||
openapi=openapi,
|
||||
docs_route=docs_route,
|
||||
description=description,
|
||||
terms_of_service=terms_of_service,
|
||||
contact=contact,
|
||||
license=license,
|
||||
openapi_route=openapi_route,
|
||||
static_route=static_route,
|
||||
)
|
||||
|
||||
# TODO: Update docs for templates
|
||||
self.templates = Templates(directory=templates_dir)
|
||||
self.requests = (
|
||||
self.session()
|
||||
) #: A Requests session that is connected to the ASGI app.
|
||||
|
||||
@staticmethod
|
||||
def _default_wsgi_app(*args, **kwargs):
|
||||
pass
|
||||
|
||||
@property
|
||||
def _apispec(self):
|
||||
spec = APISpec(
|
||||
title=self.title,
|
||||
version=self.version,
|
||||
openapi_version=self.openapi_version,
|
||||
plugins=[MarshmallowPlugin()],
|
||||
)
|
||||
def static_app(self):
|
||||
if not hasattr(self, "_static_app"):
|
||||
assert self.static_dir is not None
|
||||
self._static_app = StaticFiles(directory=self.static_dir)
|
||||
return self._static_app
|
||||
|
||||
for route in self.routes:
|
||||
if self.routes[route].description:
|
||||
operations = yaml_utils.load_operations_from_docstring(
|
||||
self.routes[route].description
|
||||
)
|
||||
spec.add_path(path=route, operations=operations)
|
||||
def before_request(self, websocket=False):
|
||||
def decorator(f):
|
||||
self.router.before_request(f, websocket=websocket)
|
||||
return f
|
||||
|
||||
for name, schema in self.schemas.items():
|
||||
spec.definition(name, schema=schema)
|
||||
|
||||
return spec
|
||||
|
||||
@property
|
||||
def openapi(self):
|
||||
return self._apispec.to_yaml()
|
||||
return decorator
|
||||
|
||||
def add_middleware(self, middleware_cls, **middleware_config):
|
||||
self.app = middleware_cls(self.app, **middleware_config)
|
||||
|
||||
def __call__(self, scope):
|
||||
path = scope["path"]
|
||||
root_path = scope.get("root_path", "")
|
||||
|
||||
# Call into a submounted app, if one exists.
|
||||
for path_prefix, app in self.apps.items():
|
||||
if path.startswith(path_prefix):
|
||||
scope["path"] = path[len(path_prefix) :]
|
||||
scope["root_path"] = root_path + path_prefix
|
||||
try:
|
||||
return app(scope)
|
||||
except TypeError:
|
||||
app = WsgiToAsgi(app)
|
||||
return app(scope)
|
||||
|
||||
return self.app(scope)
|
||||
|
||||
def dispatch(self, scope):
|
||||
# Call the main dispatcher.
|
||||
async def asgi(receive, send):
|
||||
nonlocal scope, self
|
||||
|
||||
req = models.Request(scope, receive=receive, api=self)
|
||||
resp = await self._dispatch_request(
|
||||
req, scope=scope, send=send, receive=receive
|
||||
)
|
||||
await resp(receive, send)
|
||||
|
||||
return asgi
|
||||
|
||||
def add_schema(self, name, schema, check_existing=True):
|
||||
"""Adds a mashmallow schema to the API specification."""
|
||||
if check_existing:
|
||||
assert name not in self.schemas
|
||||
|
||||
self.schemas[name] = schema
|
||||
|
||||
def schema(self, name, **options):
|
||||
"""Decorator for creating new routes around function and class definitions.
|
||||
|
||||
Usage::
|
||||
|
||||
from marshmallow import Schema, fields
|
||||
|
||||
@api.schema("Pet")
|
||||
class PetSchema(Schema):
|
||||
name = fields.Str()
|
||||
|
||||
"""
|
||||
|
||||
def decorator(f):
|
||||
self.add_schema(name=name, schema=f, **options)
|
||||
self.openapi.add_schema(name=name, schema=f, **options)
|
||||
return f
|
||||
|
||||
return decorator
|
||||
@@ -218,246 +171,100 @@ class API:
|
||||
|
||||
:param path: The path portion of a URL, to test all known routes against.
|
||||
"""
|
||||
for (route, route_object) in self.routes.items():
|
||||
if route_object.does_match(path):
|
||||
for route in self.router.routes:
|
||||
match, _ = route.matches(path)
|
||||
if match:
|
||||
return route
|
||||
|
||||
def _prepare_cookies(self, resp):
|
||||
if resp.cookies:
|
||||
header = " ".join([f"{k}={v}" for k, v in resp.cookies.items()])
|
||||
resp.headers["Set-Cookie"] = header
|
||||
|
||||
@property
|
||||
def _signer(self):
|
||||
return itsdangerous.Signer(self.secret_key)
|
||||
|
||||
def _prepare_session(self, resp):
|
||||
|
||||
if resp.session:
|
||||
data = self._signer.sign(json.dumps(resp.session).encode("utf-8"))
|
||||
resp.cookies[self.session_cookie] = data.decode("utf-8")
|
||||
|
||||
@staticmethod
|
||||
def no_response(req, resp, **params):
|
||||
pass
|
||||
|
||||
async def _dispatch_request(self, req, **options):
|
||||
# Set formats on Request object.
|
||||
req.formats = self.formats
|
||||
|
||||
# Get the route.
|
||||
route = self.path_matches_route(req.url.path)
|
||||
route = self.routes.get(route)
|
||||
|
||||
# Create the response object.
|
||||
cont = False
|
||||
if route:
|
||||
if route.uses_websocket:
|
||||
resp = WebSocket(**options)
|
||||
|
||||
else:
|
||||
resp = models.Response(req=req, formats=self.formats)
|
||||
|
||||
params = route.incoming_matches(req.url.path)
|
||||
|
||||
if route.is_graphql:
|
||||
await self.graphql_response(req, resp, schema=route.endpoint)
|
||||
|
||||
elif route.is_function:
|
||||
try:
|
||||
try:
|
||||
# Run the view.
|
||||
r = route.endpoint(req, resp, **params)
|
||||
# If it's async, await it.
|
||||
if hasattr(r, "cr_running"):
|
||||
await r
|
||||
except TypeError as e:
|
||||
cont = True
|
||||
except Exception:
|
||||
self.default_response(req, resp, error=True)
|
||||
|
||||
elif route.is_class_based or cont:
|
||||
try:
|
||||
view = route.endpoint(**params)
|
||||
except TypeError:
|
||||
view = route.endpoint()
|
||||
|
||||
# Run on_request first.
|
||||
try:
|
||||
# Run the view.
|
||||
r = getattr(view, "on_request", self.no_response)(
|
||||
req, resp, **params
|
||||
)
|
||||
# If it's async, await it.
|
||||
if hasattr(r, "send"):
|
||||
await r
|
||||
except Exception as e:
|
||||
self.default_response(req, resp, error=True)
|
||||
|
||||
# Then on_get.
|
||||
method = req.method
|
||||
|
||||
# Run on_request first.
|
||||
try:
|
||||
# Run the view.
|
||||
r = getattr(view, f"on_{method}", self.no_response)(
|
||||
req, resp, **params
|
||||
)
|
||||
# If it's async, await it.
|
||||
if hasattr(r, "send"):
|
||||
await r
|
||||
except Exception as e:
|
||||
|
||||
self.default_response(req, resp, error=True)
|
||||
|
||||
else:
|
||||
resp = models.Response(req=req, formats=self.formats)
|
||||
self.default_response(req, resp, notfound=True)
|
||||
self.default_response(req, resp)
|
||||
|
||||
self._prepare_session(resp)
|
||||
self._prepare_cookies(resp)
|
||||
|
||||
return resp
|
||||
|
||||
def add_route(
|
||||
self,
|
||||
route,
|
||||
route=None,
|
||||
endpoint=None,
|
||||
*,
|
||||
default=False,
|
||||
static=False,
|
||||
static=True,
|
||||
check_existing=True,
|
||||
websocket=False,
|
||||
before_request=False,
|
||||
):
|
||||
"""Add a route to the API.
|
||||
"""Adds a route to the API.
|
||||
|
||||
:param route: A string representation of the route.
|
||||
:param endpoint: The endpoint for the route -- can be a callable, a class, or graphene schema (GraphQL).
|
||||
:param endpoint: The endpoint for the route -- can be a callable, or a class.
|
||||
:param default: If ``True``, all unknown requests will route to this view.
|
||||
:param static: If ``True``, and no endpoint was passed, render "static/index.html", and it will become a default route.
|
||||
:param check_existing: If ``True``, an AssertionError will be raised, if the route is already defined.
|
||||
"""
|
||||
if check_existing:
|
||||
assert route not in self.routes
|
||||
|
||||
if not endpoint and static:
|
||||
endpoint = self.static_response
|
||||
default = True
|
||||
# Path
|
||||
if static:
|
||||
assert self.static_dir is not None
|
||||
if not endpoint:
|
||||
endpoint = self._static_response
|
||||
default = True
|
||||
|
||||
if default:
|
||||
self.default_endpoint = endpoint
|
||||
|
||||
# Can we remove it ?
|
||||
try:
|
||||
if callable(endpoint):
|
||||
endpoint.is_routed = True
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
self.routes[route] = Route(route, endpoint, websocket=websocket)
|
||||
# TODO: A better data structure or sort it once the app is loaded
|
||||
self.routes = dict(
|
||||
sorted(self.routes.items(), key=lambda item: item[1]._weight())
|
||||
self.router.add_route(
|
||||
route,
|
||||
endpoint,
|
||||
default=default,
|
||||
websocket=websocket,
|
||||
before_request=before_request,
|
||||
check_existing=check_existing,
|
||||
)
|
||||
|
||||
def default_response(self, req, resp, notfound=False, error=False):
|
||||
if resp.status_code is None:
|
||||
resp.status_code = 200
|
||||
async def _static_response(self, req, resp):
|
||||
assert self.static_dir is not None
|
||||
|
||||
if self.default_endpoint and notfound:
|
||||
self.default_endpoint(req, resp)
|
||||
else:
|
||||
if notfound:
|
||||
resp.status_code = status_codes.HTTP_404
|
||||
resp.text = "Not found."
|
||||
if error:
|
||||
resp.status_code = status_codes.HTTP_500
|
||||
resp.text = "Application error."
|
||||
|
||||
def docs_response(self, req, resp):
|
||||
resp.text = self.docs
|
||||
|
||||
def static_response(self, req, resp):
|
||||
index = (self.static_dir / "index.html").resolve()
|
||||
resp.content = ""
|
||||
if os.path.exists(index):
|
||||
with open(index, "r") as f:
|
||||
resp.text = f.read()
|
||||
|
||||
def schema_response(self, req, resp):
|
||||
resp.status_code = status_codes.HTTP_200
|
||||
resp.headers["Content-Type"] = "application/x-yaml"
|
||||
resp.content = self.openapi
|
||||
resp.html = f.read()
|
||||
else:
|
||||
resp.status_code = status_codes.HTTP_404
|
||||
resp.text = "Not found."
|
||||
|
||||
def redirect(
|
||||
self, resp, location, *, set_text=True, status_code=status_codes.HTTP_301
|
||||
):
|
||||
"""Redirects a given response to a given location.
|
||||
|
||||
:param resp: The Response to mutate.
|
||||
:param location: The location of the redirect.
|
||||
:param set_text: If ``True``, sets the Redirect body content automatically.
|
||||
:param status_code: an `API.status_codes` attribute, or an integer, representing the HTTP status code of the redirect.
|
||||
"""
|
||||
resp.redirect(location, set_text=set_text, status_code=status_code)
|
||||
|
||||
assert resp.status_code.is_300(status_code)
|
||||
def on_event(self, event_type: str, **args):
|
||||
"""Decorator for registering functions or coroutines to run at certain events
|
||||
Supported events: startup, shutdown
|
||||
|
||||
resp.status_code = status_code
|
||||
if set_text:
|
||||
resp.text = f"Redirecting to: {location}"
|
||||
resp.headers.update({"Location": location})
|
||||
Usage::
|
||||
|
||||
@staticmethod
|
||||
async def _resolve_graphql_query(req):
|
||||
# TODO: Get variables and operation_name from form data, params, request text?
|
||||
@api.on_event('startup')
|
||||
async def open_database_connection_pool():
|
||||
...
|
||||
|
||||
if "json" in req.mimetype:
|
||||
json_media = await req.media("json")
|
||||
return (
|
||||
json_media["query"],
|
||||
json_media.get("variables"),
|
||||
json_media.get("operationName"),
|
||||
)
|
||||
@api.on_event('shutdown')
|
||||
async def close_database_connection_pool():
|
||||
...
|
||||
|
||||
# Support query/q in form data.
|
||||
# Form data is awaiting https://github.com/encode/starlette/pull/102
|
||||
# if "query" in req.media("form"):
|
||||
# return req.media("form")["query"], None, None
|
||||
# if "q" in req.media("form"):
|
||||
# return req.media("form")["q"], None, None
|
||||
"""
|
||||
|
||||
# Support query/q in params.
|
||||
if "query" in req.params:
|
||||
return req.params["query"], None, None
|
||||
if "q" in req.params:
|
||||
return req.params["q"], None, None
|
||||
def decorator(func):
|
||||
self.add_event_handler(event_type, func, **args)
|
||||
return func
|
||||
|
||||
# Otherwise, the request text is used (typical).
|
||||
# TODO: Make some assertions about content-type here.
|
||||
return req.text, None, None
|
||||
return decorator
|
||||
|
||||
async def graphql_response(self, req, resp, schema):
|
||||
show_graphiql = req.method == "get" and req.accepts("text/html")
|
||||
def add_event_handler(self, event_type, handler):
|
||||
"""Adds an event handler to the API.
|
||||
|
||||
if show_graphiql:
|
||||
resp.content = self.template_string(GRAPHIQL, endpoint=req.url.path)
|
||||
return
|
||||
:param event_type: A string in ("startup", "shutdown")
|
||||
:param handler: The function to run. Can be either a function or a coroutine.
|
||||
"""
|
||||
|
||||
query, variables, operation_name = await self._resolve_graphql_query(req)
|
||||
result = schema.execute(
|
||||
query, variables=variables, operation_name=operation_name
|
||||
)
|
||||
result, status_code = encode_execution_results(
|
||||
[result],
|
||||
is_batch=False,
|
||||
format_error=default_format_error,
|
||||
encode=partial(json_encode, pretty=False),
|
||||
)
|
||||
resp.media = json.loads(result)
|
||||
return (query, result, status_code)
|
||||
self.router.add_event_handler(event_type, handler)
|
||||
|
||||
def route(self, route, **options):
|
||||
def route(self, route=None, **options):
|
||||
"""Decorator for creating new routes around function and class definitions.
|
||||
|
||||
Usage::
|
||||
@@ -480,7 +287,7 @@ class API:
|
||||
:param route: String representation of the route to be used (shouldn't be parameterized).
|
||||
:param app: The other WSGI / ASGI app.
|
||||
"""
|
||||
self.apps.update({route: app})
|
||||
self.router.apps.update({route: app})
|
||||
|
||||
def session(self, base_url="http://;"):
|
||||
"""Testing HTTP client. Returns a Requests session object, able to send HTTP requests to the Responder application.
|
||||
@@ -489,89 +296,37 @@ class API:
|
||||
"""
|
||||
|
||||
if self._session is None:
|
||||
self._session = TestClient(self)
|
||||
self._session = TestClient(self, base_url=base_url)
|
||||
return self._session
|
||||
|
||||
def _route_for(self, endpoint):
|
||||
for (route, route_object) in self.routes.items():
|
||||
if route_object.endpoint == endpoint:
|
||||
return route_object
|
||||
elif route_object.endpoint_name == endpoint:
|
||||
return route_object
|
||||
|
||||
def url_for(self, endpoint, **params):
|
||||
# TODO: Absolute_url
|
||||
"""Given an endpoint, returns a rendered URL for its route.
|
||||
|
||||
:param view: The route endpoint you're searching for.
|
||||
:param endpoint: The route endpoint you're searching for.
|
||||
:param params: Data to pass into the URL generator (for parameterized URLs).
|
||||
"""
|
||||
route_object = self._route_for(endpoint)
|
||||
if route_object:
|
||||
return route_object.url(**params)
|
||||
raise ValueError
|
||||
return self.router.url_for(endpoint, **params)
|
||||
|
||||
def static_url(self, asset):
|
||||
"""Given a static asset, return its URL path."""
|
||||
return f"{self.static_route}/{str(asset)}"
|
||||
|
||||
@property
|
||||
def docs(self):
|
||||
|
||||
loader = jinja2.PrefixLoader(
|
||||
{
|
||||
self.docs_theme: jinja2.PackageLoader(
|
||||
"apistar", os.path.join("themes", self.docs_theme, "templates")
|
||||
)
|
||||
}
|
||||
)
|
||||
env = jinja2.Environment(autoescape=True, loader=loader)
|
||||
document = apistar.document.Document()
|
||||
document.content = yaml.safe_load(self.openapi)
|
||||
|
||||
template = env.get_template("/".join([self.docs_theme, "index.html"]))
|
||||
|
||||
def static_url(asset):
|
||||
return f"{self.static_route}/{asset}"
|
||||
# return asset
|
||||
|
||||
return template.render(
|
||||
document=document,
|
||||
langs=["javascript", "python"],
|
||||
code_style=None,
|
||||
static_url=static_url,
|
||||
schema_url="/schema.yml",
|
||||
)
|
||||
|
||||
def template(self, name_, **values):
|
||||
def template(self, filename, *args, **kwargs):
|
||||
"""Renders the given `jinja2 <http://jinja.pocoo.org/docs/>`_ template, with provided values supplied.
|
||||
|
||||
Note: The current ``api`` instance is by default passed into the view. This is set in the dict ``api.jinja_values_base``.
|
||||
|
||||
:param name_: The filename of the jinja2 template, in ``templates_dir``.
|
||||
:param values: Data to pass into the template.
|
||||
:param filename: The filename of the jinja2 template, in ``templates_dir``.
|
||||
:param *args: Data to pass into the template.
|
||||
:param *kwargs: Date to pass into the template.
|
||||
"""
|
||||
# Prepopulate values with base
|
||||
values = {**self.jinja_values_base, **values}
|
||||
return self.templates.render(filename, *args, **kwargs)
|
||||
|
||||
template = self.jinja_env.get_template(name_)
|
||||
return template.render(**values)
|
||||
|
||||
def template_string(self, s_, **values):
|
||||
def template_string(self, source, *args, **kwargs):
|
||||
"""Renders the given `jinja2 <http://jinja.pocoo.org/docs/>`_ template string, with provided values supplied.
|
||||
|
||||
Note: The current ``api`` instance is by default passed into the view. This is set in the dict ``api.jinja_values_base``.
|
||||
|
||||
:param s_: The template to use.
|
||||
:param values: Data to pass into the template.
|
||||
:param source: The template to use.
|
||||
:param *args: Data to pass into the template.
|
||||
:param **kwargs: Data to pass into the template.
|
||||
"""
|
||||
# Prepopulate values with base
|
||||
values = {**self.jinja_values_base, **values}
|
||||
return self.templates.render_string(source, *args, **kwargs)
|
||||
|
||||
template = self.jinja_env.from_string(s_)
|
||||
return template.render(**values)
|
||||
|
||||
def run(self, address=None, port=None, debug=False, **options):
|
||||
def serve(self, *, address=None, port=None, debug=False, **options):
|
||||
"""Runs the application with uvicorn. If the ``PORT`` environment
|
||||
variable is set, requests will be served on that port automatically to all
|
||||
known hosts.
|
||||
@@ -592,4 +347,15 @@ class API:
|
||||
if port is None:
|
||||
port = 5042
|
||||
|
||||
uvicorn.run(self, host=address, port=port, debug=debug, **options)
|
||||
def spawn():
|
||||
uvicorn.run(self, host=address, port=port, debug=debug, **options)
|
||||
|
||||
spawn()
|
||||
|
||||
def run(self, **kwargs):
|
||||
if "debug" not in kwargs:
|
||||
kwargs.update({"debug": self.debug})
|
||||
self.serve(**kwargs)
|
||||
|
||||
async def __call__(self, scope, receive, send):
|
||||
await self.app(scope, receive, send)
|
||||
|
||||
+11
-2
@@ -1,6 +1,9 @@
|
||||
import traceback
|
||||
import multiprocessing
|
||||
import asyncio
|
||||
import functools
|
||||
import concurrent.futures
|
||||
import multiprocessing
|
||||
import traceback
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
|
||||
|
||||
class BackgroundQueue:
|
||||
@@ -33,3 +36,9 @@ class BackgroundQueue:
|
||||
return result
|
||||
|
||||
return do_task
|
||||
|
||||
async def __call__(self, func, *args, **kwargs) -> None:
|
||||
if asyncio.iscoroutinefunction(func):
|
||||
return await asyncio.ensure_future(func(*args, **kwargs))
|
||||
else:
|
||||
return await run_in_threadpool(func, *args, **kwargs)
|
||||
|
||||
+1
-1
@@ -40,4 +40,4 @@ def cli():
|
||||
prop = "api"
|
||||
|
||||
app = __import__(module)
|
||||
getattr(app, prop).run()
|
||||
getattr(app, prop).run()
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
from .graphql import GraphQLView
|
||||
@@ -0,0 +1,70 @@
|
||||
import json
|
||||
from functools import partial
|
||||
|
||||
from graphql_server import default_format_error, encode_execution_results, json_encode
|
||||
|
||||
from .templates import GRAPHIQL
|
||||
|
||||
|
||||
class GraphQLView:
|
||||
def __init__(self, *, api, schema):
|
||||
self.api = api
|
||||
self.schema = schema
|
||||
|
||||
@staticmethod
|
||||
async def _resolve_graphql_query(req):
|
||||
# TODO: Get variables and operation_name from form data, params, request text?
|
||||
|
||||
if "json" in req.mimetype:
|
||||
json_media = await req.media("json")
|
||||
return (
|
||||
json_media["query"],
|
||||
json_media.get("variables"),
|
||||
json_media.get("operationName"),
|
||||
)
|
||||
|
||||
# Support query/q in form data.
|
||||
# Form data is awaiting https://github.com/encode/starlette/pull/102
|
||||
# if "query" in req.media("form"):
|
||||
# return req.media("form")["query"], None, None
|
||||
# if "q" in req.media("form"):
|
||||
# return req.media("form")["q"], None, None
|
||||
|
||||
# Support query/q in params.
|
||||
if "query" in req.params:
|
||||
return req.params["query"], None, None
|
||||
if "q" in req.params:
|
||||
return req.params["q"], None, None
|
||||
|
||||
# Otherwise, the request text is used (typical).
|
||||
# TODO: Make some assertions about content-type here.
|
||||
return req.text, None, None
|
||||
|
||||
async def graphql_response(self, req, resp, schema):
|
||||
show_graphiql = req.method == "get" and req.accepts("text/html")
|
||||
|
||||
if show_graphiql:
|
||||
resp.content = self.api.templates.render_string(
|
||||
GRAPHIQL, endpoint=req.url.path
|
||||
)
|
||||
return
|
||||
|
||||
query, variables, operation_name = await self._resolve_graphql_query(req)
|
||||
context = {"request": req, "response": resp}
|
||||
result = schema.execute(
|
||||
query, variables=variables, operation_name=operation_name, context=context
|
||||
)
|
||||
result, status_code = encode_execution_results(
|
||||
[result],
|
||||
is_batch=False,
|
||||
format_error=default_format_error,
|
||||
encode=partial(json_encode, pretty=False),
|
||||
)
|
||||
resp.media = json.loads(result)
|
||||
return (query, result, status_code)
|
||||
|
||||
async def on_request(self, req, resp):
|
||||
await self.graphql_response(req, resp, self.schema)
|
||||
|
||||
async def __call__(self, req, resp):
|
||||
await self.on_request(req, resp)
|
||||
@@ -0,0 +1,160 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import apistar
|
||||
import jinja2
|
||||
import yaml
|
||||
from apispec import APISpec, yaml_utils
|
||||
from apispec.ext.marshmallow import MarshmallowPlugin
|
||||
|
||||
from responder.statics import DEFAULT_API_THEME
|
||||
from responder.staticfiles import StaticFiles
|
||||
from responder import status_codes
|
||||
|
||||
|
||||
class Schema:
|
||||
def __init__(
|
||||
self,
|
||||
app,
|
||||
title,
|
||||
version,
|
||||
plugins=None,
|
||||
description=None,
|
||||
terms_of_service=None,
|
||||
contact=None,
|
||||
license=None,
|
||||
openapi=None,
|
||||
openapi_route="/schema.yml",
|
||||
docs_route="/docs/",
|
||||
static_route="/static",
|
||||
):
|
||||
self.app = app
|
||||
self.schemas = {}
|
||||
self.title = title
|
||||
self.version = version
|
||||
self.description = description
|
||||
self.terms_of_service = terms_of_service
|
||||
self.contact = contact
|
||||
self.license = license
|
||||
|
||||
self.openapi_version = openapi
|
||||
self.openapi_route = openapi_route
|
||||
|
||||
self.docs_theme = DEFAULT_API_THEME
|
||||
self.docs_route = docs_route
|
||||
|
||||
self.plugins = [MarshmallowPlugin()] if plugins is None else plugins
|
||||
|
||||
if self.openapi_version is not None:
|
||||
self.app.add_route(self.openapi_route, self.schema_response)
|
||||
|
||||
if self.docs_route is not None:
|
||||
self.app.add_route(self.docs_route, self.docs_response)
|
||||
|
||||
theme_path = (
|
||||
Path(apistar.__file__).parent / "themes" / self.docs_theme / "static"
|
||||
).resolve()
|
||||
|
||||
self.static_route = static_route
|
||||
|
||||
self.app.static_app.add_directory(theme_path)
|
||||
|
||||
@property
|
||||
def _apispec(self):
|
||||
|
||||
info = {}
|
||||
if self.description is not None:
|
||||
info["description"] = self.description
|
||||
if self.terms_of_service is not None:
|
||||
info["termsOfService"] = self.terms_of_service
|
||||
if self.contact is not None:
|
||||
info["contact"] = self.contact
|
||||
if self.license is not None:
|
||||
info["license"] = self.license
|
||||
|
||||
spec = APISpec(
|
||||
title=self.title,
|
||||
version=self.version,
|
||||
openapi_version=self.openapi_version,
|
||||
plugins=self.plugins,
|
||||
info=info,
|
||||
)
|
||||
|
||||
for route in self.app.router.routes:
|
||||
if route.description:
|
||||
operations = yaml_utils.load_operations_from_docstring(
|
||||
route.description
|
||||
)
|
||||
spec.path(path=route.route, operations=operations)
|
||||
|
||||
for name, schema in self.schemas.items():
|
||||
spec.components.schema(name, schema=schema)
|
||||
|
||||
return spec
|
||||
|
||||
@property
|
||||
def openapi(self):
|
||||
return self._apispec.to_yaml()
|
||||
|
||||
def add_schema(self, name, schema, check_existing=True):
|
||||
"""Adds a marshmallow schema to the API specification."""
|
||||
if check_existing:
|
||||
assert name not in self.schemas
|
||||
|
||||
self.schemas[name] = schema
|
||||
|
||||
def schema(self, name, **options):
|
||||
"""Decorator for creating new routes around function and class definitions.
|
||||
|
||||
Usage::
|
||||
|
||||
from marshmallow import Schema, fields
|
||||
|
||||
@api.schema("Pet")
|
||||
class PetSchema(Schema):
|
||||
name = fields.Str()
|
||||
|
||||
"""
|
||||
|
||||
def decorator(f):
|
||||
self.add_schema(name=name, schema=f, **options)
|
||||
return f
|
||||
|
||||
return decorator
|
||||
|
||||
@property
|
||||
def docs(self):
|
||||
|
||||
loader = jinja2.PrefixLoader(
|
||||
{
|
||||
self.docs_theme: jinja2.PackageLoader(
|
||||
"apistar", os.path.join("themes", self.docs_theme, "templates")
|
||||
)
|
||||
}
|
||||
)
|
||||
env = jinja2.Environment(autoescape=True, loader=loader)
|
||||
document = apistar.document.Document()
|
||||
document.content = yaml.safe_load(self.openapi)
|
||||
|
||||
template = env.get_template("/".join([self.docs_theme, "index.html"]))
|
||||
|
||||
return template.render(
|
||||
document=document,
|
||||
langs=["javascript", "python"],
|
||||
code_style=None,
|
||||
static_url=self.static_url,
|
||||
schema_url="/schema.yml",
|
||||
)
|
||||
|
||||
def static_url(self, asset):
|
||||
"""Given a static asset, return its URL path."""
|
||||
assert self.static_route is not None
|
||||
return f"{self.static_route}/{str(asset)}"
|
||||
|
||||
def docs_response(self, req, resp):
|
||||
resp.html = self.docs
|
||||
|
||||
def schema_response(self, req, resp):
|
||||
resp.status_code = status_codes.HTTP_200
|
||||
resp.headers["Content-Type"] = "application/x-yaml"
|
||||
resp.content = self.openapi
|
||||
+31
-6
@@ -1,15 +1,31 @@
|
||||
from urllib.parse import parse_qs
|
||||
import json
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import yaml
|
||||
import json
|
||||
from parse import findall
|
||||
from .models import QueryDict
|
||||
from requests_toolbelt.multipart import decoder
|
||||
|
||||
from .models import QueryDict
|
||||
|
||||
|
||||
async def format_form(r, encode=False):
|
||||
if encode:
|
||||
pass
|
||||
elif "multipart/form-data" in r.headers.get("Content-Type"):
|
||||
decode = decoder.MultipartDecoder(await r.content, r.mimetype)
|
||||
querys = list()
|
||||
for part in decode.parts:
|
||||
header = part.headers.get(b"Content-Disposition").decode("utf-8")
|
||||
text = part.text
|
||||
|
||||
for section in [h.strip() for h in header.split(";")]:
|
||||
split = section.split("=")
|
||||
if len(split) > 1:
|
||||
key = split[1]
|
||||
key = key[1:-1]
|
||||
querys.append((key, text))
|
||||
|
||||
content = urlencode(querys)
|
||||
return QueryDict(content)
|
||||
else:
|
||||
return QueryDict(await r.text)
|
||||
|
||||
@@ -38,6 +54,7 @@ async def format_files(r, encode=False):
|
||||
dump = {}
|
||||
for part in decoded.parts:
|
||||
header = part.headers[b"Content-Disposition"].decode("utf-8")
|
||||
mimetype = part.headers.get(b"Content-Type", None)
|
||||
filename = None
|
||||
|
||||
for section in [h.strip() for h in header.split(";")]:
|
||||
@@ -50,9 +67,17 @@ async def format_files(r, encode=False):
|
||||
|
||||
if key == "filename":
|
||||
filename = value
|
||||
elif key == "name":
|
||||
formname = value
|
||||
|
||||
if filename:
|
||||
dump[filename] = part.content
|
||||
if mimetype is None:
|
||||
dump[formname] = part.content
|
||||
else:
|
||||
dump[formname] = {
|
||||
"filename": filename,
|
||||
"content": part.content,
|
||||
"content-type": mimetype.decode("utf-8"),
|
||||
}
|
||||
return dump
|
||||
|
||||
|
||||
|
||||
+137
-46
@@ -1,22 +1,28 @@
|
||||
import functools
|
||||
import io
|
||||
import inspect
|
||||
import json
|
||||
import gzip
|
||||
from urllib.parse import parse_qs
|
||||
from base64 import b64decode
|
||||
from http.cookies import SimpleCookie
|
||||
|
||||
|
||||
import chardet
|
||||
import rfc3986
|
||||
import graphene
|
||||
import yaml
|
||||
|
||||
from requests.structures import CaseInsensitiveDict
|
||||
from requests.cookies import RequestsCookieJar
|
||||
|
||||
from starlette.datastructures import MutableHeaders
|
||||
from starlette.requests import Request as StarletteRequest
|
||||
from starlette.responses import Response as StarletteResponse
|
||||
from starlette.requests import Request as StarletteRequest, State
|
||||
from starlette.responses import (
|
||||
Response as StarletteResponse,
|
||||
StreamingResponse as StarletteStreamingResponse,
|
||||
)
|
||||
|
||||
from urllib.parse import parse_qs
|
||||
|
||||
from .status_codes import HTTP_200
|
||||
from .status_codes import HTTP_200, HTTP_301
|
||||
from .statics import DEFAULT_ENCODING
|
||||
|
||||
|
||||
@@ -88,13 +94,20 @@ class QueryDict(dict):
|
||||
yield from super().items()
|
||||
|
||||
|
||||
# TODO: add slots
|
||||
class Request:
|
||||
__slots__ = ["_starlette", "formats", "_headers", "_encoding", "api", "_content"]
|
||||
__slots__ = [
|
||||
"_starlette",
|
||||
"formats",
|
||||
"_headers",
|
||||
"_encoding",
|
||||
"api",
|
||||
"_content",
|
||||
"_cookies",
|
||||
]
|
||||
|
||||
def __init__(self, scope, receive, api=None):
|
||||
def __init__(self, scope, receive, api=None, formats=None):
|
||||
self._starlette = StarletteRequest(scope, receive)
|
||||
self.formats = None
|
||||
self.formats = formats
|
||||
self._encoding = None
|
||||
self.api = api
|
||||
self._content = None
|
||||
@@ -104,15 +117,12 @@ class Request:
|
||||
headers[key] = value
|
||||
|
||||
self._headers = headers
|
||||
self._cookies = None
|
||||
|
||||
@property
|
||||
def session(self):
|
||||
"""The session data, in dict form, from the Request."""
|
||||
if "Responder-Session" in self.cookies:
|
||||
data = self.cookies[self.api.session_cookie]
|
||||
data = self.api._signer.unsign(data)
|
||||
return json.loads(data)
|
||||
return {}
|
||||
return self._starlette.session
|
||||
|
||||
@property
|
||||
def headers(self):
|
||||
@@ -141,14 +151,17 @@ class Request:
|
||||
@property
|
||||
def cookies(self):
|
||||
"""The cookies sent in the Request, as a dictionary."""
|
||||
cookies = RequestsCookieJar()
|
||||
cookie_header = self.headers.get("cookie", "")
|
||||
if self._cookies is None:
|
||||
cookies = RequestsCookieJar()
|
||||
cookie_header = self.headers.get("Cookie", "")
|
||||
|
||||
bc = SimpleCookie(cookie_header)
|
||||
for k, v in bc.items():
|
||||
cookies[k] = v
|
||||
bc = SimpleCookie(cookie_header)
|
||||
for key, morsel in bc.items():
|
||||
cookies[key] = morsel.value
|
||||
|
||||
return cookies.get_dict()
|
||||
self._cookies = cookies.get_dict()
|
||||
|
||||
return self._cookies
|
||||
|
||||
@property
|
||||
def params(self):
|
||||
@@ -158,6 +171,19 @@ class Request:
|
||||
except AttributeError:
|
||||
return QueryDict({})
|
||||
|
||||
@property
|
||||
def state(self) -> State:
|
||||
"""
|
||||
Use the state to store additional information.
|
||||
|
||||
This can be a very helpful feature, if you want to hand over
|
||||
information from a middelware or a route decorator to the
|
||||
actual route handler.
|
||||
|
||||
Usage: ``request.state.time_started = time.time()``
|
||||
"""
|
||||
return self._starlette.state
|
||||
|
||||
@property
|
||||
async def encoding(self):
|
||||
"""The encoding of the Request's body. Can be set, manually. Must be awaited."""
|
||||
@@ -165,13 +191,7 @@ class Request:
|
||||
if self._encoding:
|
||||
return self._encoding
|
||||
|
||||
# Then try what's defined by the Request.
|
||||
elif await self.declared_encoding:
|
||||
return self.declared_encoding
|
||||
|
||||
# Then, automatically detect the encoding.
|
||||
else:
|
||||
return await self.apparent_encoding
|
||||
return await self.apparent_encoding
|
||||
|
||||
@encoding.setter
|
||||
def encoding(self, value):
|
||||
@@ -201,8 +221,8 @@ class Request:
|
||||
|
||||
if declared_encoding:
|
||||
return declared_encoding
|
||||
else:
|
||||
return chardet.detect(await self.content)["encoding"]
|
||||
|
||||
return chardet.detect(await self.content)["encoding"] or DEFAULT_ENCODING
|
||||
|
||||
@property
|
||||
def is_secure(self):
|
||||
@@ -228,11 +248,21 @@ class Request:
|
||||
return await format(self)
|
||||
|
||||
|
||||
def content_setter(mimetype):
|
||||
def getter(instance):
|
||||
return instance.content
|
||||
|
||||
def setter(instance, value):
|
||||
instance.content = value
|
||||
instance.mimetype = mimetype
|
||||
|
||||
return property(fget=getter, fset=setter)
|
||||
|
||||
|
||||
class Response:
|
||||
__slots__ = [
|
||||
"req",
|
||||
"status_code",
|
||||
"text",
|
||||
"content",
|
||||
"encoding",
|
||||
"media",
|
||||
@@ -240,33 +270,58 @@ class Response:
|
||||
"formats",
|
||||
"cookies",
|
||||
"session",
|
||||
"mimetype",
|
||||
"_stream",
|
||||
]
|
||||
|
||||
text = content_setter("text/plain")
|
||||
html = content_setter("text/html")
|
||||
|
||||
def __init__(self, req, *, formats):
|
||||
self.req = req
|
||||
self.status_code = None #: The HTTP Status Code to use for the Response.
|
||||
self.text = None #: A unicode representation of the response body.
|
||||
self.content = None #: A bytes representation of the response body.
|
||||
self.mimetype = None
|
||||
self.encoding = DEFAULT_ENCODING
|
||||
self.media = (
|
||||
None
|
||||
) #: A Python object that will be content-negotiated and sent back to the client. Typically, in JSON formatting.
|
||||
self.media = None #: A Python object that will be content-negotiated and sent back to the client. Typically, in JSON formatting.
|
||||
self._stream = None
|
||||
self.headers = (
|
||||
{}
|
||||
) #: A Python dictionary of ``{key: value}``, representing the headers of the response.
|
||||
self.formats = formats
|
||||
self.cookies = {} #: The cookies set in the Response, as a dictionary
|
||||
self.cookies = SimpleCookie() #: The cookies set in the Response
|
||||
self.session = (
|
||||
req.session.copy()
|
||||
req.session
|
||||
) #: The cookie-based session data, in dict form, to add to the Response.
|
||||
|
||||
# Property or func/dec
|
||||
def stream(self, func, *args, **kwargs):
|
||||
assert inspect.isasyncgenfunction(func)
|
||||
|
||||
self._stream = functools.partial(func, *args, **kwargs)
|
||||
|
||||
return func
|
||||
|
||||
def redirect(self, location, *, set_text=True, status_code=HTTP_301):
|
||||
self.status_code = status_code
|
||||
if set_text:
|
||||
self.text = f"Redirecting to: {location}"
|
||||
self.headers.update({"Location": location})
|
||||
|
||||
@property
|
||||
async def body(self):
|
||||
if self.content:
|
||||
return (self.content, {})
|
||||
if self._stream is not None:
|
||||
return (self._stream(), {})
|
||||
|
||||
if self.text:
|
||||
return (self.text.encode(self.encoding), {"Encoding": self.encoding})
|
||||
if self.content is not None:
|
||||
headers = {}
|
||||
content = self.content
|
||||
if self.mimetype is not None:
|
||||
headers["Content-Type"] = self.mimetype
|
||||
if self.mimetype == "text/plain" and self.encoding is not None:
|
||||
headers["Encoding"] = self.encoding
|
||||
content = content.encode(self.encoding)
|
||||
return (content, headers)
|
||||
|
||||
for format in self.formats:
|
||||
if self.req.accepts(format):
|
||||
@@ -278,12 +333,48 @@ class Response:
|
||||
{"Content-Type": "application/json"},
|
||||
)
|
||||
|
||||
async def __call__(self, receive, send):
|
||||
def set_cookie(
|
||||
self,
|
||||
key,
|
||||
value="",
|
||||
expires=None,
|
||||
path="/",
|
||||
domain=None,
|
||||
max_age=None,
|
||||
secure=False,
|
||||
httponly=True,
|
||||
):
|
||||
self.cookies[key] = value
|
||||
morsel = self.cookies[key]
|
||||
if expires is not None:
|
||||
morsel["expires"] = expires
|
||||
if path is not None:
|
||||
morsel["path"] = path
|
||||
if domain is not None:
|
||||
morsel["domain"] = domain
|
||||
if max_age is not None:
|
||||
morsel["max-age"] = max_age
|
||||
morsel["secure"] = secure
|
||||
morsel["httponly"] = httponly
|
||||
|
||||
def _prepare_cookies(self, starlette_response):
|
||||
cookie_header = (
|
||||
(b"set-cookie", morsel.output(header="").lstrip().encode("latin-1"))
|
||||
for morsel in self.cookies.values()
|
||||
)
|
||||
starlette_response.raw_headers.extend(cookie_header)
|
||||
|
||||
async def __call__(self, scope, receive, send):
|
||||
body, headers = await self.body
|
||||
if self.headers:
|
||||
headers.update(self.headers)
|
||||
|
||||
response = StarletteResponse(
|
||||
body, status_code=self.status_code, headers=headers
|
||||
)
|
||||
await response(receive, send)
|
||||
if self._stream is not None:
|
||||
response_cls = StarletteStreamingResponse
|
||||
else:
|
||||
response_cls = StarletteResponse
|
||||
|
||||
response = response_cls(body, status_code=self.status_code, headers=headers)
|
||||
self._prepare_cookies(response)
|
||||
|
||||
await response(scope, receive, send)
|
||||
|
||||
+326
-50
@@ -1,36 +1,77 @@
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
from parse import parse
|
||||
import inspect
|
||||
import traceback
|
||||
from collections import defaultdict
|
||||
|
||||
from starlette.middleware.wsgi import WSGIMiddleware
|
||||
from starlette.websockets import WebSocket, WebSocketClose
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
from starlette.exceptions import HTTPException
|
||||
|
||||
from .models import Request, Response
|
||||
from . import status_codes
|
||||
from .formats import get_formats
|
||||
from .statics import DEFAULT_SESSION_COOKIE
|
||||
|
||||
|
||||
def memoize(f):
|
||||
def helper(self, s):
|
||||
memoize_key = f"{f.__name__}:{s}"
|
||||
if memoize_key not in self._memo:
|
||||
self._memo[memoize_key] = f(self, s)
|
||||
return self._memo[memoize_key]
|
||||
_CONVERTORS = {
|
||||
"int": (int, r"\d+"),
|
||||
"str": (str, r"[^/]+"),
|
||||
"float": (float, r"\d+(.\d+)?"),
|
||||
}
|
||||
|
||||
return helper
|
||||
PARAM_RE = re.compile("{([a-zA-Z_][a-zA-Z0-9_]*)(:[a-zA-Z_][a-zA-Z0-9_]*)?}")
|
||||
|
||||
|
||||
class Route:
|
||||
_param_pattern = re.compile(r"{([^{}]*)}")
|
||||
def compile_path(path):
|
||||
path_re = "^"
|
||||
param_convertors = {}
|
||||
idx = 0
|
||||
|
||||
def __init__(self, route, endpoint, websocket=False):
|
||||
for match in PARAM_RE.finditer(path):
|
||||
param_name, convertor_type = match.groups(default="str")
|
||||
convertor_type = convertor_type.lstrip(":")
|
||||
assert (
|
||||
convertor_type in _CONVERTORS.keys()
|
||||
), f"Unknown path convertor '{convertor_type}'"
|
||||
convertor, convertor_re = _CONVERTORS[convertor_type]
|
||||
|
||||
path_re += path[idx : match.start()]
|
||||
path_re += rf"(?P<{param_name}>{convertor_re})"
|
||||
|
||||
param_convertors[param_name] = convertor
|
||||
|
||||
idx = match.end()
|
||||
|
||||
path_re += path[idx:] + "$"
|
||||
|
||||
return re.compile(path_re), param_convertors
|
||||
|
||||
|
||||
class BaseRoute:
|
||||
def matches(self, scope):
|
||||
raise NotImplementedError()
|
||||
|
||||
async def __call__(self, scope, receive, send):
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
class Route(BaseRoute):
|
||||
def __init__(self, route, endpoint, *, before_request=False):
|
||||
assert route.startswith("/"), "Route path must start with '/'"
|
||||
self.route = route
|
||||
self.endpoint = endpoint
|
||||
self.uses_websocket = websocket
|
||||
self._memo = {}
|
||||
self.before_request = before_request
|
||||
|
||||
self.path_re, self.param_convertors = compile_path(route)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Route {self.route!r}={self.endpoint!r}>"
|
||||
|
||||
def __eq__(self, other):
|
||||
if hasattr(other, "route"):
|
||||
# Being compared to other routes.
|
||||
return self.route == other.route
|
||||
else:
|
||||
# Strings.
|
||||
return self.does_match(other)
|
||||
def url(self, **params):
|
||||
return self.route.format(**params)
|
||||
|
||||
@property
|
||||
def endpoint_name(self):
|
||||
@@ -40,43 +81,278 @@ class Route:
|
||||
def description(self):
|
||||
return self.endpoint.__doc__
|
||||
|
||||
@property
|
||||
def has_parameters(self):
|
||||
return bool(self._param_pattern.search(self.route))
|
||||
def matches(self, scope):
|
||||
if scope["type"] != "http":
|
||||
return False, {}
|
||||
|
||||
@memoize
|
||||
def does_match(self, s):
|
||||
if s == self.route:
|
||||
return True
|
||||
path = scope["path"]
|
||||
match = self.path_re.match(path)
|
||||
|
||||
named = self.incoming_matches(s)
|
||||
return bool(len(named))
|
||||
if match is None:
|
||||
return False, {}
|
||||
|
||||
@memoize
|
||||
def incoming_matches(self, s):
|
||||
results = parse(self.route, s)
|
||||
return results.named if results else {}
|
||||
matched_params = match.groupdict()
|
||||
for key, value in matched_params.items():
|
||||
matched_params[key] = self.param_convertors[key](value)
|
||||
|
||||
return True, {"path_params": {**matched_params}}
|
||||
|
||||
async def __call__(self, scope, receive, send):
|
||||
request = Request(scope, receive, formats=get_formats())
|
||||
response = Response(req=request, formats=get_formats())
|
||||
|
||||
path_params = scope.get("path_params", {})
|
||||
before_requests = scope.get("before_requests", [])
|
||||
|
||||
for before_request in before_requests.get("http", []):
|
||||
if asyncio.iscoroutinefunction(before_request):
|
||||
await before_request(request, response)
|
||||
else:
|
||||
await run_in_threadpool(before_request, request, response)
|
||||
|
||||
views = []
|
||||
|
||||
if inspect.isclass(self.endpoint):
|
||||
endpoint = self.endpoint()
|
||||
on_request = getattr(endpoint, "on_request", None)
|
||||
if on_request:
|
||||
views.append(on_request)
|
||||
|
||||
method_name = f"on_{request.method}"
|
||||
try:
|
||||
view = getattr(endpoint, method_name)
|
||||
views.append(view)
|
||||
except AttributeError:
|
||||
if on_request is None:
|
||||
raise HTTPException(status_code=status_codes.HTTP_405)
|
||||
else:
|
||||
views.append(self.endpoint)
|
||||
|
||||
for view in views:
|
||||
# "Monckey patch" for graphql: explicitly checking __call__
|
||||
if asyncio.iscoroutinefunction(view) or asyncio.iscoroutinefunction(
|
||||
view.__call__
|
||||
):
|
||||
await view(request, response, **path_params)
|
||||
else:
|
||||
await run_in_threadpool(view, request, response, **path_params)
|
||||
|
||||
if response.status_code is None:
|
||||
response.status_code = status_codes.HTTP_200
|
||||
|
||||
await response(scope, receive, send)
|
||||
|
||||
def __eq__(self, other):
|
||||
# [TODO] compare to str ?
|
||||
return self.route == other.route and self.endpoint == other.endpoint
|
||||
|
||||
def __hash__(self):
|
||||
return hash(self.route) ^ hash(self.endpoint) ^ hash(self.before_request)
|
||||
|
||||
|
||||
class WebSocketRoute(BaseRoute):
|
||||
def __init__(self, route, endpoint, *, before_request=False):
|
||||
assert route.startswith("/"), "Route path must start with '/'"
|
||||
self.route = route
|
||||
self.endpoint = endpoint
|
||||
self.before_request = before_request
|
||||
|
||||
self.path_re, self.param_convertors = compile_path(route)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Route {self.route!r}={self.endpoint!r}>"
|
||||
|
||||
def url(self, **params):
|
||||
return self.route.format(**params)
|
||||
|
||||
def _weight(self):
|
||||
params = set(self._param_pattern.findall(self.route))
|
||||
params_count = len(params)
|
||||
return params_count != 0, -params_count
|
||||
@property
|
||||
def endpoint_name(self):
|
||||
return self.endpoint.__name__
|
||||
|
||||
@property
|
||||
def is_graphql(self):
|
||||
return hasattr(self.endpoint, "get_graphql_type")
|
||||
def description(self):
|
||||
return self.endpoint.__doc__
|
||||
|
||||
@property
|
||||
def is_class_based(self):
|
||||
return hasattr(self.endpoint, "__class__")
|
||||
|
||||
@property
|
||||
def is_function(self):
|
||||
# TODO: Should we remove is_routed ?
|
||||
routed = hasattr(self.endpoint, "is_routed")
|
||||
code = hasattr(self.endpoint, "__code__")
|
||||
kwdefaults = hasattr(self.endpoint, "__kwdefaults__")
|
||||
return all((callable(self.endpoint), code, kwdefaults))
|
||||
def matches(self, scope):
|
||||
if scope["type"] != "websocket":
|
||||
return False, {}
|
||||
|
||||
path = scope["path"]
|
||||
match = self.path_re.match(path)
|
||||
|
||||
if match is None:
|
||||
return False, {}
|
||||
|
||||
matched_params = match.groupdict()
|
||||
for key, value in matched_params.items():
|
||||
matched_params[key] = self.param_convertors[key](value)
|
||||
|
||||
return True, {"path_params": {**matched_params}}
|
||||
|
||||
async def __call__(self, scope, receive, send):
|
||||
ws = WebSocket(scope, receive, send)
|
||||
|
||||
before_requests = scope.get("before_requests", [])
|
||||
for before_request in before_requests.get("ws", []):
|
||||
await before_request(ws)
|
||||
|
||||
await self.endpoint(ws)
|
||||
|
||||
def __eq__(self, other):
|
||||
# [TODO] compare to str ?
|
||||
return self.route == other.route and self.endpoint == other.endpoint
|
||||
|
||||
def __hash__(self):
|
||||
return hash(self.route) ^ hash(self.endpoint) ^ hash(self.before_request)
|
||||
|
||||
|
||||
class Router:
|
||||
def __init__(self, routes=None, default_response=None, before_requests=None):
|
||||
self.routes = [] if routes is None else list(routes)
|
||||
# [TODO] Make its own router
|
||||
self.apps = {}
|
||||
self.default_endpoint = (
|
||||
self.default_response if default_response is None else default_response
|
||||
)
|
||||
self.before_requests = (
|
||||
{"http": [], "ws": []} if before_requests is None else before_requests
|
||||
)
|
||||
self.events = defaultdict(list)
|
||||
|
||||
def add_route(
|
||||
self,
|
||||
route=None,
|
||||
endpoint=None,
|
||||
*,
|
||||
default=False,
|
||||
websocket=False,
|
||||
before_request=False,
|
||||
check_existing=False,
|
||||
):
|
||||
"""Adds a route to the router.
|
||||
:param route: A string representation of the route
|
||||
:param endpoint: The endpoint for the route -- can be callable, or class.
|
||||
:param default: If ``True``, all unknown requests will route to this view.
|
||||
"""
|
||||
if before_request:
|
||||
if websocket:
|
||||
self.before_requests.setdefault("ws", []).append(endpoint)
|
||||
else:
|
||||
self.before_requests.setdefault("http", []).append(endpoint)
|
||||
return
|
||||
|
||||
if check_existing:
|
||||
assert not self.routes or route not in (
|
||||
item.route for item in self.routes
|
||||
), f"Route '{route}' already exists"
|
||||
|
||||
if default:
|
||||
self.default_endpoint = endpoint
|
||||
|
||||
if websocket:
|
||||
route = WebSocketRoute(route, endpoint)
|
||||
else:
|
||||
route = Route(route, endpoint)
|
||||
|
||||
self.routes.append(route)
|
||||
|
||||
def mount(self, route, app):
|
||||
"""Mounts ASGI / WSGI applications at a given route"""
|
||||
self.apps.update(route, app)
|
||||
|
||||
def add_event_handler(self, event_type, handler):
|
||||
assert event_type in (
|
||||
"startup",
|
||||
"shutdown",
|
||||
), f"Only 'startup' and 'shutdown' events are supported, not {event_type}."
|
||||
self.events[event_type].append(handler)
|
||||
|
||||
async def trigger_event(self, event_type):
|
||||
for handler in self.events.get(event_type, []):
|
||||
if asyncio.iscoroutinefunction(handler):
|
||||
await handler()
|
||||
else:
|
||||
handler()
|
||||
|
||||
def before_request(self, endpoint, websocket=False):
|
||||
if websocket:
|
||||
self.before_requests.setdefault("ws", []).append(endpoint)
|
||||
else:
|
||||
self.before_requests.setdefault("http", []).append(endpoint)
|
||||
|
||||
def url_for(self, endpoint, **params):
|
||||
# TODO: Check for params
|
||||
for route in self.routes:
|
||||
if endpoint in (route.endpoint, route.endpoint.__name__):
|
||||
return route.url(**params)
|
||||
return None
|
||||
|
||||
async def default_response(self, scope, receive, send):
|
||||
if scope["type"] == "websocket":
|
||||
websocket_close = WebSocketClose()
|
||||
await websocket_close(receive, send)
|
||||
return
|
||||
|
||||
request = Request(scope, receive)
|
||||
response = Response(request, formats=get_formats())
|
||||
|
||||
raise HTTPException(status_code=status_codes.HTTP_404)
|
||||
|
||||
def _resolve_route(self, scope):
|
||||
for route in self.routes:
|
||||
matches, child_scope = route.matches(scope)
|
||||
if matches:
|
||||
scope.update(child_scope)
|
||||
return route
|
||||
return None
|
||||
|
||||
async def lifespan(self, scope, receive, send):
|
||||
message = await receive()
|
||||
assert message["type"] == "lifespan.startup"
|
||||
|
||||
try:
|
||||
await self.trigger_event("startup")
|
||||
except BaseException:
|
||||
msg = traceback.format_exc()
|
||||
await send({"type": "lifespan.startup.failed", "message": msg})
|
||||
raise
|
||||
|
||||
await send({"type": "lifespan.startup.complete"})
|
||||
message = await receive()
|
||||
assert message["type"] == "lifespan.shutdown"
|
||||
await self.trigger_event("shutdown")
|
||||
await send({"type": "lifespan.shutdown.complete"})
|
||||
|
||||
async def __call__(self, scope, receive, send):
|
||||
assert scope["type"] in ("http", "websocket", "lifespan")
|
||||
|
||||
if scope["type"] == "lifespan":
|
||||
await self.lifespan(scope, receive, send)
|
||||
return
|
||||
|
||||
path = scope["path"]
|
||||
root_path = scope.get("root_path", "")
|
||||
|
||||
# Check "primary" mounted routes first (before submounted apps)
|
||||
route = self._resolve_route(scope)
|
||||
|
||||
scope["before_requests"] = self.before_requests
|
||||
|
||||
if route is not None:
|
||||
await route(scope, receive, send)
|
||||
return
|
||||
|
||||
# Call into a submounted app, if one exists.
|
||||
for path_prefix, app in self.apps.items():
|
||||
if path.startswith(path_prefix):
|
||||
scope["path"] = path[len(path_prefix) :]
|
||||
scope["root_path"] = root_path + path_prefix
|
||||
try:
|
||||
await app(scope, receive, send)
|
||||
return
|
||||
except TypeError:
|
||||
app = WSGIMiddleware(app)
|
||||
await app(scope, receive, send)
|
||||
return
|
||||
|
||||
await self.default_endpoint(scope, receive, send)
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import typing
|
||||
|
||||
from starlette.staticfiles import StaticFiles
|
||||
|
||||
|
||||
class StaticFiles(StaticFiles):
|
||||
"""I've created an issue to disccuss allowing multiple directories in starletter's `StaticFiles`.
|
||||
|
||||
https://github.com/encode/starlette/issues/625
|
||||
|
||||
I've also made a PR to add this method to starlette StaticFiles
|
||||
Once accepted we will remove this.
|
||||
|
||||
https://github.com/encode/starlette/pull/626
|
||||
"""
|
||||
|
||||
def add_directory(self, directory: str) -> None:
|
||||
self.all_directories = [*self.all_directories, *self.get_directories(directory)]
|
||||
@@ -1 +1,14 @@
|
||||
DEFAULT_ENCODING = "utf-8"
|
||||
DEFAULT_API_THEME = "swaggerui"
|
||||
DEFAULT_SESSION_COOKIE = "Responder-Session"
|
||||
DEFAULT_SECRET_KEY = "NOTASECRET"
|
||||
|
||||
DEFAULT_CORS_PARAMS = {
|
||||
"allow_origins": (),
|
||||
"allow_methods": ("GET",),
|
||||
"allow_headers": (),
|
||||
"allow_credentials": False,
|
||||
"allow_origin_regex": None,
|
||||
"expose_headers": (),
|
||||
"max_age": 600,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
from contextlib import contextmanager
|
||||
|
||||
import jinja2
|
||||
|
||||
|
||||
class Templates:
|
||||
def __init__(
|
||||
self, directory="templates", autoescape=True, context=None, enable_async=False
|
||||
):
|
||||
self.directory = directory
|
||||
self._env = jinja2.Environment(
|
||||
loader=jinja2.FileSystemLoader([str(self.directory)]),
|
||||
autoescape=autoescape,
|
||||
enable_async=enable_async,
|
||||
)
|
||||
self.default_context = {} if context is None else {**context}
|
||||
self._env.globals.update(self.default_context)
|
||||
|
||||
@property
|
||||
def context(self):
|
||||
return self._env.globals
|
||||
|
||||
@context.setter
|
||||
def context(self, context):
|
||||
self._env.globals = {**self.default_context, **context}
|
||||
|
||||
def get_template(self, name):
|
||||
return self._env.get_template(name)
|
||||
|
||||
def render(self, template, *args, **kwargs):
|
||||
"""Renders the given `jinja2 <http://jinja.pocoo.org/docs/>`_ template, with provided values supplied.
|
||||
|
||||
:param template: The filename of the jinja2 template.
|
||||
:param **kwargs: Data to pass into the template.
|
||||
:param **kwargs: Data to pass into the template.
|
||||
"""
|
||||
return self.get_template(template).render(*args, **kwargs)
|
||||
|
||||
@contextmanager
|
||||
def _async(self):
|
||||
self._env.is_async = True
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
self._env.is_async = False
|
||||
|
||||
async def render_async(self, template, *args, **kwargs):
|
||||
with self._async():
|
||||
return await self.get_template(template).render_async(*args, **kwargs)
|
||||
|
||||
def render_string(self, source, *args, **kwargs):
|
||||
"""Renders the given `jinja2 <http://jinja.pocoo.org/docs/>`_ template string, with provided values supplied.
|
||||
|
||||
:param source: The template to use.
|
||||
:param *args, **kwargs: Data to pass into the template.
|
||||
:param **kwargs: Data to pass into the template.
|
||||
"""
|
||||
template = self._env.from_string(source)
|
||||
return template.render(*args, **kwargs)
|
||||
@@ -22,27 +22,24 @@ if sys.argv[-1] == "publish":
|
||||
sys.exit()
|
||||
|
||||
required = [
|
||||
"starlette",
|
||||
"uvicorn",
|
||||
"starlette==0.13.*",
|
||||
"uvicorn[standard]>=0.12.0,<0.13.3",
|
||||
"aiofiles",
|
||||
"pyyaml",
|
||||
"requests",
|
||||
"graphene",
|
||||
"graphene<3.0",
|
||||
"graphql-server-core>=1.1",
|
||||
"jinja2",
|
||||
"parse",
|
||||
"uvloop ; sys_platform != 'win32'",
|
||||
"rfc3986",
|
||||
"python-multipart",
|
||||
"chardet",
|
||||
"apispec>=1.0.0b1",
|
||||
"marshmallow",
|
||||
"asgiref",
|
||||
"whitenoise",
|
||||
"docopt",
|
||||
"itsdangerous",
|
||||
"requests-toolbelt",
|
||||
"apistar",
|
||||
"itsdangerous",
|
||||
]
|
||||
|
||||
|
||||
@@ -70,7 +67,7 @@ class DebCommand(Command):
|
||||
rmtree(os.path.join(here, "deb_dist"))
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
self.status(u"Creating debian mainfest…")
|
||||
self.status(u"Creating debian manifest…")
|
||||
os.system(
|
||||
"python setup.py --command-packages=stdeb.command sdist_dsc -z artful --package3=pipenv --depends3=python3-virtualenv-clone"
|
||||
)
|
||||
@@ -123,21 +120,7 @@ setup(
|
||||
url="https://github.com/kennethreitz/responder",
|
||||
packages=find_packages(exclude=["tests"]),
|
||||
entry_points={"console_scripts": ["responder=responder.cli:cli"]},
|
||||
package_data={
|
||||
# "": ["LICENSE", "NOTICES"],
|
||||
# "pipenv.vendor.requests": ["*.pem"],
|
||||
# "pipenv.vendor.certifi": ["*.pem"],
|
||||
# "pipenv.vendor.click_completion": ["*.j2"],
|
||||
# "pipenv.patched.notpip._vendor.certifi": ["*.pem"],
|
||||
# "pipenv.patched.notpip._vendor.requests": ["*.pem"],
|
||||
# "pipenv.patched.notpip._vendor.distlib._backport": ["sysconfig.cfg"],
|
||||
# "pipenv.patched.notpip._vendor.distlib": [
|
||||
# "t32.exe",
|
||||
# "t64.exe",
|
||||
# "w32.exe",
|
||||
# "w64.exe",
|
||||
# ],
|
||||
},
|
||||
package_data={},
|
||||
python_requires=">=3.6",
|
||||
setup_requires=[],
|
||||
install_requires=required,
|
||||
@@ -145,7 +128,7 @@ setup(
|
||||
include_package_data=True,
|
||||
license="Apache 2.0",
|
||||
classifiers=[
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"License :: OSI Approved :: Apache Software License",
|
||||
"Programming Language :: Python",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.6",
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
lorem
|
||||
@@ -1,3 +0,0 @@
|
||||
this is a test
|
||||
|
||||
{{ api.static_url('test') }}
|
||||
+12
-1
@@ -2,6 +2,8 @@ import graphene
|
||||
import responder
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
import multiprocessing
|
||||
import concurrent.futures
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -16,7 +18,7 @@ def current_dir():
|
||||
|
||||
@pytest.fixture
|
||||
def api():
|
||||
return responder.API()
|
||||
return responder.API(debug=False, allowed_hosts=[";"])
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -54,3 +56,12 @@ def schema():
|
||||
return f"Hello {name}"
|
||||
|
||||
return graphene.Schema(query=Query)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def template_path(tmpdir):
|
||||
# create a Jinja template file on the filesystem
|
||||
template_name = "test.html"
|
||||
template_file = tmpdir.mkdir("static").join(template_name)
|
||||
template_file.write("{{ var }}")
|
||||
return template_file
|
||||
|
||||
+622
-58
@@ -1,7 +1,18 @@
|
||||
import concurrent
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
import random
|
||||
import responder
|
||||
import requests
|
||||
import string
|
||||
import io
|
||||
from responder.routes import Router, Route, WebSocketRoute
|
||||
from responder.templates import Templates
|
||||
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.responses import PlainTextResponse
|
||||
from starlette.testclient import TestClient as StarletteTestClient
|
||||
|
||||
|
||||
def test_api_basic_route(api):
|
||||
@@ -10,6 +21,45 @@ def test_api_basic_route(api):
|
||||
resp.text = "hello world!"
|
||||
|
||||
|
||||
def test_route_repr():
|
||||
def home(req, resp):
|
||||
"""Home page"""
|
||||
resp.text = "Hello !"
|
||||
|
||||
route = Route("/", home)
|
||||
|
||||
assert route.__repr__() == f"<Route '/'={home!r}>"
|
||||
|
||||
assert route.endpoint_name == home.__name__
|
||||
assert route.description == home.__doc__
|
||||
|
||||
|
||||
def test_websocket_route_repr():
|
||||
def chat_endpoint(ws):
|
||||
"""Chat"""
|
||||
pass
|
||||
|
||||
route = WebSocketRoute("/", chat_endpoint)
|
||||
|
||||
assert route.__repr__() == f"<Route '/'={chat_endpoint!r}>"
|
||||
|
||||
assert route.endpoint_name == chat_endpoint.__name__
|
||||
assert route.description == chat_endpoint.__doc__
|
||||
|
||||
|
||||
def test_route_eq():
|
||||
def home(req, resp):
|
||||
resp.text = "Hello !"
|
||||
|
||||
assert Route("/", home) == Route("/", home)
|
||||
|
||||
def chat(ws):
|
||||
pass
|
||||
|
||||
assert WebSocketRoute("/", home) == WebSocketRoute("/", home)
|
||||
|
||||
|
||||
"""
|
||||
def test_api_basic_route_overlap(api):
|
||||
@api.route("/")
|
||||
def home(req, resp):
|
||||
@@ -20,39 +70,7 @@ def test_api_basic_route_overlap(api):
|
||||
@api.route("/")
|
||||
def home2(req, resp):
|
||||
resp.text = "hello world!"
|
||||
|
||||
|
||||
def test_api_basic_route_overlap_alternative(api):
|
||||
@api.route("/")
|
||||
def home(req, resp):
|
||||
resp.text = "hello world!"
|
||||
|
||||
def home2(req, resp):
|
||||
resp.text = "hello world!"
|
||||
|
||||
with pytest.raises(AssertionError):
|
||||
api.add_route("/", home2)
|
||||
|
||||
|
||||
def test_api_basic_route_overlap_allowed(api):
|
||||
@api.route("/")
|
||||
def home(req, resp):
|
||||
resp.text = "hello world!"
|
||||
|
||||
def home2(req, resp):
|
||||
resp.text = "hello world!"
|
||||
|
||||
api.add_route("/", home2, check_existing=False)
|
||||
|
||||
|
||||
def test_api_basic_route_overlap_allowed_alternative(api):
|
||||
@api.route("/")
|
||||
def home(req, resp):
|
||||
resp.text = "hello world!"
|
||||
|
||||
@api.route("/", check_existing=False)
|
||||
def home2(req, resp):
|
||||
resp.text = "hello world!"
|
||||
"""
|
||||
|
||||
|
||||
def test_class_based_view_registration(api):
|
||||
@@ -65,10 +83,10 @@ def test_class_based_view_registration(api):
|
||||
def test_class_based_view_parameters(api):
|
||||
@api.route("/{greeting}")
|
||||
class Greeting:
|
||||
def on_request(self, req, resp, *, greeting):
|
||||
resp.text = f"{greeting}, world!"
|
||||
pass
|
||||
|
||||
assert api.session().get("http://;/Hello").ok
|
||||
resp = api.session().get("http://;/Hello")
|
||||
assert resp.status_code == api.status_codes.HTTP_405
|
||||
|
||||
|
||||
def test_requests_session(api):
|
||||
@@ -76,14 +94,14 @@ def test_requests_session(api):
|
||||
assert api.requests
|
||||
|
||||
|
||||
def test_requests_session_works(api, url):
|
||||
def test_requests_session_works(api):
|
||||
TEXT = "spiral out"
|
||||
|
||||
@api.route("/")
|
||||
def hello(req, resp):
|
||||
resp.text = TEXT
|
||||
|
||||
assert api.requests.get(url("/")).text == TEXT
|
||||
assert api.requests.get("/").text == TEXT
|
||||
|
||||
|
||||
def test_status_code(api):
|
||||
@@ -118,11 +136,11 @@ def test_yaml_media(api):
|
||||
r = api.requests.get("http://;/", headers={"Accept": "yaml"})
|
||||
|
||||
assert "yaml" in r.headers["Content-Type"]
|
||||
assert yaml.load(r.content) == dump
|
||||
assert yaml.load(r.content, Loader=yaml.FullLoader) == dump
|
||||
|
||||
|
||||
def test_graphql_schema_query_querying(api, schema):
|
||||
api.add_route("/", schema)
|
||||
api.add_route("/", responder.ext.GraphQLView(schema=schema, api=api))
|
||||
|
||||
r = api.requests.get("http://;/?q={ hello }", headers={"Accept": "json"})
|
||||
assert r.json() == {"data": {"hello": "Hello stranger"}}
|
||||
@@ -214,7 +232,7 @@ def test_media_parsing(api):
|
||||
assert r.json() == dump
|
||||
|
||||
r = api.requests.get(api.url_for(route), headers={"Accept": "application/x-yaml"})
|
||||
assert r.text == "{hello: sam}\n"
|
||||
assert r.text == "hello: sam\n"
|
||||
|
||||
|
||||
def test_background(api):
|
||||
@@ -250,14 +268,14 @@ def test_multiple_routes(api):
|
||||
|
||||
|
||||
def test_graphql_schema_json_query(api, schema):
|
||||
api.add_route("/", schema)
|
||||
api.add_route("/", responder.ext.GraphQLView(schema=schema, api=api))
|
||||
|
||||
r = api.requests.post("http://;/", json={"query": "{ hello }"})
|
||||
assert r.ok
|
||||
|
||||
|
||||
def test_graphiql(api, schema):
|
||||
api.add_route("/", schema)
|
||||
api.add_route("/", responder.ext.GraphQLView(schema=schema, api=api))
|
||||
|
||||
r = api.requests.get("http://;/", headers={"Accept": "text/html"})
|
||||
assert r.ok
|
||||
@@ -297,6 +315,11 @@ def test_form_uploads(api):
|
||||
r = api.requests.post(api.url_for(route), data=dump)
|
||||
assert r.json() == dump
|
||||
|
||||
# requests with boundary
|
||||
files = {"complicated": (None, "times")}
|
||||
r = api.requests.post(api.url_for(route), files=files)
|
||||
assert r.json() == {"complicated": "times"}
|
||||
|
||||
|
||||
def test_json_downloads(api):
|
||||
dump = {"testing": "123"}
|
||||
@@ -324,11 +347,45 @@ def test_yaml_downloads(api):
|
||||
assert yaml.safe_load(r.content) == dump
|
||||
|
||||
|
||||
def test_schema_generation_explicit():
|
||||
import responder
|
||||
from responder.ext.schema import Schema as OpenAPISchema
|
||||
import marshmallow
|
||||
|
||||
api = responder.API()
|
||||
|
||||
schema = OpenAPISchema(app=api, title="Web Service", version="1.0", openapi="3.0.2")
|
||||
|
||||
@schema.schema("Pet")
|
||||
class PetSchema(marshmallow.Schema):
|
||||
name = marshmallow.fields.Str()
|
||||
|
||||
@api.route("/")
|
||||
def route(req, resp):
|
||||
"""A cute furry animal endpoint.
|
||||
---
|
||||
get:
|
||||
description: Get a random pet
|
||||
responses:
|
||||
200:
|
||||
description: A pet to be returned
|
||||
schema:
|
||||
$ref: "#/components/schemas/Pet"
|
||||
"""
|
||||
resp.media = PetSchema().dump({"name": "little orange"})
|
||||
|
||||
r = api.requests.get("http://;/schema.yml")
|
||||
dump = yaml.safe_load(r.content)
|
||||
|
||||
assert dump
|
||||
assert dump["openapi"] == "3.0.2"
|
||||
|
||||
|
||||
def test_schema_generation():
|
||||
import responder
|
||||
from marshmallow import Schema, fields
|
||||
|
||||
api = responder.API(title="Web Service", openapi="3.0")
|
||||
api = responder.API(title="Web Service", openapi="3.0.2")
|
||||
|
||||
@api.schema("Pet")
|
||||
class PetSchema(Schema):
|
||||
@@ -344,7 +401,7 @@ def test_schema_generation():
|
||||
200:
|
||||
description: A pet to be returned
|
||||
schema:
|
||||
$ref = "#/components/schemas/Pet"
|
||||
$ref: "#/components/schemas/Pet"
|
||||
"""
|
||||
resp.media = PetSchema().dump({"name": "little orange"})
|
||||
|
||||
@@ -352,7 +409,111 @@ def test_schema_generation():
|
||||
dump = yaml.safe_load(r.content)
|
||||
|
||||
assert dump
|
||||
assert dump["openapi"] == "3.0"
|
||||
assert dump["openapi"] == "3.0.2"
|
||||
|
||||
|
||||
def test_documentation_explicit():
|
||||
import responder
|
||||
from responder.ext.schema import Schema as OpenAPISchema
|
||||
|
||||
import marshmallow
|
||||
|
||||
description = "This is a sample server for a pet store."
|
||||
terms_of_service = "http://example.com/terms/"
|
||||
contact = {
|
||||
"name": "API Support",
|
||||
"url": "http://www.example.com/support",
|
||||
"email": "support@example.com",
|
||||
}
|
||||
license = {
|
||||
"name": "Apache 2.0",
|
||||
"url": "https://www.apache.org/licenses/LICENSE-2.0.html",
|
||||
}
|
||||
|
||||
api = responder.API(allowed_hosts=["testserver", ";"])
|
||||
|
||||
schema = OpenAPISchema(
|
||||
app=api,
|
||||
title="Web Service",
|
||||
version="1.0",
|
||||
openapi="3.0.2",
|
||||
docs_route="/docs",
|
||||
description=description,
|
||||
terms_of_service=terms_of_service,
|
||||
contact=contact,
|
||||
license=license,
|
||||
)
|
||||
|
||||
@schema.schema("Pet")
|
||||
class PetSchema(marshmallow.Schema):
|
||||
name = marshmallow.fields.Str()
|
||||
|
||||
@api.route("/")
|
||||
def route(req, resp):
|
||||
"""A cute furry animal endpoint.
|
||||
---
|
||||
get:
|
||||
description: Get a random pet
|
||||
responses:
|
||||
200:
|
||||
description: A pet to be returned
|
||||
schema:
|
||||
$ref: "#/components/schemas/Pet"
|
||||
"""
|
||||
resp.media = PetSchema().dump({"name": "little orange"})
|
||||
|
||||
r = api.requests.get("/docs")
|
||||
assert "html" in r.text
|
||||
|
||||
|
||||
def test_documentation():
|
||||
import responder
|
||||
from marshmallow import Schema, fields
|
||||
|
||||
description = "This is a sample server for a pet store."
|
||||
terms_of_service = "http://example.com/terms/"
|
||||
contact = {
|
||||
"name": "API Support",
|
||||
"url": "http://www.example.com/support",
|
||||
"email": "support@example.com",
|
||||
}
|
||||
license = {
|
||||
"name": "Apache 2.0",
|
||||
"url": "https://www.apache.org/licenses/LICENSE-2.0.html",
|
||||
}
|
||||
|
||||
api = responder.API(
|
||||
title="Web Service",
|
||||
version="1.0",
|
||||
openapi="3.0.2",
|
||||
docs_route="/docs",
|
||||
description=description,
|
||||
terms_of_service=terms_of_service,
|
||||
contact=contact,
|
||||
license=license,
|
||||
allowed_hosts=["testserver", ";"],
|
||||
)
|
||||
|
||||
@api.schema("Pet")
|
||||
class PetSchema(Schema):
|
||||
name = fields.Str()
|
||||
|
||||
@api.route("/")
|
||||
def route(req, resp):
|
||||
"""A cute furry animal endpoint.
|
||||
---
|
||||
get:
|
||||
description: Get a random pet
|
||||
responses:
|
||||
200:
|
||||
description: A pet to be returned
|
||||
schema:
|
||||
$ref: "#/components/schemas/Pet"
|
||||
"""
|
||||
resp.media = PetSchema().dump({"name": "little orange"})
|
||||
|
||||
r = api.requests.get("/docs")
|
||||
assert "html" in r.text
|
||||
|
||||
|
||||
def test_mount_wsgi_app(api, flask):
|
||||
@@ -382,15 +543,26 @@ def test_cookies(api):
|
||||
def cookies(req, resp):
|
||||
resp.media = {"cookies": req.cookies}
|
||||
resp.cookies["sent"] = "true"
|
||||
resp.set_cookie(
|
||||
"hello",
|
||||
"world",
|
||||
expires=123,
|
||||
path="/",
|
||||
max_age=123,
|
||||
secure=False,
|
||||
httponly=True,
|
||||
)
|
||||
|
||||
r = api.requests.get(api.url_for(cookies), cookies={"hello": "universe"})
|
||||
assert r.json() == {"cookies": {"hello": "universe"}}
|
||||
assert "sent" in r.cookies
|
||||
assert "hello" in r.cookies
|
||||
|
||||
r = api.requests.get(api.url_for(cookies))
|
||||
assert r.json() == {"cookies": {"sent": "true"}}
|
||||
assert r.json() == {"cookies": {"hello": "world", "sent": "true"}}
|
||||
|
||||
|
||||
@pytest.mark.xfail
|
||||
def test_sessions(api):
|
||||
@api.route("/")
|
||||
def view(req, resp):
|
||||
@@ -398,17 +570,17 @@ def test_sessions(api):
|
||||
resp.media = resp.session
|
||||
|
||||
r = api.requests.get(api.url_for(view))
|
||||
assert "Responder-Session" in r.cookies
|
||||
assert api.session_cookie in r.cookies
|
||||
|
||||
r = api.requests.get(api.url_for(view))
|
||||
assert (
|
||||
r.cookies["Responder-Session"]
|
||||
== '{"hello": "world"}.lJVWJULPqR9kdao_oT4pUglV281bxHfGvcKQ7XF8qNqaiIZlRcMvqKNdA1-d5z7DycAx5eqmzJZoqWPP759-Cw'
|
||||
r.cookies[api.session_cookie]
|
||||
== '{"hello": "world"}.r3EB04hEEyLYIJaAXCEq3d4YEbs'
|
||||
)
|
||||
assert r.json() == {"hello": "world"}
|
||||
|
||||
|
||||
def test_template_rendering(api):
|
||||
def test_template_string_rendering(api):
|
||||
@api.route("/")
|
||||
def view(req, resp):
|
||||
resp.content = api.template_string("{{ var }}", var="hello")
|
||||
@@ -417,18 +589,53 @@ def test_template_rendering(api):
|
||||
assert r.text == "hello"
|
||||
|
||||
|
||||
def test_template_rendering(template_path):
|
||||
api = responder.API(templates_dir=template_path.dirpath())
|
||||
|
||||
@api.route("/")
|
||||
def view(req, resp):
|
||||
resp.content = api.template(template_path.basename, var="hello")
|
||||
|
||||
r = api.requests.get(api.url_for(view))
|
||||
assert r.text == "hello"
|
||||
|
||||
|
||||
def test_template(api, template_path):
|
||||
templates = Templates(directory=template_path.dirpath())
|
||||
|
||||
@api.route("/{var}/")
|
||||
def view(req, resp, var):
|
||||
resp.html = templates.render(template_path.basename, var=var)
|
||||
|
||||
r = api.requests.get("/test/")
|
||||
assert r.text == "test"
|
||||
|
||||
|
||||
def test_template_async(api, template_path):
|
||||
templates = Templates(directory=template_path.dirpath(), enable_async=True)
|
||||
|
||||
@api.route("/{var}/async")
|
||||
async def view_async(req, resp, var):
|
||||
resp.html = await templates.render_async(template_path.basename, var=var)
|
||||
|
||||
r = api.requests.get("/test/async")
|
||||
assert r.text == "test"
|
||||
|
||||
|
||||
def test_file_uploads(api):
|
||||
@api.route("/")
|
||||
async def upload(req, resp):
|
||||
|
||||
files = await req.media("files")
|
||||
files["hello"] = files["hello"].decode("utf-8")
|
||||
resp.media = {"files": files}
|
||||
result = {}
|
||||
result["hello"] = files["hello"]["content"].decode("utf-8")
|
||||
result["not-a-file"] = files["not-a-file"].decode("utf-8")
|
||||
resp.media = {"files": result}
|
||||
|
||||
world = io.StringIO("world")
|
||||
data = {"hello": world}
|
||||
data = {"hello": ("hello.txt", world, "text/plain"), "not-a-file": b"data only"}
|
||||
r = api.requests.post(api.url_for(upload), files=data)
|
||||
assert r.json() == {"files": {"hello": "world"}}
|
||||
assert r.json() == {"files": {"hello": "world", "not-a-file": "data only"}}
|
||||
|
||||
|
||||
def test_500(api):
|
||||
@@ -436,8 +643,12 @@ def test_500(api):
|
||||
def view(req, resp):
|
||||
raise ValueError
|
||||
|
||||
r = api.requests.get(api.url_for(view))
|
||||
dumb_client = responder.api.TestClient(
|
||||
api, base_url="http://;", raise_server_exceptions=False
|
||||
)
|
||||
r = dumb_client.get(api.url_for(view))
|
||||
assert not r.ok
|
||||
assert r.status_code == responder.status_codes.HTTP_500
|
||||
|
||||
|
||||
def test_404(api):
|
||||
@@ -446,9 +657,362 @@ def test_404(api):
|
||||
assert r.status_code == responder.status_codes.HTTP_404
|
||||
|
||||
|
||||
def test_kinda_websockets(api):
|
||||
def test_websockets_text(api):
|
||||
payload = "Hello via websocket!"
|
||||
|
||||
@api.route("/ws", websocket=True)
|
||||
async def websocket(ws):
|
||||
await ws.accept()
|
||||
await ws.send_text("Hello via websocket!")
|
||||
await ws.send_text(payload)
|
||||
await ws.close()
|
||||
|
||||
client = StarletteTestClient(api)
|
||||
with client.websocket_connect("ws://;/ws") as websocket:
|
||||
data = websocket.receive_text()
|
||||
assert data == payload
|
||||
|
||||
|
||||
def test_websockets_bytes(api):
|
||||
payload = b"Hello via websocket!"
|
||||
|
||||
@api.route("/ws", websocket=True)
|
||||
async def websocket(ws):
|
||||
await ws.accept()
|
||||
await ws.send_bytes(payload)
|
||||
await ws.close()
|
||||
|
||||
client = StarletteTestClient(api)
|
||||
with client.websocket_connect("ws://;/ws") as websocket:
|
||||
data = websocket.receive_bytes()
|
||||
assert data == payload
|
||||
|
||||
|
||||
def test_websockets_json(api):
|
||||
payload = {"Hello": "via websocket!"}
|
||||
|
||||
@api.route("/ws", websocket=True)
|
||||
async def websocket(ws):
|
||||
await ws.accept()
|
||||
await ws.send_json(payload)
|
||||
await ws.close()
|
||||
|
||||
client = StarletteTestClient(api)
|
||||
with client.websocket_connect("ws://;/ws") as websocket:
|
||||
data = websocket.receive_json()
|
||||
assert data == payload
|
||||
|
||||
|
||||
def test_before_websockets(api):
|
||||
payload = {"Hello": "via websocket!"}
|
||||
|
||||
@api.route("/ws", websocket=True)
|
||||
async def websocket(ws):
|
||||
await ws.send_json(payload)
|
||||
await ws.close()
|
||||
|
||||
@api.before_request(websocket=True)
|
||||
async def before_request(ws):
|
||||
await ws.accept()
|
||||
await ws.send_json({"before": "request"})
|
||||
|
||||
client = StarletteTestClient(api)
|
||||
with client.websocket_connect("ws://;/ws") as websocket:
|
||||
data = websocket.receive_json()
|
||||
assert data == {"before": "request"}
|
||||
data = websocket.receive_json()
|
||||
assert data == payload
|
||||
|
||||
|
||||
def test_startup(api):
|
||||
who = [None]
|
||||
|
||||
@api.route("/{greeting}")
|
||||
async def greet_world(req, resp, *, greeting):
|
||||
resp.text = f"{greeting}, {who[0]}!"
|
||||
|
||||
@api.on_event("startup")
|
||||
async def run_startup():
|
||||
who[0] = "world"
|
||||
|
||||
with api.requests as session:
|
||||
r = session.get(f"http://;/hello")
|
||||
assert r.text == "hello, world!"
|
||||
|
||||
|
||||
def test_redirects(api, session):
|
||||
@api.route("/2")
|
||||
def two(req, resp):
|
||||
api.redirect(resp, location="/1")
|
||||
|
||||
@api.route("/1")
|
||||
def one(req, resp):
|
||||
resp.text = "redirected"
|
||||
|
||||
assert session.get("/2").url == "http://;/1"
|
||||
|
||||
|
||||
def test_session_thoroughly(api, session):
|
||||
@api.route("/set")
|
||||
def set(req, resp):
|
||||
resp.session["hello"] = "world"
|
||||
api.redirect(resp, location="/get")
|
||||
|
||||
@api.route("/get")
|
||||
def get(req, resp):
|
||||
resp.media = {"session": req.session}
|
||||
|
||||
r = session.get(api.url_for(set))
|
||||
r = session.get(api.url_for(get))
|
||||
assert r.json() == {"session": {"hello": "world"}}
|
||||
|
||||
|
||||
def test_before_response(api, session):
|
||||
@api.route("/get")
|
||||
def get(req, resp):
|
||||
resp.media = req.session
|
||||
|
||||
@api.route(before_request=True)
|
||||
async def async_before_request(req, resp):
|
||||
resp.headers["x-pizza"] = "1"
|
||||
|
||||
@api.route(before_request=True)
|
||||
def before_request(req, resp):
|
||||
resp.headers["x-pizza"] = "1"
|
||||
|
||||
r = session.get(api.url_for(get))
|
||||
assert "x-pizza" in r.headers
|
||||
|
||||
|
||||
@pytest.mark.parametrize("enable_hsts", [True, False])
|
||||
@pytest.mark.parametrize("cors", [True, False])
|
||||
def test_allowed_hosts(enable_hsts, cors):
|
||||
api = responder.API(
|
||||
allowed_hosts=[";", "tenant.;"], enable_hsts=enable_hsts, cors=cors
|
||||
)
|
||||
|
||||
@api.route("/")
|
||||
def get(req, resp):
|
||||
pass
|
||||
|
||||
# Exact match
|
||||
r = api.requests.get(api.url_for(get))
|
||||
assert r.status_code == 200
|
||||
|
||||
# Reset the session
|
||||
api._session = None
|
||||
r = api.session(base_url="http://tenant.;").get(api.url_for(get))
|
||||
assert r.status_code == 200
|
||||
|
||||
# Reset the session
|
||||
api._session = None
|
||||
r = api.session(base_url="http://unkownhost").get(api.url_for(get))
|
||||
assert r.status_code == 400
|
||||
|
||||
# Reset the session
|
||||
api._session = None
|
||||
r = api.session(base_url="http://unkown_tenant.;").get(api.url_for(get))
|
||||
assert r.status_code == 400
|
||||
|
||||
api = responder.API(allowed_hosts=["*.;"])
|
||||
|
||||
@api.route("/")
|
||||
def get(req, resp):
|
||||
pass
|
||||
|
||||
# Wildcard domains
|
||||
# Using http://;
|
||||
r = api.requests.get(api.url_for(get))
|
||||
assert r.status_code == 400
|
||||
|
||||
# Reset the session
|
||||
api._session = None
|
||||
r = api.session(base_url="http://tenant1.;").get(api.url_for(get))
|
||||
assert r.status_code == 200
|
||||
|
||||
# Reset the session
|
||||
api._session = None
|
||||
r = api.session(base_url="http://tenant2.;").get(api.url_for(get))
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
def create_asset(static_dir, name=None, parent_dir=None):
|
||||
if name is None:
|
||||
name = random.choices(string.ascii_letters, k=6)
|
||||
# :3
|
||||
ext = random.choices(string.ascii_letters, k=2)
|
||||
name = f"{name}.{ext}"
|
||||
|
||||
if parent_dir is None:
|
||||
parent_dir = static_dir
|
||||
else:
|
||||
parent_dir = static_dir.mkdir(parent_dir)
|
||||
|
||||
asset = parent_dir.join(name)
|
||||
asset.write("body { color: blue; }")
|
||||
return asset
|
||||
|
||||
|
||||
@pytest.mark.parametrize("static_route", [None, "/static", "/custom/static/route"])
|
||||
def test_staticfiles(tmpdir, static_route):
|
||||
static_dir = tmpdir.mkdir("static")
|
||||
|
||||
asset1 = create_asset(static_dir)
|
||||
parent_dir = "css"
|
||||
asset2 = create_asset(static_dir, name="asset2", parent_dir=parent_dir)
|
||||
|
||||
api = responder.API(static_dir=str(static_dir), static_route=static_route)
|
||||
session = api.session()
|
||||
|
||||
static_route = api.static_route
|
||||
|
||||
# ok
|
||||
r = session.get(f"{static_route}/{asset1.basename}")
|
||||
assert r.status_code == api.status_codes.HTTP_200
|
||||
|
||||
r = session.get(f"{static_route}/{parent_dir}/{asset2.basename}")
|
||||
assert r.status_code == api.status_codes.HTTP_200
|
||||
|
||||
# Asset not found
|
||||
r = session.get(f"{static_route}/not_found.css")
|
||||
assert r.status_code == api.status_codes.HTTP_404
|
||||
|
||||
# Not found on dir listing
|
||||
r = session.get(f"{static_route}")
|
||||
assert r.status_code == api.status_codes.HTTP_404
|
||||
|
||||
r = session.get(f"{static_route}/{parent_dir}")
|
||||
assert r.status_code == api.status_codes.HTTP_404
|
||||
|
||||
|
||||
def test_staticfiles_none_dir(tmpdir):
|
||||
api = responder.API(static_dir=None)
|
||||
session = api.session()
|
||||
|
||||
static_dir = tmpdir.mkdir("static")
|
||||
|
||||
asset = create_asset(static_dir)
|
||||
|
||||
static_route = api.static_route
|
||||
|
||||
# ok
|
||||
r = session.get(f"{static_route}/{asset.basename}")
|
||||
assert r.status_code == api.status_codes.HTTP_404
|
||||
|
||||
# dir listing
|
||||
r = session.get(f"{static_route}")
|
||||
assert r.status_code == api.status_codes.HTTP_404
|
||||
|
||||
# SPA
|
||||
with pytest.raises(Exception) as excinfo:
|
||||
api.add_route("/spa", static=True)
|
||||
|
||||
|
||||
def test_response_html_property(api):
|
||||
@api.route("/")
|
||||
def view(req, resp):
|
||||
resp.html = "<h1>Hello !</h1>"
|
||||
|
||||
assert resp.content == "<h1>Hello !</h1>"
|
||||
assert resp.mimetype == "text/html"
|
||||
|
||||
r = api.requests.get(api.url_for(view))
|
||||
assert r.content == b"<h1>Hello !</h1>"
|
||||
assert r.headers["Content-Type"] == "text/html"
|
||||
|
||||
|
||||
def test_response_text_property(api):
|
||||
@api.route("/")
|
||||
def view(req, resp):
|
||||
resp.text = "<h1>Hello !</h1>"
|
||||
|
||||
assert resp.content == "<h1>Hello !</h1>"
|
||||
assert resp.mimetype == "text/plain"
|
||||
|
||||
r = api.requests.get(api.url_for(view))
|
||||
assert r.content == b"<h1>Hello !</h1>"
|
||||
assert r.headers["Content-Type"] == "text/plain"
|
||||
|
||||
|
||||
def test_stream(api, session):
|
||||
async def shout_stream(who):
|
||||
for c in who.upper():
|
||||
yield c
|
||||
|
||||
@api.route("/{who}")
|
||||
async def greeting(req, resp, *, who):
|
||||
|
||||
resp.stream(shout_stream, who)
|
||||
|
||||
r = session.get("/morocco")
|
||||
assert r.text == "MOROCCO"
|
||||
|
||||
@api.route("/")
|
||||
async def home(req, resp):
|
||||
# Raise when it's not an async generator
|
||||
with pytest.raises(AssertionError):
|
||||
|
||||
def foo():
|
||||
pass
|
||||
|
||||
resp.stream(foo)
|
||||
|
||||
with pytest.raises(AssertionError):
|
||||
|
||||
async def foo():
|
||||
pass
|
||||
|
||||
resp.stream(foo)
|
||||
|
||||
with pytest.raises(AssertionError):
|
||||
|
||||
def foo():
|
||||
yield "oopsie"
|
||||
|
||||
resp.stream(foo)
|
||||
|
||||
|
||||
def test_empty_req_text(api):
|
||||
content = "It's working"
|
||||
|
||||
@api.route("/")
|
||||
async def home(req, resp):
|
||||
await req.text
|
||||
resp.text = content
|
||||
|
||||
r = api.requests.post("/")
|
||||
assert r.text == content
|
||||
|
||||
def test_api_request_state(api, url):
|
||||
class StateMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request, call_next):
|
||||
request.state.test1 = 42
|
||||
request.state.test2 = "Foo"
|
||||
|
||||
response = await call_next(request)
|
||||
return response
|
||||
|
||||
api.add_middleware(StateMiddleware)
|
||||
|
||||
@api.route("/")
|
||||
def home(req, resp):
|
||||
resp.text = "{}_{}".format(req.state.test2, req.state.test1)
|
||||
|
||||
assert api.requests.get(url("/")).text == "Foo_42"
|
||||
|
||||
|
||||
def test_path_matches_route(api):
|
||||
@api.route("/hello")
|
||||
def home(req, resp):
|
||||
resp.text = "hello world!"
|
||||
|
||||
route = api.path_matches_route({"type": "http", "path": "/hello"})
|
||||
assert route.endpoint_name == "home"
|
||||
|
||||
assert not api.path_matches_route({"type": "http", "path": "/foo"})
|
||||
|
||||
|
||||
def test_route_without_endpoint(api):
|
||||
# test that a route without endpoint gets a default static response
|
||||
api.add_route("/")
|
||||
route = api.router.routes[0]
|
||||
assert route.endpoint_name == "_static_response"
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
import pytest
|
||||
from responder import routes
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"route, expected",
|
||||
[
|
||||
pytest.param("/", False, id="home path without params"),
|
||||
pytest.param("/test_path", False, id="sub path without params"),
|
||||
pytest.param("/{test_path}", True, id="path with params"),
|
||||
],
|
||||
)
|
||||
def test_parameter(route, expected):
|
||||
r = routes.Route(route, "test_endpoint")
|
||||
assert r.has_parameters is expected
|
||||
|
||||
|
||||
def test_url():
|
||||
r = routes.Route("/{my_path}", "test_endpoint")
|
||||
url = r.url(my_path="path")
|
||||
assert url == "/path"
|
||||
|
||||
|
||||
def test_equal():
|
||||
r = routes.Route("/{path_param}", "test_endpoint")
|
||||
r2 = routes.Route("/{path_param}", "test_endpoint")
|
||||
r3 = routes.Route("/test_path", "test_endpoint")
|
||||
|
||||
assert r == r2
|
||||
assert r != r3
|
||||
|
||||
|
||||
def test_incoming_matches():
|
||||
# Test Route with one param
|
||||
r = routes.Route("/{greetings}", "test_endpoint")
|
||||
assert r.incoming_matches("/hello") == {"greetings": "hello"}
|
||||
assert r.incoming_matches("/foo") == {"greetings": "foo"}
|
||||
|
||||
assert r._memo == {
|
||||
"incoming_matches:/hello": {"greetings": "hello"},
|
||||
"incoming_matches:/foo": {"greetings": "foo"},
|
||||
}
|
||||
|
||||
# Test Route with two params
|
||||
r = routes.Route("/{greetings}/{name}", "test_endpoint")
|
||||
assert r.incoming_matches("/hi/john") == {"greetings": "hi", "name": "john"}
|
||||
assert r.incoming_matches("/hello/jane") == {"greetings": "hello", "name": "jane"}
|
||||
|
||||
# Test Route with no param
|
||||
assert r._memo == {
|
||||
"incoming_matches:/hi/john": {"greetings": "hi", "name": "john"},
|
||||
"incoming_matches:/hello/jane": {"greetings": "hello", "name": "jane"},
|
||||
}
|
||||
|
||||
r = routes.Route("/hello", "test_endpoint")
|
||||
assert r.incoming_matches("/hello") == {}
|
||||
assert r.incoming_matches("/bye") == {}
|
||||
assert r._memo == {"incoming_matches:/hello": {}, "incoming_matches:/bye": {}}
|
||||
|
||||
|
||||
def test_incoming_matches_with_concrete_path_no_match():
|
||||
r = routes.Route("/concrete_path", "test_endpoint")
|
||||
assert r.incoming_matches("hello") == {}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"route, match, expected",
|
||||
[
|
||||
pytest.param(
|
||||
"/{path_param}",
|
||||
"/{path_param}",
|
||||
True,
|
||||
id="with both parametrized path match",
|
||||
),
|
||||
pytest.param(
|
||||
"/concrete", "/concrete", True, id="with both concrete path match"
|
||||
),
|
||||
pytest.param("/concrete", "/no_match", False, id="with no match"),
|
||||
],
|
||||
)
|
||||
def test_does_match_with_route(route, match, expected):
|
||||
r = routes.Route(route, "test_endpoint")
|
||||
assert r.does_match(match) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"path_param, expected_weight",
|
||||
[
|
||||
pytest.param("/{greetings}", (True, -1), id="with one param"),
|
||||
pytest.param(
|
||||
"/{greetings}.{name}", (True, -2), id="with 2 params and dot in the middle"
|
||||
),
|
||||
pytest.param("/{greetings}/{name}", (True, -2), id="with 2 param and subpath"),
|
||||
pytest.param(
|
||||
"/{greetings}/{name}/{hello}", (True, -3), id="with 3 param and subpath"
|
||||
),
|
||||
pytest.param(
|
||||
"/{greetings}_{name}", (True, -2), id="with 2 param and underscore"
|
||||
),
|
||||
pytest.param("/hello", (False, 0), id="with 2 param and underscore"),
|
||||
],
|
||||
)
|
||||
def test_weight(path_param, expected_weight):
|
||||
r = routes.Route(path_param, "test_endpoint")
|
||||
assert r._weight() == expected_weight
|
||||
@@ -8,7 +8,7 @@ from responder import status_codes
|
||||
pytest.param(101, True, id="Normal 101"),
|
||||
pytest.param(199, True, id="Not actual status code but within 100"),
|
||||
pytest.param(0, False, id="Zero case (below 100)"),
|
||||
pytest.param(200, False, id="Above 100")
|
||||
pytest.param(200, False, id="Above 100"),
|
||||
],
|
||||
)
|
||||
def test_is_100(status_code, expected):
|
||||
@@ -21,7 +21,7 @@ def test_is_100(status_code, expected):
|
||||
pytest.param(201, True, id="Normal 201"),
|
||||
pytest.param(299, True, id="Not actual status code but within 200"),
|
||||
pytest.param(0, False, id="Zero case (below 200)"),
|
||||
pytest.param(300, False, id="Above 200")
|
||||
pytest.param(300, False, id="Above 200"),
|
||||
],
|
||||
)
|
||||
def test_is_200(status_code, expected):
|
||||
@@ -34,7 +34,7 @@ def test_is_200(status_code, expected):
|
||||
pytest.param(301, True, id="Normal 301"),
|
||||
pytest.param(399, True, id="Not actual status code but within 300"),
|
||||
pytest.param(0, False, id="Zero case (below 300)"),
|
||||
pytest.param(400, False, id="Above 300")
|
||||
pytest.param(400, False, id="Above 300"),
|
||||
],
|
||||
)
|
||||
def test_is_300(status_code, expected):
|
||||
@@ -47,7 +47,7 @@ def test_is_300(status_code, expected):
|
||||
pytest.param(401, True, id="Normal 401"),
|
||||
pytest.param(499, True, id="Not actual status code but within 400"),
|
||||
pytest.param(0, False, id="Zero case (below 400)"),
|
||||
pytest.param(500, False, id="Above 400")
|
||||
pytest.param(500, False, id="Above 400"),
|
||||
],
|
||||
)
|
||||
def test_is_400(status_code, expected):
|
||||
@@ -57,10 +57,10 @@ def test_is_400(status_code, expected):
|
||||
@pytest.mark.parametrize(
|
||||
"status_code, expected",
|
||||
[
|
||||
pytest.param(501, True, id="Normal 401"),
|
||||
pytest.param(599, True, id="Not actual status code but within 400"),
|
||||
pytest.param(0, False, id="Zero case (below 400)"),
|
||||
pytest.param(600, False, id="Above 500")
|
||||
pytest.param(501, True, id="Normal 501"),
|
||||
pytest.param(599, True, id="Not actual status code but within 500"),
|
||||
pytest.param(0, False, id="Zero case (below 500)"),
|
||||
pytest.param(600, False, id="Above 500"),
|
||||
],
|
||||
)
|
||||
def test_is_500(status_code, expected):
|
||||
|
||||
Reference in New Issue
Block a user