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 index 0e8e7159..dfad587e 100644 --- a/.travis.yml +++ b/.travis.yml @@ -8,6 +8,10 @@ matrix: 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 diff --git a/AUTHORS b/AUTHORS index 18fe967e..048ecf1c 100644 --- a/AUTHORS +++ b/AUTHORS @@ -22,3 +22,4 @@ Patches and Suggestions - 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 index 35abff6f..819006bb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,17 +1,22 @@ -FROM python:3-alpine +FROM ubuntu:18.04 -ENV WEB_CONCURRENCY=4 +LABEL name="httpbin" +LABEL version="0.9.2" +LABEL description="A simple HTTP service." +LABEL org.kennethreitz.vendor="Kenneth Reitz" -ADD . /httpbin +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 -RUN apk add -U ca-certificates libffi libstdc++ && \ - apk add --virtual build-deps build-base libffi-dev && \ - # Pip - pip install --no-cache-dir gunicorn /httpbin && \ - # Cleaning up - apk del build-deps && \ - rm -rf /var/cache/apk/* +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 8080 +EXPOSE 80 -CMD ["gunicorn", "-b", "0.0.0.0:8080", "httpbin:app"] +CMD ["gunicorn", "-b", "0.0.0.0:80", "httpbin:app", "-k", "gevent"] diff --git a/MANIFEST.in b/MANIFEST.in index 63308a83..894af4cc 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,2 +1,3 @@ -include README.rst LICENSE AUTHORS requirements.txt test_httpbin.py +include httpbin/VERSION README.md LICENSE AUTHORS test_httpbin.py recursive-include httpbin/templates * +recursive-include httpbin/static * diff --git a/Pipfile b/Pipfile index 2dab2232..8ad29bb8 100644 --- a/Pipfile +++ b/Pipfile @@ -8,9 +8,11 @@ decorator = "*" brotlipy = "*" gevent = "*" Flask = "*" -Flask-Common = "*" meinheld = "*" werkzeug = ">=0.14.1" +six = "*" +flasgger = "*" +pyyaml = {git = "https://github.com/yaml/pyyaml.git"} -[packages.raven] -extras = [ "flask",] +[dev-packages] +rope = "*" diff --git a/Pipfile.lock b/Pipfile.lock index f8059825..baa2566d 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "e2d593192acadabf3463785be0dc3a1e163d41713ca8d4cd53d4e99c03d927a4" + "sha256": "b709c9b498d9be5088c0f485aafe18a04a8ed5144d397111a8f1d8bd06d7a16e" }, "pipfile-spec": 6, "requires": {}, @@ -13,12 +13,6 @@ ] }, "default": { - "blinker": { - "hashes": [ - "sha256:471aee25f3992bd325afa3772f1063dbdbbca947a041b8b89466dc00d606f8b6" - ], - "version": "==1.4" - }, "brotlipy": { "hashes": [ "sha256:07194f4768eb62a4f4ea76b6d0df6ade185e24ebd85877c351daa0a069f1111a", @@ -59,9 +53,12 @@ "sha256:1553d1e99f035ace1c0544050622b7bc963374a00c467edafac50ad7bd276aef", "sha256:1b0493c091a1898f1136e3f4f991a784437fac3673780ff9de3bcf46c80b6b50", "sha256:2ba8a45822b7aee805ab49abfe7eec16b90587f7f26df20c71dd89e45a97076f", + "sha256:3bb6bd7266598f318063e584378b8e27c67de998a43362e8fce664c54ee52d30", "sha256:3c85641778460581c42924384f5e68076d724ceac0f267d66c757f7535069c93", "sha256:3eb6434197633b7748cea30bf0ba9f66727cdce45117a712b29a443943733257", + "sha256:495c5c2d43bf6cebe0178eb3e88f9c4aa48d8934aa6e3cddb865c058da76756b", "sha256:4c91af6e967c2015729d3e69c2e51d92f9898c330d6a851bf8f121236f3defd3", + "sha256:57b2533356cb2d8fac1555815929f7f5f14d68ac77b085d2326b571310f34f6e", "sha256:770f3782b31f50b68627e22f91cb182c48c47c02eb405fd689472aa7b7aa16dc", "sha256:79f9b6f7c46ae1f8ded75f68cf8ad50e5729ed4d590c74840471fc2823457d04", "sha256:7a33145e04d44ce95bcd71e522b478d282ad0eafaf34fe1ec5bbd73e662f22b6", @@ -70,11 +67,13 @@ "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", @@ -92,88 +91,53 @@ ], "version": "==6.7" }, - "colorama": { - "hashes": [ - "sha256:463f8483208e921368c9f306094eb6f725c6ca42b0f97e313cb5d5512459feda", - "sha256:48eb22f4f8461b1df5734a074b57042430fb06e1d61bd1e11b078c0fe6d7a1f1" - ], - "version": "==0.3.9" - }, - "crayons": { - "hashes": [ - "sha256:5e17691605e564d63482067eb6327d01a584bbaf870beffd4456a3391bd8809d", - "sha256:6f51241d0c4faec1c04c1c0ac6a68f1d66a4655476ce1570b3f37e5166a599cc" - ], - "version": "==0.1.2" - }, - "dateparser": { - "hashes": [ - "sha256:940828183c937bcec530753211b70f673c0a9aab831e43273489b310538dff86", - "sha256:b452ef8b36cd78ae86a50721794bc674aa3994e19b570f7ba92810f4e0a2ae03" - ], - "version": "==0.7.0" - }, "decorator": { "hashes": [ - "sha256:7d46dd9f3ea1cf5f06ee0e4e1277ae618cf48dfb10ada7c8427cd46c42702a0e", - "sha256:94d1d8905f5010d74bbbd86c30471255661a14187c45f8d7f3e5aa8540fdb2e5" + "sha256:2c51dff8ef3c447388fe5e4453d24a2bf128d3a4c32af3fabef1f01c6851ab82", + "sha256:c39efa13fbdeb4506c476c9b3babf6a718da943dab7811c206005a4a956c080c" ], - "version": "==4.2.1" + "version": "==4.3.0" }, - "flask": { - "hashes": [ - "sha256:0749df235e3ff61ac108f69ac178c9770caeaccad2509cb762ce1f65570a8856", - "sha256:49f44461237b69ecd901cc7ce66feea0319b9158743dd27a2899962ab214dac1" - ], - "version": "==0.12.2" - }, - "flask-cache": { + "flasgger": { "hashes": [ - "sha256:33187b3ddceeee233fe3db68ffcc118b5498e8ad28edde711bcbdcbf4924ce35", - "sha256:90126ca9bc063854ef8ee276e95d38b2b4ec8e45fd77d5751d37971ee27c7ef4", - "sha256:ae9d1ac4549517dfbc1f178ccc5429f61f836be3cc109a0b2481c98b3711c329" + "sha256:1c9c03a4b55b60688f2bb2c2d8ff4534cb18eda70fd02973141be8c3bde586b3", + "sha256:efee892b0554c60f716b441ee78fddcaf7af20bc764696d9eecd6a389fb7f195" ], - "version": "==0.13.1" + "version": "==0.9.0" }, - "flask-common": { + "flask": { "hashes": [ - "sha256:44fbb57a12bc7478d56c223eb5de7b2fb98ce42a70314c74ffecf5dbe75ed1b8" + "sha256:2271c0070dbcb5275fad4a82e29f23ab92682dc45f9dfbc22c02ba9b9322ce48", + "sha256:a080b744b7e345ccfcbc77954861cb05b3c63786e93f2b3875e0913d44b43f05" ], - "version": "==0.2.0" + "version": "==1.0.2" }, "gevent": { "hashes": [ - "sha256:0901975628790e8a57fc92bb7062e5b856edea48c8de9caf36cfda14eae07329", - "sha256:1af93825db5753550fa8ff5ab2f2132e8733170b3f8d38347b34fa4a984cb624", - "sha256:2ff045a91509c35664c27a849c8cbf742a227f587b7cdbc88301e9c85dcaedff", - "sha256:33fa6759eabc9176ddbe0d29b66867a82e19a61f06eb7cfabbac35343c0ecf24", - "sha256:35790f1a3c8e431ada3471b70bb2105050009ea4beb15cbe41b86bc716a7ffa9", - "sha256:4791c8ae9c57d6f153354736e1ccab1e2baf6c8d9ae5a77a9ac90f41e2966b2d", - "sha256:4f098002126ebef7f2907188b6c8b09e5193161ce968847d9e6a8bc832b0db9a", - "sha256:552719cec4721673b8c7d2f9de666e3f7591b9b182f801ecaef1c76e638052aa", - "sha256:59e9237af027f8db85e5d78a9da2e328ae96f01d67a0d62abcecad3db7876908", - "sha256:60109741377367eef8ded9283a1bf629621b73acaf3e1e8aac9d1a0f50fa0f05", - "sha256:6892fabc9051e8c0a171d543b6536859aabeb6d169db79b2f45d64dc2a15808c", - "sha256:70558dd45c7a1f8046ba45792e489dd0f409bd8a3b7a0635ca9d3055223b3dff", - "sha256:74bce0c30bb2240e3d5d515ba8cb3eadf840c2bde7109a1979c7a26c9d0f5a6a", - "sha256:7f93b67b680f4a921f517294048d05f8f6f0ed5962b78d6685a6cf0fcd7d8202", - "sha256:81cb24e0f7bd9888596364e8d8ed0d65c2547c84884c67bb46d956faeed67396", - "sha256:833bebdc36bfeeedefc200ca9aee9b8eddd80f56b63ca1e886e18b97b1240edd", - "sha256:8a710eddb3e9e5f22bdbd458b5f211b94f59409ecd6896f15b9fee2cba266a59", - "sha256:9b492bb1a043540abb6e54fdb5537531e24962ca49c09f3b47dc4f9c37f6297c", - "sha256:a0ed8ba787b9c0c1c565c2675d71652e6c1e2d4e91f53530860d0303e867fe85", - "sha256:a16db4f56699ef07f0249b953ff949aae641e50b2bdc4710f11c0d8d9089b296", - "sha256:a66cf99f08da65c501826a19e30f5a6e7ba942fdd79baba5ce2d51eebaa13444", - "sha256:b67a10799923f9fed546ca5f8b93a2819c71a60132d7a97b4a13fbdab66b278a", - "sha256:b7e0e6400c2f3ce78a9ae1cdd55b53166feedd003d60c033863881227129a4d3", - "sha256:c35b29de49211014ec66d056fd4f9ba7a04795e2a654697f72879c0cf365d6d4", - "sha256:c9dd6534c46ed782e2d7236767cd07115cb29ce8670c2fc0794f264de9024fe0", - "sha256:de13a8e378103af84a8bf6015ad1d2761d46f29b8393e8dd6d9bb7cb51bbb713", - "sha256:deafd70d04ab62428d4e291e8e2c0fb22f38690e6a9f23a67ee6c304087634da", - "sha256:df52e06a2754c2d905aad75a7dc06a732c804d9edbc87f06f47c8f483ba98bca", - "sha256:fce894a64db3911897cdad6c37fbb23dfb18b7bf8b9cb8c00a8ea0a7253651c9" - ], - "version": "==1.2.2" + "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": [ @@ -187,6 +151,7 @@ "sha256:5b49b3049697aeae17ef7bf21267e69972d9e04917658b4e788986ea5cc518e8", "sha256:75c413551a436b462d5929255b6dc9c0c3c2b25cbeaee5271a56c7fda8ca49c0", "sha256:769b740aeebd584cd59232be84fdcaf6270b8adc356596cdea5b2152c82caaac", + "sha256:a1852b51b06d1367e2d70321f6801844f5122852c9e5169bdfdff3f4d81aae30", "sha256:ad2383d39f13534f3ca5c48fe1fc0975676846dc39c2cece78c0f1f9891418e0", "sha256:b417bb7ff680d43e7bd7a13e2e08956fa6acb11fd432f74c97b7664f8bdb6ec1", "sha256:b6ef0cabaf5a6ecb5ac122e689d25ba12433a90c7b067b12e5f28bdb7fb78254", @@ -195,20 +160,15 @@ "sha256:f8f2a0ae8de0b49c7b5b2daca4f150fdd9c1173e854df2cce3b04123244f9f45", "sha256:fcfadaf4bf68a27e5dc2f42cbb2f4b4ceea9f05d1d0b8f7787e640bed2801634" ], + "markers": "platform_python_implementation == 'CPython'", "version": "==0.4.13" }, "gunicorn": { "hashes": [ - "sha256:75af03c99389535f218cc596c7de74df4763803f7b63eb09d77e92b3956b36c6", - "sha256:eee1169f0ca667be05db3351a0960765620dad53f53434262ff8901b68a1b622" + "sha256:aa8e0b40b4157b36a5df5e599f45c9c76d6af43845ba3b3b0efe2c70473c2471", + "sha256:fa2662097c66f920f53f70621c6c58ca4a3c4d3434205e608e121b5b3b71f4f3" ], - "version": "==19.7.1" - }, - "humanize": { - "hashes": [ - "sha256:a43f57115831ac7c70de098e6ac46ac13be00d69abbf60bdcac251344785bb19" - ], - "version": "==0.5.1" + "version": "==19.9.0" }, "itsdangerous": { "hashes": [ @@ -223,18 +183,18 @@ ], "version": "==2.10" }, - "markupsafe": { + "jsonschema": { "hashes": [ - "sha256:a6be69091dac236ea9c6bc7d012beab42010fa914c459791d627dad4910eb665" + "sha256:000e68abd33c972a5248544925a0cae7d1125f9bf6c58280d37546b946769a08", + "sha256:6ff5f3180870836cae40f06fa10419f557208175f13ad7bc26caa77beb1f6e02" ], - "version": "==1.0" + "version": "==2.6.0" }, - "maya": { + "markupsafe": { "hashes": [ - "sha256:ad1969bae78afb148c45a2f63591a7575ec05b4a0ab7ec04987ab7d73649f9d6", - "sha256:d8a7ed8513b2990036fe456c9f595b54d19ec49cb4461cd95a2ef6c487fb55eb" + "sha256:a6be69091dac236ea9c6bc7d012beab42010fa914c459791d627dad4910eb665" ], - "version": "==0.3.4" + "version": "==1.0" }, "meinheld": { "hashes": [ @@ -243,18 +203,12 @@ ], "version": "==0.6.1" }, - "pendulum": { + "mistune": { "hashes": [ - "sha256:0c14388546db6605a860b8b7112cb69d0b11c9ce5e072210504544e0d4575799", - "sha256:39a255776528afe11ea0d57814f9bf3729c1e0b99063af2e5c6cfd750c3e1f7f", - "sha256:3c85e8cbc91f45e1cc916cc9180b34153cd6aaaaacfb51a48b3156318314fa82", - "sha256:8199206c479b13947dcac63c025575d035331bb3819d1783dc1d568a11962906", - "sha256:8798aeca58b3dd7ffdc5a4993c9eaafedc4048165429e8f499ddd62c73bf3964", - "sha256:881efe37328de0785c0731d462e1485a45712f2cd5cb55907d6c15458460ebeb", - "sha256:bcca072f82e84b419efec1320cd3ee5c230d263f3a601b146651ed4db77d89f0", - "sha256:ff0c5fa3af4a471a218408c448b804ac6bccb105127727474f4e83c0e4072e97" + "sha256:b4c512ce2fc99e5a62eb95a4aba4b73e5f90264115c40b70a21e1f7d4e0eac91", + "sha256:bc10c33bfdcaa4e749b779f62f60d6e12f8215c46a292d05e486b869ae306619" ], - "version": "==1.4.2" + "version": "==0.8.3" }, "pycparser": { "hashes": [ @@ -262,82 +216,9 @@ ], "version": "==2.18" }, - "python-dateutil": { - "hashes": [ - "sha256:07009062406cffd554a9b4135cd2ff167c9bf6b7aac61fe946c93e69fad1bbd8", - "sha256:8f95bb7e6edbb2456a51a1fb58c8dca942024b4f5844cae62c90aa88afe6e300" - ], - "version": "==2.7.0" - }, - "pytz": { - "hashes": [ - "sha256:07edfc3d4d2705a20a6e99d97f0c4b61c800b8232dc1c04d87e8554f130148dd", - "sha256:3a47ff71597f821cd84a162e71593004286e5be07a340fd462f0d33a760782b5", - "sha256:410bcd1d6409026fbaa65d9ed33bf6dd8b1e94a499e32168acfc7b332e4095c0", - "sha256:5bd55c744e6feaa4d599a6cbd8228b4f8f9ba96de2c38d56f08e534b3c9edf0d", - "sha256:61242a9abc626379574a166dc0e96a66cd7c3b27fc10868003fa210be4bff1c9", - "sha256:887ab5e5b32e4d0c86efddd3d055c1f363cbaa583beb8da5e22d2fa2f64d51ef", - "sha256:ba18e6a243b3625513d85239b3e49055a2f0318466e0b8a92b8fb8ca7ccdf55f", - "sha256:ed6509d9af298b7995d69a440e2822288f2eca1681b8cce37673dbb10091e5fe", - "sha256:f93ddcdd6342f94cea379c73cddb5724e0d6d0a1c91c9bdef364dc0368ba4fda" - ], - "version": "==2018.3" - }, - "pytzdata": { - "hashes": [ - "sha256:4e2cceb54335cd6c28caea46b15cd592e2aec5e8b05b0241cbccfb1b23c02ae7", - "sha256:7cd949123e2c2060fd12793de3a4a449e36b5dea5e169b810a3ac3f0b9877cfa" - ], - "version": "==2018.3" - }, - "raven": { - "hashes": [ - "sha256:738a52019d01955d5b44b49d67c9f2f4cedb1b4f70d4fb0b493931174d00e044", - "sha256:92bf4c4819472ed20f1b9905eeeafe1bc6fe5f273d7c14506fdb8fb3a6ab2074" - ], - "version": "==6.6.0" - }, - "regex": { - "hashes": [ - "sha256:1b428a296531ea1642a7da48562746309c5c06471a97bd0c02dd6a82e9cecee8", - "sha256:27d72bb42dffb32516c28d218bb054ce128afd3e18464f30837166346758af67", - "sha256:32cf4743debee9ea12d3626ee21eae83052763740e04086304e7a74778bf58c9", - "sha256:32f6408dbca35040bc65f9f4ae1444d5546411fde989cb71443a182dd643305e", - "sha256:333687d9a44738c486735955993f83bd22061a416c48f5a5f9e765e90cf1b0c9", - "sha256:35eeccf17af3b017a54d754e160af597036435c58eceae60f1dd1364ae1250c7", - "sha256:361a1fd703a35580a4714ec28d85e29780081a4c399a99bbfb2aee695d72aedb", - "sha256:494bed6396a20d3aa6376bdf2d3fbb1005b8f4339558d8ac7b53256755f80303", - "sha256:5b9c0ddd5b4afa08c9074170a2ea9b34ea296e32aeea522faaaaeeeb2fe0af2e", - "sha256:a50532f61b23d4ab9d216a6214f359dd05c911c1a1ad20986b6738a782926c1a", - "sha256:a9243d7b359b72c681a2c32eaa7ace8d346b7e8ce09d172a683acf6853161d9c", - "sha256:b44624a38d07d3c954c84ad302c29f7930f4bf01443beef5589e9157b14e2a29", - "sha256:be42a601aaaeb7a317f818490a39d153952a97c40c6e9beeb2a1103616405348", - "sha256:eee4d94b1a626490fc8170ffd788883f8c641b576e11ba9b4a29c9f6623371e0", - "sha256:f69d1201a4750f763971ea8364ed95ee888fc128968b39d38883a72a4d005895" - ], - "version": "==2018.2.21" - }, - "ruamel.yaml": { - "hashes": [ - "sha256:01e30ecb1b1c0ebf9fce814dc20dace402571517277799291202b61b22096c24", - "sha256:02babffd019911841ba01b76e23dfec7c9e9b2725503fb2698c4982fa1a6e835", - "sha256:072f6364a89972e8dc0afdce3335a709d5464dfeaa4f736d092a54574338b874", - "sha256:14d161558e3bf89e87d77c218098be22fa9a0d6d0bea40250fce525b1d0cbee2", - "sha256:5504398fc755a2b14c9983b2101161a8591a4b30812590cc1c365e7fcc117dfa", - "sha256:68c8f2986bcb91b6db1aea8698941769840c7257e951a9377048f7eff35be773", - "sha256:6d05c5a5baf829c70916c226ef3200650846a7227de226bca8a59efaf88bb973", - "sha256:6d7929b24e329d662fa43b657fddfee5260e2d35d0a543065cd755d4e17a9b2f", - "sha256:8dc74821e4bb6b21fb1ab35964e159391d99ee44981d07d57bf96e2395f3ef75", - "sha256:9225c83952d28f302cfc23c3d9a6f8231bfd581476d7aff1e3c7de49eecb4ee9", - "sha256:b6c5d5f03ba78e3f27c7188a00c4e09b6a4507fe3154ba40a294e09cb30ee016", - "sha256:c0908896e34b617ead40552cab03c1769bdc43d1da02419160dc900c5dfddde2", - "sha256:c41e04b526d0153c9246cfab87d7ddefdc9f165cb8886a8ec48ba7a2b73069f6", - "sha256:e2d2715bf92156bec5fb42e92e95dac1c4d9904f8a3d4e2d0c438758fe9092d7", - "sha256:e3bbfe0d294e08fdbb0cb05485435a2ceb4e168e98b5dc611f051c1864986b4b", - "sha256:f2d02a4af5a13b09d0b823cdd0317b54f3e0115e50b5ac4d9840c3a1b566817f", - "sha256:fcfc24a21594c071cc4588e84b7657a1f47ebcf6037c6c43fa15c4bbd3989ec2" - ], - "version": "==0.15.35" + "pyyaml": { + "git": "https://github.com/yaml/pyyaml.git", + "ref": "a9c28e0b521967f5330f0316edd90a57f99cdd32" }, "six": { "hashes": [ @@ -346,26 +227,20 @@ ], "version": "==1.11.0" }, - "tzlocal": { - "hashes": [ - "sha256:4ebeb848845ac898da6519b9b31879cf13b6626f7184c496037b818e238f2c4e" - ], - "version": "==1.5.1" - }, "werkzeug": { "hashes": [ "sha256:c3fd7a7d41976d9f44db327260e263132466836cef6f91512889ed60ad26557c", "sha256:d5da73735293558eb1651ee2fddc4d0dedcfa06538b8813a2e20011583c9e49b" ], "version": "==0.14.1" - }, - "whitenoise": { + } + }, + "develop": { + "rope": { "hashes": [ - "sha256:15f43b2e701821b95c9016cf469d29e2a546cb1c7dead584ba82c36f843995cf", - "sha256:9d81515f2b5b27051910996e1e860b1332e354d9e7bcf30c98f21dcb6713e0dd" + "sha256:a09edfd2034fd50099a67822f9bd851fbd0f4e98d3b87519f6267b60e50d80d1" ], - "version": "==3.3.1" + "version": "==0.10.7" } - }, - "develop": {} + } } diff --git a/Procfile b/Procfile index 9ec97240..5ab46ac6 100644 --- a/Procfile +++ b/Procfile @@ -1 +1 @@ -web: gunicorn httpbin:app +web: gunicorn httpbin:app -k gevent diff --git a/README.md b/README.md index 05954644..8148d684 100644 --- a/README.md +++ b/README.md @@ -5,25 +5,27 @@ A [Kenneth Reitz](http://kennethreitz.org/bitcoin) Project. ![ice cream](http://farm1.staticflickr.com/572/32514669683_4daf2ab7bc_k_d.jpg) +Run locally: +```sh +docker pull kennethreitz/httpbin +docker run -p 80:80 kennethreitz/httpbin +``` + See http://httpbin.org for more information. ## Officially Deployed at: - http://httpbin.org - https://httpbin.org -- http://eu.httpbin.org -- https://eu.httpbin.org - https://hub.docker.com/r/kennethreitz/httpbin/ ## SEE ALSO -- http://httpbin.org -- https://www.hurl.it - http://requestb.in - http://python-requests.org - https://grpcb.in/ ## Build Status -[![Build Status](https://travis-ci.org/kennethreitz/httpbin.svg?branch=master)](https://travis-ci.org/kennethreitz/httpbin) +[![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 34049e5e..91f42beb 100644 --- a/app.json +++ b/app.json @@ -1,7 +1,7 @@ { "name": "httpbin", "description": "HTTP Request & Response Service, written in Python + Flask.", - "repository": "https://github.com/Runscope/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"], 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 99b233e9..305c9882 100644 --- a/httpbin/core.py +++ b/httpbin/core.py @@ -15,54 +15,150 @@ import uuid import argparse -from flask import Flask, Response, request, render_template, redirect, jsonify as flask_jsonify, make_response, url_for, abort -from flask_common import Common +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 werkzeug.http import parse_authorization_header -from raven.contrib.flask import Sentry +from flasgger import Swagger, NO_SANITIZER from . import filters -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 .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' + 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')) - -# Setup Flask-Common. -common = Common(app) - -# Send app errors to Sentry. -if 'SENTRY_DSN' in os.environ: - sentry = Sentry(app, dsn=os.environ['SENTRY_DSN']) +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 @@ -72,11 +168,15 @@ def jsonify(*args, **kwargs): 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.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.") @@ -95,31 +195,40 @@ def jsonify(*args, **kwargs): - 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']) + 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 + 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', '*') - response.headers['Access-Control-Allow-Credentials'] = 'true' + response.headers["Access-Control-Allow-Origin"] = request.headers.get("Origin", "*") + response.headers["Access-Control-Allow-Credentials"] = "true" - if request.method == 'OPTIONS': + 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'] + 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 @@ -127,23 +236,41 @@ def set_cors_headers(response): # Routes # ------ -@app.route('/') + +@app.route("/legacy") def view_landing_page(): - """Generates Landing Page.""" - tracking_enabled = 'HTTPBIN_TRACKING' in os.environ - return render_template('index.html', tracking_enabled=tracking_enabled) + """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 @@ -151,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" @@ -161,227 +297,510 @@ def view_deny_page(): # return "YOU SHOULDN'T BE HERE" -@app.route('/ip') +@app.route("/ip") def view_origin(): - """Returns Origin IP.""" + """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)) + return jsonify(origin=request.headers.get("X-Forwarded-For", request.remote_addr)) -@app.route('/uuid') +@app.route("/uuid") def view_uuid(): - """Returns a UUID.""" + """Return a UUID4. + --- + tags: + - Dynamic data + produces: + - application/json + responses: + 200: + description: A UUID4. + """ return jsonify(uuid=str(uuid.uuid4())) -@app.route('/headers') +@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('/anything', methods=['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'TRACE']) -@app.route('/anything/', methods=['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'TRACE']) + """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 request data.""" - - return jsonify(get_dict('url', 'args', 'headers', 'origin', 'method', 'form', 'data', 'files', 'json')) - - -@app.route('/post', methods=('POST',)) + """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 DELETE Data.""" - - return jsonify(get_dict( - 'url', 'args', 'form', 'data', 'origin', 'headers', 'files', '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.""" + """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)) + return jsonify(get_dict("origin", "headers", method=request.method, deflated=True)) -@app.route('/brotli') +@app.route("/brotli") @filters.brotli def view_brotli_encoded_content(): - """Returns Brotli-Encoded Data.""" + """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)) + return jsonify(get_dict("origin", "headers", method=request.method, brotli=True)) -@app.route('/redirect/') +@app.route("/redirect/") def redirect_n_times(n): - """302 Redirects n times.""" + """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' + absolute = request.args.get("absolute", "false").lower() == "true" if n == 1: - return redirect(url_for('view_get', _external=absolute)) + return redirect(url_for("view_get", _external=absolute)) if absolute: - return _redirect('absolute', n, True) + return _redirect("absolute", n, True) else: - return _redirect('relative', n, False) + return _redirect("relative", n, False) def _redirect(kind, n, external): - return redirect(url_for('{0}_redirect_n_times'.format(kind), n=n - 1, _external=external)) + return redirect( + url_for("{0}_redirect_n_times".format(kind), n=n - 1, _external=external) + ) -@app.route('/redirect-to', methods=['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'TRACE']) +@app.route("/redirect-to", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "TRACE"]) def redirect_to(): - """302/3XX 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 - if 'status_code' in args: - status_code = int(args['status_code']) + 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') + response.headers["Location"] = args["url"].encode("utf-8") return response -@app.route('/relative-redirect/') +@app.route("/relative-redirect/") def relative_redirect_n_times(n): - """302 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'] = url_for('view_get') + response.headers["Location"] = url_for("view_get") return response - response.headers['Location'] = url_for('relative_redirect_n_times', n=n - 1) + response.headers["Location"] = url_for("relative_redirect_n_times", n=n - 1) return response -@app.route('/absolute-redirect/') +@app.route("/absolute-redirect/") def absolute_redirect_n_times(n): - """302 Redirects n times.""" + """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(url_for("view_get", _external=True)) - return _redirect('absolute', n, True) + return _redirect("absolute", n, True) -@app.route('/stream/') +@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={ - "Content-Type": "application/json", - }) + return Response(generate_stream(), headers={"Content-Type": "application/json"}) -@app.route('/status/', methods=['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'TRACE']) +@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: + """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 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(":") try: choices.append((int(code), float(weight))) except ValueError: - return Response('Invalid status code', status=400) + return Response("Invalid status code", status=400) code = weighted_choice(choices) return status_code(code) -@app.route('/response-headers', methods=['GET', 'POST']) +@app.route("/response-headers", methods=["GET", "POST"]) def response_headers(): - """Returns a set of response headers from the query string """ + """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())) @@ -402,13 +821,22 @@ def response_headers(): 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] @@ -418,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(url_for('view_cookies'))) + """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(url_for('view_cookies'))) + r = app.make_response(redirect(url_for("view_cookies"))) for key, value in cookies.items(): 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(url_for('view_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) @@ -469,223 +969,511 @@ 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('/bearer') +@app.route("/bearer") def bearer_auth(): - """Authenticates using bearer authentication.""" - if 'Authorization' not in request.headers: - response = app.make_response('') - response.headers['WWW-Authenticate'] = 'Bearer' + """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 - authorization = request.headers.get('Authorization') - token = authorization.lstrip('Bearer ') + 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'): - return digest_auth(qop, user, passwd, "MD5", 'never') - - -@app.route('/digest-auth////') -def digest_auth_nostale(qop=None, user='user', passwd='passwd', algorithm='MD5'): - 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 HTTP Digest auth""" - 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'): +@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 - authorization = request.headers.get('Authorization') + 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)): + 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') + response.set_cookie("stale_after", value=stale_after) + response.set_cookie("fake", value="fake_value") return response - 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') + 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') + 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'): + 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') + 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') + 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)) + 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""" + """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 = min(int(args.get('numbytes', 10)),(10 * 1024 * 1024)) # set 10MB limit - code = int(args.get('code', 200)) + 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) + 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) - response = Response(generate_bytes(), headers={ - "Content-Type": "application/octet-stream", - "Content-Length": str(numbytes), - }) + response = Response( + generate_bytes(), + headers={ + "Content-Type": "application/octet-stream", + "Content-Length": str(numbytes), + }, + ) response.status_code = code return response -@app.route('/base64/') + +@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') + """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',)) +@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('/etag/', methods=('GET',)) + +@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.""" - if_none_match = parse_multi_value_header(request.headers.get('If-None-Match')) - if_match = parse_multi_value_header(request.headers.get('If-Match')) + """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: + if etag in if_none_match or "*" in if_none_match: response = status_code(304) - response.headers['ETag'] = etag + response.headers["ETag"] = etag return response elif if_match: - if etag not in if_match and '*' not in 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 + response.headers["ETag"] = etag return response -@app.route('/cache/') + +@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('/encoding/utf8') +@app.route("/encoding/utf8") def encoding(): - return render_template('UTF-8-demo.txt') + """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/') + +@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() # 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' + 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 @@ -695,49 +1483,68 @@ def generate_bytes(): for i in xrange(n): chunks.append(random.randint(0, 255)) if len(chunks) == chunk_size: - yield(bytes(chunks)) + yield (bytes(chunks)) chunks = bytearray() if chunks: - yield(bytes(chunks)) + yield (bytes(chunks)) - headers = {'Content-Type': 'application/octet-stream'} + headers = {"Content-Type": "application/octet-stream"} return Response(generate_bytes(), headers=headers) -@app.route('/range/') + +@app.route("/range/") def range_request(numbytes): - """Streams n random bytes generated with given seed, at given chunk size per packet.""" + """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 = 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]' + response.data = "number of bytes must be in the range (0, 102400]" return response params = CaseInsensitiveDict(request.args.items()) - 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 - duration = float(params.get('duration', 0)) + 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', - }) + 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 @@ -748,23 +1555,23 @@ def generate_bytes(): # 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)) + chunks.append(ord("a") + (i % 26)) if len(chunks) == chunk_size: - yield(bytes(chunks)) + yield (bytes(chunks)) time.sleep(pause_per_byte * chunk_size) chunks = bytearray() if chunks: time.sleep(pause_per_byte * len(chunks)) - yield(bytes(chunks)) + yield (bytes(chunks)) - content_range = 'bytes %d-%d/%d' % (first_byte_pos, last_byte_pos, numbytes) + 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 + "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) @@ -776,91 +1583,202 @@ def generate_bytes(): return response -@app.route('/links//') + +@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 + """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'] + html = ["Links"] for i in xrange(n): if i == offset: html.append("{0} ".format(i)) else: - html.append(link.format(url_for('link_page', n=n, offset=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(url_for('link_page', n=n, offset=0)) + 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 'accept' not in headers: - return image_png() # Default media type to png + if "accept" not in headers: + return image_png() # Default media type to png - accept = headers['accept'].lower() + accept = headers["accept"].lower() - if 'image/webp' in accept: + if "image/webp" in accept: return image_webp() - elif 'image/svg+xml' in accept: + elif "image/svg+xml" in accept: return image_svg() - elif 'image/jpeg' in accept: + elif "image/jpeg" in accept: return image_jpeg() - elif 'image/png' in accept or 'image/*' in accept: + elif "image/png" in accept or "image/*" in accept: return image_png() else: - return status_code(406) # Unsupported media type + return status_code(406) # Unsupported media type -@app.route('/image/png') +@app.route("/image/png") def image_png(): - data = resource('images/pig_icon.png') - return Response(data, headers={'Content-Type': 'image/png'}) - - -@app.route('/image/jpeg') + """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(): - data = resource('images/jackal.jpg') - return Response(data, headers={'Content-Type': 'image/jpeg'}) - - -@app.route('/image/webp') + """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(): - data = resource('images/wolf_1.webp') - return Response(data, headers={'Content-Type': 'image/webp'}) - - -@app.route('/image/svg') + """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(): - data = resource('images/svg_logo.svg') - return Response(data, headers={'Content-Type': 'image/svg+xml'}) + """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) - return open(path, 'rb').read() + 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.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") diff --git a/httpbin/helpers.py b/httpbin/helpers.py index 1983c068..b29e1835 100644 --- a/httpbin/helpers.py +++ b/httpbin/helpers.py @@ -48,7 +48,6 @@ 'X-Heroku-Queue-Wait-Time', 'X-Forwarded-For', 'X-Heroku-Dynos-In-Use', - 'X-Forwarded-For', 'X-Forwarded-Protocol', 'X-Forwarded-Port', 'X-Request-Id', @@ -288,7 +287,7 @@ def HA1(realm, username, password, algorithm): password.encode('utf-8')]), algorithm) -def HA2(credentails, request, algorithm): +def HA2(credentials, request, algorithm): """Create HA2 md5 hash If the qop directive's value is "auth" or is unspecified, then HA2: @@ -296,9 +295,9 @@ def HA2(credentails, request, algorithm): 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: + 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 credentails.get("qop") == "auth-int": + elif credentials.get("qop") == "auth-int": for k in 'method', 'uri', 'body': if k not in request: raise ValueError("%s required" % k) @@ -309,7 +308,7 @@ def HA2(credentails, request, 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: @@ -318,34 +317,34 @@ 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 = credentails.get('algorithm') + algorithm = credentials.get('algorithm') HA1_value = HA1( - credentails.get('realm'), - credentails.get('username'), + credentials.get('realm'), + credentials.get('username'), password, algorithm ) - HA2_value = HA2(credentails, request, algorithm) - 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'), + credentials.get('nonce', '').encode('utf-8'), HA2_value.encode('utf-8') ]), algorithm) - elif credentails.get('qop') == 'auth' or credentails.get('qop') == 'auth-int': + 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'), + 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") @@ -357,16 +356,16 @@ 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 request_uri = request.script_root + request.path if request.query_string: request_uri += '?' + request.query_string - response_hash = response(credentails, passwd, dict(uri=request_uri, + response_hash = response(credentials, passwd, dict(uri=request_uri, body=request.data, method=request.method)) - if credentails.get('response') == response_hash: + if credentials.get('response') == response_hash: return True return False 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/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/httpbin.1.html b/httpbin/templates/httpbin.1.html index 5793d82e..0d0c8386 100644 --- a/httpbin/templates/httpbin.1.html +++ b/httpbin/templates/httpbin.1.html @@ -1,6 +1,6 @@

httpbin(1): HTTP Request & Response Service

-

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

+

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

BONUSPOINTS

diff --git a/httpbin/templates/index.html b/httpbin/templates/index.html index d466f3ad..0348536f 100644 --- a/httpbin/templates/index.html +++ b/httpbin/templates/index.html @@ -1,64 +1,251 @@ + httpbin(1): HTTP Client Testing Service -Fork me on GitHub + + + -{% include 'httpbin.1.html' %} - -{% if tracking_enabled %} - {% include 'trackingscripts.html' %} -{% endif %} + {% include 'httpbin.1.html' %} {% if tracking_enabled %} {% include 'trackingscripts.html' %} {% endif %} + diff --git a/httpbin/templates/trackingscripts.html b/httpbin/templates/trackingscripts.html index 4fd235ef..080bbcd4 100644 --- a/httpbin/templates/trackingscripts.html +++ b/httpbin/templates/trackingscripts.html @@ -1,15 +1,6 @@ {# place tracking scripts (like Google Analytics) here #} -