├── runtime.txt ├── httpbin ├── VERSION ├── __init__.py ├── static │ └── favicon.ico ├── templates │ ├── images │ │ ├── jackal.jpg │ │ ├── pig_icon.png │ │ ├── wolf_1.webp │ │ └── svg_logo.svg │ ├── footer.html │ ├── sample.xml │ ├── trackingscripts.html │ ├── forms-post.html │ ├── moby.html │ ├── index.html │ ├── UTF-8-demo.txt │ ├── flasgger │ │ └── index.html │ └── httpbin.1.html ├── utils.py ├── structures.py ├── filters.py ├── helpers.py └── core.py ├── .dockerignore ├── Procfile ├── setup.cfg ├── docker-compose.yml ├── .gitignore ├── MANIFEST.in ├── now.json ├── tox.ini ├── Pipfile ├── .travis.yml ├── app.json ├── Dockerfile ├── AUTHORS ├── LICENSE ├── README.md ├── setup.py ├── Pipfile.lock └── test_httpbin.py /runtime.txt: -------------------------------------------------------------------------------- 1 | python-3.6.5 -------------------------------------------------------------------------------- /httpbin/VERSION: -------------------------------------------------------------------------------- 1 | 0.9.2 2 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | Dockerfile 2 | .git 3 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: gunicorn httpbin:app -k gevent 2 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal = 1 3 | -------------------------------------------------------------------------------- /httpbin/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from .core import * 4 | -------------------------------------------------------------------------------- /httpbin/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/postmanlabs/httpbin/master/httpbin/static/favicon.ico -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | services: 3 | httpbin: 4 | build: '.' 5 | ports: 6 | - '80:80' -------------------------------------------------------------------------------- /httpbin/templates/images/jackal.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/postmanlabs/httpbin/master/httpbin/templates/images/jackal.jpg -------------------------------------------------------------------------------- /httpbin/templates/images/pig_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/postmanlabs/httpbin/master/httpbin/templates/images/pig_icon.png -------------------------------------------------------------------------------- /httpbin/templates/images/wolf_1.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/postmanlabs/httpbin/master/httpbin/templates/images/wolf_1.webp -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | env/ 2 | build/ 3 | dist/ 4 | .eggs/ 5 | .workon 6 | .epio-app 7 | *.pyc 8 | .tox 9 | *.egg-info 10 | *.swp 11 | .vscode/ 12 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include httpbin/VERSION README.md LICENSE AUTHORS test_httpbin.py 2 | recursive-include httpbin/templates * 3 | recursive-include httpbin/static * 4 | -------------------------------------------------------------------------------- /now.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "httpbin", 3 | "regions": [ 4 | "all" 5 | ], 6 | "alias": [ 7 | "httpbin.org" 8 | ], 9 | "type": "docker" 10 | } 11 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py27,py36,py37 3 | 4 | [testenv] 5 | commands=python test_httpbin.py 6 | 7 | [testenv:release] 8 | skipdist = true 9 | usedevelop = false 10 | deps = 11 | twine>=1.6.0 12 | wheel 13 | commands = 14 | python setup.py sdist bdist_wheel 15 | twine upload --skip-existing dist/* 16 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | url = "https://pypi.python.org/simple" 3 | verify_ssl = true 4 | 5 | [packages] 6 | gunicorn = "*" 7 | decorator = "*" 8 | brotlipy = "*" 9 | gevent = "*" 10 | Flask = "*" 11 | meinheld = "*" 12 | werkzeug = ">=0.14.1" 13 | six = "*" 14 | flasgger = "*" 15 | pyyaml = {git = "https://github.com/yaml/pyyaml.git"} 16 | 17 | [dev-packages] 18 | rope = "*" 19 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | dist: trusty 2 | language: python 3 | 4 | sudo: false 5 | matrix: 6 | include: 7 | - python: 2.7 8 | env: TOXENV=py27 9 | - python: 3.6 10 | env: TOXENV=py36 11 | - python: 3.7 12 | env: TOXENV=py37 13 | dist: xenial 14 | sudo: true 15 | 16 | install: 17 | - travis_retry pip install tox 18 | 19 | script: 20 | - tox 21 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "httpbin", 3 | "description": "HTTP Request & Response Service, written in Python + Flask.", 4 | "repository": "https://github.com/requests/httpbin", 5 | "website": "https://httpbin.org", 6 | "logo": "https://s3.amazonaws.com/f.cl.ly/items/333Y191Z2C0G2J3m3Y0b/httpbin.svg", 7 | "keywords": ["http", "rest", "API", "testing", "integration", "python", "flask"], 8 | "addons": "sentry" 9 | } 10 | -------------------------------------------------------------------------------- /httpbin/templates/footer.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |
5 | 6 |

Other Utilities

7 | 8 |
    9 |
  • 10 | HTML form that posts to /post /forms/post
  • 11 |
12 | 13 |
14 |
15 |
16 |
17 |
18 |
19 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ubuntu:18.04 2 | 3 | LABEL name="httpbin" 4 | LABEL version="0.9.2" 5 | LABEL description="A simple HTTP service." 6 | LABEL org.kennethreitz.vendor="Kenneth Reitz" 7 | 8 | ENV LC_ALL=C.UTF-8 9 | ENV LANG=C.UTF-8 10 | 11 | RUN apt update -y && apt install python3-pip git -y && pip3 install --no-cache-dir pipenv 12 | 13 | ADD Pipfile Pipfile.lock /httpbin/ 14 | WORKDIR /httpbin 15 | RUN /bin/bash -c "pip3 install --no-cache-dir -r <(pipenv lock -r)" 16 | 17 | ADD . /httpbin 18 | RUN pip3 install --no-cache-dir /httpbin 19 | 20 | EXPOSE 80 21 | 22 | CMD ["gunicorn", "-b", "0.0.0.0:80", "httpbin:app", "-k", "gevent"] 23 | -------------------------------------------------------------------------------- /httpbin/templates/sample.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 10 | 11 | 12 | 13 | Wake up to WonderWidgets! 14 | 15 | 16 | 17 | 18 | Overview 19 | Why WonderWidgets are great 20 | 21 | Who buys WonderWidgets 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /httpbin/templates/trackingscripts.html: -------------------------------------------------------------------------------- 1 | {# 2 | place tracking scripts (like Google Analytics) here 3 | #} 4 | 5 | -------------------------------------------------------------------------------- /httpbin/utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | httpbin.utils 5 | ~~~~~~~~~~~~~~~ 6 | 7 | Utility functions. 8 | """ 9 | 10 | import random 11 | import bisect 12 | 13 | 14 | def weighted_choice(choices): 15 | """Returns a value from choices chosen by weighted random selection 16 | 17 | choices should be a list of (value, weight) tuples. 18 | 19 | eg. weighted_choice([('val1', 5), ('val2', 0.3), ('val3', 1)]) 20 | 21 | """ 22 | values, weights = zip(*choices) 23 | total = 0 24 | cum_weights = [] 25 | for w in weights: 26 | total += w 27 | cum_weights.append(total) 28 | x = random.uniform(0, total) 29 | i = bisect.bisect(cum_weights, x) 30 | return values[i] 31 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | HttpBin is written and maintained by Kenneth Reitz and 2 | various contributors: 3 | 4 | Development Lead 5 | ```````````````` 6 | 7 | - Kenneth Reitz <_@kennethreitz.com> 8 | 9 | 10 | Patches and Suggestions 11 | ``````````````````````` 12 | 13 | - Zbigniew Siciarz 14 | - Andrey Petrov 15 | - Lispython 16 | - Kyle Conroy 17 | - Flavio Percoco 18 | - Radomir Stevanovic (http://github.com/randomir) 19 | - Steven Honson 20 | - Bob Carroll @rcarz 21 | - Cory Benfield (Lukasa) 22 | - Matt Robenolt (https://github.com/mattrobenolt) 23 | - Dave Challis (https://github.com/davechallis) 24 | - Florian Bruhin (https://github.com/The-Compiler) 25 | - Brett Randall (https://github.com/javabrett) 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | ISC License 2 | 3 | Copyright (c) 2017 Kenneth Reitz. 4 | 5 | Permission to use, copy, modify, and/or distribute this software for any 6 | purpose with or without fee is hereby granted, provided that the above 7 | copyright notice and this permission notice appear in all copies. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 10 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 11 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 12 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 13 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 14 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 15 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # httpbin(1): HTTP Request & Response Service 2 | 3 | 4 | A [Kenneth Reitz](http://kennethreitz.org/bitcoin) Project. 5 | 6 | ![ice cream](http://farm1.staticflickr.com/572/32514669683_4daf2ab7bc_k_d.jpg) 7 | 8 | Run locally: 9 | ```sh 10 | docker pull kennethreitz/httpbin 11 | docker run -p 80:80 kennethreitz/httpbin 12 | ``` 13 | 14 | See http://httpbin.org for more information. 15 | 16 | ## Officially Deployed at: 17 | 18 | - http://httpbin.org 19 | - https://httpbin.org 20 | - https://hub.docker.com/r/kennethreitz/httpbin/ 21 | 22 | 23 | ## SEE ALSO 24 | 25 | - http://requestb.in 26 | - http://python-requests.org 27 | - https://grpcb.in/ 28 | 29 | ## Build Status 30 | 31 | [![Build Status](https://travis-ci.org/requests/httpbin.svg?branch=master)](https://travis-ci.org/requests/httpbin) 32 | -------------------------------------------------------------------------------- /httpbin/structures.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | httpbin.structures 5 | ~~~~~~~~~~~~~~~~~~~ 6 | 7 | Data structures that power httpbin. 8 | """ 9 | 10 | 11 | class CaseInsensitiveDict(dict): 12 | """Case-insensitive Dictionary for headers. 13 | 14 | For example, ``headers['content-encoding']`` will return the 15 | value of a ``'Content-Encoding'`` response header. 16 | """ 17 | 18 | def _lower_keys(self): 19 | return [k.lower() for k in self.keys()] 20 | 21 | def __contains__(self, key): 22 | return key.lower() in self._lower_keys() 23 | 24 | def __getitem__(self, key): 25 | # We allow fall-through here, so values default to None 26 | if key in self: 27 | return list(self.items())[self._lower_keys().index(key.lower())][1] 28 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | import os 3 | import io 4 | 5 | 6 | with open(os.path.join(os.path.realpath(os.path.dirname(__file__)), 'httpbin', 'VERSION')) as version_file: 7 | version = version_file.read().strip() 8 | 9 | setup( 10 | name="httpbin", 11 | version=version, 12 | description="HTTP Request and Response Service", 13 | long_description="A simple HTTP Request & Response Service, written in Python + Flask.", 14 | 15 | # The project URL. 16 | url='https://github.com/requests/httpbin', 17 | 18 | # Author details 19 | author='Kenneth Reitz', 20 | author_email='me@kennethreitz.org', 21 | 22 | # Choose your license 23 | license='MIT', 24 | 25 | classifiers=[ 26 | 'Development Status :: 5 - Production/Stable', 27 | 'Intended Audience :: Developers', 28 | 'Natural Language :: English', 29 | 'License :: OSI Approved :: MIT License', 30 | 'Programming Language :: Python', 31 | 'Programming Language :: Python :: 2.7', 32 | 'Programming Language :: Python :: 3.6', 33 | ], 34 | test_suite="test_httpbin", 35 | packages=find_packages(), 36 | include_package_data = True, # include files listed in MANIFEST.in 37 | install_requires=[ 38 | 'Flask', 'MarkupSafe', 'decorator', 'itsdangerous', 'six', 'brotlipy', 39 | 'raven[flask]', 'werkzeug>=0.14.1', 'gevent', 'flasgger' 40 | ], 41 | ) 42 | -------------------------------------------------------------------------------- /httpbin/templates/forms-post.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 |

9 |

10 |

11 |
12 | Pizza Size 13 |

14 |

15 |

16 |
17 |
18 | Pizza Toppings 19 |

20 |

21 |

22 |

23 |
24 |

25 |

26 |

27 |
28 | 29 | 30 | -------------------------------------------------------------------------------- /httpbin/filters.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | httpbin.filters 5 | ~~~~~~~~~~~~~~~ 6 | 7 | This module provides response filter decorators. 8 | """ 9 | 10 | import gzip as gzip2 11 | import zlib 12 | 13 | import brotli as _brotli 14 | 15 | from six import BytesIO 16 | from decimal import Decimal 17 | from time import time as now 18 | 19 | from decorator import decorator 20 | from flask import Flask, Response 21 | 22 | 23 | app = Flask(__name__) 24 | 25 | 26 | @decorator 27 | def x_runtime(f, *args, **kwargs): 28 | """X-Runtime Flask Response Decorator.""" 29 | 30 | _t0 = now() 31 | r = f(*args, **kwargs) 32 | _t1 = now() 33 | r.headers['X-Runtime'] = '{0}s'.format(Decimal(str(_t1 - _t0))) 34 | 35 | return r 36 | 37 | 38 | @decorator 39 | def gzip(f, *args, **kwargs): 40 | """GZip Flask Response Decorator.""" 41 | 42 | data = f(*args, **kwargs) 43 | 44 | if isinstance(data, Response): 45 | content = data.data 46 | else: 47 | content = data 48 | 49 | gzip_buffer = BytesIO() 50 | gzip_file = gzip2.GzipFile( 51 | mode='wb', 52 | compresslevel=4, 53 | fileobj=gzip_buffer 54 | ) 55 | gzip_file.write(content) 56 | gzip_file.close() 57 | 58 | gzip_data = gzip_buffer.getvalue() 59 | 60 | if isinstance(data, Response): 61 | data.data = gzip_data 62 | data.headers['Content-Encoding'] = 'gzip' 63 | data.headers['Content-Length'] = str(len(data.data)) 64 | 65 | return data 66 | 67 | return gzip_data 68 | 69 | 70 | @decorator 71 | def deflate(f, *args, **kwargs): 72 | """Deflate Flask Response Decorator.""" 73 | 74 | data = f(*args, **kwargs) 75 | 76 | if isinstance(data, Response): 77 | content = data.data 78 | else: 79 | content = data 80 | 81 | deflater = zlib.compressobj() 82 | deflated_data = deflater.compress(content) 83 | deflated_data += deflater.flush() 84 | 85 | if isinstance(data, Response): 86 | data.data = deflated_data 87 | data.headers['Content-Encoding'] = 'deflate' 88 | data.headers['Content-Length'] = str(len(data.data)) 89 | 90 | return data 91 | 92 | return deflated_data 93 | 94 | 95 | @decorator 96 | def brotli(f, *args, **kwargs): 97 | """Brotli Flask Response Decorator""" 98 | 99 | data = f(*args, **kwargs) 100 | 101 | if isinstance(data, Response): 102 | content = data.data 103 | else: 104 | content = data 105 | 106 | deflated_data = _brotli.compress(content) 107 | 108 | if isinstance(data, Response): 109 | data.data = deflated_data 110 | data.headers['Content-Encoding'] = 'br' 111 | data.headers['Content-Length'] = str(len(data.data)) 112 | 113 | return data 114 | 115 | return deflated_data 116 | -------------------------------------------------------------------------------- /httpbin/templates/moby.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 |

Herman Melville - Moby-Dick

7 | 8 |
9 |

10 | Availing himself of the mild, summer-cool weather that now reigned in these latitudes, and in preparation for the peculiarly active pursuits shortly to be anticipated, Perth, the begrimed, blistered old blacksmith, had not removed his portable forge to the hold again, after concluding his contributory work for Ahab's leg, but still retained it on deck, fast lashed to ringbolts by the foremast; being now almost incessantly invoked by the headsmen, and harpooneers, and bowsmen to do some little job for them; altering, or repairing, or new shaping their various weapons and boat furniture. Often he would be surrounded by an eager circle, all waiting to be served; holding boat-spades, pike-heads, harpoons, and lances, and jealously watching his every sooty movement, as he toiled. Nevertheless, this old man's was a patient hammer wielded by a patient arm. No murmur, no impatience, no petulance did come from him. Silent, slow, and solemn; bowing over still further his chronically broken back, he toiled away, as if toil were life itself, and the heavy beating of his hammer the heavy beating of his heart. And so it was.—Most miserable! A peculiar walk in this old man, a certain slight but painful appearing yawing in his gait, had at an early period of the voyage excited the curiosity of the mariners. And to the importunity of their persisted questionings he had finally given in; and so it came to pass that every one now knew the shameful story of his wretched fate. Belated, and not innocently, one bitter winter's midnight, on the road running between two country towns, the blacksmith half-stupidly felt the deadly numbness stealing over him, and sought refuge in a leaning, dilapidated barn. The issue was, the loss of the extremities of both feet. Out of this revelation, part by part, at last came out the four acts of the gladness, and the one long, and as yet uncatastrophied fifth act of the grief of his life's drama. He was an old man, who, at the age of nearly sixty, had postponedly encountered that thing in sorrow's technicals called ruin. He had been an artisan of famed excellence, and with plenty to do; owned a house and garden; embraced a youthful, daughter-like, loving wife, and three blithe, ruddy children; every Sunday went to a cheerful-looking church, planted in a grove. But one night, under cover of darkness, and further concealed in a most cunning disguisement, a desperate burglar slid into his happy home, and robbed them all of everything. And darker yet to tell, the blacksmith himself did ignorantly conduct this burglar into his family's heart. It was the Bottle Conjuror! Upon the opening of that fatal cork, forth flew the fiend, and shrivelled up his home. Now, for prudent, most wise, and economic reasons, the blacksmith's shop was in the basement of his dwelling, but with a separate entrance to it; so that always had the young and loving healthy wife listened with no unhappy nervousness, but with vigorous pleasure, to the stout ringing of her young-armed old husband's hammer; whose reverberations, muffled by passing through the floors and walls, came up to her, not unsweetly, in her nursery; and so, to stout Labor's iron lullaby, the blacksmith's infants were rocked to slumber. Oh, woe on woe! Oh, Death, why canst thou not sometimes be timely? Hadst thou taken this old blacksmith to thyself ere his full ruin came upon him, then had the young widow had a delicious grief, and her orphans a truly venerable, legendary sire to dream of in their after years; and all of them a care-killing competency. 11 |

12 |
13 | 14 | 15 | -------------------------------------------------------------------------------- /httpbin/templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | httpbin(1): HTTP Client Testing Service 8 | 211 | 231 | 232 | 233 | 234 | 235 | 243 | 244 | 245 | 246 | 247 | {% include 'httpbin.1.html' %} {% if tracking_enabled %} {% include 'trackingscripts.html' %} {% endif %} 248 | 249 | 250 | 251 | 252 | -------------------------------------------------------------------------------- /httpbin/templates/UTF-8-demo.txt: -------------------------------------------------------------------------------- 1 |

Unicode Demo

2 | 3 |

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

5 | 6 |
  7 | 
  8 | UTF-8 encoded sample plain-text file
  9 | ‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾
 10 | 
 11 | Markus Kuhn [ˈmaʳkʊs kuːn]  — 2002-07-25
 12 | 
 13 | 
 14 | The ASCII compatible UTF-8 encoding used in this plain-text file
 15 | is defined in Unicode, ISO 10646-1, and RFC 2279.
 16 | 
 17 | 
 18 | Using Unicode/UTF-8, you can write in emails and source code things such as
 19 | 
 20 | Mathematics and sciences:
 21 | 
 22 |   ∮ E⋅da = Q,  n → ∞, ∑ f(i) = ∏ g(i),      ⎧⎡⎛┌─────┐⎞⎤⎫
 23 |                                             ⎪⎢⎜│a²+b³ ⎟⎥⎪
 24 |   ∀x∈ℝ: ⌈x⌉ = −⌊−x⌋, α ∧ ¬β = ¬(¬α ∨ β),    ⎪⎢⎜│───── ⎟⎥⎪
 25 |                                             ⎪⎢⎜⎷ c₈   ⎟⎥⎪
 26 |   ℕ ⊆ ℕ₀ ⊂ ℤ ⊂ ℚ ⊂ ℝ ⊂ ℂ,                   ⎨⎢⎜       ⎟⎥⎬
 27 |                                             ⎪⎢⎜ ∞     ⎟⎥⎪
 28 |   ⊥ < a ≠ b ≡ c ≤ d ≪ ⊤ ⇒ (⟦A⟧ ⇔ ⟪B⟫),      ⎪⎢⎜ ⎲     ⎟⎥⎪
 29 |                                             ⎪⎢⎜ ⎳aⁱ-bⁱ⎟⎥⎪
 30 |   2H₂ + O₂ ⇌ 2H₂O, R = 4.7 kΩ, ⌀ 200 mm     ⎩⎣⎝i=1    ⎠⎦⎭
 31 | 
 32 | Linguistics and dictionaries:
 33 | 
 34 |   ði ıntəˈnæʃənəl fəˈnɛtık əsoʊsiˈeıʃn
 35 |   Y [ˈʏpsilɔn], Yen [jɛn], Yoga [ˈjoːgɑ]
 36 | 
 37 | APL:
 38 | 
 39 |   ((V⍳V)=⍳⍴V)/V←,V    ⌷←⍳→⍴∆∇⊃‾⍎⍕⌈
 40 | 
 41 | Nicer typography in plain text files:
 42 | 
 43 |   ╔══════════════════════════════════════════╗
 44 |   ║                                          ║
 45 |   ║   • ‘single’ and “double” quotes         ║
 46 |   ║                                          ║
 47 |   ║   • Curly apostrophes: “We’ve been here” ║
 48 |   ║                                          ║
 49 |   ║   • Latin-1 apostrophe and accents: '´`  ║
 50 |   ║                                          ║
 51 |   ║   • ‚deutsche‘ „Anführungszeichen“       ║
 52 |   ║                                          ║
 53 |   ║   • †, ‡, ‰, •, 3–4, —, −5/+5, ™, …      ║
 54 |   ║                                          ║
 55 |   ║   • ASCII safety test: 1lI|, 0OD, 8B     ║
 56 |   ║                      ╭─────────╮         ║
 57 |   ║   • the euro symbol: │ 14.95 € │         ║
 58 |   ║                      ╰─────────╯         ║
 59 |   ╚══════════════════════════════════════════╝
 60 | 
 61 | Combining characters:
 62 | 
 63 |   STARGΛ̊TE SG-1, a = v̇ = r̈, a⃑ ⊥ b⃑
 64 | 
 65 | Greek (in Polytonic):
 66 | 
 67 |   The Greek anthem:
 68 | 
 69 |   Σὲ γνωρίζω ἀπὸ τὴν κόψη
 70 |   τοῦ σπαθιοῦ τὴν τρομερή,
 71 |   σὲ γνωρίζω ἀπὸ τὴν ὄψη
 72 |   ποὺ μὲ βία μετράει τὴ γῆ.
 73 | 
 74 |   ᾿Απ᾿ τὰ κόκκαλα βγαλμένη
 75 |   τῶν ῾Ελλήνων τὰ ἱερά
 76 |   καὶ σὰν πρῶτα ἀνδρειωμένη
 77 |   χαῖρε, ὦ χαῖρε, ᾿Ελευθεριά!
 78 | 
 79 |   From a speech of Demosthenes in the 4th century BC:
 80 | 
 81 |   Οὐχὶ ταὐτὰ παρίσταταί μοι γιγνώσκειν, ὦ ἄνδρες ᾿Αθηναῖοι,
 82 |   ὅταν τ᾿ εἰς τὰ πράγματα ἀποβλέψω καὶ ὅταν πρὸς τοὺς
 83 |   λόγους οὓς ἀκούω· τοὺς μὲν γὰρ λόγους περὶ τοῦ
 84 |   τιμωρήσασθαι Φίλιππον ὁρῶ γιγνομένους, τὰ δὲ πράγματ᾿
 85 |   εἰς τοῦτο προήκοντα,  ὥσθ᾿ ὅπως μὴ πεισόμεθ᾿ αὐτοὶ
 86 |   πρότερον κακῶς σκέψασθαι δέον. οὐδέν οὖν ἄλλο μοι δοκοῦσιν
 87 |   οἱ τὰ τοιαῦτα λέγοντες ἢ τὴν ὑπόθεσιν, περὶ ἧς βουλεύεσθαι,
 88 |   οὐχὶ τὴν οὖσαν παριστάντες ὑμῖν ἁμαρτάνειν. ἐγὼ δέ, ὅτι μέν
 89 |   ποτ᾿ ἐξῆν τῇ πόλει καὶ τὰ αὑτῆς ἔχειν ἀσφαλῶς καὶ Φίλιππον
 90 |   τιμωρήσασθαι, καὶ μάλ᾿ ἀκριβῶς οἶδα· ἐπ᾿ ἐμοῦ γάρ, οὐ πάλαι
 91 |   γέγονεν ταῦτ᾿ ἀμφότερα· νῦν μέντοι πέπεισμαι τοῦθ᾿ ἱκανὸν
 92 |   προλαβεῖν ἡμῖν εἶναι τὴν πρώτην, ὅπως τοὺς συμμάχους
 93 |   σώσομεν. ἐὰν γὰρ τοῦτο βεβαίως ὑπάρξῃ, τότε καὶ περὶ τοῦ
 94 |   τίνα τιμωρήσεταί τις καὶ ὃν τρόπον ἐξέσται σκοπεῖν· πρὶν δὲ
 95 |   τὴν ἀρχὴν ὀρθῶς ὑποθέσθαι, μάταιον ἡγοῦμαι περὶ τῆς
 96 |   τελευτῆς ὁντινοῦν ποιεῖσθαι λόγον.
 97 | 
 98 |   Δημοσθένους, Γ´ ᾿Ολυνθιακὸς
 99 | 
100 | Georgian:
101 | 
102 |   From a Unicode conference invitation:
103 | 
104 |   გთხოვთ ახლავე გაიაროთ რეგისტრაცია Unicode-ის მეათე საერთაშორისო
105 |   კონფერენციაზე დასასწრებად, რომელიც გაიმართება 10-12 მარტს,
106 |   ქ. მაინცში, გერმანიაში. კონფერენცია შეჰკრებს ერთად მსოფლიოს
107 |   ექსპერტებს ისეთ დარგებში როგორიცაა ინტერნეტი და Unicode-ი,
108 |   ინტერნაციონალიზაცია და ლოკალიზაცია, Unicode-ის გამოყენება
109 |   ოპერაციულ სისტემებსა, და გამოყენებით პროგრამებში, შრიფტებში,
110 |   ტექსტების დამუშავებასა და მრავალენოვან კომპიუტერულ სისტემებში.
111 | 
112 | Russian:
113 | 
114 |   From a Unicode conference invitation:
115 | 
116 |   Зарегистрируйтесь сейчас на Десятую Международную Конференцию по
117 |   Unicode, которая состоится 10-12 марта 1997 года в Майнце в Германии.
118 |   Конференция соберет широкий круг экспертов по  вопросам глобального
119 |   Интернета и Unicode, локализации и интернационализации, воплощению и
120 |   применению Unicode в различных операционных системах и программных
121 |   приложениях, шрифтах, верстке и многоязычных компьютерных системах.
122 | 
123 | Thai (UCS Level 2):
124 | 
125 |   Excerpt from a poetry on The Romance of The Three Kingdoms (a Chinese
126 |   classic 'San Gua'):
127 | 
128 |   [----------------------------|------------------------]
129 |     ๏ แผ่นดินฮั่นเสื่อมโทรมแสนสังเวช  พระปกเกศกองบู๊กู้ขึ้นใหม่
130 |   สิบสองกษัตริย์ก่อนหน้าแลถัดไป       สององค์ไซร้โง่เขลาเบาปัญญา
131 |     ทรงนับถือขันทีเป็นที่พึ่ง           บ้านเมืองจึงวิปริตเป็นนักหนา
132 |   โฮจิ๋นเรียกทัพทั่วหัวเมืองมา         หมายจะฆ่ามดชั่วตัวสำคัญ
133 |     เหมือนขับไสไล่เสือจากเคหา      รับหมาป่าเข้ามาเลยอาสัญ
134 |   ฝ่ายอ้องอุ้นยุแยกให้แตกกัน          ใช้สาวนั้นเป็นชนวนชื่นชวนใจ
135 |     พลันลิฉุยกุยกีกลับก่อเหตุ          ช่างอาเพศจริงหนาฟ้าร้องไห้
136 |   ต้องรบราฆ่าฟันจนบรรลัย           ฤๅหาใครค้ำชูกู้บรรลังก์ ฯ
137 | 
138 |   (The above is a two-column text. If combining characters are handled
139 |   correctly, the lines of the second column should be aligned with the
140 |   | character above.)
141 | 
142 | Ethiopian:
143 | 
144 |   Proverbs in the Amharic language:
145 | 
146 |   ሰማይ አይታረስ ንጉሥ አይከሰስ።
147 |   ብላ ካለኝ እንደአባቴ በቆመጠኝ።
148 |   ጌጥ ያለቤቱ ቁምጥና ነው።
149 |   ደሀ በሕልሙ ቅቤ ባይጠጣ ንጣት በገደለው።
150 |   የአፍ ወለምታ በቅቤ አይታሽም።
151 |   አይጥ በበላ ዳዋ ተመታ።
152 |   ሲተረጉሙ ይደረግሙ።
153 |   ቀስ በቀስ፥ ዕንቁላል በእግሩ ይሄዳል።
154 |   ድር ቢያብር አንበሳ ያስር።
155 |   ሰው እንደቤቱ እንጅ እንደ ጉረቤቱ አይተዳደርም።
156 |   እግዜር የከፈተውን ጉሮሮ ሳይዘጋው አይድርም።
157 |   የጎረቤት ሌባ፥ ቢያዩት ይስቅ ባያዩት ያጠልቅ።
158 |   ሥራ ከመፍታት ልጄን ላፋታት።
159 |   ዓባይ ማደሪያ የለው፥ ግንድ ይዞ ይዞራል።
160 |   የእስላም አገሩ መካ የአሞራ አገሩ ዋርካ።
161 |   ተንጋሎ ቢተፉ ተመልሶ ባፉ።
162 |   ወዳጅህ ማር ቢሆን ጨርስህ አትላሰው።
163 |   እግርህን በፍራሽህ ልክ ዘርጋ።
164 | 
165 | Runes:
166 | 
167 |   ᚻᛖ ᚳᚹᚫᚦ ᚦᚫᛏ ᚻᛖ ᛒᚢᛞᛖ ᚩᚾ ᚦᚫᛗ ᛚᚪᚾᛞᛖ ᚾᚩᚱᚦᚹᛖᚪᚱᛞᚢᛗ ᚹᛁᚦ ᚦᚪ ᚹᛖᛥᚫ
168 | 
169 |   (Old English, which transcribed into Latin reads 'He cwaeth that he
170 |   bude thaem lande northweardum with tha Westsae.' and means 'He said
171 |   that he lived in the northern land near the Western Sea.')
172 | 
173 | Braille:
174 | 
175 |   ⡌⠁⠧⠑ ⠼⠁⠒  ⡍⠜⠇⠑⠹⠰⠎ ⡣⠕⠌
176 | 
177 |   ⡍⠜⠇⠑⠹ ⠺⠁⠎ ⠙⠑⠁⠙⠒ ⠞⠕ ⠃⠑⠛⠔ ⠺⠊⠹⠲ ⡹⠻⠑ ⠊⠎ ⠝⠕ ⠙⠳⠃⠞
178 |   ⠱⠁⠞⠑⠧⠻ ⠁⠃⠳⠞ ⠹⠁⠞⠲ ⡹⠑ ⠗⠑⠛⠊⠌⠻ ⠕⠋ ⠙⠊⠎ ⠃⠥⠗⠊⠁⠇ ⠺⠁⠎
179 |   ⠎⠊⠛⠝⠫ ⠃⠹ ⠹⠑ ⠊⠇⠻⠛⠹⠍⠁⠝⠂ ⠹⠑ ⠊⠇⠻⠅⠂ ⠹⠑ ⠥⠝⠙⠻⠞⠁⠅⠻⠂
180 |   ⠁⠝⠙ ⠹⠑ ⠡⠊⠑⠋ ⠍⠳⠗⠝⠻⠲ ⡎⠊⠗⠕⠕⠛⠑ ⠎⠊⠛⠝⠫ ⠊⠞⠲ ⡁⠝⠙
181 |   ⡎⠊⠗⠕⠕⠛⠑⠰⠎ ⠝⠁⠍⠑ ⠺⠁⠎ ⠛⠕⠕⠙ ⠥⠏⠕⠝ ⠰⡡⠁⠝⠛⠑⠂ ⠋⠕⠗ ⠁⠝⠹⠹⠔⠛ ⠙⠑
182 |   ⠡⠕⠎⠑ ⠞⠕ ⠏⠥⠞ ⠙⠊⠎ ⠙⠁⠝⠙ ⠞⠕⠲
183 | 
184 |   ⡕⠇⠙ ⡍⠜⠇⠑⠹ ⠺⠁⠎ ⠁⠎ ⠙⠑⠁⠙ ⠁⠎ ⠁ ⠙⠕⠕⠗⠤⠝⠁⠊⠇⠲
185 | 
186 |   ⡍⠔⠙⠖ ⡊ ⠙⠕⠝⠰⠞ ⠍⠑⠁⠝ ⠞⠕ ⠎⠁⠹ ⠹⠁⠞ ⡊ ⠅⠝⠪⠂ ⠕⠋ ⠍⠹
187 |   ⠪⠝ ⠅⠝⠪⠇⠫⠛⠑⠂ ⠱⠁⠞ ⠹⠻⠑ ⠊⠎ ⠏⠜⠞⠊⠊⠥⠇⠜⠇⠹ ⠙⠑⠁⠙ ⠁⠃⠳⠞
188 |   ⠁ ⠙⠕⠕⠗⠤⠝⠁⠊⠇⠲ ⡊ ⠍⠊⠣⠞ ⠙⠁⠧⠑ ⠃⠑⠲ ⠔⠊⠇⠔⠫⠂ ⠍⠹⠎⠑⠇⠋⠂ ⠞⠕
189 |   ⠗⠑⠛⠜⠙ ⠁ ⠊⠕⠋⠋⠔⠤⠝⠁⠊⠇ ⠁⠎ ⠹⠑ ⠙⠑⠁⠙⠑⠌ ⠏⠊⠑⠊⠑ ⠕⠋ ⠊⠗⠕⠝⠍⠕⠝⠛⠻⠹
190 |   ⠔ ⠹⠑ ⠞⠗⠁⠙⠑⠲ ⡃⠥⠞ ⠹⠑ ⠺⠊⠎⠙⠕⠍ ⠕⠋ ⠳⠗ ⠁⠝⠊⠑⠌⠕⠗⠎
191 |   ⠊⠎ ⠔ ⠹⠑ ⠎⠊⠍⠊⠇⠑⠆ ⠁⠝⠙ ⠍⠹ ⠥⠝⠙⠁⠇⠇⠪⠫ ⠙⠁⠝⠙⠎
192 |   ⠩⠁⠇⠇ ⠝⠕⠞ ⠙⠊⠌⠥⠗⠃ ⠊⠞⠂ ⠕⠗ ⠹⠑ ⡊⠳⠝⠞⠗⠹⠰⠎ ⠙⠕⠝⠑ ⠋⠕⠗⠲ ⡹⠳
193 |   ⠺⠊⠇⠇ ⠹⠻⠑⠋⠕⠗⠑ ⠏⠻⠍⠊⠞ ⠍⠑ ⠞⠕ ⠗⠑⠏⠑⠁⠞⠂ ⠑⠍⠏⠙⠁⠞⠊⠊⠁⠇⠇⠹⠂ ⠹⠁⠞
194 |   ⡍⠜⠇⠑⠹ ⠺⠁⠎ ⠁⠎ ⠙⠑⠁⠙ ⠁⠎ ⠁ ⠙⠕⠕⠗⠤⠝⠁⠊⠇⠲
195 | 
196 |   (The first couple of paragraphs of "A Christmas Carol" by Dickens)
197 | 
198 | Compact font selection example text:
199 | 
200 |   ABCDEFGHIJKLMNOPQRSTUVWXYZ /0123456789
201 |   abcdefghijklmnopqrstuvwxyz £©µÀÆÖÞßéöÿ
202 |   –—‘“”„†•…‰™œŠŸž€ ΑΒΓΔΩαβγδω АБВГДабвгд
203 |   ∀∂∈ℝ∧∪≡∞ ↑↗↨↻⇣ ┐┼╔╘░►☺♀ fi�⑀₂ἠḂӥẄɐː⍎אԱა
204 | 
205 | Greetings in various languages:
206 | 
207 |   Hello world, Καλημέρα κόσμε, コンニチハ
208 | 
209 | Box drawing alignment tests:                                          █
210 |                                                                       ▉
211 |   ╔══╦══╗  ┌──┬──┐  ╭──┬──╮  ╭──┬──╮  ┏━━┳━━┓  ┎┒┏┑   ╷  ╻ ┏┯┓ ┌┰┐    ▊ ╱╲╱╲╳╳╳
212 |   ║┌─╨─┐║  │╔═╧═╗│  │╒═╪═╕│  │╓─╁─╖│  ┃┌─╂─┐┃  ┗╃╄┙  ╶┼╴╺╋╸┠┼┨ ┝╋┥    ▋ ╲╱╲╱╳╳╳
213 |   ║│╲ ╱│║  │║   ║│  ││ │ ││  │║ ┃ ║│  ┃│ ╿ │┃  ┍╅╆┓   ╵  ╹ ┗┷┛ └┸┘    ▌ ╱╲╱╲╳╳╳
214 |   ╠╡ ╳ ╞╣  ├╢   ╟┤  ├┼─┼─┼┤  ├╫─╂─╫┤  ┣┿╾┼╼┿┫  ┕┛┖┚     ┌┄┄┐ ╎ ┏┅┅┓ ┋ ▍ ╲╱╲╱╳╳╳
215 |   ║│╱ ╲│║  │║   ║│  ││ │ ││  │║ ┃ ║│  ┃│ ╽ │┃  ░░▒▒▓▓██ ┊  ┆ ╎ ╏  ┇ ┋ ▎
216 |   ║└─╥─┘║  │╚═╤═╝│  │╘═╪═╛│  │╙─╀─╜│  ┃└─╂─┘┃  ░░▒▒▓▓██ ┊  ┆ ╎ ╏  ┇ ┋ ▏
217 |   ╚══╩══╝  └──┴──┘  ╰──┴──╯  ╰──┴──╯  ┗━━┻━━┛  ▗▄▖▛▀▜   └╌╌┘ ╎ ┗╍╍┛ ┋  ▁▂▃▄▅▆▇█
218 |                                                ▝▀▘▙▄▟
219 | 
220 | 
221 | -------------------------------------------------------------------------------- /httpbin/templates/images/svg_logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | SVG Logo 4 | 5 | 7 | 8 | 15 | 16 | 23 | 24 | 33 | 34 | 35 | 40 | 41 | 46 | 47 | 52 | 53 | 58 | 59 | 64 | 65 | 70 | 71 | 76 | 77 | 82 | 83 | 84 | 85 | 104 | 105 | 157 | 158 | 159 | 160 | 169 | 170 | 181 | 182 | 183 | SVG 184 | 215 | 227 | 228 | 256 | 257 | 258 | 259 | 260 | -------------------------------------------------------------------------------- /httpbin/templates/flasgger/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {{ title }} 7 | 9 | 10 | 11 | 29 | 30 | 31 | 32 | 33 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 51 | 52 | 53 | 54 | 56 | 57 | 58 | 59 | 61 | 62 | 63 | 64 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 |

httpbin.org 89 | 90 |
0.9.2
91 |
92 |

93 |
[ Base URL: httpbin.org/ ]
94 |
95 |
96 |
97 |

A simple HTTP Request & Response Service. 98 |
99 |
100 | Run locally: 101 | $ docker run -p 80:80 kennethreitz/httpbin 102 |

103 |
104 |
105 | 111 |
112 | 113 |
114 |
115 |
116 | 117 |
118 |
119 |
120 |
121 |
122 | 123 | 124 |
125 |
126 |
127 | 128 | [Powered by 129 | Flasgger] 130 |
131 |
132 |
133 |
134 |
135 | 136 | 137 | 138 | 139 | 140 | 141 | {% if tracking_enabled %} {% include 'trackingscripts.html' %} {% endif %} {% include 'footer.html' %} 210 | 211 | 212 | 213 | -------------------------------------------------------------------------------- /httpbin/templates/httpbin.1.html: -------------------------------------------------------------------------------- 1 |
2 |

httpbin(1): HTTP Request & Response Service

3 |

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

4 | 5 |

BONUSPOINTS

6 | 7 | 10 | 11 |

ENDPOINTS

12 | 13 | 66 | 67 |

DESCRIPTION

68 | 69 |

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.

70 | 71 |

All endpoint responses are JSON-encoded.

72 | 73 |

EXAMPLES

74 | 75 |

$ curl http://httpbin.org/ip

76 | 77 |
{"origin": "24.127.96.129"}
 78 | 
79 | 80 |

$ curl http://httpbin.org/user-agent

81 | 82 |
{"user-agent": "curl/7.19.7 (universal-apple-darwin10.0) libcurl/7.19.7 OpenSSL/0.9.8l zlib/1.2.3"}
 83 | 
84 | 85 |

$ curl http://httpbin.org/get

86 | 87 |
{
 88 |    "args": {},
 89 |    "headers": {
 90 |       "Accept": "*/*",
 91 |       "Connection": "close",
 92 |       "Content-Length": "",
 93 |       "Content-Type": "",
 94 |       "Host": "httpbin.org",
 95 |       "User-Agent": "curl/7.19.7 (universal-apple-darwin10.0) libcurl/7.19.7 OpenSSL/0.9.8l zlib/1.2.3"
 96 |    },
 97 |    "origin": "24.127.96.129",
 98 |    "url": "http://httpbin.org/get"
 99 | }
