diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..96f81bce --- /dev/null +++ b/.dockerignore @@ -0,0 +1,2 @@ +Dockerfile +.git diff --git a/.gitignore b/.gitignore index ec156a95..eac3867b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,11 @@ env/ build/ dist/ +.eggs/ .workon .epio-app *.pyc .tox *.egg-info *.swp +.vscode/ diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 00000000..dfad587e --- /dev/null +++ b/.travis.yml @@ -0,0 +1,20 @@ +dist: trusty +language: python + +sudo: false +matrix: + include: + - python: 2.7 + env: TOXENV=py27 + - python: 3.6 + env: TOXENV=py36 + - python: 3.7 + env: TOXENV=py37 + dist: xenial + sudo: true + +install: + - travis_retry pip install tox + +script: + - tox diff --git a/AUTHORS b/AUTHORS index a4561f83..048ecf1c 100644 --- a/AUTHORS +++ b/AUTHORS @@ -21,3 +21,5 @@ Patches and Suggestions - Cory Benfield (Lukasa) - Matt Robenolt (https://github.com/mattrobenolt) - Dave Challis (https://github.com/davechallis) +- Florian Bruhin (https://github.com/The-Compiler) +- Brett Randall (https://github.com/javabrett) diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..819006bb --- /dev/null +++ b/Dockerfile @@ -0,0 +1,22 @@ +FROM ubuntu:18.04 + +LABEL name="httpbin" +LABEL version="0.9.2" +LABEL description="A simple HTTP service." +LABEL org.kennethreitz.vendor="Kenneth Reitz" + +ENV LC_ALL=C.UTF-8 +ENV LANG=C.UTF-8 + +RUN apt update -y && apt install python3-pip git -y && pip3 install --no-cache-dir pipenv + +ADD Pipfile Pipfile.lock /httpbin/ +WORKDIR /httpbin +RUN /bin/bash -c "pip3 install --no-cache-dir -r <(pipenv lock -r)" + +ADD . /httpbin +RUN pip3 install --no-cache-dir /httpbin + +EXPOSE 80 + +CMD ["gunicorn", "-b", "0.0.0.0:80", "httpbin:app", "-k", "gevent"] diff --git a/LICENSE b/LICENSE index 8a9ee989..885f52d8 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,6 @@ -Copyright (c) 2011 Kenneth Reitz. +ISC License + +Copyright (c) 2017 Kenneth Reitz. Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above @@ -10,4 +12,4 @@ MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF -OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. \ No newline at end of file +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. diff --git a/MANIFEST.in b/MANIFEST.in index aa7d55a1..894af4cc 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,2 +1,3 @@ -include README.md LICENSE AUTHORS requirements.txt -include httpbin/templates/* \ No newline at end of file +include httpbin/VERSION README.md LICENSE AUTHORS test_httpbin.py +recursive-include httpbin/templates * +recursive-include httpbin/static * diff --git a/Makefile b/Makefile deleted file mode 100644 index ef53584a..00000000 --- a/Makefile +++ /dev/null @@ -1,5 +0,0 @@ -html: - cat README.md|sed 's/(http:\/\/httpbin.org\//(\//'|ronn -5 -f --style 80c --pipe > ./httpbin/templates/httpbin.1.html - -run: html - foreman start diff --git a/Pipfile b/Pipfile new file mode 100644 index 00000000..8ad29bb8 --- /dev/null +++ b/Pipfile @@ -0,0 +1,18 @@ +[[source]] +url = "https://pypi.python.org/simple" +verify_ssl = true + +[packages] +gunicorn = "*" +decorator = "*" +brotlipy = "*" +gevent = "*" +Flask = "*" +meinheld = "*" +werkzeug = ">=0.14.1" +six = "*" +flasgger = "*" +pyyaml = {git = "https://github.com/yaml/pyyaml.git"} + +[dev-packages] +rope = "*" diff --git a/Pipfile.lock b/Pipfile.lock new file mode 100644 index 00000000..baa2566d --- /dev/null +++ b/Pipfile.lock @@ -0,0 +1,246 @@ +{ + "_meta": { + "hash": { + "sha256": "b709c9b498d9be5088c0f485aafe18a04a8ed5144d397111a8f1d8bd06d7a16e" + }, + "pipfile-spec": 6, + "requires": {}, + "sources": [ + { + "url": "https://pypi.python.org/simple", + "verify_ssl": true + } + ] + }, + "default": { + "brotlipy": { + "hashes": [ + "sha256:07194f4768eb62a4f4ea76b6d0df6ade185e24ebd85877c351daa0a069f1111a", + "sha256:091b299bf36dd6ef7a06570dbc98c0f80a504a56c5b797f31934d2ad01ae7d17", + "sha256:09ec3e125d16749b31c74f021aba809541b3564e5359f8c265cbae442810b41a", + "sha256:0be698678a114addcf87a4b9496c552c68a2c99bf93cf8e08f5738b392e82057", + "sha256:0fa6088a9a87645d43d7e21e32b4a6bf8f7c3939015a50158c10972aa7f425b7", + "sha256:1ea4e578241504b58f2456a6c69952c88866c794648bdc74baee74839da61d44", + "sha256:2699945a0a992c04fc7dc7fa2f1d0575a2c8b4b769f2874a08e8eae46bef36ae", + "sha256:2a80319ae13ea8dd60ecdc4f5ccf6da3ae64787765923256b62c598c5bba4121", + "sha256:2e5c64522364a9ebcdf47c5744a5ddeb3f934742d31e61ebfbbc095460b47162", + "sha256:36def0b859beaf21910157b4c33eb3b06d8ce459c942102f16988cca6ea164df", + "sha256:3a3e56ced8b15fbbd363380344f70f3b438e0fd1fcf27b7526b6172ea950e867", + "sha256:3c1d5e2cf945a46975bdb11a19257fa057b67591eb232f393d260e7246d9e571", + "sha256:50ca336374131cfad20612f26cc43c637ac0bfd2be3361495e99270883b52962", + "sha256:5de6f7d010b7558f72f4b061a07395c5c3fd57f0285c5af7f126a677b976a868", + "sha256:637847560d671657f993313ecc6c6c6666a936b7a925779fd044065c7bc035b9", + "sha256:653faef61241bf8bf99d73ca7ec4baa63401ba7b2a2aa88958394869379d67c7", + "sha256:786afc8c9bd67de8d31f46e408a3386331e126829114e4db034f91eacb05396d", + "sha256:79aaf217072840f3e9a3b641cccc51f7fc23037496bd71e26211856b93f4b4cb", + "sha256:7e31f7adcc5851ca06134705fcf3478210da45d35ad75ec181e1ce9ce345bb38", + "sha256:8b39abc3256c978f575df5cd7893153277216474f303e26f0e43ba3d3969ef96", + "sha256:9448227b0df082e574c45c983fa5cd4bda7bfb11ea6b59def0940c1647be0c3c", + "sha256:96bc59ff9b5b5552843dc67999486a220e07a0522dddd3935da05dc194fa485c", + "sha256:a07647886e24e2fb2d68ca8bf3ada398eb56fd8eac46c733d4d95c64d17f743b", + "sha256:af65d2699cb9f13b26ec3ba09e75e80d31ff422c03675fcb36ee4dabe588fdc2", + "sha256:b4c98b0d2c9c7020a524ca5bbff42027db1004c6571f8bc7b747f2b843128e7a", + "sha256:c6cc0036b1304dd0073eec416cb2f6b9e37ac8296afd9e481cac3b1f07f9db25", + "sha256:d2c1c724c4ac375feb2110f1af98ecdc0e5a8ea79d068efb5891f621a5b235cb", + "sha256:dc6c5ee0df9732a44d08edab32f8a616b769cc5a4155a12d2d010d248eb3fb07", + "sha256:fd1d1c64214af5d90014d82cee5d8141b13d44c92ada7a0c0ec0679c6f15a471" + ], + "version": "==0.7.0" + }, + "cffi": { + "hashes": [ + "sha256:151b7eefd035c56b2b2e1eb9963c90c6302dc15fbd8c1c0a83a163ff2c7d7743", + "sha256:1553d1e99f035ace1c0544050622b7bc963374a00c467edafac50ad7bd276aef", + "sha256:1b0493c091a1898f1136e3f4f991a784437fac3673780ff9de3bcf46c80b6b50", + "sha256:2ba8a45822b7aee805ab49abfe7eec16b90587f7f26df20c71dd89e45a97076f", + "sha256:3bb6bd7266598f318063e584378b8e27c67de998a43362e8fce664c54ee52d30", + "sha256:3c85641778460581c42924384f5e68076d724ceac0f267d66c757f7535069c93", + "sha256:3eb6434197633b7748cea30bf0ba9f66727cdce45117a712b29a443943733257", + "sha256:495c5c2d43bf6cebe0178eb3e88f9c4aa48d8934aa6e3cddb865c058da76756b", + "sha256:4c91af6e967c2015729d3e69c2e51d92f9898c330d6a851bf8f121236f3defd3", + "sha256:57b2533356cb2d8fac1555815929f7f5f14d68ac77b085d2326b571310f34f6e", + "sha256:770f3782b31f50b68627e22f91cb182c48c47c02eb405fd689472aa7b7aa16dc", + "sha256:79f9b6f7c46ae1f8ded75f68cf8ad50e5729ed4d590c74840471fc2823457d04", + "sha256:7a33145e04d44ce95bcd71e522b478d282ad0eafaf34fe1ec5bbd73e662f22b6", + "sha256:857959354ae3a6fa3da6651b966d13b0a8bed6bbc87a0de7b38a549db1d2a359", + "sha256:87f37fe5130574ff76c17cab61e7d2538a16f843bb7bca8ebbc4b12de3078596", + "sha256:95d5251e4b5ca00061f9d9f3d6fe537247e145a8524ae9fd30a2f8fbce993b5b", + "sha256:9d1d3e63a4afdc29bd76ce6aa9d58c771cd1599fbba8cf5057e7860b203710dd", + "sha256:a36c5c154f9d42ec176e6e620cb0dd275744aa1d804786a71ac37dc3661a5e95", + "sha256:a6a5cb8809091ec9ac03edde9304b3ad82ad4466333432b16d78ef40e0cce0d5", + "sha256:ae5e35a2c189d397b91034642cb0eab0e346f776ec2eb44a49a459e6615d6e2e", + "sha256:b0f7d4a3df8f06cf49f9f121bead236e328074de6449866515cea4907bbc63d6", + "sha256:b75110fb114fa366b29a027d0c9be3709579602ae111ff61674d28c93606acca", + "sha256:ba5e697569f84b13640c9e193170e89c13c6244c24400fc57e88724ef610cd31", + "sha256:be2a9b390f77fd7676d80bc3cdc4f8edb940d8c198ed2d8c0be1319018c778e1", + "sha256:ca1bd81f40adc59011f58159e4aa6445fc585a32bb8ac9badf7a2c1aa23822f2", + "sha256:d5d8555d9bfc3f02385c1c37e9f998e2011f0db4f90e250e5bc0c0a85a813085", + "sha256:e55e22ac0a30023426564b1059b035973ec82186ddddbac867078435801c7801", + "sha256:e90f17980e6ab0f3c2f3730e56d1fe9bcba1891eeea58966e89d352492cc74f4", + "sha256:ecbb7b01409e9b782df5ded849c178a0aa7c906cf8c5a67368047daab282b184", + "sha256:ed01918d545a38998bfa5902c7c00e0fee90e957ce036a4000a88e3fe2264917", + "sha256:edabd457cd23a02965166026fd9bfd196f4324fe6032e866d0f3bd0301cd486f", + "sha256:fdf1c1dc5bafc32bc5d08b054f94d659422b05aba244d6be4ddc1c72d9aa70fb" + ], + "version": "==1.11.5" + }, + "click": { + "hashes": [ + "sha256:29f99fc6125fbc931b758dc053b3114e55c77a6e4c6c3a2674a2dc986016381d", + "sha256:f15516df478d5a56180fbf80e68f206010e6d160fc39fa508b65e035fd75130b" + ], + "version": "==6.7" + }, + "decorator": { + "hashes": [ + "sha256:2c51dff8ef3c447388fe5e4453d24a2bf128d3a4c32af3fabef1f01c6851ab82", + "sha256:c39efa13fbdeb4506c476c9b3babf6a718da943dab7811c206005a4a956c080c" + ], + "version": "==4.3.0" + }, + "flasgger": { + "hashes": [ + "sha256:1c9c03a4b55b60688f2bb2c2d8ff4534cb18eda70fd02973141be8c3bde586b3", + "sha256:efee892b0554c60f716b441ee78fddcaf7af20bc764696d9eecd6a389fb7f195" + ], + "version": "==0.9.0" + }, + "flask": { + "hashes": [ + "sha256:2271c0070dbcb5275fad4a82e29f23ab92682dc45f9dfbc22c02ba9b9322ce48", + "sha256:a080b744b7e345ccfcbc77954861cb05b3c63786e93f2b3875e0913d44b43f05" + ], + "version": "==1.0.2" + }, + "gevent": { + "hashes": [ + "sha256:00a45774ad6e7a8641af5db011807f53c1f0e0bc62cbdcab83e4db18e6201b6e", + "sha256:15dbcc07cdd09f87b9814ee26483ec49e0d71fdc65d7a61b21c2c56bbb550168", + "sha256:16143db7b760d9b512edfaf4d0bbef01cf0391e773362c43084901e3ecb892d5", + "sha256:1a0d422d6c960c36088201d4bbc925dfde87dc4a4e442bf2e4d36ae455f24a96", + "sha256:22187d0aba6506b57075dd05d0df495b04bfd4b047bbf776eeaac93117a6e9d2", + "sha256:33320f60be19a865396a7f5e10c15b14e338790ae807c97c90edc990d644dc1c", + "sha256:3498fec10e3695f3ad31253857c624435378c6a47969babb54a83ac0101615d3", + "sha256:3c9fbc0dac62e552dc5d03bb67ceaefc5f74d7b4ac04a4bf797cdb0a4438b1db", + "sha256:53c4dc705886d028f5d81e698b1d1479994a421498cd6529cb9711b5e2a84f74", + "sha256:57729118fbcf0f39ecf721ae9b318a4a738eb5d9b972af6c6c8c96303e30f011", + "sha256:6c41413e1eb0b7bf77dcea42ff276e62903bfdc62cb936d71458d338b9edc9a6", + "sha256:72f7cab120e2af89d3a9d6c526e49da5c0b6c94d47e23ab7a26ae8471ee97ffb", + "sha256:7ac5a4945fc47e3824d55bb50b6dd65823868e87fac841bea5762f79b9d22019", + "sha256:7bb0e1ef3adfea008688617fedb1741009856f98e26133983646203c718f7f39", + "sha256:8c41ef269bc743b5bb88a4553627cd4611be5c59589d5390e29956a8d3ab8623", + "sha256:a1f32f0b01ceb15f93b2914b7057acb008c5173181813424621dc444f73c00e2", + "sha256:a51456f842f7de83fff473a0230e313e44ac6fa83e492412e696924f417088b8", + "sha256:a72a23829ce8eb18086ec6f855715c3f52d3c1e12b83fd040d9fb854e77c0565", + "sha256:c7e5f8a6bf865ef507db27f85376808991d3189df185864a5ee326d97e144ec4", + "sha256:cf707886b9b45e56114c6f5522fc556058de5b5bf8674b609e82dfa2f9633c41", + "sha256:d83370528327364354cfb54c96ca401853599bd7a15f382e6962fd8318cede50", + "sha256:e9d64081e419eb8a268edaa90bba95fb4c78a6278d2105dcc080b24b42679535" + ], + "version": "==1.3.4" + }, + "greenlet": { + "hashes": [ + "sha256:09ef2636ea35782364c830f07127d6c7a70542b178268714a9a9ba16318e7e8b", + "sha256:0fef83d43bf87a5196c91e73cb9772f945a4caaff91242766c5916d1dd1381e4", + "sha256:1b7df09c6598f5cfb40f843ade14ed1eb40596e75cd79b6fa2efc750ba01bb01", + "sha256:1fff21a2da5f9e03ddc5bd99131a6b8edf3d7f9d6bc29ba21784323d17806ed7", + "sha256:42118bf608e0288e35304b449a2d87e2ba77d1e373e8aa221ccdea073de026fa", + "sha256:50643fd6d54fd919f9a0a577c5f7b71f5d21f0959ab48767bd4bb73ae0839500", + "sha256:58798b5d30054bb4f6cf0f712f08e6092df23a718b69000786634a265e8911a9", + "sha256:5b49b3049697aeae17ef7bf21267e69972d9e04917658b4e788986ea5cc518e8", + "sha256:75c413551a436b462d5929255b6dc9c0c3c2b25cbeaee5271a56c7fda8ca49c0", + "sha256:769b740aeebd584cd59232be84fdcaf6270b8adc356596cdea5b2152c82caaac", + "sha256:a1852b51b06d1367e2d70321f6801844f5122852c9e5169bdfdff3f4d81aae30", + "sha256:ad2383d39f13534f3ca5c48fe1fc0975676846dc39c2cece78c0f1f9891418e0", + "sha256:b417bb7ff680d43e7bd7a13e2e08956fa6acb11fd432f74c97b7664f8bdb6ec1", + "sha256:b6ef0cabaf5a6ecb5ac122e689d25ba12433a90c7b067b12e5f28bdb7fb78254", + "sha256:c2de19c88bdb0366c976cc125dca1002ec1b346989d59524178adfd395e62421", + "sha256:c7b04a6dc74087b1598de8d713198de4718fa30ec6cbb84959b26426c198e041", + "sha256:f8f2a0ae8de0b49c7b5b2daca4f150fdd9c1173e854df2cce3b04123244f9f45", + "sha256:fcfadaf4bf68a27e5dc2f42cbb2f4b4ceea9f05d1d0b8f7787e640bed2801634" + ], + "markers": "platform_python_implementation == 'CPython'", + "version": "==0.4.13" + }, + "gunicorn": { + "hashes": [ + "sha256:aa8e0b40b4157b36a5df5e599f45c9c76d6af43845ba3b3b0efe2c70473c2471", + "sha256:fa2662097c66f920f53f70621c6c58ca4a3c4d3434205e608e121b5b3b71f4f3" + ], + "version": "==19.9.0" + }, + "itsdangerous": { + "hashes": [ + "sha256:cbb3fcf8d3e33df861709ecaf89d9e6629cff0a217bc2848f1b41cd30d360519" + ], + "version": "==0.24" + }, + "jinja2": { + "hashes": [ + "sha256:74c935a1b8bb9a3947c50a54766a969d4846290e1e788ea44c1392163723c3bd", + "sha256:f84be1bb0040caca4cea721fcbbbbd61f9be9464ca236387158b0feea01914a4" + ], + "version": "==2.10" + }, + "jsonschema": { + "hashes": [ + "sha256:000e68abd33c972a5248544925a0cae7d1125f9bf6c58280d37546b946769a08", + "sha256:6ff5f3180870836cae40f06fa10419f557208175f13ad7bc26caa77beb1f6e02" + ], + "version": "==2.6.0" + }, + "markupsafe": { + "hashes": [ + "sha256:a6be69091dac236ea9c6bc7d012beab42010fa914c459791d627dad4910eb665" + ], + "version": "==1.0" + }, + "meinheld": { + "hashes": [ + "sha256:293eff4983b7fcbd9134b47706b22189883fe354993bd10163c65869d141e565", + "sha256:40d9dbce0165b2d9142f364d26fd6d59d3682f89d0dfe2117717a8ddad1f4133" + ], + "version": "==0.6.1" + }, + "mistune": { + "hashes": [ + "sha256:b4c512ce2fc99e5a62eb95a4aba4b73e5f90264115c40b70a21e1f7d4e0eac91", + "sha256:bc10c33bfdcaa4e749b779f62f60d6e12f8215c46a292d05e486b869ae306619" + ], + "version": "==0.8.3" + }, + "pycparser": { + "hashes": [ + "sha256:99a8ca03e29851d96616ad0404b4aad7d9ee16f25c9f9708a11faf2810f7b226" + ], + "version": "==2.18" + }, + "pyyaml": { + "git": "https://github.com/yaml/pyyaml.git", + "ref": "a9c28e0b521967f5330f0316edd90a57f99cdd32" + }, + "six": { + "hashes": [ + "sha256:70e8a77beed4562e7f14fe23a786b54f6296e34344c23bc42f07b15018ff98e9", + "sha256:832dc0e10feb1aa2c68dcc57dbb658f1c7e65b9b61af69048abc87a2db00a0eb" + ], + "version": "==1.11.0" + }, + "werkzeug": { + "hashes": [ + "sha256:c3fd7a7d41976d9f44db327260e263132466836cef6f91512889ed60ad26557c", + "sha256:d5da73735293558eb1651ee2fddc4d0dedcfa06538b8813a2e20011583c9e49b" + ], + "version": "==0.14.1" + } + }, + "develop": { + "rope": { + "hashes": [ + "sha256:a09edfd2034fd50099a67822f9bd851fbd0f4e98d3b87519f6267b60e50d80d1" + ], + "version": "==0.10.7" + } + } +} diff --git a/Procfile b/Procfile index 54dd6225..5ab46ac6 100644 --- a/Procfile +++ b/Procfile @@ -1 +1 @@ -web: gunicorn httpbin:app -w 6 \ No newline at end of file +web: gunicorn httpbin:app -k gevent diff --git a/README.md b/README.md index 2c88fc20..8148d684 100644 --- a/README.md +++ b/README.md @@ -1,140 +1,31 @@ -httpbin(1): HTTP Request & Response Service -=========================================== +# httpbin(1): HTTP Request & Response Service -Freely hosted in [HTTP](http://httpbin.org) & -[HTTPS](https://httpbin.org) flavors. -## ENDPOINTS +A [Kenneth Reitz](http://kennethreitz.org/bitcoin) Project. -- [`/`](http://httpbin.org/) This page. -- [`/ip`](http://httpbin.org/ip) Returns Origin IP. -- [`/user-agent`](http://httpbin.org/user-agent) Returns user-agent. -- [`/headers`](http://httpbin.org/headers) Returns header dict. -- [`/get`](http://httpbin.org/get) Returns GET data. -- [`/post`](http://hurl.it/hurls/190ccaa90ee1ed35d34abfa4ac6ef088bc319402/d834bcdf7718c44c4184bf914ee473b6ebd8e566) Returns POST data. -- [`/patch`](http://hurl.it/hurls/18016368fa5e5eef80db935f5dae615d5858a4a4/a512d904f5da64df9627ee998c040f7874d6436a) Returns PATCH data. -- [`/put`](http://hurl.it/hurls/18016368fa5e5eef80db935f5dae615d5858a4a4/a512d904f5da64df9627ee998c040f7874d6436a) Returns PUT data. -- [`/delete`](http://hurl.it/hurls/6faafea5191f77172fca4cfe1505739230d5f769/bc255ffc69e04c2c8b968822c59544746bbb872c) Returns DELETE data -- [`/gzip`](http://httpbin.org/gzip) Returns gzip-encoded data. -- [`/deflate`](http://httpbin.org/deflate) Returns deflate-encoded data. -- [`/status/:code`](http://httpbin.org/status/418) Returns given HTTP Status code. -- [`/response-headers?key=val`](http://httpbin.org/response-headers?Content-Type=text/plain;%20charset=UTF-8&Server=httpbin) Returns given response headers. -- [`/redirect/:n`](http://httpbin.org/redirect/6) 302 Redirects *n* times. -- [`/redirect-to?url=foo`](http://httpbin.org/redirect-to?url=http://example.com/) 302 Redirects to the *foo* URL. -- [`/relative-redirect/:n`](http://httpbin.org/relative-redirect/6) 302 Relative redirects *n* times. -- [`/cookies`](http://httpbin.org/cookies) Returns cookie data. -- [`/cookies/set?name=value`](http://httpbin.org/cookies/set?k1=v1&k2=v2) Sets one or more simple cookies. -- [`/cookies/delete?name`](http://httpbin.org/cookies/delete?k1&k2) Deletes one or more simple cookies. -- [`/basic-auth/:user/:passwd`](http://httpbin.org/basic-auth/user/passwd) Challenges HTTPBasic Auth. -- [`/hidden-basic-auth/:user/:passwd`](http://httpbin.org/hidden-basic-auth/user/passwd) 404'd BasicAuth. -- [`/digest-auth/:qop/:user/:passwd`](http://httpbin.org/digest-auth/auth/user/passwd) Challenges HTTP Digest Auth. -- [`/stream/:n`](http://httpbin.org/stream/20) Streams *n*–100 lines. -- [`/delay/:n`](http://httpbin.org/delay/3) Delays responding for *n*–10 seconds. -- [`/drip?numbytes=n&duration=s&delay=s`](http://httpbin.org/drip?numbytes=5&duration=5) Drips data over a duration after an optional initial delay. -- [`/html`](http://httpbin.org/html) Renders an HTML Page. -- [`/robots.txt`](http://httpbin.org/robots.txt) Returns some robots.txt rules. -- [`/deny`](http://httpbin.org/deny) Denied by robots.txt file. -- [`/cache`](http://httpbin.org/cache) Returns 200 unless an If-Modified-Since or If-None-Match header is provided, when it returns a 304. -- [`/cache/:n`](http://httpbin.org/cache/60) Sets a Cache-Control header for *n* seconds. -- [`/bytes/:n`](http://httpbin.org/bytes/1024) Generates *n* random bytes of binary data, accepts optional *seed* integer parameter. -- [`/stream-bytes/:n`](http://httpbin.org/stream-bytes/1024) Streams *n* random bytes of binary data, accepts optional *seed* and *chunk_size* integer parameters. -- [`/links/:n`](http://httpbin.org/links/10) Returns page containing *n* HTML links. +![ice cream](http://farm1.staticflickr.com/572/32514669683_4daf2ab7bc_k_d.jpg) -## DESCRIPTION - -Testing an HTTP Library can become difficult sometimes. PostBin.org is fantastic -for testing POST requests, but not much else. This exists to cover all kinds of HTTP -scenarios. Additional endpoints are being considered. - -All endpoint responses are JSON-encoded. - - -## EXAMPLES - -### $ curl http://httpbin.org/ip - - {"origin": "24.127.96.129"} - -### $ curl http://httpbin.org/user-agent - - {"user-agent": "curl/7.19.7 (universal-apple-darwin10.0) libcurl/7.19.7 OpenSSL/0.9.8l zlib/1.2.3"} - -### $ curl http://httpbin.org/get - - { - "args": {}, - "headers": { - "Accept": "*/*", - "Connection": "close", - "Content-Length": "", - "Content-Type": "", - "Host": "httpbin.org", - "User-Agent": "curl/7.19.7 (universal-apple-darwin10.0) libcurl/7.19.7 OpenSSL/0.9.8l zlib/1.2.3" - }, - "origin": "24.127.96.129", - "url": "http://httpbin.org/get" - } - -### $ curl -I http://httpbin.org/status/418 - - HTTP/1.1 418 I'M A TEAPOT - Server: nginx/0.7.67 - Date: Mon, 13 Jun 2011 04:25:38 GMT - Connection: close - x-more-info: http://tools.ietf.org/html/rfc2324 - Content-Length: 135 - - -### $ curl https://httpbin.org/get?show_env=1 - - { - "headers": { - "Content-Length": "", - "Accept-Language": "en-US,en;q=0.8", - "Accept-Encoding": "gzip,deflate,sdch", - "X-Forwarded-Port": "443", - "X-Forwarded-For": "109.60.101.240", - "X-Heroku-Dynos-In-Use": "1", - "Host": "httpbin.org", - "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", - "User-Agent": "Mozilla/5.0 (X11; Linux i686) AppleWebKit/535.11 (KHTML, like Gecko) Chrome/17.0.963.83 Safari/535.11", - "X-Request-Start": "1350053933441", - "Accept-Charset": "ISO-8859-1,utf-8;q=0.7,*;q=0.3", - "Connection": "keep-alive", - "X-Forwarded-Proto": "https", - "Cookie": "_gauges_unique_day=1; _gauges_unique_month=1; _gauges_unique_year=1; _gauges_unique=1; _gauges_unique_hour=1", - "X-Heroku-Queue-Depth": "0", - "X-Heroku-Queue-Wait-Time": "11", - "Content-Type": "" - }, - "args": { - "show_env": "1" - }, - "origin": "109.60.101.240", - "url": "http://httpbin.org/get?show_env=1" - } - -## Installing and running from PyPI - -You can install httpbin as a library from PyPI and run it as a WSGI app. For example, using Gunicorn: - -```bash -$ pip install httpbin -$ gunicorn httpbin:app +Run locally: +```sh +docker pull kennethreitz/httpbin +docker run -p 80:80 kennethreitz/httpbin ``` -## Changelog +See http://httpbin.org for more information. -* 0.1.2: Fix a couple Python3 bugs with the random byte endpoints, fix a bug when uploading files without a Content-Type header set. -* 0.1.1: Added templates as data in setup.py -* 0.1.0: Added python3 support and (re)publish on PyPI +## Officially Deployed at: -## AUTHOR +- http://httpbin.org +- https://httpbin.org +- https://hub.docker.com/r/kennethreitz/httpbin/ -A [Kenneth Reitz](http://kennethreitz.com/pages/open-projects.html) -Project. ## SEE ALSO - +- http://requestb.in +- http://python-requests.org +- https://grpcb.in/ + +## Build Status +[![Build Status](https://travis-ci.org/requests/httpbin.svg?branch=master)](https://travis-ci.org/requests/httpbin) diff --git a/app.json b/app.json index be600b4b..91f42beb 100644 --- a/app.json +++ b/app.json @@ -1,8 +1,9 @@ { "name": "httpbin", "description": "HTTP Request & Response Service, written in Python + Flask.", - "repository": "https://github.com/kennethreitz/httpbin", + "repository": "https://github.com/requests/httpbin", "website": "https://httpbin.org", "logo": "https://s3.amazonaws.com/f.cl.ly/items/333Y191Z2C0G2J3m3Y0b/httpbin.svg", - "keywords": ["http", "rest", "API", "testing", "integration", "python", "flask"] + "keywords": ["http", "rest", "API", "testing", "integration", "python", "flask"], + "addons": "sentry" } diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..a7765f7b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,6 @@ +version: '2' +services: + httpbin: + build: '.' + ports: + - '80:80' \ No newline at end of file diff --git a/httpbin/VERSION b/httpbin/VERSION new file mode 100644 index 00000000..2003b639 --- /dev/null +++ b/httpbin/VERSION @@ -0,0 +1 @@ +0.9.2 diff --git a/httpbin/core.py b/httpbin/core.py index 9b81d68c..305c9882 100644 --- a/httpbin/core.py +++ b/httpbin/core.py @@ -10,53 +10,225 @@ import base64 import json import os +import random import time import uuid -import random -import base64 - -from flask import Flask, Response, request, render_template, redirect, jsonify, make_response -from werkzeug.datastructures import WWWAuthenticate +import argparse + +from flask import ( + Flask, + Response, + request, + render_template, + redirect, + jsonify as flask_jsonify, + make_response, + url_for, + abort, +) +from six.moves import range as xrange +from werkzeug.datastructures import WWWAuthenticate, MultiDict from werkzeug.http import http_date from werkzeug.wrappers import BaseResponse -from six.moves import range as xrange +from werkzeug.http import parse_authorization_header +from flasgger import Swagger, NO_SANITIZER from . import filters -from .helpers import get_headers, status_code, get_dict, check_basic_auth, check_digest_auth, H, ROBOT_TXT, ANGRY_ASCII +from .helpers import ( + get_headers, + status_code, + get_dict, + get_request_range, + check_basic_auth, + check_digest_auth, + secure_cookie, + H, + ROBOT_TXT, + ANGRY_ASCII, + parse_multi_value_header, + next_stale_after_value, + digest_challenge_response, +) from .utils import weighted_choice from .structures import CaseInsensitiveDict +with open( + os.path.join(os.path.realpath(os.path.dirname(__file__)), "VERSION") +) as version_file: + version = version_file.read().strip() + ENV_COOKIES = ( - '_gauges_unique', - '_gauges_unique_year', - '_gauges_unique_month', - '_gauges_unique_day', - '_gauges_unique_hour', - '__utmz', - '__utma', - '__utmb' + "_gauges_unique", + "_gauges_unique_year", + "_gauges_unique_month", + "_gauges_unique_day", + "_gauges_unique_hour", + "__utmz", + "__utma", + "__utmb", ) + +def jsonify(*args, **kwargs): + response = flask_jsonify(*args, **kwargs) + if not response.data.endswith(b"\n"): + response.data += b"\n" + return response + + # Prevent WSGI from correcting the casing of the Location header BaseResponse.autocorrect_location_header = False # Find the correct template folder when running from a different location -tmpl_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'templates') +tmpl_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "templates") app = Flask(__name__, template_folder=tmpl_dir) - +app.debug = bool(os.environ.get("DEBUG")) +app.config["JSONIFY_PRETTYPRINT_REGULAR"] = True + +app.add_template_global("HTTPBIN_TRACKING" in os.environ, name="tracking_enabled") + +app.config["SWAGGER"] = {"title": "httpbin.org", "uiversion": 3} + +template = { + "swagger": "2.0", + "info": { + "title": "httpbin.org", + "description": ( + "A simple HTTP Request & Response Service." + "

Run locally: $ docker run -p 80:80 kennethreitz/httpbin" + ), + "contact": { + "responsibleOrganization": "Kenneth Reitz", + "responsibleDeveloper": "Kenneth Reitz", + "email": "me@kennethreitz.org", + "url": "https://kennethreitz.org", + }, + # "termsOfService": "http://me.com/terms", + "version": version, + }, + "host": "httpbin.org", # overrides localhost:5000 + "basePath": "/", # base bash for blueprint registration + "schemes": ["https"], + "protocol": "https", + "tags": [ + { + "name": "HTTP Methods", + "description": "Testing different HTTP verbs", + # 'externalDocs': {'description': 'Learn more', 'url': 'https://www.w3.org/Protocols/rfc2616/rfc2616-sec9.html'} + }, + {"name": "Auth", "description": "Auth methods"}, + { + "name": "Status codes", + "description": "Generates responses with given status code", + }, + {"name": "Request inspection", "description": "Inspect the request data"}, + { + "name": "Response inspection", + "description": "Inspect the response data like caching and headers", + }, + { + "name": "Response formats", + "description": "Returns responses in different data formats", + }, + {"name": "Dynamic data", "description": "Generates random and dynamic data"}, + {"name": "Cookies", "description": "Creates, reads and deletes Cookies"}, + {"name": "Images", "description": "Returns different image formats"}, + {"name": "Redirects", "description": "Returns different redirect responses"}, + { + "name": "Anything", + "description": "Returns anything that is passed to request", + }, + ], +} + +swagger_config = { + "headers": [], + "specs": [ + { + "endpoint": "spec", + "route": "/spec.json", + "rule_filter": lambda rule: True, # all in + "model_filter": lambda tag: True, # all in + } + ], + "static_url_path": "/flasgger_static", + # "static_folder": "static", # must be set by user + "swagger_ui": True, + "specs_route": "/", +} + +swagger = Swagger(app, sanitizer=NO_SANITIZER, template=template, config=swagger_config) + +# Set up Bugsnag exception tracking, if desired. To use Bugsnag, install the +# Bugsnag Python client with the command "pip install bugsnag", and set the +# environment variable BUGSNAG_API_KEY. You can also optionally set +# BUGSNAG_RELEASE_STAGE. +if os.environ.get("BUGSNAG_API_KEY") is not None: + try: + import bugsnag + import bugsnag.flask + + release_stage = os.environ.get("BUGSNAG_RELEASE_STAGE") or "production" + bugsnag.configure( + api_key=os.environ.get("BUGSNAG_API_KEY"), + project_root=os.path.dirname(os.path.abspath(__file__)), + use_ssl=True, + release_stage=release_stage, + ignore_classes=["werkzeug.exceptions.NotFound"], + ) + bugsnag.flask.handle_exceptions(app) + except: + app.logger.warning("Unable to initialize Bugsnag exception handling.") # ----------- # Middlewares # ----------- +""" +https://github.com/kennethreitz/httpbin/issues/340 +Adds a middleware to provide chunked request encoding support running under +gunicorn only. +Werkzeug required environ 'wsgi.input_terminated' to be set otherwise it +empties the input request stream. +- gunicorn seems to support input_terminated but does not add the environ, + so we add it here. +- flask will hang and does not seem to properly terminate the request, so + we explicitly deny chunked requests. +""" + + +@app.before_request +def before_request(): + if request.environ.get("HTTP_TRANSFER_ENCODING", "").lower() == "chunked": + server = request.environ.get("SERVER_SOFTWARE", "") + if server.lower().startswith("gunicorn/"): + if "wsgi.input_terminated" in request.environ: + app.logger.debug( + "environ wsgi.input_terminated already set, keeping: %s" + % request.environ["wsgi.input_terminated"] + ) + else: + request.environ["wsgi.input_terminated"] = 1 + else: + abort(501, "Chunked requests are not supported for server %s" % server) + + @app.after_request def set_cors_headers(response): - response.headers['Access-Control-Allow-Origin'] = request.headers.get('Origin', '*') - - if request.method == 'OPTIONS': - response.headers['Access-Control-Allow-Credentials'] = 'true' - response.headers['Access-Control-Allow-Methods'] = 'GET, POST, PUT, DELETE, PATCH, OPTIONS' - response.headers['Access-Control-Max-Age'] = '3600' # 1 hour cache + response.headers["Access-Control-Allow-Origin"] = request.headers.get("Origin", "*") + response.headers["Access-Control-Allow-Credentials"] = "true" + + if request.method == "OPTIONS": + # Both of these headers are only used for the "preflight request" + # http://www.w3.org/TR/cors/#access-control-allow-methods-response-header + response.headers[ + "Access-Control-Allow-Methods" + ] = "GET, POST, PUT, DELETE, PATCH, OPTIONS" + response.headers["Access-Control-Max-Age"] = "3600" # 1 hour cache + if request.headers.get("Access-Control-Request-Headers") is not None: + response.headers["Access-Control-Allow-Headers"] = request.headers[ + "Access-Control-Request-Headers" + ] return response @@ -64,23 +236,41 @@ def set_cors_headers(response): # Routes # ------ -@app.route('/') -def view_landing_page(): - """Generates Landing Page.""" - return render_template('index.html') +@app.route("/legacy") +def view_landing_page(): + """Generates Landing Page in legacy layout.""" + return render_template("index.html") -@app.route('/html') +@app.route("/html") def view_html_page(): - """Simple Html Page""" + """Returns a simple HTML document. + --- + tags: + - Response formats + produces: + - text/html + responses: + 200: + description: An HTML page. + """ - return render_template('moby.html') + return render_template("moby.html") -@app.route('/robots.txt') +@app.route("/robots.txt") def view_robots_page(): - """Simple Html Page""" + """Returns some robots.txt rules. + --- + tags: + - Response formats + produces: + - text/plain + responses: + 200: + description: Robots file + """ response = make_response() response.data = ROBOT_TXT @@ -88,9 +278,18 @@ def view_robots_page(): return response -@app.route('/deny') +@app.route("/deny") def view_deny_page(): - """Simple Html Page""" + """Returns page denied by robots.txt rules. + --- + tags: + - Response formats + produces: + - text/plain + responses: + 200: + description: Denied message + """ response = make_response() response.data = ANGRY_ASCII response.content_type = "text/plain" @@ -98,193 +297,546 @@ def view_deny_page(): # return "YOU SHOULDN'T BE HERE" -@app.route('/ip') +@app.route("/ip") def view_origin(): - """Returns Origin IP.""" - - return jsonify(origin=request.headers.get('X-Forwarded-For', request.remote_addr)) - - -@app.route('/headers') + """Returns the requester's IP Address. + --- + tags: + - Request inspection + produces: + - application/json + responses: + 200: + description: The Requester's IP Address. + """ + + return jsonify(origin=request.headers.get("X-Forwarded-For", request.remote_addr)) + + +@app.route("/uuid") +def view_uuid(): + """Return a UUID4. + --- + tags: + - Dynamic data + produces: + - application/json + responses: + 200: + description: A UUID4. + """ + + return jsonify(uuid=str(uuid.uuid4())) + + +@app.route("/headers") def view_headers(): - """Returns HTTP HEADERS.""" + """Return the incoming request's HTTP headers. + --- + tags: + - Request inspection + produces: + - application/json + responses: + 200: + description: The request's headers. + """ return jsonify(get_dict('headers')) -@app.route('/user-agent') +@app.route("/user-agent") def view_user_agent(): - """Returns User-Agent.""" + """Return the incoming requests's User-Agent header. + --- + tags: + - Request inspection + produces: + - application/json + responses: + 200: + description: The request's User-Agent header. + """ headers = get_headers() - return jsonify({'user-agent': headers['user-agent']}) + return jsonify({"user-agent": headers["user-agent"]}) -@app.route('/get', methods=('GET',)) +@app.route("/get", methods=("GET",)) def view_get(): - """Returns GET Data.""" - - return jsonify(get_dict('url', 'args', 'headers', 'origin')) - - -@app.route('/post', methods=('POST',)) + """The request's query parameters. + --- + tags: + - HTTP Methods + produces: + - application/json + responses: + 200: + description: The request's query parameters. + """ + + return jsonify(get_dict("url", "args", "headers", "origin")) + + +@app.route("/anything", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "TRACE"]) +@app.route( + "/anything/", + methods=["GET", "POST", "PUT", "DELETE", "PATCH", "TRACE"], +) +def view_anything(anything=None): + """Returns anything passed in request data. + --- + tags: + - Anything + produces: + - application/json + responses: + 200: + description: Anything passed in request + """ + + return jsonify( + get_dict( + "url", + "args", + "headers", + "origin", + "method", + "form", + "data", + "files", + "json", + ) + ) + + +@app.route("/post", methods=("POST",)) def view_post(): - """Returns POST Data.""" - - return jsonify(get_dict( - 'url', 'args', 'form', 'data', 'origin', 'headers', 'files', 'json')) - - -@app.route('/put', methods=('PUT',)) + """The request's POST parameters. + --- + tags: + - HTTP Methods + produces: + - application/json + responses: + 200: + description: The request's POST parameters. + """ + + return jsonify( + get_dict("url", "args", "form", "data", "origin", "headers", "files", "json") + ) + + +@app.route("/put", methods=("PUT",)) def view_put(): - """Returns PUT Data.""" - - return jsonify(get_dict( - 'url', 'args', 'form', 'data', 'origin', 'headers', 'files', 'json')) - - -@app.route('/patch', methods=('PATCH',)) + """The request's PUT parameters. + --- + tags: + - HTTP Methods + produces: + - application/json + responses: + 200: + description: The request's PUT parameters. + """ + + return jsonify( + get_dict("url", "args", "form", "data", "origin", "headers", "files", "json") + ) + + +@app.route("/patch", methods=("PATCH",)) def view_patch(): - """Returns PATCH Data.""" - - return jsonify(get_dict( - 'url', 'args', 'form', 'data', 'origin', 'headers', 'files', 'json')) - - -@app.route('/delete', methods=('DELETE',)) + """The request's PATCH parameters. + --- + tags: + - HTTP Methods + produces: + - application/json + responses: + 200: + description: The request's PATCH parameters. + """ + + return jsonify( + get_dict("url", "args", "form", "data", "origin", "headers", "files", "json") + ) + + +@app.route("/delete", methods=("DELETE",)) def view_delete(): - """Returns DETLETE Data.""" - - return jsonify(get_dict('url', 'args', 'data', 'origin', 'headers', 'json')) - - -@app.route('/gzip') + """The request's DELETE parameters. + --- + tags: + - HTTP Methods + produces: + - application/json + responses: + 200: + description: The request's DELETE parameters. + """ + + return jsonify( + get_dict("url", "args", "form", "data", "origin", "headers", "files", "json") + ) + + +@app.route("/gzip") @filters.gzip def view_gzip_encoded_content(): - """Returns GZip-Encoded Data.""" + """Returns GZip-encoded data. + --- + tags: + - Response formats + produces: + - application/json + responses: + 200: + description: GZip-encoded data. + """ - return jsonify(get_dict( - 'origin', 'headers', method=request.method, gzipped=True)) + return jsonify(get_dict("origin", "headers", method=request.method, gzipped=True)) -@app.route('/deflate') +@app.route("/deflate") @filters.deflate def view_deflate_encoded_content(): - """Returns Deflate-Encoded Data.""" - - return jsonify(get_dict( - 'origin', 'headers', method=request.method, deflated=True)) + """Returns Deflate-encoded data. + --- + tags: + - Response formats + produces: + - application/json + responses: + 200: + description: Defalte-encoded data. + """ + + return jsonify(get_dict("origin", "headers", method=request.method, deflated=True)) + + +@app.route("/brotli") +@filters.brotli +def view_brotli_encoded_content(): + """Returns Brotli-encoded data. + --- + tags: + - Response formats + produces: + - application/json + responses: + 200: + description: Brotli-encoded data. + """ + + return jsonify(get_dict("origin", "headers", method=request.method, brotli=True)) + + +@app.route("/redirect/") +def redirect_n_times(n): + """302 Redirects n times. + --- + tags: + - Redirects + parameters: + - in: path + name: n + type: int + produces: + - text/html + responses: + 302: + description: A redirection. + """ + assert n > 0 + absolute = request.args.get("absolute", "false").lower() == "true" -@app.route('/redirect/') -def redirect_n_times(n): - """301 Redirects n times.""" + if n == 1: + return redirect(url_for("view_get", _external=absolute)) - assert n > 0 + if absolute: + return _redirect("absolute", n, True) + else: + return _redirect("relative", n, False) - if (n == 1): - return redirect('/get') - return redirect('/redirect/{0}'.format(n - 1)) +def _redirect(kind, n, external): + return redirect( + url_for("{0}_redirect_n_times".format(kind), n=n - 1, _external=external) + ) -@app.route('/redirect-to') +@app.route("/redirect-to", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "TRACE"]) def redirect_to(): - """302 Redirects to the given URL.""" - - args = CaseInsensitiveDict(request.args.items()) + """302/3XX Redirects to the given URL. + --- + tags: + - Redirects + produces: + - text/html + get: + parameters: + - in: query + name: url + type: string + required: true + - in: query + name: status_code + type: int + post: + consumes: + - application/x-www-form-urlencoded + parameters: + - in: formData + name: url + type: string + required: true + - in: formData + name: status_code + type: int + required: false + patch: + consumes: + - application/x-www-form-urlencoded + parameters: + - in: formData + name: url + type: string + required: true + - in: formData + name: status_code + type: int + required: false + put: + consumes: + - application/x-www-form-urlencoded + parameters: + - in: formData + name: url + type: string + required: true + - in: formData + name: status_code + type: int + required: false + responses: + 302: + description: A redirection. + """ + + args_dict = request.args.items() + args = CaseInsensitiveDict(args_dict) # We need to build the response manually and convert to UTF-8 to prevent # werkzeug from "fixing" the URL. This endpoint should set the Location # header to the exact string supplied. - response = app.make_response('') + response = app.make_response("") response.status_code = 302 - response.headers['Location'] = args['url'].encode('utf-8') + if "status_code" in args: + status_code = int(args["status_code"]) + if status_code >= 300 and status_code < 400: + response.status_code = status_code + response.headers["Location"] = args["url"].encode("utf-8") return response -@app.route('/relative-redirect/') +@app.route("/relative-redirect/") def relative_redirect_n_times(n): - """301 Redirects n times.""" + """Relatively 302 Redirects n times. + --- + tags: + - Redirects + parameters: + - in: path + name: n + type: int + produces: + - text/html + responses: + 302: + description: A redirection. + """ assert n > 0 - response = app.make_response('') + response = app.make_response("") response.status_code = 302 - if (n == 1): - response.headers['Location'] = '/get' + if n == 1: + response.headers["Location"] = url_for("view_get") return response - response.headers['Location'] = '/relative-redirect/{0}'.format(n - 1) + response.headers["Location"] = url_for("relative_redirect_n_times", n=n - 1) return response -@app.route('/stream/') +@app.route("/absolute-redirect/") +def absolute_redirect_n_times(n): + """Absolutely 302 Redirects n times. + --- + tags: + - Redirects + parameters: + - in: path + name: n + type: int + produces: + - text/html + responses: + 302: + description: A redirection. + """ + + assert n > 0 + + if n == 1: + return redirect(url_for("view_get", _external=True)) + + return _redirect("absolute", n, True) + + +@app.route("/stream/") def stream_n_messages(n): - """Stream n JSON messages""" - response = get_dict('url', 'args', 'headers', 'origin') + """Stream n JSON responses + --- + tags: + - Dynamic data + parameters: + - in: path + name: n + type: int + produces: + - application/json + responses: + 200: + description: Streamed JSON responses. + """ + response = get_dict("url", "args", "headers", "origin") n = min(n, 100) def generate_stream(): for i in range(n): - response['id'] = i - yield json.dumps(response) + '\n' + response["id"] = i + yield json.dumps(response) + "\n" - return Response(generate_stream(), headers={ - "Transfer-Encoding": "chunked", - "Content-Type": "application/json", - }) + return Response(generate_stream(), headers={"Content-Type": "application/json"}) -@app.route('/status/') +@app.route( + "/status/", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "TRACE"] +) def view_status_code(codes): - """Return status code or random status code if more than one are given""" - - if not ',' in codes: - code = int(codes) + """Return status code or random status code if more than one are given + --- + tags: + - Status codes + parameters: + - in: path + name: codes + produces: + - text/plain + responses: + 100: + description: Informational responses + 200: + description: Success + 300: + description: Redirection + 400: + description: Client Errors + 500: + description: Server Errors + """ + + if "," not in codes: + try: + code = int(codes) + except ValueError: + return Response("Invalid status code", status=400) return status_code(code) choices = [] - for choice in codes.split(','): - if not ':' in choice: + for choice in codes.split(","): + if ":" not in choice: code = choice weight = 1 else: - code, weight = choice.split(':') + code, weight = choice.split(":") - choices.append((int(code), float(weight))) + try: + choices.append((int(code), float(weight))) + except ValueError: + return Response("Invalid status code", status=400) code = weighted_choice(choices) return status_code(code) -@app.route('/response-headers') +@app.route("/response-headers", methods=["GET", "POST"]) def response_headers(): - """Returns a set of response headers from the query string """ - headers = CaseInsensitiveDict(request.args.items()) - response = jsonify(headers.items()) + """Returns a set of response headers from the query string. + --- + tags: + - Response inspection + parameters: + - in: query + name: freeform + explode: true + allowEmptyValue: true + schema: + type: object + additionalProperties: + type: string + style: form + produces: + - application/json + responses: + 200: + description: Response headers + """ + # Pending swaggerUI update + # https://github.com/swagger-api/swagger-ui/issues/3850 + headers = MultiDict(request.args.items(multi=True)) + response = jsonify(list(headers.lists())) while True: - content_len_shown = response.headers['Content-Length'] - response = jsonify(response.headers.items()) - for key, value in headers.items(): - response.headers[key] = value - if response.headers['Content-Length'] == content_len_shown: + original_data = response.data + d = {} + for key in response.headers.keys(): + value = response.headers.get_all(key) + if len(value) == 1: + value = value[0] + d[key] = value + response = jsonify(d) + for key, value in headers.items(multi=True): + response.headers.add(key, value) + response_has_changed = response.data != original_data + if not response_has_changed: break return response -@app.route('/cookies') +@app.route("/cookies") def view_cookies(hide_env=True): - """Returns cookie data.""" + """Returns cookie data. + --- + tags: + - Cookies + produces: + - application/json + responses: + 200: + description: Set cookies. + """ cookies = dict(request.cookies.items()) - if hide_env and ('show_env' not in request.args): + if hide_env and ("show_env" not in request.args): for key in ENV_COOKIES: try: del cookies[key] @@ -294,50 +846,122 @@ def view_cookies(hide_env=True): return jsonify(cookies=cookies) -@app.route('/forms/post') +@app.route("/forms/post") def view_forms_post(): """Simple HTML form.""" - return render_template('forms-post.html') + return render_template("forms-post.html") -@app.route('/cookies/set//') +@app.route("/cookies/set//") def set_cookie(name, value): - """Sets a cookie and redirects to cookie list.""" - - r = app.make_response(redirect('/cookies')) - r.set_cookie(key=name, value=value) + """Sets a cookie and redirects to cookie list. + --- + tags: + - Cookies + parameters: + - in: path + name: name + type: string + - in: path + name: value + type: string + produces: + - text/plain + responses: + 200: + description: Set cookies and redirects to cookie list. + """ + + r = app.make_response(redirect(url_for("view_cookies"))) + r.set_cookie(key=name, value=value, secure=secure_cookie()) return r -@app.route('/cookies/set') +@app.route("/cookies/set") def set_cookies(): - """Sets cookie(s) as provided by the query string and redirects to cookie list.""" + """Sets cookie(s) as provided by the query string and redirects to cookie list. + --- + tags: + - Cookies + parameters: + - in: query + name: freeform + explode: true + allowEmptyValue: true + schema: + type: object + additionalProperties: + type: string + style: form + produces: + - text/plain + responses: + 200: + description: Redirect to cookie list + """ cookies = dict(request.args.items()) - r = app.make_response(redirect('/cookies')) + r = app.make_response(redirect(url_for("view_cookies"))) for key, value in cookies.items(): - r.set_cookie(key=key, value=value) + r.set_cookie(key=key, value=value, secure=secure_cookie()) return r -@app.route('/cookies/delete') +@app.route("/cookies/delete") def delete_cookies(): - """Deletes cookie(s) as provided by the query string and redirects to cookie list.""" + """Deletes cookie(s) as provided by the query string and redirects to cookie list. + --- + tags: + - Cookies + parameters: + - in: query + name: freeform + explode: true + allowEmptyValue: true + schema: + type: object + additionalProperties: + type: string + style: form + produces: + - text/plain + responses: + 200: + description: Redirect to cookie list + """ cookies = dict(request.args.items()) - r = app.make_response(redirect('/cookies')) + r = app.make_response(redirect(url_for("view_cookies"))) for key, value in cookies.items(): r.delete_cookie(key=key) return r -@app.route('/basic-auth//') -def basic_auth(user='user', passwd='passwd'): - """Prompts the user for authorization using HTTP Basic Auth.""" +@app.route("/basic-auth//") +def basic_auth(user="user", passwd="passwd"): + """Prompts the user for authorization using HTTP Basic Auth. + --- + tags: + - Auth + parameters: + - in: path + name: user + type: string + - in: path + name: passwd + type: string + produces: + - application/json + responses: + 200: + description: Sucessful authentication. + 401: + description: Unsuccessful authentication. + """ if not check_basic_auth(user, passwd): return status_code(401) @@ -345,192 +969,818 @@ def basic_auth(user='user', passwd='passwd'): return jsonify(authenticated=True, user=user) -@app.route('/hidden-basic-auth//') -def hidden_basic_auth(user='user', passwd='passwd'): - """Prompts the user for authorization using HTTP Basic Auth.""" +@app.route("/hidden-basic-auth//") +def hidden_basic_auth(user="user", passwd="passwd"): + """Prompts the user for authorization using HTTP Basic Auth. + --- + tags: + - Auth + parameters: + - in: path + name: user + type: string + - in: path + name: passwd + type: string + produces: + - application/json + responses: + 200: + description: Sucessful authentication. + 404: + description: Unsuccessful authentication. + """ if not check_basic_auth(user, passwd): return status_code(404) return jsonify(authenticated=True, user=user) -@app.route('/digest-auth///') -def digest_auth(qop=None, user='user', passwd='passwd'): - """Prompts the user for authorization using HTTP Digest auth""" - if qop not in ('auth', 'auth-int'): - qop = None - if not request.headers.get('Authorization'): - response = app.make_response('') +@app.route("/bearer") +def bearer_auth(): + """Prompts the user for authorization using bearer authentication. + --- + tags: + - Auth + parameters: + - in: header + name: Authorization + schema: + type: string + produces: + - application/json + responses: + 200: + description: Sucessful authentication. + 401: + description: Unsuccessful authentication. + """ + authorization = request.headers.get("Authorization") + if not (authorization and authorization.startswith("Bearer ")): + response = app.make_response("") + response.headers["WWW-Authenticate"] = "Bearer" response.status_code = 401 + return response + slice_start = len("Bearer ") + token = authorization[slice_start:] + + return jsonify(authenticated=True, token=token) + + +@app.route("/digest-auth///") +def digest_auth_md5(qop=None, user="user", passwd="passwd"): + """Prompts the user for authorization using Digest Auth. + --- + tags: + - Auth + parameters: + - in: path + name: qop + type: string + description: auth or auth-int + - in: path + name: user + type: string + - in: path + name: passwd + type: string + produces: + - application/json + responses: + 200: + description: Sucessful authentication. + 401: + description: Unsuccessful authentication. + """ + return digest_auth(qop, user, passwd, "MD5", "never") + + +@app.route("/digest-auth////") +def digest_auth_nostale(qop=None, user="user", passwd="passwd", algorithm="MD5"): + """Prompts the user for authorization using Digest Auth + Algorithm. + --- + tags: + - Auth + parameters: + - in: path + name: qop + type: string + description: auth or auth-int + - in: path + name: user + type: string + - in: path + name: passwd + type: string + - in: path + name: algorithm + type: string + description: MD5, SHA-256, SHA-512 + default: MD5 + produces: + - application/json + responses: + 200: + description: Sucessful authentication. + 401: + description: Unsuccessful authentication. + """ + return digest_auth(qop, user, passwd, algorithm, "never") + + +@app.route("/digest-auth/////") +def digest_auth( + qop=None, user="user", passwd="passwd", algorithm="MD5", stale_after="never" +): + """Prompts the user for authorization using Digest Auth + Algorithm. + allow settings the stale_after argument. + --- + tags: + - Auth + parameters: + - in: path + name: qop + type: string + description: auth or auth-int + - in: path + name: user + type: string + - in: path + name: passwd + type: string + - in: path + name: algorithm + type: string + description: MD5, SHA-256, SHA-512 + default: MD5 + - in: path + name: stale_after + type: string + default: never + produces: + - application/json + responses: + 200: + description: Sucessful authentication. + 401: + description: Unsuccessful authentication. + """ + require_cookie_handling = request.args.get("require-cookie", "").lower() in ( + "1", + "t", + "true", + ) + if algorithm not in ("MD5", "SHA-256", "SHA-512"): + algorithm = "MD5" + + if qop not in ("auth", "auth-int"): + qop = None - # RFC2616 Section4.2: HTTP headers are ASCII. That means - # request.remote_addr was originally ASCII, so I should be able to - # encode it back to ascii. Also, RFC2617 says about nonces: "The - # contents of the nonce are implementation dependent" - nonce = H(b''.join([ - getattr(request,'remote_addr',u'').encode('ascii'), - b':', - str(time.time()).encode('ascii'), - b':', - os.urandom(10) - ])) - opaque = H(os.urandom(10)) - - auth = WWWAuthenticate("digest") - auth.set_digest('me@kennethreitz.com', nonce, opaque=opaque, - qop=('auth', 'auth-int') if qop is None else (qop, )) - response.headers['WWW-Authenticate'] = auth.to_header() - response.headers['Set-Cookie'] = 'fake=fake_value' + authorization = request.headers.get("Authorization") + credentials = None + if authorization: + credentials = parse_authorization_header(authorization) + + if ( + not authorization + or not credentials + or credentials.type.lower() != "digest" + or (require_cookie_handling and "Cookie" not in request.headers) + ): + response = digest_challenge_response(app, qop, algorithm) + response.set_cookie("stale_after", value=stale_after) + response.set_cookie("fake", value="fake_value") return response - elif not (check_digest_auth(user, passwd) and - request.headers.get('Cookie')): - return status_code(401) - return jsonify(authenticated=True, user=user) + + if require_cookie_handling and request.cookies.get("fake") != "fake_value": + response = jsonify({"errors": ["missing cookie set on challenge"]}) + response.set_cookie("fake", value="fake_value") + response.status_code = 403 + return response + + current_nonce = credentials.get("nonce") + + stale_after_value = None + if "stale_after" in request.cookies: + stale_after_value = request.cookies.get("stale_after") + + if ( + "last_nonce" in request.cookies + and current_nonce == request.cookies.get("last_nonce") + or stale_after_value == "0" + ): + response = digest_challenge_response(app, qop, algorithm, True) + response.set_cookie("stale_after", value=stale_after) + response.set_cookie("last_nonce", value=current_nonce) + response.set_cookie("fake", value="fake_value") + return response + + if not check_digest_auth(user, passwd): + response = digest_challenge_response(app, qop, algorithm, False) + response.set_cookie("stale_after", value=stale_after) + response.set_cookie("last_nonce", value=current_nonce) + response.set_cookie("fake", value="fake_value") + return response + + response = jsonify(authenticated=True, user=user) + response.set_cookie("fake", value="fake_value") + if stale_after_value: + response.set_cookie( + "stale_after", value=next_stale_after_value(stale_after_value) + ) + + return response -@app.route('/delay/') +@app.route("/delay/", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "TRACE"]) def delay_response(delay): - """Returns a delayed response""" - delay = min(delay, 10) + """Returns a delayed response (max of 10 seconds). + --- + tags: + - Dynamic data + parameters: + - in: path + name: delay + type: int + produces: + - application/json + responses: + 200: + description: A delayed response. + """ + delay = min(float(delay), 10) time.sleep(delay) - return jsonify(get_dict( - 'url', 'args', 'form', 'data', 'origin', 'headers', 'files')) + return jsonify( + get_dict("url", "args", "form", "data", "origin", "headers", "files") + ) -@app.route('/drip') + +@app.route("/drip") def drip(): - """Drips data over a duration after an optional initial delay.""" + """Drips data over a duration after an optional initial delay. + --- + tags: + - Dynamic data + parameters: + - in: query + name: duration + type: number + description: The amount of time (in seconds) over which to drip each byte + default: 2 + required: false + - in: query + name: numbytes + type: integer + description: The number of bytes to respond with + default: 10 + required: false + - in: query + name: code + type: integer + description: The response code that will be returned + default: 200 + required: false + - in: query + name: delay + type: number + description: The amount of time (in seconds) to delay before responding + default: 2 + required: false + produces: + - application/octet-stream + responses: + 200: + description: A dripped response. + """ args = CaseInsensitiveDict(request.args.items()) - duration = float(args.get('duration', 2)) - numbytes = int(args.get('numbytes', 10)) - pause = duration / numbytes + duration = float(args.get("duration", 2)) + numbytes = min(int(args.get("numbytes", 10)), (10 * 1024 * 1024)) # set 10MB limit + code = int(args.get("code", 200)) + + if numbytes <= 0: + response = Response("number of bytes must be positive", status=400) + return response - delay = float(args.get('delay', 0)) + delay = float(args.get("delay", 0)) if delay > 0: time.sleep(delay) + pause = duration / numbytes + def generate_bytes(): for i in xrange(numbytes): - yield u"*".encode('utf-8') + yield b"*" time.sleep(pause) - return Response(generate_bytes(), headers={ - "Content-Type": "application/octet-stream", - }) + response = Response( + generate_bytes(), + headers={ + "Content-Type": "application/octet-stream", + "Content-Length": str(numbytes), + }, + ) -@app.route('/base64/') -def decode_base64(value): - """Decodes base64url-encoded string""" - encoded = value.encode('utf-8') # base64 expects binary string as input - return base64.urlsafe_b64decode(encoded).decode('utf-8') + response.status_code = code + + return response -@app.route('/cache', methods=('GET',)) +@app.route("/base64/") +def decode_base64(value): + """Decodes base64url-encoded string. + --- + tags: + - Dynamic data + parameters: + - in: path + name: value + type: string + default: SFRUUEJJTiBpcyBhd2Vzb21l + produces: + - text/html + responses: + 200: + description: Decoded base64 content. + """ + encoded = value.encode("utf-8") # base64 expects binary string as input + try: + return base64.urlsafe_b64decode(encoded).decode("utf-8") + except: + return "Incorrect Base64 data try: SFRUUEJJTiBpcyBhd2Vzb21l" + + +@app.route("/cache", methods=("GET",)) def cache(): - """Returns a 304 if an If-Modified-Since header or If-None-Match is present. Returns the same as a GET otherwise.""" - is_conditional = request.headers.get('If-Modified-Since') or request.headers.get('If-None-Match') + """Returns a 304 if an If-Modified-Since header or If-None-Match is present. Returns the same as a GET otherwise. + --- + tags: + - Response inspection + parameters: + - in: header + name: If-Modified-Since + - in: header + name: If-None-Match + produces: + - application/json + responses: + 200: + description: Cached response + 304: + description: Modified + + """ + is_conditional = request.headers.get("If-Modified-Since") or request.headers.get( + "If-None-Match" + ) if is_conditional is None: response = view_get() - response.headers['Last-Modified'] = http_date() - response.headers['ETag'] = uuid.uuid4().hex + response.headers["Last-Modified"] = http_date() + response.headers["ETag"] = uuid.uuid4().hex return response else: return status_code(304) -@app.route('/cache/') +@app.route("/etag/", methods=("GET",)) +def etag(etag): + """Assumes the resource has the given etag and responds to If-None-Match and If-Match headers appropriately. + --- + tags: + - Response inspection + parameters: + - in: header + name: If-None-Match + - in: header + name: If-Match + produces: + - application/json + responses: + 200: + description: Normal response + 412: + description: match + + """ + if_none_match = parse_multi_value_header(request.headers.get("If-None-Match")) + if_match = parse_multi_value_header(request.headers.get("If-Match")) + + if if_none_match: + if etag in if_none_match or "*" in if_none_match: + response = status_code(304) + response.headers["ETag"] = etag + return response + elif if_match: + if etag not in if_match and "*" not in if_match: + return status_code(412) + + # Special cases don't apply, return normal response + response = view_get() + response.headers["ETag"] = etag + return response + + +@app.route("/cache/") def cache_control(value): - """Sets a Cache-Control header.""" + """Sets a Cache-Control header for n seconds. + --- + tags: + - Response inspection + parameters: + - in: path + name: value + type: integer + produces: + - application/json + responses: + 200: + description: Cache control set + """ response = view_get() - response.headers['Cache-Control'] = 'public, max-age={0}'.format(value) + response.headers["Cache-Control"] = "public, max-age={0}".format(value) return response -@app.route('/bytes/') +@app.route("/encoding/utf8") +def encoding(): + """Returns a UTF-8 encoded body. + --- + tags: + - Response formats + produces: + - text/html + responses: + 200: + description: Encoded UTF-8 content. + """ + + return render_template("UTF-8-demo.txt") + + +@app.route("/bytes/") def random_bytes(n): - """Returns n random bytes generated with given seed.""" - n = min(n, 100 * 1024) # set 100KB limit + """Returns n random bytes generated with given seed + --- + tags: + - Dynamic data + parameters: + - in: path + name: n + type: int + produces: + - application/octet-stream + responses: + 200: + description: Bytes. + """ + + n = min(n, 100 * 1024) # set 100KB limit params = CaseInsensitiveDict(request.args.items()) - if 'seed' in params: - random.seed(int(params['seed'])) + if "seed" in params: + random.seed(int(params["seed"])) response = make_response() - response.data = os.urandom(n) - response.content_type = 'application/octet-stream' + + # Note: can't just use os.urandom here because it ignores the seed + response.data = bytearray(random.randint(0, 255) for i in range(n)) + response.content_type = "application/octet-stream" return response -@app.route('/stream-bytes/') +@app.route("/stream-bytes/") def stream_random_bytes(n): - """Streams n random bytes generated with given seed, at given chunk size per packet.""" - n = min(n, 100 * 1024) # set 100KB limit + """Streams n random bytes generated with given seed, at given chunk size per packet. + --- + tags: + - Dynamic data + parameters: + - in: path + name: n + type: int + produces: + - application/octet-stream + responses: + 200: + description: Bytes. + """ + n = min(n, 100 * 1024) # set 100KB limit params = CaseInsensitiveDict(request.args.items()) - if 'seed' in params: - random.seed(int(params['seed'])) + if "seed" in params: + random.seed(int(params["seed"])) - if 'chunk_size' in params: - chunk_size = max(1, int(params['chunk_size'])) + if "chunk_size" in params: + chunk_size = max(1, int(params["chunk_size"])) else: chunk_size = 10 * 1024 def generate_bytes(): - chunks = [] + chunks = bytearray() for i in xrange(n): - chunks.append(os.urandom(1)) + chunks.append(random.randint(0, 255)) if len(chunks) == chunk_size: - yield(bytes().join(chunks)) - chunks = [] + yield (bytes(chunks)) + chunks = bytearray() if chunks: - yield(bytes().join(chunks)) + yield (bytes(chunks)) - headers = {'Transfer-Encoding': 'chunked', - 'Content-Type': 'application/octet-stream'} + headers = {"Content-Type": "application/octet-stream"} return Response(generate_bytes(), headers=headers) -@app.route('/links//') -def link_page(n, offset): - """Generate a page containing n links to other pages which do the same.""" - n = min(max(1, n), 200) # limit to between 1 and 200 links +@app.route("/range/") +def range_request(numbytes): + """Streams n random bytes generated with given seed, at given chunk size per packet. + --- + tags: + - Dynamic data + parameters: + - in: path + name: numbytes + type: int + produces: + - application/octet-stream + responses: + 200: + description: Bytes. + """ + + if numbytes <= 0 or numbytes > (100 * 1024): + response = Response( + headers={"ETag": "range%d" % numbytes, "Accept-Ranges": "bytes"} + ) + response.status_code = 404 + response.data = "number of bytes must be in the range (0, 102400]" + return response - link = "{2} " + params = CaseInsensitiveDict(request.args.items()) + if "chunk_size" in params: + chunk_size = max(1, int(params["chunk_size"])) + else: + chunk_size = 10 * 1024 + + duration = float(params.get("duration", 0)) + pause_per_byte = duration / numbytes + + request_headers = get_headers() + first_byte_pos, last_byte_pos = get_request_range(request_headers, numbytes) + range_length = (last_byte_pos + 1) - first_byte_pos + + if ( + first_byte_pos > last_byte_pos + or first_byte_pos not in xrange(0, numbytes) + or last_byte_pos not in xrange(0, numbytes) + ): + response = Response( + headers={ + "ETag": "range%d" % numbytes, + "Accept-Ranges": "bytes", + "Content-Range": "bytes */%d" % numbytes, + "Content-Length": "0", + } + ) + response.status_code = 416 + return response - html = ['Links'] + def generate_bytes(): + chunks = bytearray() + + for i in xrange(first_byte_pos, last_byte_pos + 1): + + # We don't want the resource to change across requests, so we need + # to use a predictable data generation function + chunks.append(ord("a") + (i % 26)) + if len(chunks) == chunk_size: + yield (bytes(chunks)) + time.sleep(pause_per_byte * chunk_size) + chunks = bytearray() + + if chunks: + time.sleep(pause_per_byte * len(chunks)) + yield (bytes(chunks)) + + content_range = "bytes %d-%d/%d" % (first_byte_pos, last_byte_pos, numbytes) + response_headers = { + "Content-Type": "application/octet-stream", + "ETag": "range%d" % numbytes, + "Accept-Ranges": "bytes", + "Content-Length": str(range_length), + "Content-Range": content_range, + } + + response = Response(generate_bytes(), headers=response_headers) + + if (first_byte_pos == 0) and (last_byte_pos == (numbytes - 1)): + response.status_code = 200 + else: + response.status_code = 206 + + return response + + +@app.route("/links//") +def link_page(n, offset): + """Generate a page containing n links to other pages which do the same. + --- + tags: + - Dynamic data + parameters: + - in: path + name: n + type: int + - in: path + name: offset + type: int + produces: + - text/html + responses: + 200: + description: HTML links. + """ + n = min(max(1, n), 200) # limit to between 1 and 200 links + + link = "{1} " + + html = ["Links"] for i in xrange(n): if i == offset: html.append("{0} ".format(i)) else: - html.append(link.format(n, i, i)) - html.append('') + html.append(link.format(url_for("link_page", n=n, offset=i), i)) + html.append("") - return ''.join(html) + return "".join(html) -@app.route('/links/') +@app.route("/links/") def links(n): """Redirect to first links page.""" - return redirect("/links/{0}/0".format(n)) + return redirect(url_for("link_page", n=n, offset=0)) -@app.route('/image') +@app.route("/image") def image(): - """Returns a simple image of the type suggest by the Accept header.""" + """Returns a simple image of the type suggest by the Accept header. + --- + tags: + - Images + produces: + - image/webp + - image/svg+xml + - image/jpeg + - image/png + - image/* + responses: + 200: + description: An image. + """ headers = get_headers() - if headers['accept'].lower() == 'image/png' or headers['accept'].lower() == 'image/*': - return Response(base64.b64decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAACklEQVR4nGMAAQAABQABDQottAAAAABJRU5ErkJggg=='), headers={'Content-Type': 'image/png'}) - elif headers['accept'].lower() == 'image/jpeg': - return Response(base64.b64decode('/9j/4AAQSkZJRgABAQEASABIAAD/2wBDAAMCAgICAgMCAgIDAwMDBAYEBAQEBAgGBgUGCQgKCgkICQkKDA8MCgsOCwkJDRENDg8QEBEQCgwSExIQEw8QEBD/yQALCAABAAEBAREA/8wABgAQEAX/2gAIAQEAAD8A0s8g/9k='), headers={'Content-Type': 'image/jpeg'}) + if "accept" not in headers: + return image_png() # Default media type to png + + accept = headers["accept"].lower() + + if "image/webp" in accept: + return image_webp() + elif "image/svg+xml" in accept: + return image_svg() + elif "image/jpeg" in accept: + return image_jpeg() + elif "image/png" in accept or "image/*" in accept: + return image_png() else: - return status_code(404) + return status_code(406) # Unsupported media type + + +@app.route("/image/png") +def image_png(): + """Returns a simple PNG image. + --- + tags: + - Images + produces: + - image/png + responses: + 200: + description: A PNG image. + """ + data = resource("images/pig_icon.png") + return Response(data, headers={"Content-Type": "image/png"}) + + +@app.route("/image/jpeg") +def image_jpeg(): + """Returns a simple JPEG image. + --- + tags: + - Images + produces: + - image/jpeg + responses: + 200: + description: A JPEG image. + """ + data = resource("images/jackal.jpg") + return Response(data, headers={"Content-Type": "image/jpeg"}) + + +@app.route("/image/webp") +def image_webp(): + """Returns a simple WEBP image. + --- + tags: + - Images + produces: + - image/webp + responses: + 200: + description: A WEBP image. + """ + data = resource("images/wolf_1.webp") + return Response(data, headers={"Content-Type": "image/webp"}) + + +@app.route("/image/svg") +def image_svg(): + """Returns a simple SVG image. + --- + tags: + - Images + produces: + - image/svg+xml + responses: + 200: + description: An SVG image. + """ + data = resource("images/svg_logo.svg") + return Response(data, headers={"Content-Type": "image/svg+xml"}) + + +def resource(filename): + path = os.path.join(tmpl_dir, filename) + with open(path, "rb") as f: + return f.read() + + +@app.route("/xml") +def xml(): + """Returns a simple XML document. + --- + tags: + - Response formats + produces: + - application/xml + responses: + 200: + description: An XML document. + """ + response = make_response(render_template("sample.xml")) + response.headers["Content-Type"] = "application/xml" + return response -if __name__ == '__main__': - app.run() +@app.route("/json") +def a_json_endpoint(): + """Returns a simple JSON document. + --- + tags: + - Response formats + produces: + - application/json + responses: + 200: + description: An JSON document. + """ + return flask_jsonify( + slideshow={ + "title": "Sample Slide Show", + "date": "date of publication", + "author": "Yours Truly", + "slides": [ + {"type": "all", "title": "Wake up to WonderWidgets!"}, + { + "type": "all", + "title": "Overview", + "items": [ + "Why WonderWidgets are great", + "Who buys WonderWidgets", + ], + }, + ], + } + ) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("--port", type=int, default=5000) + parser.add_argument("--host", default="127.0.0.1") + args = parser.parse_args() + app.run(port=args.port, host=args.host) diff --git a/httpbin/filters.py b/httpbin/filters.py index e18382c2..c6268b6a 100644 --- a/httpbin/filters.py +++ b/httpbin/filters.py @@ -10,6 +10,8 @@ import gzip as gzip2 import zlib +import brotli as _brotli + from six import BytesIO from decimal import Decimal from time import time as now @@ -88,3 +90,26 @@ def deflate(f, *args, **kwargs): return data return deflated_data + + +@decorator +def brotli(f, *args, **kwargs): + """Brotli Flask Response Decorator""" + + data = f(*args, **kwargs) + + if isinstance(data, Response): + content = data.data + else: + content = data + + deflated_data = _brotli.compress(content) + + if isinstance(data, Response): + data.data = deflated_data + data.headers['Content-Encoding'] = 'br' + data.headers['Content-Length'] = str(len(data.data)) + + return data + + return deflated_data diff --git a/httpbin/helpers.py b/httpbin/helpers.py index 1e350a8f..b29e1835 100644 --- a/httpbin/helpers.py +++ b/httpbin/helpers.py @@ -9,10 +9,15 @@ import json import base64 -from hashlib import md5 +import re +import time +import os +from hashlib import md5, sha256, sha512 from werkzeug.http import parse_authorization_header +from werkzeug.datastructures import WWWAuthenticate from flask import request, make_response +from six.moves.urllib.parse import urlparse, urlunparse from .structures import CaseInsensitiveDict @@ -38,18 +43,31 @@ 'X-Heroku-Queue-Depth', 'X-Real-Ip', 'X-Forwarded-Proto', + 'X-Forwarded-Protocol', + 'X-Forwarded-Ssl', 'X-Heroku-Queue-Wait-Time', 'X-Forwarded-For', 'X-Heroku-Dynos-In-Use', - 'X-Forwarded-For', 'X-Forwarded-Protocol', - 'X-Forwarded-Port' + 'X-Forwarded-Port', + 'X-Request-Id', + 'Via', + 'Total-Route-Time', + 'Connect-Time' ) ROBOT_TXT = """User-agent: * Disallow: /deny """ +ACCEPTED_MEDIA_TYPES = [ + 'image/webp', + 'image/svg+xml', + 'image/jpeg', + 'image/png', + 'image/*' +] + ANGRY_ASCII =""" .-''''''-. .' _ _ '. @@ -60,7 +78,7 @@ \ .-"` `"-. / '. .' '-......-' - YOU SHOUDN'T BE HERE + YOU SHOULDN'T BE HERE """ @@ -76,7 +94,8 @@ def json_safe(string, content_type='application/octet-stream'): URL scheme was chosen for its simplicity. """ try: - _encoded = json.dumps(string) + string = string.decode('utf-8') + json.dumps(string) return string except (ValueError, TypeError): return b''.join([ @@ -133,16 +152,29 @@ def semiflatten(multi): else: return multi +def get_url(request): + """ + Since we might be hosted behind a proxy, we need to check the + X-Forwarded-Proto, X-Forwarded-Protocol, or X-Forwarded-SSL headers + to find out what protocol was used to access us. + """ + protocol = request.headers.get('X-Forwarded-Proto') or request.headers.get('X-Forwarded-Protocol') + if protocol is None and request.headers.get('X-Forwarded-Ssl') == 'on': + protocol = 'https' + if protocol is None: + return request.url + url = list(urlparse(request.url)) + url[0] = protocol + return urlunparse(url) + def get_dict(*keys, **extras): """Returns request dict of given keys.""" - _keys = ('url', 'args', 'form', 'data', 'origin', 'headers', 'files', 'json') + _keys = ('url', 'args', 'form', 'data', 'origin', 'headers', 'files', 'json', 'method') assert all(map(_keys.__contains__, keys)) - data = request.data - form = request.form form = semiflatten(request.form) try: @@ -151,14 +183,15 @@ def get_dict(*keys, **extras): _json = None d = dict( - url=request.url, + url=get_url(request), args=semiflatten(request.args), form=form, data=json_safe(data), origin=request.headers.get('X-Forwarded-For', request.remote_addr), headers=get_headers(), files=get_files(), - json=_json + json=_json, + method=request.method, ) out_d = dict() @@ -190,6 +223,13 @@ def status_code(code): 'x-more-info': 'http://vimeo.com/22053820' } ), + 406: dict(data=json.dumps({ + 'message': 'Client did not request a supported media type.', + 'accept': ACCEPTED_MEDIA_TYPES + }), + headers={ + 'Content-Type': 'application/json' + }), 407: dict(headers={'Proxy-Authenticate': 'Basic realm="Fake Realm"'}), 418: dict( # I'm a teapot! data=ASCII_ART, @@ -226,11 +266,16 @@ def check_basic_auth(user, passwd): # Digest auth helpers # qop is a quality of protection -def H(data): - return md5(data).hexdigest() +def H(data, algorithm): + if algorithm == 'SHA-256': + return sha256(data).hexdigest() + elif algorithm == 'SHA-512': + return sha512(data).hexdigest() + else: + return md5(data).hexdigest() -def HA1(realm, username, password): +def HA1(realm, username, password, algorithm): """Create HA1 hash by realm, username, password HA1 = md5(A1) = MD5(username:realm:password) @@ -239,10 +284,10 @@ def HA1(realm, username, password): realm = u'' return H(b":".join([username.encode('utf-8'), realm.encode('utf-8'), - password.encode('utf-8')])) + password.encode('utf-8')]), algorithm) -def HA2(credentails, request): +def HA2(credentials, request, algorithm): """Create HA2 md5 hash If the qop directive's value is "auth" or is unspecified, then HA2: @@ -250,19 +295,20 @@ def HA2(credentails, request): If the qop directive's value is "auth-int" , then HA2 is HA2 = md5(A2) = MD5(method:digestURI:MD5(entityBody)) """ - if credentails.get("qop") == "auth" or credentails.get('qop') is None: - return H(b":".join([request['method'].encode('utf-8'), request['uri'].encode('utf-8')])) - elif credentails.get("qop") == "auth-int": + if credentials.get("qop") == "auth" or credentials.get('qop') is None: + return H(b":".join([request['method'].encode('utf-8'), request['uri'].encode('utf-8')]), algorithm) + elif credentials.get("qop") == "auth-int": for k in 'method', 'uri', 'body': if k not in request: raise ValueError("%s required" % k) - return H("%s:%s:%s" % (request['method'], - request['uri'], - H(request['body']))) + A2 = b":".join([request['method'].encode('utf-8'), + request['uri'].encode('utf-8'), + H(request['body'], algorithm).encode('utf-8')]) + return H(A2, algorithm) raise ValueError -def response(credentails, password, request): +def response(credentials, password, request): """Compile digest auth response If the qop directive's value is "auth" or "auth-int" , then compute the response as follows: @@ -271,33 +317,35 @@ def response(credentails, password, request): RESPONSE = MD5(HA1:nonce:HA2) Arguments: - - `credentails`: credentails dict + - `credentials`: credentials dict - `password`: request user password - `request`: request dict """ response = None + algorithm = credentials.get('algorithm') HA1_value = HA1( - credentails.get('realm'), - credentails.get('username'), - password + credentials.get('realm'), + credentials.get('username'), + password, + algorithm ) - HA2_value = HA2(credentails, request) - if credentails.get('qop') is None: + HA2_value = HA2(credentials, request, algorithm) + if credentials.get('qop') is None: response = H(b":".join([ - HA1_value.encode('utf-8'), - credentails.get('nonce').encode('utf-8'), + HA1_value.encode('utf-8'), + credentials.get('nonce', '').encode('utf-8'), HA2_value.encode('utf-8') - ])) - elif credentails.get('qop') == 'auth' or credentails.get('qop') == 'auth-int': + ]), algorithm) + elif credentials.get('qop') == 'auth' or credentials.get('qop') == 'auth-int': for k in 'nonce', 'nc', 'cnonce', 'qop': - if k not in credentails: + if k not in credentials: raise ValueError("%s required for response H" % k) response = H(b":".join([HA1_value.encode('utf-8'), - credentails.get('nonce').encode('utf-8'), - credentails.get('nc').encode('utf-8'), - credentails.get('cnonce').encode('utf-8'), - credentails.get('qop').encode('utf-8'), - HA2_value.encode('utf-8')])) + credentials.get('nonce').encode('utf-8'), + credentials.get('nc').encode('utf-8'), + credentials.get('cnonce').encode('utf-8'), + credentials.get('qop').encode('utf-8'), + HA2_value.encode('utf-8')]), algorithm) else: raise ValueError("qop value are wrong") @@ -308,12 +356,119 @@ def check_digest_auth(user, passwd): """Check user authentication using HTTP Digest auth""" if request.headers.get('Authorization'): - credentails = parse_authorization_header(request.headers.get('Authorization')) - if not credentails: + credentials = parse_authorization_header(request.headers.get('Authorization')) + if not credentials: return - response_hash = response(credentails, passwd, dict(uri=request.path, + request_uri = request.script_root + request.path + if request.query_string: + request_uri += '?' + request.query_string + response_hash = response(credentials, passwd, dict(uri=request_uri, body=request.data, method=request.method)) - if credentails['response'] == response_hash: + if credentials.get('response') == response_hash: return True return False + +def secure_cookie(): + """Return true if cookie should have secure attribute""" + return request.environ['wsgi.url_scheme'] == 'https' + +def __parse_request_range(range_header_text): + """ Return a tuple describing the byte range requested in a GET request + If the range is open ended on the left or right side, then a value of None + will be set. + RFC7233: http://svn.tools.ietf.org/svn/wg/httpbis/specs/rfc7233.html#header.range + Examples: + Range : bytes=1024- + Range : bytes=10-20 + Range : bytes=-999 + """ + + left = None + right = None + + if not range_header_text: + return left, right + + range_header_text = range_header_text.strip() + if not range_header_text.startswith('bytes'): + return left, right + + components = range_header_text.split("=") + if len(components) != 2: + return left, right + + components = components[1].split("-") + + try: + right = int(components[1]) + except: + pass + + try: + left = int(components[0]) + except: + pass + + return left, right + +def get_request_range(request_headers, upper_bound): + first_byte_pos, last_byte_pos = __parse_request_range(request_headers['range']) + + if first_byte_pos is None and last_byte_pos is None: + # Request full range + first_byte_pos = 0 + last_byte_pos = upper_bound - 1 + elif first_byte_pos is None: + # Request the last X bytes + first_byte_pos = max(0, upper_bound - last_byte_pos) + last_byte_pos = upper_bound - 1 + elif last_byte_pos is None: + # Request the last X bytes + last_byte_pos = upper_bound - 1 + + return first_byte_pos, last_byte_pos + +def parse_multi_value_header(header_str): + """Break apart an HTTP header string that is potentially a quoted, comma separated list as used in entity headers in RFC2616.""" + parsed_parts = [] + if header_str: + parts = header_str.split(',') + for part in parts: + match = re.search('\s*(W/)?\"?([^"]*)\"?\s*', part) + if match is not None: + parsed_parts.append(match.group(2)) + return parsed_parts + + +def next_stale_after_value(stale_after): + try: + stal_after_count = int(stale_after) - 1 + return str(stal_after_count) + except ValueError: + return 'never' + + +def digest_challenge_response(app, qop, algorithm, stale = False): + response = app.make_response('') + response.status_code = 401 + + # RFC2616 Section4.2: HTTP headers are ASCII. That means + # request.remote_addr was originally ASCII, so I should be able to + # encode it back to ascii. Also, RFC2617 says about nonces: "The + # contents of the nonce are implementation dependent" + nonce = H(b''.join([ + getattr(request, 'remote_addr', u'').encode('ascii'), + b':', + str(time.time()).encode('ascii'), + b':', + os.urandom(10) + ]), algorithm) + opaque = H(os.urandom(10), algorithm) + + auth = WWWAuthenticate("digest") + auth.set_digest('me@kennethreitz.com', nonce, opaque=opaque, + qop=('auth', 'auth-int') if qop is None else (qop,), algorithm=algorithm) + auth.stale = stale + response.headers['WWW-Authenticate'] = auth.to_header() + return response diff --git a/httpbin/static/favicon.ico b/httpbin/static/favicon.ico new file mode 100644 index 00000000..265c7ab6 Binary files /dev/null and b/httpbin/static/favicon.ico differ diff --git a/httpbin/structures.py b/httpbin/structures.py index c57ff731..44da70f0 100644 --- a/httpbin/structures.py +++ b/httpbin/structures.py @@ -16,7 +16,7 @@ class CaseInsensitiveDict(dict): """ def _lower_keys(self): - return [str.lower(k) for k in self.keys()] + return [k.lower() for k in self.keys()] def __contains__(self, key): return key.lower() in self._lower_keys() diff --git a/httpbin/templates/UTF-8-demo.txt b/httpbin/templates/UTF-8-demo.txt new file mode 100644 index 00000000..726dd626 --- /dev/null +++ b/httpbin/templates/UTF-8-demo.txt @@ -0,0 +1,220 @@ +

Unicode Demo

+ +

Taken from http://www.cl.cam.ac.uk/~mgk25/ucs/examples/UTF-8-demo.txt

+ +
+
+UTF-8 encoded sample plain-text file
+‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾
+
+Markus Kuhn [ˈmaʳkʊs kuːn]  — 2002-07-25
+
+
+The ASCII compatible UTF-8 encoding used in this plain-text file
+is defined in Unicode, ISO 10646-1, and RFC 2279.
+
+
+Using Unicode/UTF-8, you can write in emails and source code things such as
+
+Mathematics and sciences:
+
+  ∮ E⋅da = Q,  n → ∞, ∑ f(i) = ∏ g(i),      ⎧⎡⎛┌─────┐⎞⎤⎫
+                                            ⎪⎢⎜│a²+b³ ⎟⎥⎪
+  ∀x∈ℝ: ⌈x⌉ = −⌊−x⌋, α ∧ ¬β = ¬(¬α ∨ β),    ⎪⎢⎜│───── ⎟⎥⎪
+                                            ⎪⎢⎜⎷ c₈   ⎟⎥⎪
+  ℕ ⊆ ℕ₀ ⊂ ℤ ⊂ ℚ ⊂ ℝ ⊂ ℂ,                   ⎨⎢⎜       ⎟⎥⎬
+                                            ⎪⎢⎜ ∞     ⎟⎥⎪
+  ⊥ < a ≠ b ≡ c ≤ d ≪ ⊤ ⇒ (⟦A⟧ ⇔ ⟪B⟫),      ⎪⎢⎜ ⎲     ⎟⎥⎪
+                                            ⎪⎢⎜ ⎳aⁱ-bⁱ⎟⎥⎪
+  2H₂ + O₂ ⇌ 2H₂O, R = 4.7 kΩ, ⌀ 200 mm     ⎩⎣⎝i=1    ⎠⎦⎭
+
+Linguistics and dictionaries:
+
+  ði ıntəˈnæʃənəl fəˈnɛtık əsoʊsiˈeıʃn
+  Y [ˈʏpsilɔn], Yen [jɛn], Yoga [ˈjoːgɑ]
+
+APL:
+
+  ((V⍳V)=⍳⍴V)/V←,V    ⌷←⍳→⍴∆∇⊃‾⍎⍕⌈
+
+Nicer typography in plain text files:
+
+  ╔══════════════════════════════════════════╗
+  ║                                          ║
+  ║   • ‘single’ and “double” quotes         ║
+  ║                                          ║
+  ║   • Curly apostrophes: “We’ve been here” ║
+  ║                                          ║
+  ║   • Latin-1 apostrophe and accents: '´`  ║
+  ║                                          ║
+  ║   • ‚deutsche‘ „Anführungszeichen“       ║
+  ║                                          ║
+  ║   • †, ‡, ‰, •, 3–4, —, −5/+5, ™, …      ║
+  ║                                          ║
+  ║   • ASCII safety test: 1lI|, 0OD, 8B     ║
+  ║                      ╭─────────╮         ║
+  ║   • the euro symbol: │ 14.95 € │         ║
+  ║                      ╰─────────╯         ║
+  ╚══════════════════════════════════════════╝
+
+Combining characters:
+
+  STARGΛ̊TE SG-1, a = v̇ = r̈, a⃑ ⊥ b⃑
+
+Greek (in Polytonic):
+
+  The Greek anthem:
+
+  Σὲ γνωρίζω ἀπὸ τὴν κόψη
+  τοῦ σπαθιοῦ τὴν τρομερή,
+  σὲ γνωρίζω ἀπὸ τὴν ὄψη
+  ποὺ μὲ βία μετράει τὴ γῆ.
+
+  ᾿Απ᾿ τὰ κόκκαλα βγαλμένη
+  τῶν ῾Ελλήνων τὰ ἱερά
+  καὶ σὰν πρῶτα ἀνδρειωμένη
+  χαῖρε, ὦ χαῖρε, ᾿Ελευθεριά!
+
+  From a speech of Demosthenes in the 4th century BC:
+
+  Οὐχὶ ταὐτὰ παρίσταταί μοι γιγνώσκειν, ὦ ἄνδρες ᾿Αθηναῖοι,
+  ὅταν τ᾿ εἰς τὰ πράγματα ἀποβλέψω καὶ ὅταν πρὸς τοὺς
+  λόγους οὓς ἀκούω· τοὺς μὲν γὰρ λόγους περὶ τοῦ
+  τιμωρήσασθαι Φίλιππον ὁρῶ γιγνομένους, τὰ δὲ πράγματ᾿
+  εἰς τοῦτο προήκοντα,  ὥσθ᾿ ὅπως μὴ πεισόμεθ᾿ αὐτοὶ
+  πρότερον κακῶς σκέψασθαι δέον. οὐδέν οὖν ἄλλο μοι δοκοῦσιν
+  οἱ τὰ τοιαῦτα λέγοντες ἢ τὴν ὑπόθεσιν, περὶ ἧς βουλεύεσθαι,
+  οὐχὶ τὴν οὖσαν παριστάντες ὑμῖν ἁμαρτάνειν. ἐγὼ δέ, ὅτι μέν
+  ποτ᾿ ἐξῆν τῇ πόλει καὶ τὰ αὑτῆς ἔχειν ἀσφαλῶς καὶ Φίλιππον
+  τιμωρήσασθαι, καὶ μάλ᾿ ἀκριβῶς οἶδα· ἐπ᾿ ἐμοῦ γάρ, οὐ πάλαι
+  γέγονεν ταῦτ᾿ ἀμφότερα· νῦν μέντοι πέπεισμαι τοῦθ᾿ ἱκανὸν
+  προλαβεῖν ἡμῖν εἶναι τὴν πρώτην, ὅπως τοὺς συμμάχους
+  σώσομεν. ἐὰν γὰρ τοῦτο βεβαίως ὑπάρξῃ, τότε καὶ περὶ τοῦ
+  τίνα τιμωρήσεταί τις καὶ ὃν τρόπον ἐξέσται σκοπεῖν· πρὶν δὲ
+  τὴν ἀρχὴν ὀρθῶς ὑποθέσθαι, μάταιον ἡγοῦμαι περὶ τῆς
+  τελευτῆς ὁντινοῦν ποιεῖσθαι λόγον.
+
+  Δημοσθένους, Γ´ ᾿Ολυνθιακὸς
+
+Georgian:
+
+  From a Unicode conference invitation:
+
+  გთხოვთ ახლავე გაიაროთ რეგისტრაცია Unicode-ის მეათე საერთაშორისო
+  კონფერენციაზე დასასწრებად, რომელიც გაიმართება 10-12 მარტს,
+  ქ. მაინცში, გერმანიაში. კონფერენცია შეჰკრებს ერთად მსოფლიოს
+  ექსპერტებს ისეთ დარგებში როგორიცაა ინტერნეტი და Unicode-ი,
+  ინტერნაციონალიზაცია და ლოკალიზაცია, Unicode-ის გამოყენება
+  ოპერაციულ სისტემებსა, და გამოყენებით პროგრამებში, შრიფტებში,
+  ტექსტების დამუშავებასა და მრავალენოვან კომპიუტერულ სისტემებში.
+
+Russian:
+
+  From a Unicode conference invitation:
+
+  Зарегистрируйтесь сейчас на Десятую Международную Конференцию по
+  Unicode, которая состоится 10-12 марта 1997 года в Майнце в Германии.
+  Конференция соберет широкий круг экспертов по  вопросам глобального
+  Интернета и Unicode, локализации и интернационализации, воплощению и
+  применению Unicode в различных операционных системах и программных
+  приложениях, шрифтах, верстке и многоязычных компьютерных системах.
+
+Thai (UCS Level 2):
+
+  Excerpt from a poetry on The Romance of The Three Kingdoms (a Chinese
+  classic 'San Gua'):
+
+  [----------------------------|------------------------]
+    ๏ แผ่นดินฮั่นเสื่อมโทรมแสนสังเวช  พระปกเกศกองบู๊กู้ขึ้นใหม่
+  สิบสองกษัตริย์ก่อนหน้าแลถัดไป       สององค์ไซร้โง่เขลาเบาปัญญา
+    ทรงนับถือขันทีเป็นที่พึ่ง           บ้านเมืองจึงวิปริตเป็นนักหนา
+  โฮจิ๋นเรียกทัพทั่วหัวเมืองมา         หมายจะฆ่ามดชั่วตัวสำคัญ
+    เหมือนขับไสไล่เสือจากเคหา      รับหมาป่าเข้ามาเลยอาสัญ
+  ฝ่ายอ้องอุ้นยุแยกให้แตกกัน          ใช้สาวนั้นเป็นชนวนชื่นชวนใจ
+    พลันลิฉุยกุยกีกลับก่อเหตุ          ช่างอาเพศจริงหนาฟ้าร้องไห้
+  ต้องรบราฆ่าฟันจนบรรลัย           ฤๅหาใครค้ำชูกู้บรรลังก์ ฯ
+
+  (The above is a two-column text. If combining characters are handled
+  correctly, the lines of the second column should be aligned with the
+  | character above.)
+
+Ethiopian:
+
+  Proverbs in the Amharic language:
+
+  ሰማይ አይታረስ ንጉሥ አይከሰስ።
+  ብላ ካለኝ እንደአባቴ በቆመጠኝ።
+  ጌጥ ያለቤቱ ቁምጥና ነው።
+  ደሀ በሕልሙ ቅቤ ባይጠጣ ንጣት በገደለው።
+  የአፍ ወለምታ በቅቤ አይታሽም።
+  አይጥ በበላ ዳዋ ተመታ።
+  ሲተረጉሙ ይደረግሙ።
+  ቀስ በቀስ፥ ዕንቁላል በእግሩ ይሄዳል።
+  ድር ቢያብር አንበሳ ያስር።
+  ሰው እንደቤቱ እንጅ እንደ ጉረቤቱ አይተዳደርም።
+  እግዜር የከፈተውን ጉሮሮ ሳይዘጋው አይድርም።
+  የጎረቤት ሌባ፥ ቢያዩት ይስቅ ባያዩት ያጠልቅ።
+  ሥራ ከመፍታት ልጄን ላፋታት።
+  ዓባይ ማደሪያ የለው፥ ግንድ ይዞ ይዞራል።
+  የእስላም አገሩ መካ የአሞራ አገሩ ዋርካ።
+  ተንጋሎ ቢተፉ ተመልሶ ባፉ።
+  ወዳጅህ ማር ቢሆን ጨርስህ አትላሰው።
+  እግርህን በፍራሽህ ልክ ዘርጋ።
+
+Runes:
+
+  ᚻᛖ ᚳᚹᚫᚦ ᚦᚫᛏ ᚻᛖ ᛒᚢᛞᛖ ᚩᚾ ᚦᚫᛗ ᛚᚪᚾᛞᛖ ᚾᚩᚱᚦᚹᛖᚪᚱᛞᚢᛗ ᚹᛁᚦ ᚦᚪ ᚹᛖᛥᚫ
+
+  (Old English, which transcribed into Latin reads 'He cwaeth that he
+  bude thaem lande northweardum with tha Westsae.' and means 'He said
+  that he lived in the northern land near the Western Sea.')
+
+Braille:
+
+  ⡌⠁⠧⠑ ⠼⠁⠒  ⡍⠜⠇⠑⠹⠰⠎ ⡣⠕⠌
+
+  ⡍⠜⠇⠑⠹ ⠺⠁⠎ ⠙⠑⠁⠙⠒ ⠞⠕ ⠃⠑⠛⠔ ⠺⠊⠹⠲ ⡹⠻⠑ ⠊⠎ ⠝⠕ ⠙⠳⠃⠞
+  ⠱⠁⠞⠑⠧⠻ ⠁⠃⠳⠞ ⠹⠁⠞⠲ ⡹⠑ ⠗⠑⠛⠊⠌⠻ ⠕⠋ ⠙⠊⠎ ⠃⠥⠗⠊⠁⠇ ⠺⠁⠎
+  ⠎⠊⠛⠝⠫ ⠃⠹ ⠹⠑ ⠊⠇⠻⠛⠹⠍⠁⠝⠂ ⠹⠑ ⠊⠇⠻⠅⠂ ⠹⠑ ⠥⠝⠙⠻⠞⠁⠅⠻⠂
+  ⠁⠝⠙ ⠹⠑ ⠡⠊⠑⠋ ⠍⠳⠗⠝⠻⠲ ⡎⠊⠗⠕⠕⠛⠑ ⠎⠊⠛⠝⠫ ⠊⠞⠲ ⡁⠝⠙
+  ⡎⠊⠗⠕⠕⠛⠑⠰⠎ ⠝⠁⠍⠑ ⠺⠁⠎ ⠛⠕⠕⠙ ⠥⠏⠕⠝ ⠰⡡⠁⠝⠛⠑⠂ ⠋⠕⠗ ⠁⠝⠹⠹⠔⠛ ⠙⠑
+  ⠡⠕⠎⠑ ⠞⠕ ⠏⠥⠞ ⠙⠊⠎ ⠙⠁⠝⠙ ⠞⠕⠲
+
+  ⡕⠇⠙ ⡍⠜⠇⠑⠹ ⠺⠁⠎ ⠁⠎ ⠙⠑⠁⠙ ⠁⠎ ⠁ ⠙⠕⠕⠗⠤⠝⠁⠊⠇⠲
+
+  ⡍⠔⠙⠖ ⡊ ⠙⠕⠝⠰⠞ ⠍⠑⠁⠝ ⠞⠕ ⠎⠁⠹ ⠹⠁⠞ ⡊ ⠅⠝⠪⠂ ⠕⠋ ⠍⠹
+  ⠪⠝ ⠅⠝⠪⠇⠫⠛⠑⠂ ⠱⠁⠞ ⠹⠻⠑ ⠊⠎ ⠏⠜⠞⠊⠊⠥⠇⠜⠇⠹ ⠙⠑⠁⠙ ⠁⠃⠳⠞
+  ⠁ ⠙⠕⠕⠗⠤⠝⠁⠊⠇⠲ ⡊ ⠍⠊⠣⠞ ⠙⠁⠧⠑ ⠃⠑⠲ ⠔⠊⠇⠔⠫⠂ ⠍⠹⠎⠑⠇⠋⠂ ⠞⠕
+  ⠗⠑⠛⠜⠙ ⠁ ⠊⠕⠋⠋⠔⠤⠝⠁⠊⠇ ⠁⠎ ⠹⠑ ⠙⠑⠁⠙⠑⠌ ⠏⠊⠑⠊⠑ ⠕⠋ ⠊⠗⠕⠝⠍⠕⠝⠛⠻⠹
+  ⠔ ⠹⠑ ⠞⠗⠁⠙⠑⠲ ⡃⠥⠞ ⠹⠑ ⠺⠊⠎⠙⠕⠍ ⠕⠋ ⠳⠗ ⠁⠝⠊⠑⠌⠕⠗⠎
+  ⠊⠎ ⠔ ⠹⠑ ⠎⠊⠍⠊⠇⠑⠆ ⠁⠝⠙ ⠍⠹ ⠥⠝⠙⠁⠇⠇⠪⠫ ⠙⠁⠝⠙⠎
+  ⠩⠁⠇⠇ ⠝⠕⠞ ⠙⠊⠌⠥⠗⠃ ⠊⠞⠂ ⠕⠗ ⠹⠑ ⡊⠳⠝⠞⠗⠹⠰⠎ ⠙⠕⠝⠑ ⠋⠕⠗⠲ ⡹⠳
+  ⠺⠊⠇⠇ ⠹⠻⠑⠋⠕⠗⠑ ⠏⠻⠍⠊⠞ ⠍⠑ ⠞⠕ ⠗⠑⠏⠑⠁⠞⠂ ⠑⠍⠏⠙⠁⠞⠊⠊⠁⠇⠇⠹⠂ ⠹⠁⠞
+  ⡍⠜⠇⠑⠹ ⠺⠁⠎ ⠁⠎ ⠙⠑⠁⠙ ⠁⠎ ⠁ ⠙⠕⠕⠗⠤⠝⠁⠊⠇⠲
+
+  (The first couple of paragraphs of "A Christmas Carol" by Dickens)
+
+Compact font selection example text:
+
+  ABCDEFGHIJKLMNOPQRSTUVWXYZ /0123456789
+  abcdefghijklmnopqrstuvwxyz £©µÀÆÖÞßéöÿ
+  –—‘“”„†•…‰™œŠŸž€ ΑΒΓΔΩαβγδω АБВГДабвгд
+  ∀∂∈ℝ∧∪≡∞ ↑↗↨↻⇣ ┐┼╔╘░►☺♀ fi�⑀₂ἠḂӥẄɐː⍎אԱა
+
+Greetings in various languages:
+
+  Hello world, Καλημέρα κόσμε, コンニチハ
+
+Box drawing alignment tests:                                          █
+                                                                      ▉
+  ╔══╦══╗  ┌──┬──┐  ╭──┬──╮  ╭──┬──╮  ┏━━┳━━┓  ┎┒┏┑   ╷  ╻ ┏┯┓ ┌┰┐    ▊ ╱╲╱╲╳╳╳
+  ║┌─╨─┐║  │╔═╧═╗│  │╒═╪═╕│  │╓─╁─╖│  ┃┌─╂─┐┃  ┗╃╄┙  ╶┼╴╺╋╸┠┼┨ ┝╋┥    ▋ ╲╱╲╱╳╳╳
+  ║│╲ ╱│║  │║   ║│  ││ │ ││  │║ ┃ ║│  ┃│ ╿ │┃  ┍╅╆┓   ╵  ╹ ┗┷┛ └┸┘    ▌ ╱╲╱╲╳╳╳
+  ╠╡ ╳ ╞╣  ├╢   ╟┤  ├┼─┼─┼┤  ├╫─╂─╫┤  ┣┿╾┼╼┿┫  ┕┛┖┚     ┌┄┄┐ ╎ ┏┅┅┓ ┋ ▍ ╲╱╲╱╳╳╳
+  ║│╱ ╲│║  │║   ║│  ││ │ ││  │║ ┃ ║│  ┃│ ╽ │┃  ░░▒▒▓▓██ ┊  ┆ ╎ ╏  ┇ ┋ ▎
+  ║└─╥─┘║  │╚═╤═╝│  │╘═╪═╛│  │╙─╀─╜│  ┃└─╂─┘┃  ░░▒▒▓▓██ ┊  ┆ ╎ ╏  ┇ ┋ ▏
+  ╚══╩══╝  └──┴──┘  ╰──┴──╯  ╰──┴──╯  ┗━━┻━━┛  ▗▄▖▛▀▜   └╌╌┘ ╎ ┗╍╍┛ ┋  ▁▂▃▄▅▆▇█
+                                               ▝▀▘▙▄▟
+
+
diff --git a/httpbin/templates/flasgger/index.html b/httpbin/templates/flasgger/index.html new file mode 100644 index 00000000..a51cdeba --- /dev/null +++ b/httpbin/templates/flasgger/index.html @@ -0,0 +1,212 @@ + + + + + + {{ title }} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+
+
+

httpbin.org + +
0.9.2
+
+

+
[ Base URL: httpbin.org/ ]
+
+
+
+

A simple HTTP Request & Response Service. +
+
+ Run locally: + $ docker run -p 80:80 kennethreitz/httpbin +

+
+
+ +
+ +
+
+
+ +
+
+
+
+
+ + +
+
+
+ + [Powered by + Flasgger] +
+
+
+
+
+ + + + + + + {% if tracking_enabled %} {% include 'trackingscripts.html' %} {% endif %} {% include 'footer.html' %} + + + diff --git a/httpbin/templates/footer.html b/httpbin/templates/footer.html new file mode 100644 index 00000000..4cac5ff9 --- /dev/null +++ b/httpbin/templates/footer.html @@ -0,0 +1,18 @@ +
+
+
+
+ +

Other Utilities

+ +
    +
  • + HTML form that posts to /post /forms/post
  • +
+ +
+
+
+
+
+
diff --git a/httpbin/templates/forms-post.html b/httpbin/templates/forms-post.html index e79dffd3..81f7afd4 100755 --- a/httpbin/templates/forms-post.html +++ b/httpbin/templates/forms-post.html @@ -4,7 +4,7 @@ -
+

diff --git a/httpbin/templates/httpbin.1.html b/httpbin/templates/httpbin.1.html index 005a3d36..0d0c8386 100644 --- a/httpbin/templates/httpbin.1.html +++ b/httpbin/templates/httpbin.1.html @@ -1,50 +1,72 @@

httpbin(1): HTTP Request & Response Service

-

Freely hosted in HTTP, -HTTPS & -EU flavors by Runscope.

+

Freely hosted in HTTP, HTTPS, & EU flavors by Kenneth Reitz & Heroku.

+ +

BONUSPOINTS

+ +

ENDPOINTS

-

DESCRIPTION

-

Testing an HTTP Library can become difficult sometimes. Postbin is fantastic -for testing POST requests, but not much else. This exists to cover all kinds of HTTP -scenarios. Additional endpoints are being considered.

+

Testing an HTTP Library can become difficult sometimes. RequestBin is fantastic for testing POST requests, but doesn't let you control the response. This exists to cover all kinds of HTTP scenarios. Additional endpoints are being considered.

All endpoint responses are JSON-encoded.

@@ -87,13 +109,51 @@

$ curl -I http://httpbin.org/status Content-Length: 135 +

$ curl https://httpbin.org/get?show_env=1

+ +
{
+  "headers": {
+    "Content-Length": "",
+    "Accept-Language": "en-US,en;q=0.8",
+    "Accept-Encoding": "gzip,deflate,sdch",
+    "X-Forwarded-Port": "443",
+    "X-Forwarded-For": "109.60.101.240",
+    "Host": "httpbin.org",
+    "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
+    "User-Agent": "Mozilla/5.0 (X11; Linux i686) AppleWebKit/535.11 (KHTML, like Gecko) Chrome/17.0.963.83 Safari/535.11",
+    "X-Request-Start": "1350053933441",
+    "Accept-Charset": "ISO-8859-1,utf-8;q=0.7,*;q=0.3",
+    "Connection": "keep-alive",
+    "X-Forwarded-Proto": "https",
+    "Cookie": "_gauges_unique_day=1; _gauges_unique_month=1; _gauges_unique_year=1; _gauges_unique=1; _gauges_unique_hour=1",
+    "Content-Type": ""
+  },
+  "args": {
+    "show_env": "1"
+  },
+  "origin": "109.60.101.240",
+  "url": "http://httpbin.org/get?show_env=1"
+}
+
+ +

Installing and running from PyPI

+ +

You can install httpbin as a library from PyPI and run it as a WSGI app. For example, using Gunicorn:

+ +
$ pip install httpbin
+$ gunicorn httpbin:app
+
+ +

AUTHOR

-

A Kenneth Reitz -Project.

+

A Kenneth Reitz project.

+

BTC: 1Me2iXTJ91FYZhrGvaGaRDCBtnZ4KdxCug

SEE ALSO

+

Hurl.it - Make HTTP requests.

+

RequestBin - Inspect HTTP requests.

http://python-requests.org

diff --git a/httpbin/templates/images/jackal.jpg b/httpbin/templates/images/jackal.jpg new file mode 100644 index 00000000..a4e824c2 Binary files /dev/null and b/httpbin/templates/images/jackal.jpg differ diff --git a/httpbin/templates/images/pig_icon.png b/httpbin/templates/images/pig_icon.png new file mode 100644 index 00000000..cc0c1282 Binary files /dev/null and b/httpbin/templates/images/pig_icon.png differ diff --git a/httpbin/templates/images/svg_logo.svg b/httpbin/templates/images/svg_logo.svg new file mode 100644 index 00000000..e4b7e589 --- /dev/null +++ b/httpbin/templates/images/svg_logo.svg @@ -0,0 +1,259 @@ + + + SVG Logo + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SVG + + + + + + + + diff --git a/httpbin/templates/images/wolf_1.webp b/httpbin/templates/images/wolf_1.webp new file mode 100644 index 00000000..37f5392d Binary files /dev/null and b/httpbin/templates/images/wolf_1.webp differ diff --git a/httpbin/templates/index.html b/httpbin/templates/index.html index 054e4ca9..0348536f 100644 --- a/httpbin/templates/index.html +++ b/httpbin/templates/index.html @@ -1,73 +1,251 @@ + httpbin(1): HTTP Client Testing Service -Fork me on GitHub + + + -{% include 'httpbin.1.html' %} - - + {% include 'httpbin.1.html' %} {% if tracking_enabled %} {% include 'trackingscripts.html' %} {% endif %} + diff --git a/httpbin/templates/sample.xml b/httpbin/templates/sample.xml new file mode 100644 index 00000000..5ff09579 --- /dev/null +++ b/httpbin/templates/sample.xml @@ -0,0 +1,24 @@ + + + + + + + + + Wake up to WonderWidgets! + + + + + Overview + Why WonderWidgets are great + + Who buys WonderWidgets + + + diff --git a/httpbin/templates/trackingscripts.html b/httpbin/templates/trackingscripts.html new file mode 100644 index 00000000..080bbcd4 --- /dev/null +++ b/httpbin/templates/trackingscripts.html @@ -0,0 +1,18 @@ +{# + place tracking scripts (like Google Analytics) here +#} + + \ No newline at end of file diff --git a/now.json b/now.json new file mode 100644 index 00000000..26fd17fc --- /dev/null +++ b/now.json @@ -0,0 +1,10 @@ +{ + "name": "httpbin", + "regions": [ + "all" + ], + "alias": [ + "httpbin.org" + ], + "type": "docker" +} diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 30075de5..00000000 --- a/requirements.txt +++ /dev/null @@ -1,11 +0,0 @@ -Flask==0.10.1 -Jinja2==2.7.2 -MarkupSafe==0.23 -Werkzeug==0.9.4 -decorator==3.4.0 -gevent==1.0.1 -greenlet==0.4.2 -gunicorn==18.0 -itsdangerous==0.24 -wsgiref==0.1.2 -six==1.6.1 diff --git a/runtime.txt b/runtime.txt index 7bed0b5a..4b5675da 100644 --- a/runtime.txt +++ b/runtime.txt @@ -1 +1 @@ -python-2.7.5 \ No newline at end of file +python-3.6.5 \ No newline at end of file diff --git a/setup.py b/setup.py index 899a72a5..afbef584 100644 --- a/setup.py +++ b/setup.py @@ -1,19 +1,23 @@ from setuptools import setup, find_packages -import codecs import os -import re +import io + + +with open(os.path.join(os.path.realpath(os.path.dirname(__file__)), 'httpbin', 'VERSION')) as version_file: + version = version_file.read().strip() setup( name="httpbin", - version="0.1.2", + version=version, description="HTTP Request and Response Service", + long_description="A simple HTTP Request & Response Service, written in Python + Flask.", # The project URL. - url='https://github.com/kennethreitz/httpbin', + url='https://github.com/requests/httpbin', # Author details author='Kenneth Reitz', - author_email='me@kennethreitz.com', + author_email='me@kennethreitz.org', # Choose your license license='MIT', @@ -25,9 +29,13 @@ 'License :: OSI Approved :: MIT License', 'Programming Language :: Python', 'Programming Language :: Python :: 2.7', - 'Programming Language :: Python :: 3.4', + 'Programming Language :: Python :: 3.6', ], + test_suite="test_httpbin", packages=find_packages(), include_package_data = True, # include files listed in MANIFEST.in - install_requires=['Flask','MarkupSafe','decorator','itsdangerous','six'], + install_requires=[ + 'Flask', 'MarkupSafe', 'decorator', 'itsdangerous', 'six', 'brotlipy', + 'raven[flask]', 'werkzeug>=0.14.1', 'gevent', 'flasgger' + ], ) diff --git a/test_httpbin.py b/test_httpbin.py index 624b64b1..b7104ffc 100755 --- a/test_httpbin.py +++ b/test_httpbin.py @@ -1,12 +1,35 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- +import os import base64 import unittest +import contextlib +import six +import json from werkzeug.http import parse_dict_header -from hashlib import md5 +from hashlib import md5, sha256, sha512 from six import BytesIO import httpbin +from httpbin.helpers import parse_multi_value_header + + +@contextlib.contextmanager +def _setenv(key, value): + """Context manager to set an environment variable temporarily.""" + old_value = os.environ.get(key, None) + if value is None: + os.environ.pop(key, None) + else: + os.environ[key] = value + + yield + + if old_value is None: + os.environ.pop(key, None) + else: + os.environ[key] = value + def _string_to_base64(string): @@ -14,6 +37,75 @@ def _string_to_base64(string): utf8_encoded = string.encode('utf-8') return base64.urlsafe_b64encode(utf8_encoded) +def _hash(data, algorithm): + """Encode binary data according to specified algorithm, use MD5 by default""" + if algorithm == 'SHA-256': + return sha256(data).hexdigest() + elif algorithm == 'SHA-512': + return sha512(data).hexdigest() + else: + return md5(data).hexdigest() + +def _make_digest_auth_header(username, password, method, uri, nonce, + realm=None, opaque=None, algorithm=None, + qop=None, cnonce=None, nc=None, body=None): + """Compile a digest authentication header string. + + Arguments: + - `nonce`: nonce string, received within "WWW-Authenticate" header + - `realm`: realm string, received within "WWW-Authenticate" header + - `opaque`: opaque string, received within "WWW-Authenticate" header + - `algorithm`: type of hashing algorithm, used by the client + - `qop`: type of quality-of-protection, used by the client + - `cnonce`: client nonce, required if qop is "auth" or "auth-int" + - `nc`: client nonce count, required if qop is "auth" or "auth-int" + - `body`: body of the outgoing request (bytes), used if qop is "auth-int" + """ + + assert username + assert password + assert nonce + assert method + assert uri + assert algorithm in ('MD5', 'SHA-256', 'SHA-512', None) + + a1 = ':'.join([username, realm or '', password]) + ha1 = _hash(a1.encode('utf-8'), algorithm) + + a2 = ':'.join([method, uri]) + if qop == 'auth-int': + a2 = ':'.join([a2, _hash(body or b'', algorithm)]) + ha2 = _hash(a2.encode('utf-8'), algorithm) + + a3 = ':'.join([ha1, nonce]) + if qop in ('auth', 'auth-int'): + assert cnonce + assert nc + a3 = ':'.join([a3, nc, cnonce, qop]) + + a3 = ':'.join([a3, ha2]) + auth_response = _hash(a3.encode('utf-8'), algorithm) + + auth_header = \ + 'Digest username="{0}", response="{1}", uri="{2}", nonce="{3}"'\ + .format(username, auth_response, uri, nonce) + + # 'realm' and 'opaque' should be returned unchanged, even if empty + if realm != None: + auth_header += ', realm="{0}"'.format(realm) + if opaque != None: + auth_header += ', opaque="{0}"'.format(opaque) + + if algorithm: + auth_header += ', algorithm="{0}"'.format(algorithm) + if cnonce: + auth_header += ', cnonce="{0}"'.format(cnonce) + if nc: + auth_header += ', nc={0}'.format(nc) + if qop: + auth_header += ', qop={0}'.format(qop) + + return auth_header class HttpbinTestCase(unittest.TestCase): """Httpbin tests""" @@ -22,6 +114,59 @@ def setUp(self): httpbin.app.debug = True self.app = httpbin.app.test_client() + def test_index(self): + response = self.app.get('/', headers={'User-Agent': 'test'}) + self.assertEqual(response.status_code, 200) + + def get_data(self, response): + if 'get_data' in dir(response): + return response.get_data() + else: + return response.data + + def test_response_headers_simple(self): + supported_verbs = ['get', 'post'] + for verb in supported_verbs: + method = getattr(self.app, verb) + response = method('/response-headers?animal=dog') + self.assertEqual(response.status_code, 200) + self.assertEqual(response.headers.get_all('animal'), ['dog']) + assert json.loads(response.data.decode('utf-8'))['animal'] == 'dog' + + def test_response_headers_multi(self): + supported_verbs = ['get', 'post'] + for verb in supported_verbs: + method = getattr(self.app, verb) + response = method('/response-headers?animal=dog&animal=cat') + self.assertEqual(response.status_code, 200) + self.assertEqual(response.headers.get_all('animal'), ['dog', 'cat']) + assert json.loads(response.data.decode('utf-8'))['animal'] == ['dog', 'cat'] + + def test_get(self): + response = self.app.get('/get', headers={'User-Agent': 'test'}) + self.assertEqual(response.status_code, 200) + data = json.loads(response.data.decode('utf-8')) + self.assertEqual(data['args'], {}) + self.assertEqual(data['headers']['Host'], 'localhost') + self.assertEqual(data['headers']['Content-Length'], '0') + self.assertEqual(data['headers']['User-Agent'], 'test') + # self.assertEqual(data['origin'], None) + self.assertEqual(data['url'], 'http://localhost/get') + self.assertTrue(response.data.endswith(b'\n')) + + def test_anything(self): + response = self.app.get('/anything') + self.assertEqual(response.status_code, 200) + response = self.app.get('/anything/foo/bar') + self.assertEqual(response.status_code, 200) + data = json.loads(response.data.decode('utf-8')) + self.assertEqual(data['args'], {}) + self.assertEqual(data['headers']['Host'], 'localhost') + self.assertEqual(data['headers']['Content-Length'], '0') + self.assertEqual(data['url'], 'http://localhost/anything/foo/bar') + self.assertEqual(data['method'], 'GET') + self.assertTrue(response.data.endswith(b'\n')) + def test_base64(self): greeting = u'Здравствуй, мир!' b64_encoded = _string_to_base64(greeting) @@ -41,39 +186,117 @@ def test_post_body_text(self): self.assertEqual(response.status_code, 200) def test_post_body_binary(self): - with open('httpbin/core.pyc','rb') as f: - response = self.app.post('/post', data={"file": f.read()}) + response = self.app.post( + '/post', + data={"file": b'\x01\x02\x03\x81\x82\x83'}) self.assertEqual(response.status_code, 200) + def test_post_body_unicode(self): + response = self.app.post('/post', data=u'оживлённым'.encode('utf-8')) + self.assertEqual(json.loads(response.data.decode('utf-8'))['data'], u'оживлённым') + def test_post_file_with_missing_content_type_header(self): # I built up the form data manually here because I couldn't find a way # to convince the werkzeug test client to send files without the # content-type of the file set. + data = '--bound\r\nContent-Disposition: form-data; name="media"; ' + data += 'filename="test.bin"\r\n\r\n\xa5\xc6\n--bound--\r\n' response = self.app.post( '/post', content_type='multipart/form-data; boundary=bound', - data = '--bound\r\nContent-Disposition: form-data; name="media"; filename="test.bin"\r\n\r\n\xa5\xc6\n--bound--\r\n' + data=data, ) self.assertEqual(response.status_code, 200) + """ + This is currently a sort of negative-test. + We validate that when running Flask-only server that + Transfer-Encoding: chunked requests are unsupported and + we return 501 Not Implemented + """ + def test_post_chunked(self): + data = '{"animal":"dog"}' + response = self.app.post( + '/post', + content_type='application/json', + headers=[('Transfer-Encoding', 'chunked')], + data=data, + ) + self.assertEqual(response.status_code, 501) + #self.assertEqual(response.status_code, 200) + #self.assertEqual(json.loads(response.data.decode('utf-8'))['data'], '{"animal":"dog"}') + #self.assertEqual(json.loads(response.data.decode('utf-8'))['json'], {"animal": "dog"}) + def test_set_cors_headers_after_request(self): response = self.app.get('/get') - self.assertEqual(response.headers.get('Access-Control-Allow-Origin'), '*') + self.assertEqual( + response.headers.get('Access-Control-Allow-Origin'), '*' + ) + + def test_set_cors_credentials_headers_after_auth_request(self): + response = self.app.get('/basic-auth/foo/bar') + self.assertEqual( + response.headers.get('Access-Control-Allow-Credentials'), 'true' + ) def test_set_cors_headers_after_request_with_request_origin(self): response = self.app.get('/get', headers={'Origin': 'origin'}) - self.assertEqual(response.headers.get('Access-Control-Allow-Origin'), 'origin') + self.assertEqual( + response.headers.get('Access-Control-Allow-Origin'), 'origin' + ) def test_set_cors_headers_with_options_verb(self): response = self.app.open('/get', method='OPTIONS') - self.assertEqual(response.headers.get('Access-Control-Allow-Origin'), '*') - self.assertEqual(response.headers.get('Access-Control-Allow-Credentials'), 'true') - self.assertEqual(response.headers.get('Access-Control-Allow-Methods'), 'GET, POST, PUT, DELETE, PATCH, OPTIONS') - self.assertEqual(response.headers.get('Access-Control-Max-Age'), '3600') - self.assertNotIn('Access-Control-Allow-Headers', response.headers) # FIXME should we add any extra headers? + self.assertEqual( + response.headers.get('Access-Control-Allow-Origin'), '*' + ) + self.assertEqual( + response.headers.get('Access-Control-Allow-Credentials'), 'true' + ) + self.assertEqual( + response.headers.get('Access-Control-Allow-Methods'), + 'GET, POST, PUT, DELETE, PATCH, OPTIONS' + ) + self.assertEqual( + response.headers.get('Access-Control-Max-Age'), '3600' + ) + # FIXME should we add any extra headers? + self.assertNotIn( + 'Access-Control-Allow-Headers', response.headers + ) + def test_set_cors_allow_headers(self): + response = self.app.open('/get', method='OPTIONS', headers={'Access-Control-Request-Headers': 'X-Test-Header'}) + self.assertEqual( + response.headers.get('Access-Control-Allow-Headers'), 'X-Test-Header' + ) + + def test_headers(self): + headers = { + "Accept": "*/*", + "Host": "localhost:1234", + "User-Agent": "curl/7.54.0", + "Via": "bar" + } + response = self.app.get('/headers', headers=headers) + self.assertEqual(response.status_code, 200) + self.assertTrue({'Accept', 'Host', 'User-Agent'}.issubset(set(response.json['headers'].keys()))) + self.assertNotIn('Via', response.json) + + def test_headers_show_env(self): + headers = { + "Accept": "*/*", + "Host": "localhost:1234", + "User-Agent": "curl/7.54.0", + "Via": "bar" + } + response = self.app.get('/headers?show_env=true', headers=headers) + self.assertEqual(response.status_code, 200) + self.assertTrue({'Accept', 'Host', 'User-Agent', 'Via'}.issubset(set(response.json['headers'].keys()))) def test_user_agent(self): - response = self.app.get('/user-agent', headers={'User-Agent':'test'}) + response = self.app.get( + '/user-agent', headers={'User-Agent': 'test'} + ) self.assertIn('test', response.data.decode('utf-8')) self.assertEqual(response.status_code, 200) @@ -81,64 +304,513 @@ def test_gzip(self): response = self.app.get('/gzip') self.assertEqual(response.status_code, 200) + def test_brotli(self): + response = self.app.get('/brotli') + self.assertEqual(response.status_code, 200) + + def test_bearer_auth(self): + token = 'abcd1234' + response = self.app.get( + '/bearer', + headers={'Authorization': 'Bearer ' + token} + ) + self.assertEqual(response.status_code, 200) + assert json.loads(response.data.decode('utf-8'))['token'] == token + + def test_bearer_auth_with_wrong_authorization_type(self): + """Sending an non-Bearer Authorization header to /bearer should return a 401""" + auth_headers = ( + ('Authorization', 'Basic 1234abcd'), + ('Authorization', ''), + ('', '') + ) + for header in auth_headers: + response = self.app.get( + '/bearer', + headers={header[0]: header[1]} + ) + self.assertEqual(response.status_code, 401) + + def test_bearer_auth_with_missing_token(self): + """Sending an 'Authorization: Bearer' header with no token to /bearer should return a 401""" + response = self.app.get( + '/bearer', + headers={'Authorization': 'Bearer'} + ) + self.assertEqual(response.status_code, 401) + + def test_digest_auth_with_wrong_password(self): + auth_header = 'Digest username="user",realm="wrong",nonce="wrong",uri="/digest-auth/user/passwd/MD5",response="wrong",opaque="wrong"' + response = self.app.get( + '/digest-auth/auth/user/passwd/MD5', + environ_base={ + # httpbin's digest auth implementation uses the remote addr to + # build the nonce + 'REMOTE_ADDR': '127.0.0.1', + }, + headers={ + 'Authorization': auth_header, + } + ) + self.assertTrue('Digest' in response.headers.get('WWW-Authenticate')) + self.assertEqual(response.status_code, 401) + def test_digest_auth(self): - # make first request + """Test different combinations of digest auth parameters""" + username = 'user' + password = 'passwd' + for qop in None, 'auth', 'auth-int',: + for algorithm in None, 'MD5', 'SHA-256', 'SHA-512': + for body in None, b'', b'request payload': + for stale_after in (None, 1, 4) if algorithm else (None,) : + self._test_digest_auth(username, password, qop, algorithm, body, stale_after) + + def test_digest_auth_with_wrong_authorization_type(self): + """Sending an non-digest Authorization header to /digest-auth should return a 401""" + auth_headers = ( + ('Authorization', 'Basic 1234abcd'), + ('Authorization', ''), + ('', '') + ) + for header in auth_headers: + response = self.app.get( + '/digest-auth/auth/myname/mysecret', + headers={header[0]: header[1]} + ) + self.assertEqual(response.status_code, 401) + + def _test_digest_auth(self, username, password, qop, algorithm=None, body=None, stale_after=None): + uri = self._digest_auth_create_uri(username, password, qop, algorithm, stale_after) + + unauthorized_response = self._test_digest_auth_first_challenge(uri) + + header = unauthorized_response.headers.get('WWW-Authenticate') + + authorized_response, nonce = self._test_digest_response_for_auth_request(header, username, password, qop, uri, body) + self.assertEqual(authorized_response.status_code, 200) + + if None == stale_after : + return + + # test stale after scenerio + self._digest_auth_stale_after_check(header, username, password, uri, body, qop, stale_after) + + def _test_digest_auth_first_challenge(self, uri): unauthorized_response = self.app.get( - '/digest-auth/auth/user/passwd', - environ_base = { - 'REMOTE_ADDR':'127.0.0.1', # digest auth uses the remote addr to build the nonce - }) + uri, + environ_base={ + # digest auth uses the remote addr to build the nonce + 'REMOTE_ADDR': '127.0.0.1', + } + ) # make sure it returns a 401 self.assertEqual(unauthorized_response.status_code, 401) - header = unauthorized_response.headers.get('WWW-Authenticate') + return unauthorized_response + + def _digest_auth_create_uri(self, username, password, qop, algorithm, stale_after): + uri = '/digest-auth/{0}/{1}/{2}'.format(qop or 'wrong-qop', username, password) + if algorithm: + uri += '/' + algorithm + if stale_after: + uri += '/{0}'.format(stale_after) + return uri + + def _digest_auth_stale_after_check(self, header, username, password, uri, body, qop, stale_after): + for nc in range(2, stale_after + 1): + authorized_response, nonce = self._test_digest_response_for_auth_request(header, username, password, qop, uri, \ + body, nc) + self.assertEqual(authorized_response.status_code, 200) + stale_response, nonce = self._test_digest_response_for_auth_request(header, username, password, qop, uri, \ + body, stale_after + 1) + self.assertEqual(stale_response.status_code, 401) + header = stale_response.headers.get('WWW-Authenticate') + self.assertIn('stale=TRUE', header) + + def _test_digest_response_for_auth_request(self, header, username, password, qop, uri, body, nc=1, nonce=None): auth_type, auth_info = header.split(None, 1) + self.assertEqual(auth_type, 'Digest') - # Begin crappy digest-auth implementation d = parse_dict_header(auth_info) - a1 = b'user:' + d['realm'].encode('utf-8') + b':passwd' - ha1 = md5(a1).hexdigest().encode('utf-8') - a2 = b'GET:/digest-auth/auth/user/passwd' - ha2 = md5(a2).hexdigest().encode('utf-8') - a3 = ha1 + b':' + d['nonce'].encode('utf-8') + b':' + ha2 - auth_response = md5(a3).hexdigest() - auth_header = 'Digest username="user",realm="' + \ - d['realm'] + \ - '",nonce="' + \ - d['nonce'] + \ - '",uri="/digest-auth/auth/user/passwd",response="' + \ - auth_response + \ - '",opaque="' + \ - d['opaque'] + '"' + + nonce = nonce or d['nonce'] + realm = d['realm'] + opaque = d['opaque'] + if qop : + self.assertIn(qop, [x.strip() for x in d['qop'].split(',')], 'Challenge should contains expected qop') + algorithm = d['algorithm'] + + cnonce, nc = (_hash(os.urandom(10), "MD5"), '{:08}'.format(nc)) if qop in ('auth', 'auth-int') else (None, None) + + auth_header = _make_digest_auth_header( + username, password, 'GET', uri, nonce, realm, opaque, algorithm, qop, cnonce, nc, body) # make second request - authorized_response = self.app.get( - '/digest-auth/auth/user/passwd', - environ_base = { - 'REMOTE_ADDR':'127.0.0.1', # httpbin's digest auth implementation uses the remote addr to build the nonce + return self.app.get( + uri, + environ_base={ + # httpbin's digest auth implementation uses the remote addr to + # build the nonce + 'REMOTE_ADDR': '127.0.0.1', }, - headers = { + headers={ 'Authorization': auth_header, - } - ) + }, + data=body + ), nonce - # done! - self.assertEqual(authorized_response.status_code, 200) + def test_digest_auth_wrong_pass(self): + """Test different combinations of digest auth parameters""" + username = 'user' + password = 'passwd' + for qop in None, 'auth', 'auth-int',: + for algorithm in None, 'MD5', 'SHA-256', 'SHA-512': + for body in None, b'', b'request payload': + self._test_digest_auth_wrong_pass(username, password, qop, algorithm, body, 3) + + def _test_digest_auth_wrong_pass(self, username, password, qop, algorithm=None, body=None, stale_after=None): + uri = self._digest_auth_create_uri(username, password, qop, algorithm, stale_after) + unauthorized_response = self._test_digest_auth_first_challenge(uri) + + header = unauthorized_response.headers.get('WWW-Authenticate') + + wrong_pass_response, nonce = self._test_digest_response_for_auth_request(header, username, "wrongPassword", qop, uri, body) + self.assertEqual(wrong_pass_response.status_code, 401) + header = wrong_pass_response.headers.get('WWW-Authenticate') + self.assertNotIn('stale=TRUE', header) + + reused_nonce_response, nonce = self._test_digest_response_for_auth_request(header, username, password, qop, uri, \ + body, nonce=nonce) + self.assertEqual(reused_nonce_response.status_code, 401) + header = reused_nonce_response.headers.get('WWW-Authenticate') + self.assertIn('stale=TRUE', header) def test_drip(self): response = self.app.get('/drip?numbytes=400&duration=2&delay=1') - self.assertEqual(len(response.get_data()), 400) + self.assertEqual(response.content_length, 400) + self.assertEqual(len(self.get_data(response)), 400) self.assertEqual(response.status_code, 200) + def test_drip_with_invalid_numbytes(self): + for bad_num in -1, 0: + uri = '/drip?numbytes={0}&duration=2&delay=1'.format(bad_num) + response = self.app.get(uri) + self.assertEqual(response.status_code, 400) + + def test_drip_with_custom_code(self): + response = self.app.get('/drip?numbytes=400&duration=2&code=500') + self.assertEqual(response.content_length, 400) + self.assertEqual(len(self.get_data(response)), 400) + self.assertEqual(response.status_code, 500) + def test_get_bytes(self): response = self.app.get('/bytes/1024') - self.assertEqual(len(response.get_data()), 1024) + self.assertEqual(len(self.get_data(response)), 1024) self.assertEqual(response.status_code, 200) + def test_bytes_with_seed(self): + response = self.app.get('/bytes/10?seed=0') + # The RNG changed in python3, so even though we are + # setting the seed, we can't expect the value to be the + # same across both interpreters. + if six.PY3: + self.assertEqual( + response.data, b'\xc5\xd7\x14\x84\xf8\xcf\x9b\xf4\xb7o' + ) + else: + self.assertEqual( + response.data, b'\xd8\xc2kB\x82g\xc8Mz\x95' + ) + def test_stream_bytes(self): response = self.app.get('/stream-bytes/1024') - self.assertEqual(len(response.get_data()), 1024) + self.assertEqual(len(self.get_data(response)), 1024) + self.assertEqual(response.status_code, 200) + + def test_stream_bytes_with_seed(self): + response = self.app.get('/stream-bytes/10?seed=0') + # The RNG changed in python3, so even though we are + # setting the seed, we can't expect the value to be the + # same across both interpreters. + if six.PY3: + self.assertEqual( + response.data, b'\xc5\xd7\x14\x84\xf8\xcf\x9b\xf4\xb7o' + ) + else: + self.assertEqual( + response.data, b'\xd8\xc2kB\x82g\xc8Mz\x95' + ) + + def test_delete_endpoint_returns_body(self): + response = self.app.delete( + '/delete', + data={'name': 'kevin'}, + content_type='application/x-www-form-urlencoded' + ) + form_data = json.loads(response.data.decode('utf-8'))['form'] + self.assertEqual(form_data, {'name': 'kevin'}) + + def test_methods__to_status_endpoint(self): + methods = [ + 'GET', + 'HEAD', + 'POST', + 'PUT', + 'DELETE', + 'PATCH', + 'TRACE', + ] + for m in methods: + response = self.app.open(path='/status/418', method=m) + self.assertEqual(response.status_code, 418) + + def test_status_endpoint_invalid_code(self): + response = self.app.get(path='/status/4!9') + self.assertEqual(response.status_code, 400) + + def test_status_endpoint_invalid_codes(self): + response = self.app.get(path='/status/200,402,foo') + self.assertEqual(response.status_code, 400) + + def test_xml_endpoint(self): + response = self.app.get(path='/xml') + self.assertEqual( + response.headers.get('Content-Type'), 'application/xml' + ) + + def test_x_forwarded_proto(self): + response = self.app.get(path='/get', headers={ + 'X-Forwarded-Proto':'https' + }) + assert json.loads(response.data.decode('utf-8'))['url'].startswith('https://') + + def test_redirect_n_higher_than_1(self): + response = self.app.get('/redirect/5') + self.assertEqual( + response.headers.get('Location'), '/relative-redirect/4' + ) + + def test_redirect_to_post(self): + response = self.app.post('/redirect-to?url=/post&status_code=307', + data=b'\x01\x02\x03\x81\x82\x83', + content_type='application/octet-stream') + self.assertEqual(response.status_code, 307) + self.assertEqual( + response.headers.get('Location'), '/post' + ) + + def test_redirect_absolute_param_n_higher_than_1(self): + response = self.app.get('/redirect/5?absolute=true') + self.assertEqual( + response.headers.get('Location'), 'http://localhost/absolute-redirect/4' + ) + + def test_redirect_n_equals_to_1(self): + response = self.app.get('/redirect/1') + self.assertEqual(response.status_code, 302) + self.assertEqual( + response.headers.get('Location'), '/get' + ) + + def test_relative_redirect_n_equals_to_1(self): + response = self.app.get('/relative-redirect/1') + self.assertEqual( + response.headers.get('Location'), '/get' + ) + + def test_relative_redirect_n_higher_than_1(self): + response = self.app.get('/relative-redirect/7') + self.assertEqual(response.status_code, 302) + self.assertEqual( + response.headers.get('Location'), '/relative-redirect/6' + ) + + def test_absolute_redirect_n_higher_than_1(self): + response = self.app.get('/absolute-redirect/5') + self.assertEqual( + response.headers.get('Location'), 'http://localhost/absolute-redirect/4' + ) + + def test_absolute_redirect_n_equals_to_1(self): + response = self.app.get('/absolute-redirect/1') + self.assertEqual(response.status_code, 302) + self.assertEqual( + response.headers.get('Location'), 'http://localhost/get' + ) + + def test_request_range(self): + response1 = self.app.get('/range/1234') + self.assertEqual(response1.status_code, 200) + self.assertEqual(response1.headers.get('ETag'), 'range1234') + self.assertEqual(response1.headers.get('Content-range'), 'bytes 0-1233/1234') + self.assertEqual(response1.headers.get('Accept-ranges'), 'bytes') + self.assertEqual(len(self.get_data(response1)), 1234) + + response2 = self.app.get('/range/1234') + self.assertEqual(response2.status_code, 200) + self.assertEqual(response2.headers.get('ETag'), 'range1234') + self.assertEqual(self.get_data(response1), self.get_data(response2)) + + def test_request_range_with_parameters(self): + response = self.app.get( + '/range/100?duration=1.5&chunk_size=5', + headers={ 'Range': 'bytes=10-24' } + ) + + self.assertEqual(response.status_code, 206) + self.assertEqual(response.headers.get('ETag'), 'range100') + self.assertEqual(response.headers.get('Content-range'), 'bytes 10-24/100') + self.assertEqual(response.headers.get('Accept-ranges'), 'bytes') + self.assertEqual(response.headers.get('Content-Length'), '15') + self.assertEqual(self.get_data(response), 'klmnopqrstuvwxy'.encode('utf8')) + + def test_request_range_first_15_bytes(self): + response = self.app.get( + '/range/1000', + headers={ 'Range': 'bytes=0-15' } + ) + + self.assertEqual(response.status_code, 206) + self.assertEqual(response.headers.get('ETag'), 'range1000') + self.assertEqual(self.get_data(response), 'abcdefghijklmnop'.encode('utf8')) + self.assertEqual(response.headers.get('Content-range'), 'bytes 0-15/1000') + + def test_request_range_open_ended_last_6_bytes(self): + response = self.app.get( + '/range/26', + headers={ 'Range': 'bytes=20-' } + ) + + self.assertEqual(response.status_code, 206) + self.assertEqual(response.headers.get('ETag'), 'range26') + self.assertEqual(self.get_data(response), 'uvwxyz'.encode('utf8')) + self.assertEqual(response.headers.get('Content-range'), 'bytes 20-25/26') + self.assertEqual(response.headers.get('Content-Length'), '6') + + def test_request_range_suffix(self): + response = self.app.get( + '/range/26', + headers={ 'Range': 'bytes=-5' } + ) + + self.assertEqual(response.status_code, 206) + self.assertEqual(response.headers.get('ETag'), 'range26') + self.assertEqual(self.get_data(response), 'vwxyz'.encode('utf8')) + self.assertEqual(response.headers.get('Content-range'), 'bytes 21-25/26') + self.assertEqual(response.headers.get('Content-Length'), '5') + + def test_request_out_of_bounds(self): + response = self.app.get( + '/range/26', + headers={ 'Range': 'bytes=10-5', + } + ) + + self.assertEqual(response.status_code, 416) + self.assertEqual(response.headers.get('ETag'), 'range26') + self.assertEqual(len(self.get_data(response)), 0) + self.assertEqual(response.headers.get('Content-range'), 'bytes */26') + self.assertEqual(response.headers.get('Content-Length'), '0') + + response = self.app.get( + '/range/26', + headers={ 'Range': 'bytes=32-40', + } + ) + + self.assertEqual(response.status_code, 416) + response = self.app.get( + '/range/26', + headers={ 'Range': 'bytes=0-40', + } + ) + self.assertEqual(response.status_code, 416) + + def test_etag_if_none_match_matches(self): + response = self.app.get( + '/etag/abc', + headers={ 'If-None-Match': 'abc' } + ) + self.assertEqual(response.status_code, 304) + self.assertEqual(response.headers.get('ETag'), 'abc') + + def test_etag_if_none_match_matches_list(self): + response = self.app.get( + '/etag/abc', + headers={ 'If-None-Match': '"123", "abc"' } + ) + self.assertEqual(response.status_code, 304) + self.assertEqual(response.headers.get('ETag'), 'abc') + + def test_etag_if_none_match_matches_star(self): + response = self.app.get( + '/etag/abc', + headers={ 'If-None-Match': '*' } + ) + self.assertEqual(response.status_code, 304) + self.assertEqual(response.headers.get('ETag'), 'abc') + + def test_etag_if_none_match_w_prefix(self): + response = self.app.get( + '/etag/c3piozzzz', + headers={ 'If-None-Match': 'W/"xyzzy", W/"r2d2xxxx", W/"c3piozzzz"' } + ) + self.assertEqual(response.status_code, 304) + self.assertEqual(response.headers.get('ETag'), 'c3piozzzz') + + def test_etag_if_none_match_has_no_match(self): + response = self.app.get( + '/etag/abc', + headers={ 'If-None-Match': '123' } + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.headers.get('ETag'), 'abc') + + def test_etag_if_match_matches(self): + response = self.app.get( + '/etag/abc', + headers={ 'If-Match': 'abc' } + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.headers.get('ETag'), 'abc') + + def test_etag_if_match_matches_list(self): + response = self.app.get( + '/etag/abc', + headers={ 'If-Match': '"123", "abc"' } + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.headers.get('ETag'), 'abc') + + def test_etag_if_match_matches_star(self): + response = self.app.get( + '/etag/abc', + headers={ 'If-Match': '*' } + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.headers.get('ETag'), 'abc') + + def test_etag_if_match_has_no_match(self): + response = self.app.get( + '/etag/abc', + headers={ 'If-Match': '123' } + ) + self.assertEqual(response.status_code, 412) + self.assertNotIn('ETag', response.headers) + + def test_etag_with_no_headers(self): + response = self.app.get( + '/etag/abc' + ) self.assertEqual(response.status_code, 200) + self.assertEqual(response.headers.get('ETag'), 'abc') + def test_parse_multi_value_header(self): + self.assertEqual(parse_multi_value_header('xyzzy'), [ "xyzzy" ]) + self.assertEqual(parse_multi_value_header('"xyzzy"'), [ "xyzzy" ]) + self.assertEqual(parse_multi_value_header('W/"xyzzy"'), [ "xyzzy" ]) + self.assertEqual(parse_multi_value_header('"xyzzy", "r2d2xxxx", "c3piozzzz"'), [ "xyzzy", "r2d2xxxx", "c3piozzzz" ]) + self.assertEqual(parse_multi_value_header('W/"xyzzy", W/"r2d2xxxx", W/"c3piozzzz"'), [ "xyzzy", "r2d2xxxx", "c3piozzzz" ]) + self.assertEqual(parse_multi_value_header('*'), [ "*" ]) if __name__ == '__main__': unittest.main() diff --git a/tox.ini b/tox.ini index 3875f3e8..8495eb81 100644 --- a/tox.ini +++ b/tox.ini @@ -1,4 +1,15 @@ [tox] -envlist = py27,py34 +envlist = py27,py36,py37 + [testenv] commands=python test_httpbin.py + +[testenv:release] +skipdist = true +usedevelop = false +deps = + twine>=1.6.0 + wheel +commands = + python setup.py sdist bdist_wheel + twine upload --skip-existing dist/*