100 | 
101 | 102 |

$ curl -I http://httpbin.org/status/418

103 | 104 |
HTTP/1.1 418 I'M A TEAPOT
105 | Server: nginx/0.7.67
106 | Date: Mon, 13 Jun 2011 04:25:38 GMT
107 | Connection: close
108 | x-more-info: http://tools.ietf.org/html/rfc2324
109 | Content-Length: 135
110 | 
111 | 112 |

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

113 | 114 |
{
115 |   "headers": {
116 |     "Content-Length": "",
117 |     "Accept-Language": "en-US,en;q=0.8",
118 |     "Accept-Encoding": "gzip,deflate,sdch",
119 |     "X-Forwarded-Port": "443",
120 |     "X-Forwarded-For": "109.60.101.240",
121 |     "Host": "httpbin.org",
122 |     "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
123 |     "User-Agent": "Mozilla/5.0 (X11; Linux i686) AppleWebKit/535.11 (KHTML, like Gecko) Chrome/17.0.963.83 Safari/535.11",
124 |     "X-Request-Start": "1350053933441",
125 |     "Accept-Charset": "ISO-8859-1,utf-8;q=0.7,*;q=0.3",
126 |     "Connection": "keep-alive",
127 |     "X-Forwarded-Proto": "https",
128 |     "Cookie": "_gauges_unique_day=1; _gauges_unique_month=1; _gauges_unique_year=1; _gauges_unique=1; _gauges_unique_hour=1",
129 |     "Content-Type": ""
130 |   },
131 |   "args": {
132 |     "show_env": "1"
133 |   },
134 |   "origin": "109.60.101.240",
135 |   "url": "http://httpbin.org/get?show_env=1"
136 | }
137 | 
138 | 139 |

Installing and running from PyPI

140 | 141 |

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

142 | 143 |
$ pip install httpbin
144 | $ gunicorn httpbin:app
145 | 
146 | 147 | 148 |

AUTHOR

149 | 150 |

A Kenneth Reitz project.

151 |

BTC: 1Me2iXTJ91FYZhrGvaGaRDCBtnZ4KdxCug

152 | 153 |

SEE ALSO

154 | 155 |

Hurl.it - Make HTTP requests.

156 |

RequestBin - Inspect HTTP requests.

157 |

http://python-requests.org

158 | 159 |
160 | -------------------------------------------------------------------------------- /Pipfile.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "hash": { 4 | "sha256": "b709c9b498d9be5088c0f485aafe18a04a8ed5144d397111a8f1d8bd06d7a16e" 5 | }, 6 | "pipfile-spec": 6, 7 | "requires": {}, 8 | "sources": [ 9 | { 10 | "url": "https://pypi.python.org/simple", 11 | "verify_ssl": true 12 | } 13 | ] 14 | }, 15 | "default": { 16 | "brotlipy": { 17 | "hashes": [ 18 | "sha256:07194f4768eb62a4f4ea76b6d0df6ade185e24ebd85877c351daa0a069f1111a", 19 | "sha256:091b299bf36dd6ef7a06570dbc98c0f80a504a56c5b797f31934d2ad01ae7d17", 20 | "sha256:09ec3e125d16749b31c74f021aba809541b3564e5359f8c265cbae442810b41a", 21 | "sha256:0be698678a114addcf87a4b9496c552c68a2c99bf93cf8e08f5738b392e82057", 22 | "sha256:0fa6088a9a87645d43d7e21e32b4a6bf8f7c3939015a50158c10972aa7f425b7", 23 | "sha256:1ea4e578241504b58f2456a6c69952c88866c794648bdc74baee74839da61d44", 24 | "sha256:2699945a0a992c04fc7dc7fa2f1d0575a2c8b4b769f2874a08e8eae46bef36ae", 25 | "sha256:2a80319ae13ea8dd60ecdc4f5ccf6da3ae64787765923256b62c598c5bba4121", 26 | "sha256:2e5c64522364a9ebcdf47c5744a5ddeb3f934742d31e61ebfbbc095460b47162", 27 | "sha256:36def0b859beaf21910157b4c33eb3b06d8ce459c942102f16988cca6ea164df", 28 | "sha256:3a3e56ced8b15fbbd363380344f70f3b438e0fd1fcf27b7526b6172ea950e867", 29 | "sha256:3c1d5e2cf945a46975bdb11a19257fa057b67591eb232f393d260e7246d9e571", 30 | "sha256:50ca336374131cfad20612f26cc43c637ac0bfd2be3361495e99270883b52962", 31 | "sha256:5de6f7d010b7558f72f4b061a07395c5c3fd57f0285c5af7f126a677b976a868", 32 | "sha256:637847560d671657f993313ecc6c6c6666a936b7a925779fd044065c7bc035b9", 33 | "sha256:653faef61241bf8bf99d73ca7ec4baa63401ba7b2a2aa88958394869379d67c7", 34 | "sha256:786afc8c9bd67de8d31f46e408a3386331e126829114e4db034f91eacb05396d", 35 | "sha256:79aaf217072840f3e9a3b641cccc51f7fc23037496bd71e26211856b93f4b4cb", 36 | "sha256:7e31f7adcc5851ca06134705fcf3478210da45d35ad75ec181e1ce9ce345bb38", 37 | "sha256:8b39abc3256c978f575df5cd7893153277216474f303e26f0e43ba3d3969ef96", 38 | "sha256:9448227b0df082e574c45c983fa5cd4bda7bfb11ea6b59def0940c1647be0c3c", 39 | "sha256:96bc59ff9b5b5552843dc67999486a220e07a0522dddd3935da05dc194fa485c", 40 | "sha256:a07647886e24e2fb2d68ca8bf3ada398eb56fd8eac46c733d4d95c64d17f743b", 41 | "sha256:af65d2699cb9f13b26ec3ba09e75e80d31ff422c03675fcb36ee4dabe588fdc2", 42 | "sha256:b4c98b0d2c9c7020a524ca5bbff42027db1004c6571f8bc7b747f2b843128e7a", 43 | "sha256:c6cc0036b1304dd0073eec416cb2f6b9e37ac8296afd9e481cac3b1f07f9db25", 44 | "sha256:d2c1c724c4ac375feb2110f1af98ecdc0e5a8ea79d068efb5891f621a5b235cb", 45 | "sha256:dc6c5ee0df9732a44d08edab32f8a616b769cc5a4155a12d2d010d248eb3fb07", 46 | "sha256:fd1d1c64214af5d90014d82cee5d8141b13d44c92ada7a0c0ec0679c6f15a471" 47 | ], 48 | "version": "==0.7.0" 49 | }, 50 | "cffi": { 51 | "hashes": [ 52 | "sha256:151b7eefd035c56b2b2e1eb9963c90c6302dc15fbd8c1c0a83a163ff2c7d7743", 53 | "sha256:1553d1e99f035ace1c0544050622b7bc963374a00c467edafac50ad7bd276aef", 54 | "sha256:1b0493c091a1898f1136e3f4f991a784437fac3673780ff9de3bcf46c80b6b50", 55 | "sha256:2ba8a45822b7aee805ab49abfe7eec16b90587f7f26df20c71dd89e45a97076f", 56 | "sha256:3bb6bd7266598f318063e584378b8e27c67de998a43362e8fce664c54ee52d30", 57 | "sha256:3c85641778460581c42924384f5e68076d724ceac0f267d66c757f7535069c93", 58 | "sha256:3eb6434197633b7748cea30bf0ba9f66727cdce45117a712b29a443943733257", 59 | "sha256:495c5c2d43bf6cebe0178eb3e88f9c4aa48d8934aa6e3cddb865c058da76756b", 60 | "sha256:4c91af6e967c2015729d3e69c2e51d92f9898c330d6a851bf8f121236f3defd3", 61 | "sha256:57b2533356cb2d8fac1555815929f7f5f14d68ac77b085d2326b571310f34f6e", 62 | "sha256:770f3782b31f50b68627e22f91cb182c48c47c02eb405fd689472aa7b7aa16dc", 63 | "sha256:79f9b6f7c46ae1f8ded75f68cf8ad50e5729ed4d590c74840471fc2823457d04", 64 | "sha256:7a33145e04d44ce95bcd71e522b478d282ad0eafaf34fe1ec5bbd73e662f22b6", 65 | "sha256:857959354ae3a6fa3da6651b966d13b0a8bed6bbc87a0de7b38a549db1d2a359", 66 | "sha256:87f37fe5130574ff76c17cab61e7d2538a16f843bb7bca8ebbc4b12de3078596", 67 | "sha256:95d5251e4b5ca00061f9d9f3d6fe537247e145a8524ae9fd30a2f8fbce993b5b", 68 | "sha256:9d1d3e63a4afdc29bd76ce6aa9d58c771cd1599fbba8cf5057e7860b203710dd", 69 | "sha256:a36c5c154f9d42ec176e6e620cb0dd275744aa1d804786a71ac37dc3661a5e95", 70 | "sha256:a6a5cb8809091ec9ac03edde9304b3ad82ad4466333432b16d78ef40e0cce0d5", 71 | "sha256:ae5e35a2c189d397b91034642cb0eab0e346f776ec2eb44a49a459e6615d6e2e", 72 | "sha256:b0f7d4a3df8f06cf49f9f121bead236e328074de6449866515cea4907bbc63d6", 73 | "sha256:b75110fb114fa366b29a027d0c9be3709579602ae111ff61674d28c93606acca", 74 | "sha256:ba5e697569f84b13640c9e193170e89c13c6244c24400fc57e88724ef610cd31", 75 | "sha256:be2a9b390f77fd7676d80bc3cdc4f8edb940d8c198ed2d8c0be1319018c778e1", 76 | "sha256:ca1bd81f40adc59011f58159e4aa6445fc585a32bb8ac9badf7a2c1aa23822f2", 77 | "sha256:d5d8555d9bfc3f02385c1c37e9f998e2011f0db4f90e250e5bc0c0a85a813085", 78 | "sha256:e55e22ac0a30023426564b1059b035973ec82186ddddbac867078435801c7801", 79 | "sha256:e90f17980e6ab0f3c2f3730e56d1fe9bcba1891eeea58966e89d352492cc74f4", 80 | "sha256:ecbb7b01409e9b782df5ded849c178a0aa7c906cf8c5a67368047daab282b184", 81 | "sha256:ed01918d545a38998bfa5902c7c00e0fee90e957ce036a4000a88e3fe2264917", 82 | "sha256:edabd457cd23a02965166026fd9bfd196f4324fe6032e866d0f3bd0301cd486f", 83 | "sha256:fdf1c1dc5bafc32bc5d08b054f94d659422b05aba244d6be4ddc1c72d9aa70fb" 84 | ], 85 | "version": "==1.11.5" 86 | }, 87 | "click": { 88 | "hashes": [ 89 | "sha256:29f99fc6125fbc931b758dc053b3114e55c77a6e4c6c3a2674a2dc986016381d", 90 | "sha256:f15516df478d5a56180fbf80e68f206010e6d160fc39fa508b65e035fd75130b" 91 | ], 92 | "version": "==6.7" 93 | }, 94 | "decorator": { 95 | "hashes": [ 96 | "sha256:2c51dff8ef3c447388fe5e4453d24a2bf128d3a4c32af3fabef1f01c6851ab82", 97 | "sha256:c39efa13fbdeb4506c476c9b3babf6a718da943dab7811c206005a4a956c080c" 98 | ], 99 | "version": "==4.3.0" 100 | }, 101 | "flasgger": { 102 | "hashes": [ 103 | "sha256:1c9c03a4b55b60688f2bb2c2d8ff4534cb18eda70fd02973141be8c3bde586b3", 104 | "sha256:efee892b0554c60f716b441ee78fddcaf7af20bc764696d9eecd6a389fb7f195" 105 | ], 106 | "version": "==0.9.0" 107 | }, 108 | "flask": { 109 | "hashes": [ 110 | "sha256:2271c0070dbcb5275fad4a82e29f23ab92682dc45f9dfbc22c02ba9b9322ce48", 111 | "sha256:a080b744b7e345ccfcbc77954861cb05b3c63786e93f2b3875e0913d44b43f05" 112 | ], 113 | "version": "==1.0.2" 114 | }, 115 | "gevent": { 116 | "hashes": [ 117 | "sha256:00a45774ad6e7a8641af5db011807f53c1f0e0bc62cbdcab83e4db18e6201b6e", 118 | "sha256:15dbcc07cdd09f87b9814ee26483ec49e0d71fdc65d7a61b21c2c56bbb550168", 119 | "sha256:16143db7b760d9b512edfaf4d0bbef01cf0391e773362c43084901e3ecb892d5", 120 | "sha256:1a0d422d6c960c36088201d4bbc925dfde87dc4a4e442bf2e4d36ae455f24a96", 121 | "sha256:22187d0aba6506b57075dd05d0df495b04bfd4b047bbf776eeaac93117a6e9d2", 122 | "sha256:33320f60be19a865396a7f5e10c15b14e338790ae807c97c90edc990d644dc1c", 123 | "sha256:3498fec10e3695f3ad31253857c624435378c6a47969babb54a83ac0101615d3", 124 | "sha256:3c9fbc0dac62e552dc5d03bb67ceaefc5f74d7b4ac04a4bf797cdb0a4438b1db", 125 | "sha256:53c4dc705886d028f5d81e698b1d1479994a421498cd6529cb9711b5e2a84f74", 126 | "sha256:57729118fbcf0f39ecf721ae9b318a4a738eb5d9b972af6c6c8c96303e30f011", 127 | "sha256:6c41413e1eb0b7bf77dcea42ff276e62903bfdc62cb936d71458d338b9edc9a6", 128 | "sha256:72f7cab120e2af89d3a9d6c526e49da5c0b6c94d47e23ab7a26ae8471ee97ffb", 129 | "sha256:7ac5a4945fc47e3824d55bb50b6dd65823868e87fac841bea5762f79b9d22019", 130 | "sha256:7bb0e1ef3adfea008688617fedb1741009856f98e26133983646203c718f7f39", 131 | "sha256:8c41ef269bc743b5bb88a4553627cd4611be5c59589d5390e29956a8d3ab8623", 132 | "sha256:a1f32f0b01ceb15f93b2914b7057acb008c5173181813424621dc444f73c00e2", 133 | "sha256:a51456f842f7de83fff473a0230e313e44ac6fa83e492412e696924f417088b8", 134 | "sha256:a72a23829ce8eb18086ec6f855715c3f52d3c1e12b83fd040d9fb854e77c0565", 135 | "sha256:c7e5f8a6bf865ef507db27f85376808991d3189df185864a5ee326d97e144ec4", 136 | "sha256:cf707886b9b45e56114c6f5522fc556058de5b5bf8674b609e82dfa2f9633c41", 137 | "sha256:d83370528327364354cfb54c96ca401853599bd7a15f382e6962fd8318cede50", 138 | "sha256:e9d64081e419eb8a268edaa90bba95fb4c78a6278d2105dcc080b24b42679535" 139 | ], 140 | "version": "==1.3.4" 141 | }, 142 | "greenlet": { 143 | "hashes": [ 144 | "sha256:09ef2636ea35782364c830f07127d6c7a70542b178268714a9a9ba16318e7e8b", 145 | "sha256:0fef83d43bf87a5196c91e73cb9772f945a4caaff91242766c5916d1dd1381e4", 146 | "sha256:1b7df09c6598f5cfb40f843ade14ed1eb40596e75cd79b6fa2efc750ba01bb01", 147 | "sha256:1fff21a2da5f9e03ddc5bd99131a6b8edf3d7f9d6bc29ba21784323d17806ed7", 148 | "sha256:42118bf608e0288e35304b449a2d87e2ba77d1e373e8aa221ccdea073de026fa", 149 | "sha256:50643fd6d54fd919f9a0a577c5f7b71f5d21f0959ab48767bd4bb73ae0839500", 150 | "sha256:58798b5d30054bb4f6cf0f712f08e6092df23a718b69000786634a265e8911a9", 151 | "sha256:5b49b3049697aeae17ef7bf21267e69972d9e04917658b4e788986ea5cc518e8", 152 | "sha256:75c413551a436b462d5929255b6dc9c0c3c2b25cbeaee5271a56c7fda8ca49c0", 153 | "sha256:769b740aeebd584cd59232be84fdcaf6270b8adc356596cdea5b2152c82caaac", 154 | "sha256:a1852b51b06d1367e2d70321f6801844f5122852c9e5169bdfdff3f4d81aae30", 155 | "sha256:ad2383d39f13534f3ca5c48fe1fc0975676846dc39c2cece78c0f1f9891418e0", 156 | "sha256:b417bb7ff680d43e7bd7a13e2e08956fa6acb11fd432f74c97b7664f8bdb6ec1", 157 | "sha256:b6ef0cabaf5a6ecb5ac122e689d25ba12433a90c7b067b12e5f28bdb7fb78254", 158 | "sha256:c2de19c88bdb0366c976cc125dca1002ec1b346989d59524178adfd395e62421", 159 | "sha256:c7b04a6dc74087b1598de8d713198de4718fa30ec6cbb84959b26426c198e041", 160 | "sha256:f8f2a0ae8de0b49c7b5b2daca4f150fdd9c1173e854df2cce3b04123244f9f45", 161 | "sha256:fcfadaf4bf68a27e5dc2f42cbb2f4b4ceea9f05d1d0b8f7787e640bed2801634" 162 | ], 163 | "markers": "platform_python_implementation == 'CPython'", 164 | "version": "==0.4.13" 165 | }, 166 | "gunicorn": { 167 | "hashes": [ 168 | "sha256:aa8e0b40b4157b36a5df5e599f45c9c76d6af43845ba3b3b0efe2c70473c2471", 169 | "sha256:fa2662097c66f920f53f70621c6c58ca4a3c4d3434205e608e121b5b3b71f4f3" 170 | ], 171 | "version": "==19.9.0" 172 | }, 173 | "itsdangerous": { 174 | "hashes": [ 175 | "sha256:cbb3fcf8d3e33df861709ecaf89d9e6629cff0a217bc2848f1b41cd30d360519" 176 | ], 177 | "version": "==0.24" 178 | }, 179 | "jinja2": { 180 | "hashes": [ 181 | "sha256:74c935a1b8bb9a3947c50a54766a969d4846290e1e788ea44c1392163723c3bd", 182 | "sha256:f84be1bb0040caca4cea721fcbbbbd61f9be9464ca236387158b0feea01914a4" 183 | ], 184 | "version": "==2.10" 185 | }, 186 | "jsonschema": { 187 | "hashes": [ 188 | "sha256:000e68abd33c972a5248544925a0cae7d1125f9bf6c58280d37546b946769a08", 189 | "sha256:6ff5f3180870836cae40f06fa10419f557208175f13ad7bc26caa77beb1f6e02" 190 | ], 191 | "version": "==2.6.0" 192 | }, 193 | "markupsafe": { 194 | "hashes": [ 195 | "sha256:a6be69091dac236ea9c6bc7d012beab42010fa914c459791d627dad4910eb665" 196 | ], 197 | "version": "==1.0" 198 | }, 199 | "meinheld": { 200 | "hashes": [ 201 | "sha256:293eff4983b7fcbd9134b47706b22189883fe354993bd10163c65869d141e565", 202 | "sha256:40d9dbce0165b2d9142f364d26fd6d59d3682f89d0dfe2117717a8ddad1f4133" 203 | ], 204 | "version": "==0.6.1" 205 | }, 206 | "mistune": { 207 | "hashes": [ 208 | "sha256:b4c512ce2fc99e5a62eb95a4aba4b73e5f90264115c40b70a21e1f7d4e0eac91", 209 | "sha256:bc10c33bfdcaa4e749b779f62f60d6e12f8215c46a292d05e486b869ae306619" 210 | ], 211 | "version": "==0.8.3" 212 | }, 213 | "pycparser": { 214 | "hashes": [ 215 | "sha256:99a8ca03e29851d96616ad0404b4aad7d9ee16f25c9f9708a11faf2810f7b226" 216 | ], 217 | "version": "==2.18" 218 | }, 219 | "pyyaml": { 220 | "git": "https://github.com/yaml/pyyaml.git", 221 | "ref": "a9c28e0b521967f5330f0316edd90a57f99cdd32" 222 | }, 223 | "six": { 224 | "hashes": [ 225 | "sha256:70e8a77beed4562e7f14fe23a786b54f6296e34344c23bc42f07b15018ff98e9", 226 | "sha256:832dc0e10feb1aa2c68dcc57dbb658f1c7e65b9b61af69048abc87a2db00a0eb" 227 | ], 228 | "version": "==1.11.0" 229 | }, 230 | "werkzeug": { 231 | "hashes": [ 232 | "sha256:c3fd7a7d41976d9f44db327260e263132466836cef6f91512889ed60ad26557c", 233 | "sha256:d5da73735293558eb1651ee2fddc4d0dedcfa06538b8813a2e20011583c9e49b" 234 | ], 235 | "version": "==0.14.1" 236 | } 237 | }, 238 | "develop": { 239 | "rope": { 240 | "hashes": [ 241 | "sha256:a09edfd2034fd50099a67822f9bd851fbd0f4e98d3b87519f6267b60e50d80d1" 242 | ], 243 | "version": "==0.10.7" 244 | } 245 | } 246 | } 247 | -------------------------------------------------------------------------------- /httpbin/helpers.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | httpbin.helpers 5 | ~~~~~~~~~~~~~~~ 6 | 7 | This module provides helper functions for httpbin. 8 | """ 9 | 10 | import json 11 | import base64 12 | import re 13 | import time 14 | import os 15 | from hashlib import md5, sha256, sha512 16 | from werkzeug.http import parse_authorization_header 17 | from werkzeug.datastructures import WWWAuthenticate 18 | 19 | from flask import request, make_response 20 | from six.moves.urllib.parse import urlparse, urlunparse 21 | 22 | 23 | from .structures import CaseInsensitiveDict 24 | 25 | 26 | ASCII_ART = """ 27 | -=[ teapot ]=- 28 | 29 | _...._ 30 | .' _ _ `. 31 | | ."` ^ `". _, 32 | \_;`"---"`|// 33 | | ;/ 34 | \_ _/ 35 | `\"\"\"` 36 | """ 37 | 38 | REDIRECT_LOCATION = '/redirect/1' 39 | 40 | ENV_HEADERS = ( 41 | 'X-Varnish', 42 | 'X-Request-Start', 43 | 'X-Heroku-Queue-Depth', 44 | 'X-Real-Ip', 45 | 'X-Forwarded-Proto', 46 | 'X-Forwarded-Protocol', 47 | 'X-Forwarded-Ssl', 48 | 'X-Heroku-Queue-Wait-Time', 49 | 'X-Forwarded-For', 50 | 'X-Heroku-Dynos-In-Use', 51 | 'X-Forwarded-Protocol', 52 | 'X-Forwarded-Port', 53 | 'X-Request-Id', 54 | 'Via', 55 | 'Total-Route-Time', 56 | 'Connect-Time' 57 | ) 58 | 59 | ROBOT_TXT = """User-agent: * 60 | Disallow: /deny 61 | """ 62 | 63 | ACCEPTED_MEDIA_TYPES = [ 64 | 'image/webp', 65 | 'image/svg+xml', 66 | 'image/jpeg', 67 | 'image/png', 68 | 'image/*' 69 | ] 70 | 71 | ANGRY_ASCII =""" 72 | .-''''''-. 73 | .' _ _ '. 74 | / O O \\ 75 | : : 76 | | | 77 | : __ : 78 | \ .-"` `"-. / 79 | '. .' 80 | '-......-' 81 | YOU SHOULDN'T BE HERE 82 | """ 83 | 84 | 85 | def json_safe(string, content_type='application/octet-stream'): 86 | """Returns JSON-safe version of `string`. 87 | 88 | If `string` is a Unicode string or a valid UTF-8, it is returned unmodified, 89 | as it can safely be encoded to JSON string. 90 | 91 | If `string` contains raw/binary data, it is Base64-encoded, formatted and 92 | returned according to "data" URL scheme (RFC2397). Since JSON is not 93 | suitable for binary data, some additional encoding was necessary; "data" 94 | URL scheme was chosen for its simplicity. 95 | """ 96 | try: 97 | string = string.decode('utf-8') 98 | json.dumps(string) 99 | return string 100 | except (ValueError, TypeError): 101 | return b''.join([ 102 | b'data:', 103 | content_type.encode('utf-8'), 104 | b';base64,', 105 | base64.b64encode(string) 106 | ]).decode('utf-8') 107 | 108 | 109 | def get_files(): 110 | """Returns files dict from request context.""" 111 | 112 | files = dict() 113 | 114 | for k, v in request.files.items(): 115 | content_type = request.files[k].content_type or 'application/octet-stream' 116 | val = json_safe(v.read(), content_type) 117 | if files.get(k): 118 | if not isinstance(files[k], list): 119 | files[k] = [files[k]] 120 | files[k].append(val) 121 | else: 122 | files[k] = val 123 | 124 | return files 125 | 126 | 127 | def get_headers(hide_env=True): 128 | """Returns headers dict from request context.""" 129 | 130 | headers = dict(request.headers.items()) 131 | 132 | if hide_env and ('show_env' not in request.args): 133 | for key in ENV_HEADERS: 134 | try: 135 | del headers[key] 136 | except KeyError: 137 | pass 138 | 139 | return CaseInsensitiveDict(headers.items()) 140 | 141 | 142 | def semiflatten(multi): 143 | """Convert a MutiDict into a regular dict. If there are more than one value 144 | for a key, the result will have a list of values for the key. Otherwise it 145 | will have the plain value.""" 146 | if multi: 147 | result = multi.to_dict(flat=False) 148 | for k, v in result.items(): 149 | if len(v) == 1: 150 | result[k] = v[0] 151 | return result 152 | else: 153 | return multi 154 | 155 | def get_url(request): 156 | """ 157 | Since we might be hosted behind a proxy, we need to check the 158 | X-Forwarded-Proto, X-Forwarded-Protocol, or X-Forwarded-SSL headers 159 | to find out what protocol was used to access us. 160 | """ 161 | protocol = request.headers.get('X-Forwarded-Proto') or request.headers.get('X-Forwarded-Protocol') 162 | if protocol is None and request.headers.get('X-Forwarded-Ssl') == 'on': 163 | protocol = 'https' 164 | if protocol is None: 165 | return request.url 166 | url = list(urlparse(request.url)) 167 | url[0] = protocol 168 | return urlunparse(url) 169 | 170 | 171 | def get_dict(*keys, **extras): 172 | """Returns request dict of given keys.""" 173 | 174 | _keys = ('url', 'args', 'form', 'data', 'origin', 'headers', 'files', 'json', 'method') 175 | 176 | assert all(map(_keys.__contains__, keys)) 177 | data = request.data 178 | form = semiflatten(request.form) 179 | 180 | try: 181 | _json = json.loads(data.decode('utf-8')) 182 | except (ValueError, TypeError): 183 | _json = None 184 | 185 | d = dict( 186 | url=get_url(request), 187 | args=semiflatten(request.args), 188 | form=form, 189 | data=json_safe(data), 190 | origin=request.headers.get('X-Forwarded-For', request.remote_addr), 191 | headers=get_headers(), 192 | files=get_files(), 193 | json=_json, 194 | method=request.method, 195 | ) 196 | 197 | out_d = dict() 198 | 199 | for key in keys: 200 | out_d[key] = d.get(key) 201 | 202 | out_d.update(extras) 203 | 204 | return out_d 205 | 206 | 207 | def status_code(code): 208 | """Returns response object of given status code.""" 209 | 210 | redirect = dict(headers=dict(location=REDIRECT_LOCATION)) 211 | 212 | code_map = { 213 | 301: redirect, 214 | 302: redirect, 215 | 303: redirect, 216 | 304: dict(data=''), 217 | 305: redirect, 218 | 307: redirect, 219 | 401: dict(headers={'WWW-Authenticate': 'Basic realm="Fake Realm"'}), 220 | 402: dict( 221 | data='Fuck you, pay me!', 222 | headers={ 223 | 'x-more-info': 'http://vimeo.com/22053820' 224 | } 225 | ), 226 | 406: dict(data=json.dumps({ 227 | 'message': 'Client did not request a supported media type.', 228 | 'accept': ACCEPTED_MEDIA_TYPES 229 | }), 230 | headers={ 231 | 'Content-Type': 'application/json' 232 | }), 233 | 407: dict(headers={'Proxy-Authenticate': 'Basic realm="Fake Realm"'}), 234 | 418: dict( # I'm a teapot! 235 | data=ASCII_ART, 236 | headers={ 237 | 'x-more-info': 'http://tools.ietf.org/html/rfc2324' 238 | } 239 | ), 240 | 241 | } 242 | 243 | r = make_response() 244 | r.status_code = code 245 | 246 | if code in code_map: 247 | 248 | m = code_map[code] 249 | 250 | if 'data' in m: 251 | r.data = m['data'] 252 | if 'headers' in m: 253 | r.headers = m['headers'] 254 | 255 | return r 256 | 257 | 258 | def check_basic_auth(user, passwd): 259 | """Checks user authentication using HTTP Basic Auth.""" 260 | 261 | auth = request.authorization 262 | return auth and auth.username == user and auth.password == passwd 263 | 264 | 265 | 266 | # Digest auth helpers 267 | # qop is a quality of protection 268 | 269 | def H(data, algorithm): 270 | if algorithm == 'SHA-256': 271 | return sha256(data).hexdigest() 272 | elif algorithm == 'SHA-512': 273 | return sha512(data).hexdigest() 274 | else: 275 | return md5(data).hexdigest() 276 | 277 | 278 | def HA1(realm, username, password, algorithm): 279 | """Create HA1 hash by realm, username, password 280 | 281 | HA1 = md5(A1) = MD5(username:realm:password) 282 | """ 283 | if not realm: 284 | realm = u'' 285 | return H(b":".join([username.encode('utf-8'), 286 | realm.encode('utf-8'), 287 | password.encode('utf-8')]), algorithm) 288 | 289 | 290 | def HA2(credentials, request, algorithm): 291 | """Create HA2 md5 hash 292 | 293 | If the qop directive's value is "auth" or is unspecified, then HA2: 294 | HA2 = md5(A2) = MD5(method:digestURI) 295 | If the qop directive's value is "auth-int" , then HA2 is 296 | HA2 = md5(A2) = MD5(method:digestURI:MD5(entityBody)) 297 | """ 298 | if credentials.get("qop") == "auth" or credentials.get('qop') is None: 299 | return H(b":".join([request['method'].encode('utf-8'), request['uri'].encode('utf-8')]), algorithm) 300 | elif credentials.get("qop") == "auth-int": 301 | for k in 'method', 'uri', 'body': 302 | if k not in request: 303 | raise ValueError("%s required" % k) 304 | A2 = b":".join([request['method'].encode('utf-8'), 305 | request['uri'].encode('utf-8'), 306 | H(request['body'], algorithm).encode('utf-8')]) 307 | return H(A2, algorithm) 308 | raise ValueError 309 | 310 | 311 | def response(credentials, password, request): 312 | """Compile digest auth response 313 | 314 | If the qop directive's value is "auth" or "auth-int" , then compute the response as follows: 315 | RESPONSE = MD5(HA1:nonce:nonceCount:clienNonce:qop:HA2) 316 | Else if the qop directive is unspecified, then compute the response as follows: 317 | RESPONSE = MD5(HA1:nonce:HA2) 318 | 319 | Arguments: 320 | - `credentials`: credentials dict 321 | - `password`: request user password 322 | - `request`: request dict 323 | """ 324 | response = None 325 | algorithm = credentials.get('algorithm') 326 | HA1_value = HA1( 327 | credentials.get('realm'), 328 | credentials.get('username'), 329 | password, 330 | algorithm 331 | ) 332 | HA2_value = HA2(credentials, request, algorithm) 333 | if credentials.get('qop') is None: 334 | response = H(b":".join([ 335 | HA1_value.encode('utf-8'), 336 | credentials.get('nonce', '').encode('utf-8'), 337 | HA2_value.encode('utf-8') 338 | ]), algorithm) 339 | elif credentials.get('qop') == 'auth' or credentials.get('qop') == 'auth-int': 340 | for k in 'nonce', 'nc', 'cnonce', 'qop': 341 | if k not in credentials: 342 | raise ValueError("%s required for response H" % k) 343 | response = H(b":".join([HA1_value.encode('utf-8'), 344 | credentials.get('nonce').encode('utf-8'), 345 | credentials.get('nc').encode('utf-8'), 346 | credentials.get('cnonce').encode('utf-8'), 347 | credentials.get('qop').encode('utf-8'), 348 | HA2_value.encode('utf-8')]), algorithm) 349 | else: 350 | raise ValueError("qop value are wrong") 351 | 352 | return response 353 | 354 | 355 | def check_digest_auth(user, passwd): 356 | """Check user authentication using HTTP Digest auth""" 357 | 358 | if request.headers.get('Authorization'): 359 | credentials = parse_authorization_header(request.headers.get('Authorization')) 360 | if not credentials: 361 | return 362 | request_uri = request.script_root + request.path 363 | if request.query_string: 364 | request_uri += '?' + request.query_string 365 | response_hash = response(credentials, passwd, dict(uri=request_uri, 366 | body=request.data, 367 | method=request.method)) 368 | if credentials.get('response') == response_hash: 369 | return True 370 | return False 371 | 372 | def secure_cookie(): 373 | """Return true if cookie should have secure attribute""" 374 | return request.environ['wsgi.url_scheme'] == 'https' 375 | 376 | def __parse_request_range(range_header_text): 377 | """ Return a tuple describing the byte range requested in a GET request 378 | If the range is open ended on the left or right side, then a value of None 379 | will be set. 380 | RFC7233: http://svn.tools.ietf.org/svn/wg/httpbis/specs/rfc7233.html#header.range 381 | Examples: 382 | Range : bytes=1024- 383 | Range : bytes=10-20 384 | Range : bytes=-999 385 | """ 386 | 387 | left = None 388 | right = None 389 | 390 | if not range_header_text: 391 | return left, right 392 | 393 | range_header_text = range_header_text.strip() 394 | if not range_header_text.startswith('bytes'): 395 | return left, right 396 | 397 | components = range_header_text.split("=") 398 | if len(components) != 2: 399 | return left, right 400 | 401 | components = components[1].split("-") 402 | 403 | try: 404 | right = int(components[1]) 405 | except: 406 | pass 407 | 408 | try: 409 | left = int(components[0]) 410 | except: 411 | pass 412 | 413 | return left, right 414 | 415 | def get_request_range(request_headers, upper_bound): 416 | first_byte_pos, last_byte_pos = __parse_request_range(request_headers['range']) 417 | 418 | if first_byte_pos is None and last_byte_pos is None: 419 | # Request full range 420 | first_byte_pos = 0 421 | last_byte_pos = upper_bound - 1 422 | elif first_byte_pos is None: 423 | # Request the last X bytes 424 | first_byte_pos = max(0, upper_bound - last_byte_pos) 425 | last_byte_pos = upper_bound - 1 426 | elif last_byte_pos is None: 427 | # Request the last X bytes 428 | last_byte_pos = upper_bound - 1 429 | 430 | return first_byte_pos, last_byte_pos 431 | 432 | def parse_multi_value_header(header_str): 433 | """Break apart an HTTP header string that is potentially a quoted, comma separated list as used in entity headers in RFC2616.""" 434 | parsed_parts = [] 435 | if header_str: 436 | parts = header_str.split(',') 437 | for part in parts: 438 | match = re.search('\s*(W/)?\"?([^"]*)\"?\s*', part) 439 | if match is not None: 440 | parsed_parts.append(match.group(2)) 441 | return parsed_parts 442 | 443 | 444 | def next_stale_after_value(stale_after): 445 | try: 446 | stal_after_count = int(stale_after) - 1 447 | return str(stal_after_count) 448 | except ValueError: 449 | return 'never' 450 | 451 | 452 | def digest_challenge_response(app, qop, algorithm, stale = False): 453 | response = app.make_response('') 454 | response.status_code = 401 455 | 456 | # RFC2616 Section4.2: HTTP headers are ASCII. That means 457 | # request.remote_addr was originally ASCII, so I should be able to 458 | # encode it back to ascii. Also, RFC2617 says about nonces: "The 459 | # contents of the nonce are implementation dependent" 460 | nonce = H(b''.join([ 461 | getattr(request, 'remote_addr', u'').encode('ascii'), 462 | b':', 463 | str(time.time()).encode('ascii'), 464 | b':', 465 | os.urandom(10) 466 | ]), algorithm) 467 | opaque = H(os.urandom(10), algorithm) 468 | 469 | auth = WWWAuthenticate("digest") 470 | auth.set_digest('me@kennethreitz.com', nonce, opaque=opaque, 471 | qop=('auth', 'auth-int') if qop is None else (qop,), algorithm=algorithm) 472 | auth.stale = stale 473 | response.headers['WWW-Authenticate'] = auth.to_header() 474 | return response 475 | -------------------------------------------------------------------------------- /test_httpbin.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | import os 4 | import base64 5 | import unittest 6 | import contextlib 7 | import six 8 | import json 9 | from werkzeug.http import parse_dict_header 10 | from hashlib import md5, sha256, sha512 11 | from six import BytesIO 12 | 13 | import httpbin 14 | from httpbin.helpers import parse_multi_value_header 15 | 16 | 17 | @contextlib.contextmanager 18 | def _setenv(key, value): 19 | """Context manager to set an environment variable temporarily.""" 20 | old_value = os.environ.get(key, None) 21 | if value is None: 22 | os.environ.pop(key, None) 23 | else: 24 | os.environ[key] = value 25 | 26 | yield 27 | 28 | if old_value is None: 29 | os.environ.pop(key, None) 30 | else: 31 | os.environ[key] = value 32 | 33 | 34 | 35 | def _string_to_base64(string): 36 | """Encodes string to utf-8 and then base64""" 37 | utf8_encoded = string.encode('utf-8') 38 | return base64.urlsafe_b64encode(utf8_encoded) 39 | 40 | def _hash(data, algorithm): 41 | """Encode binary data according to specified algorithm, use MD5 by default""" 42 | if algorithm == 'SHA-256': 43 | return sha256(data).hexdigest() 44 | elif algorithm == 'SHA-512': 45 | return sha512(data).hexdigest() 46 | else: 47 | return md5(data).hexdigest() 48 | 49 | def _make_digest_auth_header(username, password, method, uri, nonce, 50 | realm=None, opaque=None, algorithm=None, 51 | qop=None, cnonce=None, nc=None, body=None): 52 | """Compile a digest authentication header string. 53 | 54 | Arguments: 55 | - `nonce`: nonce string, received within "WWW-Authenticate" header 56 | - `realm`: realm string, received within "WWW-Authenticate" header 57 | - `opaque`: opaque string, received within "WWW-Authenticate" header 58 | - `algorithm`: type of hashing algorithm, used by the client 59 | - `qop`: type of quality-of-protection, used by the client 60 | - `cnonce`: client nonce, required if qop is "auth" or "auth-int" 61 | - `nc`: client nonce count, required if qop is "auth" or "auth-int" 62 | - `body`: body of the outgoing request (bytes), used if qop is "auth-int" 63 | """ 64 | 65 | assert username 66 | assert password 67 | assert nonce 68 | assert method 69 | assert uri 70 | assert algorithm in ('MD5', 'SHA-256', 'SHA-512', None) 71 | 72 | a1 = ':'.join([username, realm or '', password]) 73 | ha1 = _hash(a1.encode('utf-8'), algorithm) 74 | 75 | a2 = ':'.join([method, uri]) 76 | if qop == 'auth-int': 77 | a2 = ':'.join([a2, _hash(body or b'', algorithm)]) 78 | ha2 = _hash(a2.encode('utf-8'), algorithm) 79 | 80 | a3 = ':'.join([ha1, nonce]) 81 | if qop in ('auth', 'auth-int'): 82 | assert cnonce 83 | assert nc 84 | a3 = ':'.join([a3, nc, cnonce, qop]) 85 | 86 | a3 = ':'.join([a3, ha2]) 87 | auth_response = _hash(a3.encode('utf-8'), algorithm) 88 | 89 | auth_header = \ 90 | 'Digest username="{0}", response="{1}", uri="{2}", nonce="{3}"'\ 91 | .format(username, auth_response, uri, nonce) 92 | 93 | # 'realm' and 'opaque' should be returned unchanged, even if empty 94 | if realm != None: 95 | auth_header += ', realm="{0}"'.format(realm) 96 | if opaque != None: 97 | auth_header += ', opaque="{0}"'.format(opaque) 98 | 99 | if algorithm: 100 | auth_header += ', algorithm="{0}"'.format(algorithm) 101 | if cnonce: 102 | auth_header += ', cnonce="{0}"'.format(cnonce) 103 | if nc: 104 | auth_header += ', nc={0}'.format(nc) 105 | if qop: 106 | auth_header += ', qop={0}'.format(qop) 107 | 108 | return auth_header 109 | 110 | class HttpbinTestCase(unittest.TestCase): 111 | """Httpbin tests""" 112 | 113 | def setUp(self): 114 | httpbin.app.debug = True 115 | self.app = httpbin.app.test_client() 116 | 117 | def test_index(self): 118 | response = self.app.get('/', headers={'User-Agent': 'test'}) 119 | self.assertEqual(response.status_code, 200) 120 | 121 | def get_data(self, response): 122 | if 'get_data' in dir(response): 123 | return response.get_data() 124 | else: 125 | return response.data 126 | 127 | def test_response_headers_simple(self): 128 | supported_verbs = ['get', 'post'] 129 | for verb in supported_verbs: 130 | method = getattr(self.app, verb) 131 | response = method('/response-headers?animal=dog') 132 | self.assertEqual(response.status_code, 200) 133 | self.assertEqual(response.headers.get_all('animal'), ['dog']) 134 | assert json.loads(response.data.decode('utf-8'))['animal'] == 'dog' 135 | 136 | def test_response_headers_multi(self): 137 | supported_verbs = ['get', 'post'] 138 | for verb in supported_verbs: 139 | method = getattr(self.app, verb) 140 | response = method('/response-headers?animal=dog&animal=cat') 141 | self.assertEqual(response.status_code, 200) 142 | self.assertEqual(response.headers.get_all('animal'), ['dog', 'cat']) 143 | assert json.loads(response.data.decode('utf-8'))['animal'] == ['dog', 'cat'] 144 | 145 | def test_get(self): 146 | response = self.app.get('/get', headers={'User-Agent': 'test'}) 147 | self.assertEqual(response.status_code, 200) 148 | data = json.loads(response.data.decode('utf-8')) 149 | self.assertEqual(data['args'], {}) 150 | self.assertEqual(data['headers']['Host'], 'localhost') 151 | self.assertEqual(data['headers']['Content-Length'], '0') 152 | self.assertEqual(data['headers']['User-Agent'], 'test') 153 | # self.assertEqual(data['origin'], None) 154 | self.assertEqual(data['url'], 'http://localhost/get') 155 | self.assertTrue(response.data.endswith(b'\n')) 156 | 157 | def test_anything(self): 158 | response = self.app.get('/anything') 159 | self.assertEqual(response.status_code, 200) 160 | response = self.app.get('/anything/foo/bar') 161 | self.assertEqual(response.status_code, 200) 162 | data = json.loads(response.data.decode('utf-8')) 163 | self.assertEqual(data['args'], {}) 164 | self.assertEqual(data['headers']['Host'], 'localhost') 165 | self.assertEqual(data['headers']['Content-Length'], '0') 166 | self.assertEqual(data['url'], 'http://localhost/anything/foo/bar') 167 | self.assertEqual(data['method'], 'GET') 168 | self.assertTrue(response.data.endswith(b'\n')) 169 | 170 | def test_base64(self): 171 | greeting = u'Здравствуй, мир!' 172 | b64_encoded = _string_to_base64(greeting) 173 | response = self.app.get(b'/base64/' + b64_encoded) 174 | content = response.data.decode('utf-8') 175 | self.assertEqual(greeting, content) 176 | 177 | def test_post_binary(self): 178 | response = self.app.post('/post', 179 | data=b'\x01\x02\x03\x81\x82\x83', 180 | content_type='application/octet-stream') 181 | self.assertEqual(response.status_code, 200) 182 | 183 | def test_post_body_text(self): 184 | with open('httpbin/core.py') as f: 185 | response = self.app.post('/post', data={"file": f.read()}) 186 | self.assertEqual(response.status_code, 200) 187 | 188 | def test_post_body_binary(self): 189 | response = self.app.post( 190 | '/post', 191 | data={"file": b'\x01\x02\x03\x81\x82\x83'}) 192 | self.assertEqual(response.status_code, 200) 193 | 194 | def test_post_body_unicode(self): 195 | response = self.app.post('/post', data=u'оживлённым'.encode('utf-8')) 196 | self.assertEqual(json.loads(response.data.decode('utf-8'))['data'], u'оживлённым') 197 | 198 | def test_post_file_with_missing_content_type_header(self): 199 | # I built up the form data manually here because I couldn't find a way 200 | # to convince the werkzeug test client to send files without the 201 | # content-type of the file set. 202 | data = '--bound\r\nContent-Disposition: form-data; name="media"; ' 203 | data += 'filename="test.bin"\r\n\r\n\xa5\xc6\n--bound--\r\n' 204 | response = self.app.post( 205 | '/post', 206 | content_type='multipart/form-data; boundary=bound', 207 | data=data, 208 | ) 209 | self.assertEqual(response.status_code, 200) 210 | 211 | """ 212 | This is currently a sort of negative-test. 213 | We validate that when running Flask-only server that 214 | Transfer-Encoding: chunked requests are unsupported and 215 | we return 501 Not Implemented 216 | """ 217 | def test_post_chunked(self): 218 | data = '{"animal":"dog"}' 219 | response = self.app.post( 220 | '/post', 221 | content_type='application/json', 222 | headers=[('Transfer-Encoding', 'chunked')], 223 | data=data, 224 | ) 225 | self.assertEqual(response.status_code, 501) 226 | #self.assertEqual(response.status_code, 200) 227 | #self.assertEqual(json.loads(response.data.decode('utf-8'))['data'], '{"animal":"dog"}') 228 | #self.assertEqual(json.loads(response.data.decode('utf-8'))['json'], {"animal": "dog"}) 229 | 230 | def test_set_cors_headers_after_request(self): 231 | response = self.app.get('/get') 232 | self.assertEqual( 233 | response.headers.get('Access-Control-Allow-Origin'), '*' 234 | ) 235 | 236 | def test_set_cors_credentials_headers_after_auth_request(self): 237 | response = self.app.get('/basic-auth/foo/bar') 238 | self.assertEqual( 239 | response.headers.get('Access-Control-Allow-Credentials'), 'true' 240 | ) 241 | 242 | def test_set_cors_headers_after_request_with_request_origin(self): 243 | response = self.app.get('/get', headers={'Origin': 'origin'}) 244 | self.assertEqual( 245 | response.headers.get('Access-Control-Allow-Origin'), 'origin' 246 | ) 247 | 248 | def test_set_cors_headers_with_options_verb(self): 249 | response = self.app.open('/get', method='OPTIONS') 250 | self.assertEqual( 251 | response.headers.get('Access-Control-Allow-Origin'), '*' 252 | ) 253 | self.assertEqual( 254 | response.headers.get('Access-Control-Allow-Credentials'), 'true' 255 | ) 256 | self.assertEqual( 257 | response.headers.get('Access-Control-Allow-Methods'), 258 | 'GET, POST, PUT, DELETE, PATCH, OPTIONS' 259 | ) 260 | self.assertEqual( 261 | response.headers.get('Access-Control-Max-Age'), '3600' 262 | ) 263 | # FIXME should we add any extra headers? 264 | self.assertNotIn( 265 | 'Access-Control-Allow-Headers', response.headers 266 | ) 267 | def test_set_cors_allow_headers(self): 268 | response = self.app.open('/get', method='OPTIONS', headers={'Access-Control-Request-Headers': 'X-Test-Header'}) 269 | self.assertEqual( 270 | response.headers.get('Access-Control-Allow-Headers'), 'X-Test-Header' 271 | ) 272 | 273 | def test_headers(self): 274 | headers = { 275 | "Accept": "*/*", 276 | "Host": "localhost:1234", 277 | "User-Agent": "curl/7.54.0", 278 | "Via": "bar" 279 | } 280 | response = self.app.get('/headers', headers=headers) 281 | self.assertEqual(response.status_code, 200) 282 | self.assertTrue({'Accept', 'Host', 'User-Agent'}.issubset(set(response.json['headers'].keys()))) 283 | self.assertNotIn('Via', response.json) 284 | 285 | def test_headers_show_env(self): 286 | headers = { 287 | "Accept": "*/*", 288 | "Host": "localhost:1234", 289 | "User-Agent": "curl/7.54.0", 290 | "Via": "bar" 291 | } 292 | response = self.app.get('/headers?show_env=true', headers=headers) 293 | self.assertEqual(response.status_code, 200) 294 | self.assertTrue({'Accept', 'Host', 'User-Agent', 'Via'}.issubset(set(response.json['headers'].keys()))) 295 | 296 | def test_user_agent(self): 297 | response = self.app.get( 298 | '/user-agent', headers={'User-Agent': 'test'} 299 | ) 300 | self.assertIn('test', response.data.decode('utf-8')) 301 | self.assertEqual(response.status_code, 200) 302 | 303 | def test_gzip(self): 304 | response = self.app.get('/gzip') 305 | self.assertEqual(response.status_code, 200) 306 | 307 | def test_brotli(self): 308 | response = self.app.get('/brotli') 309 | self.assertEqual(response.status_code, 200) 310 | 311 | def test_bearer_auth(self): 312 | token = 'abcd1234' 313 | response = self.app.get( 314 | '/bearer', 315 | headers={'Authorization': 'Bearer ' + token} 316 | ) 317 | self.assertEqual(response.status_code, 200) 318 | assert json.loads(response.data.decode('utf-8'))['token'] == token 319 | 320 | def test_bearer_auth_with_wrong_authorization_type(self): 321 | """Sending an non-Bearer Authorization header to /bearer should return a 401""" 322 | auth_headers = ( 323 | ('Authorization', 'Basic 1234abcd'), 324 | ('Authorization', ''), 325 | ('', '') 326 | ) 327 | for header in auth_headers: 328 | response = self.app.get( 329 | '/bearer', 330 | headers={header[0]: header[1]} 331 | ) 332 | self.assertEqual(response.status_code, 401) 333 | 334 | def test_bearer_auth_with_missing_token(self): 335 | """Sending an 'Authorization: Bearer' header with no token to /bearer should return a 401""" 336 | response = self.app.get( 337 | '/bearer', 338 | headers={'Authorization': 'Bearer'} 339 | ) 340 | self.assertEqual(response.status_code, 401) 341 | 342 | def test_digest_auth_with_wrong_password(self): 343 | auth_header = 'Digest username="user",realm="wrong",nonce="wrong",uri="/digest-auth/user/passwd/MD5",response="wrong",opaque="wrong"' 344 | response = self.app.get( 345 | '/digest-auth/auth/user/passwd/MD5', 346 | environ_base={ 347 | # httpbin's digest auth implementation uses the remote addr to 348 | # build the nonce 349 | 'REMOTE_ADDR': '127.0.0.1', 350 | }, 351 | headers={ 352 | 'Authorization': auth_header, 353 | } 354 | ) 355 | self.assertTrue('Digest' in response.headers.get('WWW-Authenticate')) 356 | self.assertEqual(response.status_code, 401) 357 | 358 | def test_digest_auth(self): 359 | """Test different combinations of digest auth parameters""" 360 | username = 'user' 361 | password = 'passwd' 362 | for qop in None, 'auth', 'auth-int',: 363 | for algorithm in None, 'MD5', 'SHA-256', 'SHA-512': 364 | for body in None, b'', b'request payload': 365 | for stale_after in (None, 1, 4) if algorithm else (None,) : 366 | self._test_digest_auth(username, password, qop, algorithm, body, stale_after) 367 | 368 | def test_digest_auth_with_wrong_authorization_type(self): 369 | """Sending an non-digest Authorization header to /digest-auth should return a 401""" 370 | auth_headers = ( 371 | ('Authorization', 'Basic 1234abcd'), 372 | ('Authorization', ''), 373 | ('', '') 374 | ) 375 | for header in auth_headers: 376 | response = self.app.get( 377 | '/digest-auth/auth/myname/mysecret', 378 | headers={header[0]: header[1]} 379 | ) 380 | self.assertEqual(response.status_code, 401) 381 | 382 | def _test_digest_auth(self, username, password, qop, algorithm=None, body=None, stale_after=None): 383 | uri = self._digest_auth_create_uri(username, password, qop, algorithm, stale_after) 384 | 385 | unauthorized_response = self._test_digest_auth_first_challenge(uri) 386 | 387 | header = unauthorized_response.headers.get('WWW-Authenticate') 388 | 389 | authorized_response, nonce = self._test_digest_response_for_auth_request(header, username, password, qop, uri, body) 390 | self.assertEqual(authorized_response.status_code, 200) 391 | 392 | if None == stale_after : 393 | return 394 | 395 | # test stale after scenerio 396 | self._digest_auth_stale_after_check(header, username, password, uri, body, qop, stale_after) 397 | 398 | def _test_digest_auth_first_challenge(self, uri): 399 | unauthorized_response = self.app.get( 400 | uri, 401 | environ_base={ 402 | # digest auth uses the remote addr to build the nonce 403 | 'REMOTE_ADDR': '127.0.0.1', 404 | } 405 | ) 406 | # make sure it returns a 401 407 | self.assertEqual(unauthorized_response.status_code, 401) 408 | return unauthorized_response 409 | 410 | def _digest_auth_create_uri(self, username, password, qop, algorithm, stale_after): 411 | uri = '/digest-auth/{0}/{1}/{2}'.format(qop or 'wrong-qop', username, password) 412 | if algorithm: 413 | uri += '/' + algorithm 414 | if stale_after: 415 | uri += '/{0}'.format(stale_after) 416 | return uri 417 | 418 | def _digest_auth_stale_after_check(self, header, username, password, uri, body, qop, stale_after): 419 | for nc in range(2, stale_after + 1): 420 | authorized_response, nonce = self._test_digest_response_for_auth_request(header, username, password, qop, uri, \ 421 | body, nc) 422 | self.assertEqual(authorized_response.status_code, 200) 423 | stale_response, nonce = self._test_digest_response_for_auth_request(header, username, password, qop, uri, \ 424 | body, stale_after + 1) 425 | self.assertEqual(stale_response.status_code, 401) 426 | header = stale_response.headers.get('WWW-Authenticate') 427 | self.assertIn('stale=TRUE', header) 428 | 429 | def _test_digest_response_for_auth_request(self, header, username, password, qop, uri, body, nc=1, nonce=None): 430 | auth_type, auth_info = header.split(None, 1) 431 | self.assertEqual(auth_type, 'Digest') 432 | 433 | d = parse_dict_header(auth_info) 434 | 435 | nonce = nonce or d['nonce'] 436 | realm = d['realm'] 437 | opaque = d['opaque'] 438 | if qop : 439 | self.assertIn(qop, [x.strip() for x in d['qop'].split(',')], 'Challenge should contains expected qop') 440 | algorithm = d['algorithm'] 441 | 442 | cnonce, nc = (_hash(os.urandom(10), "MD5"), '{:08}'.format(nc)) if qop in ('auth', 'auth-int') else (None, None) 443 | 444 | auth_header = _make_digest_auth_header( 445 | username, password, 'GET', uri, nonce, realm, opaque, algorithm, qop, cnonce, nc, body) 446 | 447 | # make second request 448 | return self.app.get( 449 | uri, 450 | environ_base={ 451 | # httpbin's digest auth implementation uses the remote addr to 452 | # build the nonce 453 | 'REMOTE_ADDR': '127.0.0.1', 454 | }, 455 | headers={ 456 | 'Authorization': auth_header, 457 | }, 458 | data=body 459 | ), nonce 460 | 461 | def test_digest_auth_wrong_pass(self): 462 | """Test different combinations of digest auth parameters""" 463 | username = 'user' 464 | password = 'passwd' 465 | for qop in None, 'auth', 'auth-int',: 466 | for algorithm in None, 'MD5', 'SHA-256', 'SHA-512': 467 | for body in None, b'', b'request payload': 468 | self._test_digest_auth_wrong_pass(username, password, qop, algorithm, body, 3) 469 | 470 | def _test_digest_auth_wrong_pass(self, username, password, qop, algorithm=None, body=None, stale_after=None): 471 | uri = self._digest_auth_create_uri(username, password, qop, algorithm, stale_after) 472 | unauthorized_response = self._test_digest_auth_first_challenge(uri) 473 | 474 | header = unauthorized_response.headers.get('WWW-Authenticate') 475 | 476 | wrong_pass_response, nonce = self._test_digest_response_for_auth_request(header, username, "wrongPassword", qop, uri, body) 477 | self.assertEqual(wrong_pass_response.status_code, 401) 478 | header = wrong_pass_response.headers.get('WWW-Authenticate') 479 | self.assertNotIn('stale=TRUE', header) 480 | 481 | reused_nonce_response, nonce = self._test_digest_response_for_auth_request(header, username, password, qop, uri, \ 482 | body, nonce=nonce) 483 | self.assertEqual(reused_nonce_response.status_code, 401) 484 | header = reused_nonce_response.headers.get('WWW-Authenticate') 485 | self.assertIn('stale=TRUE', header) 486 | 487 | def test_drip(self): 488 | response = self.app.get('/drip?numbytes=400&duration=2&delay=1') 489 | self.assertEqual(response.content_length, 400) 490 | self.assertEqual(len(self.get_data(response)), 400) 491 | self.assertEqual(response.status_code, 200) 492 | 493 | def test_drip_with_invalid_numbytes(self): 494 | for bad_num in -1, 0: 495 | uri = '/drip?numbytes={0}&duration=2&delay=1'.format(bad_num) 496 | response = self.app.get(uri) 497 | self.assertEqual(response.status_code, 400) 498 | 499 | def test_drip_with_custom_code(self): 500 | response = self.app.get('/drip?numbytes=400&duration=2&code=500') 501 | self.assertEqual(response.content_length, 400) 502 | self.assertEqual(len(self.get_data(response)), 400) 503 | self.assertEqual(response.status_code, 500) 504 | 505 | def test_get_bytes(self): 506 | response = self.app.get('/bytes/1024') 507 | self.assertEqual(len(self.get_data(response)), 1024) 508 | self.assertEqual(response.status_code, 200) 509 | 510 | def test_bytes_with_seed(self): 511 | response = self.app.get('/bytes/10?seed=0') 512 | # The RNG changed in python3, so even though we are 513 | # setting the seed, we can't expect the value to be the 514 | # same across both interpreters. 515 | if six.PY3: 516 | self.assertEqual( 517 | response.data, b'\xc5\xd7\x14\x84\xf8\xcf\x9b\xf4\xb7o' 518 | ) 519 | else: 520 | self.assertEqual( 521 | response.data, b'\xd8\xc2kB\x82g\xc8Mz\x95' 522 | ) 523 | 524 | def test_stream_bytes(self): 525 | response = self.app.get('/stream-bytes/1024') 526 | self.assertEqual(len(self.get_data(response)), 1024) 527 | self.assertEqual(response.status_code, 200) 528 | 529 | def test_stream_bytes_with_seed(self): 530 | response = self.app.get('/stream-bytes/10?seed=0') 531 | # The RNG changed in python3, so even though we are 532 | # setting the seed, we can't expect the value to be the 533 | # same across both interpreters. 534 | if six.PY3: 535 | self.assertEqual( 536 | response.data, b'\xc5\xd7\x14\x84\xf8\xcf\x9b\xf4\xb7o' 537 | ) 538 | else: 539 | self.assertEqual( 540 | response.data, b'\xd8\xc2kB\x82g\xc8Mz\x95' 541 | ) 542 | 543 | def test_delete_endpoint_returns_body(self): 544 | response = self.app.delete( 545 | '/delete', 546 | data={'name': 'kevin'}, 547 | content_type='application/x-www-form-urlencoded' 548 | ) 549 | form_data = json.loads(response.data.decode('utf-8'))['form'] 550 | self.assertEqual(form_data, {'name': 'kevin'}) 551 | 552 | def test_methods__to_status_endpoint(self): 553 | methods = [ 554 | 'GET', 555 | 'HEAD', 556 | 'POST', 557 | 'PUT', 558 | 'DELETE', 559 | 'PATCH', 560 | 'TRACE', 561 | ] 562 | for m in methods: 563 | response = self.app.open(path='/status/418', method=m) 564 | self.assertEqual(response.status_code, 418) 565 | 566 | def test_status_endpoint_invalid_code(self): 567 | response = self.app.get(path='/status/4!9') 568 | self.assertEqual(response.status_code, 400) 569 | 570 | def test_status_endpoint_invalid_codes(self): 571 | response = self.app.get(path='/status/200,402,foo') 572 | self.assertEqual(response.status_code, 400) 573 | 574 | def test_xml_endpoint(self): 575 | response = self.app.get(path='/xml') 576 | self.assertEqual( 577 | response.headers.get('Content-Type'), 'application/xml' 578 | ) 579 | 580 | def test_x_forwarded_proto(self): 581 | response = self.app.get(path='/get', headers={ 582 | 'X-Forwarded-Proto':'https' 583 | }) 584 | assert json.loads(response.data.decode('utf-8'))['url'].startswith('https://') 585 | 586 | def test_redirect_n_higher_than_1(self): 587 | response = self.app.get('/redirect/5') 588 | self.assertEqual( 589 | response.headers.get('Location'), '/relative-redirect/4' 590 | ) 591 | 592 | def test_redirect_to_post(self): 593 | response = self.app.post('/redirect-to?url=/post&status_code=307', 594 | data=b'\x01\x02\x03\x81\x82\x83', 595 | content_type='application/octet-stream') 596 | self.assertEqual(response.status_code, 307) 597 | self.assertEqual( 598 | response.headers.get('Location'), '/post' 599 | ) 600 | 601 | def test_redirect_absolute_param_n_higher_than_1(self): 602 | response = self.app.get('/redirect/5?absolute=true') 603 | self.assertEqual( 604 | response.headers.get('Location'), 'http://localhost/absolute-redirect/4' 605 | ) 606 | 607 | def test_redirect_n_equals_to_1(self): 608 | response = self.app.get('/redirect/1') 609 | self.assertEqual(response.status_code, 302) 610 | self.assertEqual( 611 | response.headers.get('Location'), '/get' 612 | ) 613 | 614 | def test_relative_redirect_n_equals_to_1(self): 615 | response = self.app.get('/relative-redirect/1') 616 | self.assertEqual( 617 | response.headers.get('Location'), '/get' 618 | ) 619 | 620 | def test_relative_redirect_n_higher_than_1(self): 621 | response = self.app.get('/relative-redirect/7') 622 | self.assertEqual(response.status_code, 302) 623 | self.assertEqual( 624 | response.headers.get('Location'), '/relative-redirect/6' 625 | ) 626 | 627 | def test_absolute_redirect_n_higher_than_1(self): 628 | response = self.app.get('/absolute-redirect/5') 629 | self.assertEqual( 630 | response.headers.get('Location'), 'http://localhost/absolute-redirect/4' 631 | ) 632 | 633 | def test_absolute_redirect_n_equals_to_1(self): 634 | response = self.app.get('/absolute-redirect/1') 635 | self.assertEqual(response.status_code, 302) 636 | self.assertEqual( 637 | response.headers.get('Location'), 'http://localhost/get' 638 | ) 639 | 640 | def test_request_range(self): 641 | response1 = self.app.get('/range/1234') 642 | self.assertEqual(response1.status_code, 200) 643 | self.assertEqual(response1.headers.get('ETag'), 'range1234') 644 | self.assertEqual(response1.headers.get('Content-range'), 'bytes 0-1233/1234') 645 | self.assertEqual(response1.headers.get('Accept-ranges'), 'bytes') 646 | self.assertEqual(len(self.get_data(response1)), 1234) 647 | 648 | response2 = self.app.get('/range/1234') 649 | self.assertEqual(response2.status_code, 200) 650 | self.assertEqual(response2.headers.get('ETag'), 'range1234') 651 | self.assertEqual(self.get_data(response1), self.get_data(response2)) 652 | 653 | def test_request_range_with_parameters(self): 654 | response = self.app.get( 655 | '/range/100?duration=1.5&chunk_size=5', 656 | headers={ 'Range': 'bytes=10-24' } 657 | ) 658 | 659 | self.assertEqual(response.status_code, 206) 660 | self.assertEqual(response.headers.get('ETag'), 'range100') 661 | self.assertEqual(response.headers.get('Content-range'), 'bytes 10-24/100') 662 | self.assertEqual(response.headers.get('Accept-ranges'), 'bytes') 663 | self.assertEqual(response.headers.get('Content-Length'), '15') 664 | self.assertEqual(self.get_data(response), 'klmnopqrstuvwxy'.encode('utf8')) 665 | 666 | def test_request_range_first_15_bytes(self): 667 | response = self.app.get( 668 | '/range/1000', 669 | headers={ 'Range': 'bytes=0-15' } 670 | ) 671 | 672 | self.assertEqual(response.status_code, 206) 673 | self.assertEqual(response.headers.get('ETag'), 'range1000') 674 | self.assertEqual(self.get_data(response), 'abcdefghijklmnop'.encode('utf8')) 675 | self.assertEqual(response.headers.get('Content-range'), 'bytes 0-15/1000') 676 | 677 | def test_request_range_open_ended_last_6_bytes(self): 678 | response = self.app.get( 679 | '/range/26', 680 | headers={ 'Range': 'bytes=20-' } 681 | ) 682 | 683 | self.assertEqual(response.status_code, 206) 684 | self.assertEqual(response.headers.get('ETag'), 'range26') 685 | self.assertEqual(self.get_data(response), 'uvwxyz'.encode('utf8')) 686 | self.assertEqual(response.headers.get('Content-range'), 'bytes 20-25/26') 687 | self.assertEqual(response.headers.get('Content-Length'), '6') 688 | 689 | def test_request_range_suffix(self): 690 | response = self.app.get( 691 | '/range/26', 692 | headers={ 'Range': 'bytes=-5' } 693 | ) 694 | 695 | self.assertEqual(response.status_code, 206) 696 | self.assertEqual(response.headers.get('ETag'), 'range26') 697 | self.assertEqual(self.get_data(response), 'vwxyz'.encode('utf8')) 698 | self.assertEqual(response.headers.get('Content-range'), 'bytes 21-25/26') 699 | self.assertEqual(response.headers.get('Content-Length'), '5') 700 | 701 | def test_request_out_of_bounds(self): 702 | response = self.app.get( 703 | '/range/26', 704 | headers={ 'Range': 'bytes=10-5', 705 | } 706 | ) 707 | 708 | self.assertEqual(response.status_code, 416) 709 | self.assertEqual(response.headers.get('ETag'), 'range26') 710 | self.assertEqual(len(self.get_data(response)), 0) 711 | self.assertEqual(response.headers.get('Content-range'), 'bytes */26') 712 | self.assertEqual(response.headers.get('Content-Length'), '0') 713 | 714 | response = self.app.get( 715 | '/range/26', 716 | headers={ 'Range': 'bytes=32-40', 717 | } 718 | ) 719 | 720 | self.assertEqual(response.status_code, 416) 721 | response = self.app.get( 722 | '/range/26', 723 | headers={ 'Range': 'bytes=0-40', 724 | } 725 | ) 726 | self.assertEqual(response.status_code, 416) 727 | 728 | def test_etag_if_none_match_matches(self): 729 | response = self.app.get( 730 | '/etag/abc', 731 | headers={ 'If-None-Match': 'abc' } 732 | ) 733 | self.assertEqual(response.status_code, 304) 734 | self.assertEqual(response.headers.get('ETag'), 'abc') 735 | 736 | def test_etag_if_none_match_matches_list(self): 737 | response = self.app.get( 738 | '/etag/abc', 739 | headers={ 'If-None-Match': '"123", "abc"' } 740 | ) 741 | self.assertEqual(response.status_code, 304) 742 | self.assertEqual(response.headers.get('ETag'), 'abc') 743 | 744 | def test_etag_if_none_match_matches_star(self): 745 | response = self.app.get( 746 | '/etag/abc', 747 | headers={ 'If-None-Match': '*' } 748 | ) 749 | self.assertEqual(response.status_code, 304) 750 | self.assertEqual(response.headers.get('ETag'), 'abc') 751 | 752 | def test_etag_if_none_match_w_prefix(self): 753 | response = self.app.get( 754 | '/etag/c3piozzzz', 755 | headers={ 'If-None-Match': 'W/"xyzzy", W/"r2d2xxxx", W/"c3piozzzz"' } 756 | ) 757 | self.assertEqual(response.status_code, 304) 758 | self.assertEqual(response.headers.get('ETag'), 'c3piozzzz') 759 | 760 | def test_etag_if_none_match_has_no_match(self): 761 | response = self.app.get( 762 | '/etag/abc', 763 | headers={ 'If-None-Match': '123' } 764 | ) 765 | self.assertEqual(response.status_code, 200) 766 | self.assertEqual(response.headers.get('ETag'), 'abc') 767 | 768 | def test_etag_if_match_matches(self): 769 | response = self.app.get( 770 | '/etag/abc', 771 | headers={ 'If-Match': 'abc' } 772 | ) 773 | self.assertEqual(response.status_code, 200) 774 | self.assertEqual(response.headers.get('ETag'), 'abc') 775 | 776 | def test_etag_if_match_matches_list(self): 777 | response = self.app.get( 778 | '/etag/abc', 779 | headers={ 'If-Match': '"123", "abc"' } 780 | ) 781 | self.assertEqual(response.status_code, 200) 782 | self.assertEqual(response.headers.get('ETag'), 'abc') 783 | 784 | def test_etag_if_match_matches_star(self): 785 | response = self.app.get( 786 | '/etag/abc', 787 | headers={ 'If-Match': '*' } 788 | ) 789 | self.assertEqual(response.status_code, 200) 790 | self.assertEqual(response.headers.get('ETag'), 'abc') 791 | 792 | def test_etag_if_match_has_no_match(self): 793 | response = self.app.get( 794 | '/etag/abc', 795 | headers={ 'If-Match': '123' } 796 | ) 797 | self.assertEqual(response.status_code, 412) 798 | self.assertNotIn('ETag', response.headers) 799 | 800 | def test_etag_with_no_headers(self): 801 | response = self.app.get( 802 | '/etag/abc' 803 | ) 804 | self.assertEqual(response.status_code, 200) 805 | self.assertEqual(response.headers.get('ETag'), 'abc') 806 | 807 | def test_parse_multi_value_header(self): 808 | self.assertEqual(parse_multi_value_header('xyzzy'), [ "xyzzy" ]) 809 | self.assertEqual(parse_multi_value_header('"xyzzy"'), [ "xyzzy" ]) 810 | self.assertEqual(parse_multi_value_header('W/"xyzzy"'), [ "xyzzy" ]) 811 | self.assertEqual(parse_multi_value_header('"xyzzy", "r2d2xxxx", "c3piozzzz"'), [ "xyzzy", "r2d2xxxx", "c3piozzzz" ]) 812 | self.assertEqual(parse_multi_value_header('W/"xyzzy", W/"r2d2xxxx", W/"c3piozzzz"'), [ "xyzzy", "r2d2xxxx", "c3piozzzz" ]) 813 | self.assertEqual(parse_multi_value_header('*'), [ "*" ]) 814 | 815 | if __name__ == '__main__': 816 | unittest.main() 817 | -------------------------------------------------------------------------------- /httpbin/core.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | httpbin.core 5 | ~~~~~~~~~~~~ 6 | 7 | This module provides the core HttpBin experience. 8 | """ 9 | 10 | import base64 11 | import json 12 | import os 13 | import random 14 | import time 15 | import uuid 16 | import argparse 17 | 18 | from flask import ( 19 | Flask, 20 | Response, 21 | request, 22 | render_template, 23 | redirect, 24 | jsonify as flask_jsonify, 25 | make_response, 26 | url_for, 27 | abort, 28 | ) 29 | from six.moves import range as xrange 30 | from werkzeug.datastructures import WWWAuthenticate, MultiDict 31 | from werkzeug.http import http_date 32 | from werkzeug.wrappers import BaseResponse 33 | from werkzeug.http import parse_authorization_header 34 | from flasgger import Swagger, NO_SANITIZER 35 | 36 | from . import filters 37 | from .helpers import ( 38 | get_headers, 39 | status_code, 40 | get_dict, 41 | get_request_range, 42 | check_basic_auth, 43 | check_digest_auth, 44 | secure_cookie, 45 | H, 46 | ROBOT_TXT, 47 | ANGRY_ASCII, 48 | parse_multi_value_header, 49 | next_stale_after_value, 50 | digest_challenge_response, 51 | ) 52 | from .utils import weighted_choice 53 | from .structures import CaseInsensitiveDict 54 | 55 | with open( 56 | os.path.join(os.path.realpath(os.path.dirname(__file__)), "VERSION") 57 | ) as version_file: 58 | version = version_file.read().strip() 59 | 60 | ENV_COOKIES = ( 61 | "_gauges_unique", 62 | "_gauges_unique_year", 63 | "_gauges_unique_month", 64 | "_gauges_unique_day", 65 | "_gauges_unique_hour", 66 | "__utmz", 67 | "__utma", 68 | "__utmb", 69 | ) 70 | 71 | 72 | def jsonify(*args, **kwargs): 73 | response = flask_jsonify(*args, **kwargs) 74 | if not response.data.endswith(b"\n"): 75 | response.data += b"\n" 76 | return response 77 | 78 | 79 | # Prevent WSGI from correcting the casing of the Location header 80 | BaseResponse.autocorrect_location_header = False 81 | 82 | # Find the correct template folder when running from a different location 83 | tmpl_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "templates") 84 | 85 | app = Flask(__name__, template_folder=tmpl_dir) 86 | app.debug = bool(os.environ.get("DEBUG")) 87 | app.config["JSONIFY_PRETTYPRINT_REGULAR"] = True 88 | 89 | app.add_template_global("HTTPBIN_TRACKING" in os.environ, name="tracking_enabled") 90 | 91 | app.config["SWAGGER"] = {"title": "httpbin.org", "uiversion": 3} 92 | 93 | template = { 94 | "swagger": "2.0", 95 | "info": { 96 | "title": "httpbin.org", 97 | "description": ( 98 | "A simple HTTP Request & Response Service." 99 | "

Run locally: $ docker run -p 80:80 kennethreitz/httpbin" 100 | ), 101 | "contact": { 102 | "responsibleOrganization": "Kenneth Reitz", 103 | "responsibleDeveloper": "Kenneth Reitz", 104 | "email": "me@kennethreitz.org", 105 | "url": "https://kennethreitz.org", 106 | }, 107 | # "termsOfService": "http://me.com/terms", 108 | "version": version, 109 | }, 110 | "host": "httpbin.org", # overrides localhost:5000 111 | "basePath": "/", # base bash for blueprint registration 112 | "schemes": ["https"], 113 | "protocol": "https", 114 | "tags": [ 115 | { 116 | "name": "HTTP Methods", 117 | "description": "Testing different HTTP verbs", 118 | # 'externalDocs': {'description': 'Learn more', 'url': 'https://www.w3.org/Protocols/rfc2616/rfc2616-sec9.html'} 119 | }, 120 | {"name": "Auth", "description": "Auth methods"}, 121 | { 122 | "name": "Status codes", 123 | "description": "Generates responses with given status code", 124 | }, 125 | {"name": "Request inspection", "description": "Inspect the request data"}, 126 | { 127 | "name": "Response inspection", 128 | "description": "Inspect the response data like caching and headers", 129 | }, 130 | { 131 | "name": "Response formats", 132 | "description": "Returns responses in different data formats", 133 | }, 134 | {"name": "Dynamic data", "description": "Generates random and dynamic data"}, 135 | {"name": "Cookies", "description": "Creates, reads and deletes Cookies"}, 136 | {"name": "Images", "description": "Returns different image formats"}, 137 | {"name": "Redirects", "description": "Returns different redirect responses"}, 138 | { 139 | "name": "Anything", 140 | "description": "Returns anything that is passed to request", 141 | }, 142 | ], 143 | } 144 | 145 | swagger_config = { 146 | "headers": [], 147 | "specs": [ 148 | { 149 | "endpoint": "spec", 150 | "route": "/spec.json", 151 | "rule_filter": lambda rule: True, # all in 152 | "model_filter": lambda tag: True, # all in 153 | } 154 | ], 155 | "static_url_path": "/flasgger_static", 156 | # "static_folder": "static", # must be set by user 157 | "swagger_ui": True, 158 | "specs_route": "/", 159 | } 160 | 161 | swagger = Swagger(app, sanitizer=NO_SANITIZER, template=template, config=swagger_config) 162 | 163 | # Set up Bugsnag exception tracking, if desired. To use Bugsnag, install the 164 | # Bugsnag Python client with the command "pip install bugsnag", and set the 165 | # environment variable BUGSNAG_API_KEY. You can also optionally set 166 | # BUGSNAG_RELEASE_STAGE. 167 | if os.environ.get("BUGSNAG_API_KEY") is not None: 168 | try: 169 | import bugsnag 170 | import bugsnag.flask 171 | 172 | release_stage = os.environ.get("BUGSNAG_RELEASE_STAGE") or "production" 173 | bugsnag.configure( 174 | api_key=os.environ.get("BUGSNAG_API_KEY"), 175 | project_root=os.path.dirname(os.path.abspath(__file__)), 176 | use_ssl=True, 177 | release_stage=release_stage, 178 | ignore_classes=["werkzeug.exceptions.NotFound"], 179 | ) 180 | bugsnag.flask.handle_exceptions(app) 181 | except: 182 | app.logger.warning("Unable to initialize Bugsnag exception handling.") 183 | 184 | # ----------- 185 | # Middlewares 186 | # ----------- 187 | """ 188 | https://github.com/kennethreitz/httpbin/issues/340 189 | Adds a middleware to provide chunked request encoding support running under 190 | gunicorn only. 191 | Werkzeug required environ 'wsgi.input_terminated' to be set otherwise it 192 | empties the input request stream. 193 | - gunicorn seems to support input_terminated but does not add the environ, 194 | so we add it here. 195 | - flask will hang and does not seem to properly terminate the request, so 196 | we explicitly deny chunked requests. 197 | """ 198 | 199 | 200 | @app.before_request 201 | def before_request(): 202 | if request.environ.get("HTTP_TRANSFER_ENCODING", "").lower() == "chunked": 203 | server = request.environ.get("SERVER_SOFTWARE", "") 204 | if server.lower().startswith("gunicorn/"): 205 | if "wsgi.input_terminated" in request.environ: 206 | app.logger.debug( 207 | "environ wsgi.input_terminated already set, keeping: %s" 208 | % request.environ["wsgi.input_terminated"] 209 | ) 210 | else: 211 | request.environ["wsgi.input_terminated"] = 1 212 | else: 213 | abort(501, "Chunked requests are not supported for server %s" % server) 214 | 215 | 216 | @app.after_request 217 | def set_cors_headers(response): 218 | response.headers["Access-Control-Allow-Origin"] = request.headers.get("Origin", "*") 219 | response.headers["Access-Control-Allow-Credentials"] = "true" 220 | 221 | if request.method == "OPTIONS": 222 | # Both of these headers are only used for the "preflight request" 223 | # http://www.w3.org/TR/cors/#access-control-allow-methods-response-header 224 | response.headers[ 225 | "Access-Control-Allow-Methods" 226 | ] = "GET, POST, PUT, DELETE, PATCH, OPTIONS" 227 | response.headers["Access-Control-Max-Age"] = "3600" # 1 hour cache 228 | if request.headers.get("Access-Control-Request-Headers") is not None: 229 | response.headers["Access-Control-Allow-Headers"] = request.headers[ 230 | "Access-Control-Request-Headers" 231 | ] 232 | return response 233 | 234 | 235 | # ------ 236 | # Routes 237 | # ------ 238 | 239 | 240 | @app.route("/legacy") 241 | def view_landing_page(): 242 | """Generates Landing Page in legacy layout.""" 243 | return render_template("index.html") 244 | 245 | 246 | @app.route("/html") 247 | def view_html_page(): 248 | """Returns a simple HTML document. 249 | --- 250 | tags: 251 | - Response formats 252 | produces: 253 | - text/html 254 | responses: 255 | 200: 256 | description: An HTML page. 257 | """ 258 | 259 | return render_template("moby.html") 260 | 261 | 262 | @app.route("/robots.txt") 263 | def view_robots_page(): 264 | """Returns some robots.txt rules. 265 | --- 266 | tags: 267 | - Response formats 268 | produces: 269 | - text/plain 270 | responses: 271 | 200: 272 | description: Robots file 273 | """ 274 | 275 | response = make_response() 276 | response.data = ROBOT_TXT 277 | response.content_type = "text/plain" 278 | return response 279 | 280 | 281 | @app.route("/deny") 282 | def view_deny_page(): 283 | """Returns page denied by robots.txt rules. 284 | --- 285 | tags: 286 | - Response formats 287 | produces: 288 | - text/plain 289 | responses: 290 | 200: 291 | description: Denied message 292 | """ 293 | response = make_response() 294 | response.data = ANGRY_ASCII 295 | response.content_type = "text/plain" 296 | return response 297 | # return "YOU SHOULDN'T BE HERE" 298 | 299 | 300 | @app.route("/ip") 301 | def view_origin(): 302 | """Returns the requester's IP Address. 303 | --- 304 | tags: 305 | - Request inspection 306 | produces: 307 | - application/json 308 | responses: 309 | 200: 310 | description: The Requester's IP Address. 311 | """ 312 | 313 | return jsonify(origin=request.headers.get("X-Forwarded-For", request.remote_addr)) 314 | 315 | 316 | @app.route("/uuid") 317 | def view_uuid(): 318 | """Return a UUID4. 319 | --- 320 | tags: 321 | - Dynamic data 322 | produces: 323 | - application/json 324 | responses: 325 | 200: 326 | description: A UUID4. 327 | """ 328 | 329 | return jsonify(uuid=str(uuid.uuid4())) 330 | 331 | 332 | @app.route("/headers") 333 | def view_headers(): 334 | """Return the incoming request's HTTP headers. 335 | --- 336 | tags: 337 | - Request inspection 338 | produces: 339 | - application/json 340 | responses: 341 | 200: 342 | description: The request's headers. 343 | """ 344 | 345 | return jsonify(get_dict('headers')) 346 | 347 | 348 | @app.route("/user-agent") 349 | def view_user_agent(): 350 | """Return the incoming requests's User-Agent header. 351 | --- 352 | tags: 353 | - Request inspection 354 | produces: 355 | - application/json 356 | responses: 357 | 200: 358 | description: The request's User-Agent header. 359 | """ 360 | 361 | headers = get_headers() 362 | 363 | return jsonify({"user-agent": headers["user-agent"]}) 364 | 365 | 366 | @app.route("/get", methods=("GET",)) 367 | def view_get(): 368 | """The request's query parameters. 369 | --- 370 | tags: 371 | - HTTP Methods 372 | produces: 373 | - application/json 374 | responses: 375 | 200: 376 | description: The request's query parameters. 377 | """ 378 | 379 | return jsonify(get_dict("url", "args", "headers", "origin")) 380 | 381 | 382 | @app.route("/anything", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "TRACE"]) 383 | @app.route( 384 | "/anything/", 385 | methods=["GET", "POST", "PUT", "DELETE", "PATCH", "TRACE"], 386 | ) 387 | def view_anything(anything=None): 388 | """Returns anything passed in request data. 389 | --- 390 | tags: 391 | - Anything 392 | produces: 393 | - application/json 394 | responses: 395 | 200: 396 | description: Anything passed in request 397 | """ 398 | 399 | return jsonify( 400 | get_dict( 401 | "url", 402 | "args", 403 | "headers", 404 | "origin", 405 | "method", 406 | "form", 407 | "data", 408 | "files", 409 | "json", 410 | ) 411 | ) 412 | 413 | 414 | @app.route("/post", methods=("POST",)) 415 | def view_post(): 416 | """The request's POST parameters. 417 | --- 418 | tags: 419 | - HTTP Methods 420 | produces: 421 | - application/json 422 | responses: 423 | 200: 424 | description: The request's POST parameters. 425 | """ 426 | 427 | return jsonify( 428 | get_dict("url", "args", "form", "data", "origin", "headers", "files", "json") 429 | ) 430 | 431 | 432 | @app.route("/put", methods=("PUT",)) 433 | def view_put(): 434 | """The request's PUT parameters. 435 | --- 436 | tags: 437 | - HTTP Methods 438 | produces: 439 | - application/json 440 | responses: 441 | 200: 442 | description: The request's PUT parameters. 443 | """ 444 | 445 | return jsonify( 446 | get_dict("url", "args", "form", "data", "origin", "headers", "files", "json") 447 | ) 448 | 449 | 450 | @app.route("/patch", methods=("PATCH",)) 451 | def view_patch(): 452 | """The request's PATCH parameters. 453 | --- 454 | tags: 455 | - HTTP Methods 456 | produces: 457 | - application/json 458 | responses: 459 | 200: 460 | description: The request's PATCH parameters. 461 | """ 462 | 463 | return jsonify( 464 | get_dict("url", "args", "form", "data", "origin", "headers", "files", "json") 465 | ) 466 | 467 | 468 | @app.route("/delete", methods=("DELETE",)) 469 | def view_delete(): 470 | """The request's DELETE parameters. 471 | --- 472 | tags: 473 | - HTTP Methods 474 | produces: 475 | - application/json 476 | responses: 477 | 200: 478 | description: The request's DELETE parameters. 479 | """ 480 | 481 | return jsonify( 482 | get_dict("url", "args", "form", "data", "origin", "headers", "files", "json") 483 | ) 484 | 485 | 486 | @app.route("/gzip") 487 | @filters.gzip 488 | def view_gzip_encoded_content(): 489 | """Returns GZip-encoded data. 490 | --- 491 | tags: 492 | - Response formats 493 | produces: 494 | - application/json 495 | responses: 496 | 200: 497 | description: GZip-encoded data. 498 | """ 499 | 500 | return jsonify(get_dict("origin", "headers", method=request.method, gzipped=True)) 501 | 502 | 503 | @app.route("/deflate") 504 | @filters.deflate 505 | def view_deflate_encoded_content(): 506 | """Returns Deflate-encoded data. 507 | --- 508 | tags: 509 | - Response formats 510 | produces: 511 | - application/json 512 | responses: 513 | 200: 514 | description: Defalte-encoded data. 515 | """ 516 | 517 | return jsonify(get_dict("origin", "headers", method=request.method, deflated=True)) 518 | 519 | 520 | @app.route("/brotli") 521 | @filters.brotli 522 | def view_brotli_encoded_content(): 523 | """Returns Brotli-encoded data. 524 | --- 525 | tags: 526 | - Response formats 527 | produces: 528 | - application/json 529 | responses: 530 | 200: 531 | description: Brotli-encoded data. 532 | """ 533 | 534 | return jsonify(get_dict("origin", "headers", method=request.method, brotli=True)) 535 | 536 | 537 | @app.route("/redirect/") 538 | def redirect_n_times(n): 539 | """302 Redirects n times. 540 | --- 541 | tags: 542 | - Redirects 543 | parameters: 544 | - in: path 545 | name: n 546 | type: int 547 | produces: 548 | - text/html 549 | responses: 550 | 302: 551 | description: A redirection. 552 | """ 553 | assert n > 0 554 | 555 | absolute = request.args.get("absolute", "false").lower() == "true" 556 | 557 | if n == 1: 558 | return redirect(url_for("view_get", _external=absolute)) 559 | 560 | if absolute: 561 | return _redirect("absolute", n, True) 562 | else: 563 | return _redirect("relative", n, False) 564 | 565 | 566 | def _redirect(kind, n, external): 567 | return redirect( 568 | url_for("{0}_redirect_n_times".format(kind), n=n - 1, _external=external) 569 | ) 570 | 571 | 572 | @app.route("/redirect-to", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "TRACE"]) 573 | def redirect_to(): 574 | """302/3XX Redirects to the given URL. 575 | --- 576 | tags: 577 | - Redirects 578 | produces: 579 | - text/html 580 | get: 581 | parameters: 582 | - in: query 583 | name: url 584 | type: string 585 | required: true 586 | - in: query 587 | name: status_code 588 | type: int 589 | post: 590 | consumes: 591 | - application/x-www-form-urlencoded 592 | parameters: 593 | - in: formData 594 | name: url 595 | type: string 596 | required: true 597 | - in: formData 598 | name: status_code 599 | type: int 600 | required: false 601 | patch: 602 | consumes: 603 | - application/x-www-form-urlencoded 604 | parameters: 605 | - in: formData 606 | name: url 607 | type: string 608 | required: true 609 | - in: formData 610 | name: status_code 611 | type: int 612 | required: false 613 | put: 614 | consumes: 615 | - application/x-www-form-urlencoded 616 | parameters: 617 | - in: formData 618 | name: url 619 | type: string 620 | required: true 621 | - in: formData 622 | name: status_code 623 | type: int 624 | required: false 625 | responses: 626 | 302: 627 | description: A redirection. 628 | """ 629 | 630 | args_dict = request.args.items() 631 | args = CaseInsensitiveDict(args_dict) 632 | 633 | # We need to build the response manually and convert to UTF-8 to prevent 634 | # werkzeug from "fixing" the URL. This endpoint should set the Location 635 | # header to the exact string supplied. 636 | response = app.make_response("") 637 | response.status_code = 302 638 | if "status_code" in args: 639 | status_code = int(args["status_code"]) 640 | if status_code >= 300 and status_code < 400: 641 | response.status_code = status_code 642 | response.headers["Location"] = args["url"].encode("utf-8") 643 | 644 | return response 645 | 646 | 647 | @app.route("/relative-redirect/") 648 | def relative_redirect_n_times(n): 649 | """Relatively 302 Redirects n times. 650 | --- 651 | tags: 652 | - Redirects 653 | parameters: 654 | - in: path 655 | name: n 656 | type: int 657 | produces: 658 | - text/html 659 | responses: 660 | 302: 661 | description: A redirection. 662 | """ 663 | 664 | assert n > 0 665 | 666 | response = app.make_response("") 667 | response.status_code = 302 668 | 669 | if n == 1: 670 | response.headers["Location"] = url_for("view_get") 671 | return response 672 | 673 | response.headers["Location"] = url_for("relative_redirect_n_times", n=n - 1) 674 | return response 675 | 676 | 677 | @app.route("/absolute-redirect/") 678 | def absolute_redirect_n_times(n): 679 | """Absolutely 302 Redirects n times. 680 | --- 681 | tags: 682 | - Redirects 683 | parameters: 684 | - in: path 685 | name: n 686 | type: int 687 | produces: 688 | - text/html 689 | responses: 690 | 302: 691 | description: A redirection. 692 | """ 693 | 694 | assert n > 0 695 | 696 | if n == 1: 697 | return redirect(url_for("view_get", _external=True)) 698 | 699 | return _redirect("absolute", n, True) 700 | 701 | 702 | @app.route("/stream/") 703 | def stream_n_messages(n): 704 | """Stream n JSON responses 705 | --- 706 | tags: 707 | - Dynamic data 708 | parameters: 709 | - in: path 710 | name: n 711 | type: int 712 | produces: 713 | - application/json 714 | responses: 715 | 200: 716 | description: Streamed JSON responses. 717 | """ 718 | response = get_dict("url", "args", "headers", "origin") 719 | n = min(n, 100) 720 | 721 | def generate_stream(): 722 | for i in range(n): 723 | response["id"] = i 724 | yield json.dumps(response) + "\n" 725 | 726 | return Response(generate_stream(), headers={"Content-Type": "application/json"}) 727 | 728 | 729 | @app.route( 730 | "/status/", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "TRACE"] 731 | ) 732 | def view_status_code(codes): 733 | """Return status code or random status code if more than one are given 734 | --- 735 | tags: 736 | - Status codes 737 | parameters: 738 | - in: path 739 | name: codes 740 | produces: 741 | - text/plain 742 | responses: 743 | 100: 744 | description: Informational responses 745 | 200: 746 | description: Success 747 | 300: 748 | description: Redirection 749 | 400: 750 | description: Client Errors 751 | 500: 752 | description: Server Errors 753 | """ 754 | 755 | if "," not in codes: 756 | try: 757 | code = int(codes) 758 | except ValueError: 759 | return Response("Invalid status code", status=400) 760 | return status_code(code) 761 | 762 | choices = [] 763 | for choice in codes.split(","): 764 | if ":" not in choice: 765 | code = choice 766 | weight = 1 767 | else: 768 | code, weight = choice.split(":") 769 | 770 | try: 771 | choices.append((int(code), float(weight))) 772 | except ValueError: 773 | return Response("Invalid status code", status=400) 774 | 775 | code = weighted_choice(choices) 776 | 777 | return status_code(code) 778 | 779 | 780 | @app.route("/response-headers", methods=["GET", "POST"]) 781 | def response_headers(): 782 | """Returns a set of response headers from the query string. 783 | --- 784 | tags: 785 | - Response inspection 786 | parameters: 787 | - in: query 788 | name: freeform 789 | explode: true 790 | allowEmptyValue: true 791 | schema: 792 | type: object 793 | additionalProperties: 794 | type: string 795 | style: form 796 | produces: 797 | - application/json 798 | responses: 799 | 200: 800 | description: Response headers 801 | """ 802 | # Pending swaggerUI update 803 | # https://github.com/swagger-api/swagger-ui/issues/3850 804 | headers = MultiDict(request.args.items(multi=True)) 805 | response = jsonify(list(headers.lists())) 806 | 807 | while True: 808 | original_data = response.data 809 | d = {} 810 | for key in response.headers.keys(): 811 | value = response.headers.get_all(key) 812 | if len(value) == 1: 813 | value = value[0] 814 | d[key] = value 815 | response = jsonify(d) 816 | for key, value in headers.items(multi=True): 817 | response.headers.add(key, value) 818 | response_has_changed = response.data != original_data 819 | if not response_has_changed: 820 | break 821 | return response 822 | 823 | 824 | @app.route("/cookies") 825 | def view_cookies(hide_env=True): 826 | """Returns cookie data. 827 | --- 828 | tags: 829 | - Cookies 830 | produces: 831 | - application/json 832 | responses: 833 | 200: 834 | description: Set cookies. 835 | """ 836 | 837 | cookies = dict(request.cookies.items()) 838 | 839 | if hide_env and ("show_env" not in request.args): 840 | for key in ENV_COOKIES: 841 | try: 842 | del cookies[key] 843 | except KeyError: 844 | pass 845 | 846 | return jsonify(cookies=cookies) 847 | 848 | 849 | @app.route("/forms/post") 850 | def view_forms_post(): 851 | """Simple HTML form.""" 852 | 853 | return render_template("forms-post.html") 854 | 855 | 856 | @app.route("/cookies/set//") 857 | def set_cookie(name, value): 858 | """Sets a cookie and redirects to cookie list. 859 | --- 860 | tags: 861 | - Cookies 862 | parameters: 863 | - in: path 864 | name: name 865 | type: string 866 | - in: path 867 | name: value 868 | type: string 869 | produces: 870 | - text/plain 871 | responses: 872 | 200: 873 | description: Set cookies and redirects to cookie list. 874 | """ 875 | 876 | r = app.make_response(redirect(url_for("view_cookies"))) 877 | r.set_cookie(key=name, value=value, secure=secure_cookie()) 878 | 879 | return r 880 | 881 | 882 | @app.route("/cookies/set") 883 | def set_cookies(): 884 | """Sets cookie(s) as provided by the query string and redirects to cookie list. 885 | --- 886 | tags: 887 | - Cookies 888 | parameters: 889 | - in: query 890 | name: freeform 891 | explode: true 892 | allowEmptyValue: true 893 | schema: 894 | type: object 895 | additionalProperties: 896 | type: string 897 | style: form 898 | produces: 899 | - text/plain 900 | responses: 901 | 200: 902 | description: Redirect to cookie list 903 | """ 904 | 905 | cookies = dict(request.args.items()) 906 | r = app.make_response(redirect(url_for("view_cookies"))) 907 | for key, value in cookies.items(): 908 | r.set_cookie(key=key, value=value, secure=secure_cookie()) 909 | 910 | return r 911 | 912 | 913 | @app.route("/cookies/delete") 914 | def delete_cookies(): 915 | """Deletes cookie(s) as provided by the query string and redirects to cookie list. 916 | --- 917 | tags: 918 | - Cookies 919 | parameters: 920 | - in: query 921 | name: freeform 922 | explode: true 923 | allowEmptyValue: true 924 | schema: 925 | type: object 926 | additionalProperties: 927 | type: string 928 | style: form 929 | produces: 930 | - text/plain 931 | responses: 932 | 200: 933 | description: Redirect to cookie list 934 | """ 935 | 936 | cookies = dict(request.args.items()) 937 | r = app.make_response(redirect(url_for("view_cookies"))) 938 | for key, value in cookies.items(): 939 | r.delete_cookie(key=key) 940 | 941 | return r 942 | 943 | 944 | @app.route("/basic-auth//") 945 | def basic_auth(user="user", passwd="passwd"): 946 | """Prompts the user for authorization using HTTP Basic Auth. 947 | --- 948 | tags: 949 | - Auth 950 | parameters: 951 | - in: path 952 | name: user 953 | type: string 954 | - in: path 955 | name: passwd 956 | type: string 957 | produces: 958 | - application/json 959 | responses: 960 | 200: 961 | description: Sucessful authentication. 962 | 401: 963 | description: Unsuccessful authentication. 964 | """ 965 | 966 | if not check_basic_auth(user, passwd): 967 | return status_code(401) 968 | 969 | return jsonify(authenticated=True, user=user) 970 | 971 | 972 | @app.route("/hidden-basic-auth//") 973 | def hidden_basic_auth(user="user", passwd="passwd"): 974 | """Prompts the user for authorization using HTTP Basic Auth. 975 | --- 976 | tags: 977 | - Auth 978 | parameters: 979 | - in: path 980 | name: user 981 | type: string 982 | - in: path 983 | name: passwd 984 | type: string 985 | produces: 986 | - application/json 987 | responses: 988 | 200: 989 | description: Sucessful authentication. 990 | 404: 991 | description: Unsuccessful authentication. 992 | """ 993 | 994 | if not check_basic_auth(user, passwd): 995 | return status_code(404) 996 | return jsonify(authenticated=True, user=user) 997 | 998 | 999 | @app.route("/bearer") 1000 | def bearer_auth(): 1001 | """Prompts the user for authorization using bearer authentication. 1002 | --- 1003 | tags: 1004 | - Auth 1005 | parameters: 1006 | - in: header 1007 | name: Authorization 1008 | schema: 1009 | type: string 1010 | produces: 1011 | - application/json 1012 | responses: 1013 | 200: 1014 | description: Sucessful authentication. 1015 | 401: 1016 | description: Unsuccessful authentication. 1017 | """ 1018 | authorization = request.headers.get("Authorization") 1019 | if not (authorization and authorization.startswith("Bearer ")): 1020 | response = app.make_response("") 1021 | response.headers["WWW-Authenticate"] = "Bearer" 1022 | response.status_code = 401 1023 | return response 1024 | slice_start = len("Bearer ") 1025 | token = authorization[slice_start:] 1026 | 1027 | return jsonify(authenticated=True, token=token) 1028 | 1029 | 1030 | @app.route("/digest-auth///") 1031 | def digest_auth_md5(qop=None, user="user", passwd="passwd"): 1032 | """Prompts the user for authorization using Digest Auth. 1033 | --- 1034 | tags: 1035 | - Auth 1036 | parameters: 1037 | - in: path 1038 | name: qop 1039 | type: string 1040 | description: auth or auth-int 1041 | - in: path 1042 | name: user 1043 | type: string 1044 | - in: path 1045 | name: passwd 1046 | type: string 1047 | produces: 1048 | - application/json 1049 | responses: 1050 | 200: 1051 | description: Sucessful authentication. 1052 | 401: 1053 | description: Unsuccessful authentication. 1054 | """ 1055 | return digest_auth(qop, user, passwd, "MD5", "never") 1056 | 1057 | 1058 | @app.route("/digest-auth////") 1059 | def digest_auth_nostale(qop=None, user="user", passwd="passwd", algorithm="MD5"): 1060 | """Prompts the user for authorization using Digest Auth + Algorithm. 1061 | --- 1062 | tags: 1063 | - Auth 1064 | parameters: 1065 | - in: path 1066 | name: qop 1067 | type: string 1068 | description: auth or auth-int 1069 | - in: path 1070 | name: user 1071 | type: string 1072 | - in: path 1073 | name: passwd 1074 | type: string 1075 | - in: path 1076 | name: algorithm 1077 | type: string 1078 | description: MD5, SHA-256, SHA-512 1079 | default: MD5 1080 | produces: 1081 | - application/json 1082 | responses: 1083 | 200: 1084 | description: Sucessful authentication. 1085 | 401: 1086 | description: Unsuccessful authentication. 1087 | """ 1088 | return digest_auth(qop, user, passwd, algorithm, "never") 1089 | 1090 | 1091 | @app.route("/digest-auth/////") 1092 | def digest_auth( 1093 | qop=None, user="user", passwd="passwd", algorithm="MD5", stale_after="never" 1094 | ): 1095 | """Prompts the user for authorization using Digest Auth + Algorithm. 1096 | allow settings the stale_after argument. 1097 | --- 1098 | tags: 1099 | - Auth 1100 | parameters: 1101 | - in: path 1102 | name: qop 1103 | type: string 1104 | description: auth or auth-int 1105 | - in: path 1106 | name: user 1107 | type: string 1108 | - in: path 1109 | name: passwd 1110 | type: string 1111 | - in: path 1112 | name: algorithm 1113 | type: string 1114 | description: MD5, SHA-256, SHA-512 1115 | default: MD5 1116 | - in: path 1117 | name: stale_after 1118 | type: string 1119 | default: never 1120 | produces: 1121 | - application/json 1122 | responses: 1123 | 200: 1124 | description: Sucessful authentication. 1125 | 401: 1126 | description: Unsuccessful authentication. 1127 | """ 1128 | require_cookie_handling = request.args.get("require-cookie", "").lower() in ( 1129 | "1", 1130 | "t", 1131 | "true", 1132 | ) 1133 | if algorithm not in ("MD5", "SHA-256", "SHA-512"): 1134 | algorithm = "MD5" 1135 | 1136 | if qop not in ("auth", "auth-int"): 1137 | qop = None 1138 | 1139 | authorization = request.headers.get("Authorization") 1140 | credentials = None 1141 | if authorization: 1142 | credentials = parse_authorization_header(authorization) 1143 | 1144 | if ( 1145 | not authorization 1146 | or not credentials 1147 | or credentials.type.lower() != "digest" 1148 | or (require_cookie_handling and "Cookie" not in request.headers) 1149 | ): 1150 | response = digest_challenge_response(app, qop, algorithm) 1151 | response.set_cookie("stale_after", value=stale_after) 1152 | response.set_cookie("fake", value="fake_value") 1153 | return response 1154 | 1155 | if require_cookie_handling and request.cookies.get("fake") != "fake_value": 1156 | response = jsonify({"errors": ["missing cookie set on challenge"]}) 1157 | response.set_cookie("fake", value="fake_value") 1158 | response.status_code = 403 1159 | return response 1160 | 1161 | current_nonce = credentials.get("nonce") 1162 | 1163 | stale_after_value = None 1164 | if "stale_after" in request.cookies: 1165 | stale_after_value = request.cookies.get("stale_after") 1166 | 1167 | if ( 1168 | "last_nonce" in request.cookies 1169 | and current_nonce == request.cookies.get("last_nonce") 1170 | or stale_after_value == "0" 1171 | ): 1172 | response = digest_challenge_response(app, qop, algorithm, True) 1173 | response.set_cookie("stale_after", value=stale_after) 1174 | response.set_cookie("last_nonce", value=current_nonce) 1175 | response.set_cookie("fake", value="fake_value") 1176 | return response 1177 | 1178 | if not check_digest_auth(user, passwd): 1179 | response = digest_challenge_response(app, qop, algorithm, False) 1180 | response.set_cookie("stale_after", value=stale_after) 1181 | response.set_cookie("last_nonce", value=current_nonce) 1182 | response.set_cookie("fake", value="fake_value") 1183 | return response 1184 | 1185 | response = jsonify(authenticated=True, user=user) 1186 | response.set_cookie("fake", value="fake_value") 1187 | if stale_after_value: 1188 | response.set_cookie( 1189 | "stale_after", value=next_stale_after_value(stale_after_value) 1190 | ) 1191 | 1192 | return response 1193 | 1194 | 1195 | @app.route("/delay/", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "TRACE"]) 1196 | def delay_response(delay): 1197 | """Returns a delayed response (max of 10 seconds). 1198 | --- 1199 | tags: 1200 | - Dynamic data 1201 | parameters: 1202 | - in: path 1203 | name: delay 1204 | type: int 1205 | produces: 1206 | - application/json 1207 | responses: 1208 | 200: 1209 | description: A delayed response. 1210 | """ 1211 | delay = min(float(delay), 10) 1212 | 1213 | time.sleep(delay) 1214 | 1215 | return jsonify( 1216 | get_dict("url", "args", "form", "data", "origin", "headers", "files") 1217 | ) 1218 | 1219 | 1220 | @app.route("/drip") 1221 | def drip(): 1222 | """Drips data over a duration after an optional initial delay. 1223 | --- 1224 | tags: 1225 | - Dynamic data 1226 | parameters: 1227 | - in: query 1228 | name: duration 1229 | type: number 1230 | description: The amount of time (in seconds) over which to drip each byte 1231 | default: 2 1232 | required: false 1233 | - in: query 1234 | name: numbytes 1235 | type: integer 1236 | description: The number of bytes to respond with 1237 | default: 10 1238 | required: false 1239 | - in: query 1240 | name: code 1241 | type: integer 1242 | description: The response code that will be returned 1243 | default: 200 1244 | required: false 1245 | - in: query 1246 | name: delay 1247 | type: number 1248 | description: The amount of time (in seconds) to delay before responding 1249 | default: 2 1250 | required: false 1251 | produces: 1252 | - application/octet-stream 1253 | responses: 1254 | 200: 1255 | description: A dripped response. 1256 | """ 1257 | args = CaseInsensitiveDict(request.args.items()) 1258 | duration = float(args.get("duration", 2)) 1259 | numbytes = min(int(args.get("numbytes", 10)), (10 * 1024 * 1024)) # set 10MB limit 1260 | code = int(args.get("code", 200)) 1261 | 1262 | if numbytes <= 0: 1263 | response = Response("number of bytes must be positive", status=400) 1264 | return response 1265 | 1266 | delay = float(args.get("delay", 0)) 1267 | if delay > 0: 1268 | time.sleep(delay) 1269 | 1270 | pause = duration / numbytes 1271 | 1272 | def generate_bytes(): 1273 | for i in xrange(numbytes): 1274 | yield b"*" 1275 | time.sleep(pause) 1276 | 1277 | response = Response( 1278 | generate_bytes(), 1279 | headers={ 1280 | "Content-Type": "application/octet-stream", 1281 | "Content-Length": str(numbytes), 1282 | }, 1283 | ) 1284 | 1285 | response.status_code = code 1286 | 1287 | return response 1288 | 1289 | 1290 | @app.route("/base64/") 1291 | def decode_base64(value): 1292 | """Decodes base64url-encoded string. 1293 | --- 1294 | tags: 1295 | - Dynamic data 1296 | parameters: 1297 | - in: path 1298 | name: value 1299 | type: string 1300 | default: SFRUUEJJTiBpcyBhd2Vzb21l 1301 | produces: 1302 | - text/html 1303 | responses: 1304 | 200: 1305 | description: Decoded base64 content. 1306 | """ 1307 | encoded = value.encode("utf-8") # base64 expects binary string as input 1308 | try: 1309 | return base64.urlsafe_b64decode(encoded).decode("utf-8") 1310 | except: 1311 | return "Incorrect Base64 data try: SFRUUEJJTiBpcyBhd2Vzb21l" 1312 | 1313 | 1314 | @app.route("/cache", methods=("GET",)) 1315 | def cache(): 1316 | """Returns a 304 if an If-Modified-Since header or If-None-Match is present. Returns the same as a GET otherwise. 1317 | --- 1318 | tags: 1319 | - Response inspection 1320 | parameters: 1321 | - in: header 1322 | name: If-Modified-Since 1323 | - in: header 1324 | name: If-None-Match 1325 | produces: 1326 | - application/json 1327 | responses: 1328 | 200: 1329 | description: Cached response 1330 | 304: 1331 | description: Modified 1332 | 1333 | """ 1334 | is_conditional = request.headers.get("If-Modified-Since") or request.headers.get( 1335 | "If-None-Match" 1336 | ) 1337 | 1338 | if is_conditional is None: 1339 | response = view_get() 1340 | response.headers["Last-Modified"] = http_date() 1341 | response.headers["ETag"] = uuid.uuid4().hex 1342 | return response 1343 | else: 1344 | return status_code(304) 1345 | 1346 | 1347 | @app.route("/etag/", methods=("GET",)) 1348 | def etag(etag): 1349 | """Assumes the resource has the given etag and responds to If-None-Match and If-Match headers appropriately. 1350 | --- 1351 | tags: 1352 | - Response inspection 1353 | parameters: 1354 | - in: header 1355 | name: If-None-Match 1356 | - in: header 1357 | name: If-Match 1358 | produces: 1359 | - application/json 1360 | responses: 1361 | 200: 1362 | description: Normal response 1363 | 412: 1364 | description: match 1365 | 1366 | """ 1367 | if_none_match = parse_multi_value_header(request.headers.get("If-None-Match")) 1368 | if_match = parse_multi_value_header(request.headers.get("If-Match")) 1369 | 1370 | if if_none_match: 1371 | if etag in if_none_match or "*" in if_none_match: 1372 | response = status_code(304) 1373 | response.headers["ETag"] = etag 1374 | return response 1375 | elif if_match: 1376 | if etag not in if_match and "*" not in if_match: 1377 | return status_code(412) 1378 | 1379 | # Special cases don't apply, return normal response 1380 | response = view_get() 1381 | response.headers["ETag"] = etag 1382 | return response 1383 | 1384 | 1385 | @app.route("/cache/") 1386 | def cache_control(value): 1387 | """Sets a Cache-Control header for n seconds. 1388 | --- 1389 | tags: 1390 | - Response inspection 1391 | parameters: 1392 | - in: path 1393 | name: value 1394 | type: integer 1395 | produces: 1396 | - application/json 1397 | responses: 1398 | 200: 1399 | description: Cache control set 1400 | """ 1401 | response = view_get() 1402 | response.headers["Cache-Control"] = "public, max-age={0}".format(value) 1403 | return response 1404 | 1405 | 1406 | @app.route("/encoding/utf8") 1407 | def encoding(): 1408 | """Returns a UTF-8 encoded body. 1409 | --- 1410 | tags: 1411 | - Response formats 1412 | produces: 1413 | - text/html 1414 | responses: 1415 | 200: 1416 | description: Encoded UTF-8 content. 1417 | """ 1418 | 1419 | return render_template("UTF-8-demo.txt") 1420 | 1421 | 1422 | @app.route("/bytes/") 1423 | def random_bytes(n): 1424 | """Returns n random bytes generated with given seed 1425 | --- 1426 | tags: 1427 | - Dynamic data 1428 | parameters: 1429 | - in: path 1430 | name: n 1431 | type: int 1432 | produces: 1433 | - application/octet-stream 1434 | responses: 1435 | 200: 1436 | description: Bytes. 1437 | """ 1438 | 1439 | n = min(n, 100 * 1024) # set 100KB limit 1440 | 1441 | params = CaseInsensitiveDict(request.args.items()) 1442 | if "seed" in params: 1443 | random.seed(int(params["seed"])) 1444 | 1445 | response = make_response() 1446 | 1447 | # Note: can't just use os.urandom here because it ignores the seed 1448 | response.data = bytearray(random.randint(0, 255) for i in range(n)) 1449 | response.content_type = "application/octet-stream" 1450 | return response 1451 | 1452 | 1453 | @app.route("/stream-bytes/") 1454 | def stream_random_bytes(n): 1455 | """Streams n random bytes generated with given seed, at given chunk size per packet. 1456 | --- 1457 | tags: 1458 | - Dynamic data 1459 | parameters: 1460 | - in: path 1461 | name: n 1462 | type: int 1463 | produces: 1464 | - application/octet-stream 1465 | responses: 1466 | 200: 1467 | description: Bytes. 1468 | """ 1469 | n = min(n, 100 * 1024) # set 100KB limit 1470 | 1471 | params = CaseInsensitiveDict(request.args.items()) 1472 | if "seed" in params: 1473 | random.seed(int(params["seed"])) 1474 | 1475 | if "chunk_size" in params: 1476 | chunk_size = max(1, int(params["chunk_size"])) 1477 | else: 1478 | chunk_size = 10 * 1024 1479 | 1480 | def generate_bytes(): 1481 | chunks = bytearray() 1482 | 1483 | for i in xrange(n): 1484 | chunks.append(random.randint(0, 255)) 1485 | if len(chunks) == chunk_size: 1486 | yield (bytes(chunks)) 1487 | chunks = bytearray() 1488 | 1489 | if chunks: 1490 | yield (bytes(chunks)) 1491 | 1492 | headers = {"Content-Type": "application/octet-stream"} 1493 | 1494 | return Response(generate_bytes(), headers=headers) 1495 | 1496 | 1497 | @app.route("/range/") 1498 | def range_request(numbytes): 1499 | """Streams n random bytes generated with given seed, at given chunk size per packet. 1500 | --- 1501 | tags: 1502 | - Dynamic data 1503 | parameters: 1504 | - in: path 1505 | name: numbytes 1506 | type: int 1507 | produces: 1508 | - application/octet-stream 1509 | responses: 1510 | 200: 1511 | description: Bytes. 1512 | """ 1513 | 1514 | if numbytes <= 0 or numbytes > (100 * 1024): 1515 | response = Response( 1516 | headers={"ETag": "range%d" % numbytes, "Accept-Ranges": "bytes"} 1517 | ) 1518 | response.status_code = 404 1519 | response.data = "number of bytes must be in the range (0, 102400]" 1520 | return response 1521 | 1522 | params = CaseInsensitiveDict(request.args.items()) 1523 | if "chunk_size" in params: 1524 | chunk_size = max(1, int(params["chunk_size"])) 1525 | else: 1526 | chunk_size = 10 * 1024 1527 | 1528 | duration = float(params.get("duration", 0)) 1529 | pause_per_byte = duration / numbytes 1530 | 1531 | request_headers = get_headers() 1532 | first_byte_pos, last_byte_pos = get_request_range(request_headers, numbytes) 1533 | range_length = (last_byte_pos + 1) - first_byte_pos 1534 | 1535 | if ( 1536 | first_byte_pos > last_byte_pos 1537 | or first_byte_pos not in xrange(0, numbytes) 1538 | or last_byte_pos not in xrange(0, numbytes) 1539 | ): 1540 | response = Response( 1541 | headers={ 1542 | "ETag": "range%d" % numbytes, 1543 | "Accept-Ranges": "bytes", 1544 | "Content-Range": "bytes */%d" % numbytes, 1545 | "Content-Length": "0", 1546 | } 1547 | ) 1548 | response.status_code = 416 1549 | return response 1550 | 1551 | def generate_bytes(): 1552 | chunks = bytearray() 1553 | 1554 | for i in xrange(first_byte_pos, last_byte_pos + 1): 1555 | 1556 | # We don't want the resource to change across requests, so we need 1557 | # to use a predictable data generation function 1558 | chunks.append(ord("a") + (i % 26)) 1559 | if len(chunks) == chunk_size: 1560 | yield (bytes(chunks)) 1561 | time.sleep(pause_per_byte * chunk_size) 1562 | chunks = bytearray() 1563 | 1564 | if chunks: 1565 | time.sleep(pause_per_byte * len(chunks)) 1566 | yield (bytes(chunks)) 1567 | 1568 | content_range = "bytes %d-%d/%d" % (first_byte_pos, last_byte_pos, numbytes) 1569 | response_headers = { 1570 | "Content-Type": "application/octet-stream", 1571 | "ETag": "range%d" % numbytes, 1572 | "Accept-Ranges": "bytes", 1573 | "Content-Length": str(range_length), 1574 | "Content-Range": content_range, 1575 | } 1576 | 1577 | response = Response(generate_bytes(), headers=response_headers) 1578 | 1579 | if (first_byte_pos == 0) and (last_byte_pos == (numbytes - 1)): 1580 | response.status_code = 200 1581 | else: 1582 | response.status_code = 206 1583 | 1584 | return response 1585 | 1586 | 1587 | @app.route("/links//") 1588 | def link_page(n, offset): 1589 | """Generate a page containing n links to other pages which do the same. 1590 | --- 1591 | tags: 1592 | - Dynamic data 1593 | parameters: 1594 | - in: path 1595 | name: n 1596 | type: int 1597 | - in: path 1598 | name: offset 1599 | type: int 1600 | produces: 1601 | - text/html 1602 | responses: 1603 | 200: 1604 | description: HTML links. 1605 | """ 1606 | n = min(max(1, n), 200) # limit to between 1 and 200 links 1607 | 1608 | link = "{1} " 1609 | 1610 | html = ["Links"] 1611 | for i in xrange(n): 1612 | if i == offset: 1613 | html.append("{0} ".format(i)) 1614 | else: 1615 | html.append(link.format(url_for("link_page", n=n, offset=i), i)) 1616 | html.append("") 1617 | 1618 | return "".join(html) 1619 | 1620 | 1621 | @app.route("/links/") 1622 | def links(n): 1623 | """Redirect to first links page.""" 1624 | return redirect(url_for("link_page", n=n, offset=0)) 1625 | 1626 | 1627 | @app.route("/image") 1628 | def image(): 1629 | """Returns a simple image of the type suggest by the Accept header. 1630 | --- 1631 | tags: 1632 | - Images 1633 | produces: 1634 | - image/webp 1635 | - image/svg+xml 1636 | - image/jpeg 1637 | - image/png 1638 | - image/* 1639 | responses: 1640 | 200: 1641 | description: An image. 1642 | """ 1643 | 1644 | headers = get_headers() 1645 | if "accept" not in headers: 1646 | return image_png() # Default media type to png 1647 | 1648 | accept = headers["accept"].lower() 1649 | 1650 | if "image/webp" in accept: 1651 | return image_webp() 1652 | elif "image/svg+xml" in accept: 1653 | return image_svg() 1654 | elif "image/jpeg" in accept: 1655 | return image_jpeg() 1656 | elif "image/png" in accept or "image/*" in accept: 1657 | return image_png() 1658 | else: 1659 | return status_code(406) # Unsupported media type 1660 | 1661 | 1662 | @app.route("/image/png") 1663 | def image_png(): 1664 | """Returns a simple PNG image. 1665 | --- 1666 | tags: 1667 | - Images 1668 | produces: 1669 | - image/png 1670 | responses: 1671 | 200: 1672 | description: A PNG image. 1673 | """ 1674 | data = resource("images/pig_icon.png") 1675 | return Response(data, headers={"Content-Type": "image/png"}) 1676 | 1677 | 1678 | @app.route("/image/jpeg") 1679 | def image_jpeg(): 1680 | """Returns a simple JPEG image. 1681 | --- 1682 | tags: 1683 | - Images 1684 | produces: 1685 | - image/jpeg 1686 | responses: 1687 | 200: 1688 | description: A JPEG image. 1689 | """ 1690 | data = resource("images/jackal.jpg") 1691 | return Response(data, headers={"Content-Type": "image/jpeg"}) 1692 | 1693 | 1694 | @app.route("/image/webp") 1695 | def image_webp(): 1696 | """Returns a simple WEBP image. 1697 | --- 1698 | tags: 1699 | - Images 1700 | produces: 1701 | - image/webp 1702 | responses: 1703 | 200: 1704 | description: A WEBP image. 1705 | """ 1706 | data = resource("images/wolf_1.webp") 1707 | return Response(data, headers={"Content-Type": "image/webp"}) 1708 | 1709 | 1710 | @app.route("/image/svg") 1711 | def image_svg(): 1712 | """Returns a simple SVG image. 1713 | --- 1714 | tags: 1715 | - Images 1716 | produces: 1717 | - image/svg+xml 1718 | responses: 1719 | 200: 1720 | description: An SVG image. 1721 | """ 1722 | data = resource("images/svg_logo.svg") 1723 | return Response(data, headers={"Content-Type": "image/svg+xml"}) 1724 | 1725 | 1726 | def resource(filename): 1727 | path = os.path.join(tmpl_dir, filename) 1728 | with open(path, "rb") as f: 1729 | return f.read() 1730 | 1731 | 1732 | @app.route("/xml") 1733 | def xml(): 1734 | """Returns a simple XML document. 1735 | --- 1736 | tags: 1737 | - Response formats 1738 | produces: 1739 | - application/xml 1740 | responses: 1741 | 200: 1742 | description: An XML document. 1743 | """ 1744 | response = make_response(render_template("sample.xml")) 1745 | response.headers["Content-Type"] = "application/xml" 1746 | return response 1747 | 1748 | 1749 | @app.route("/json") 1750 | def a_json_endpoint(): 1751 | """Returns a simple JSON document. 1752 | --- 1753 | tags: 1754 | - Response formats 1755 | produces: 1756 | - application/json 1757 | responses: 1758 | 200: 1759 | description: An JSON document. 1760 | """ 1761 | return flask_jsonify( 1762 | slideshow={ 1763 | "title": "Sample Slide Show", 1764 | "date": "date of publication", 1765 | "author": "Yours Truly", 1766 | "slides": [ 1767 | {"type": "all", "title": "Wake up to WonderWidgets!"}, 1768 | { 1769 | "type": "all", 1770 | "title": "Overview", 1771 | "items": [ 1772 | "Why WonderWidgets are great", 1773 | "Who buys WonderWidgets", 1774 | ], 1775 | }, 1776 | ], 1777 | } 1778 | ) 1779 | 1780 | 1781 | if __name__ == "__main__": 1782 | parser = argparse.ArgumentParser() 1783 | parser.add_argument("--port", type=int, default=5000) 1784 | parser.add_argument("--host", default="127.0.0.1") 1785 | args = parser.parse_args() 1786 | app.run(port=args.port, host=args.host) 1787 | --------------------------------------------------------------------------------