From c0e3cd03fa1011c9866749ca9f56977e01fb05ff Mon Sep 17 00:00:00 2001 From: Kevin McCarthy Date: Tue, 16 Jun 2026 13:54:36 -0400 Subject: [PATCH] Make flasgger an optional dependency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit flasgger is only needed to render the Swagger UI homepage and /spec.json on httpbin.org; it's dead weight (and an extra dependency tree, including an indirect Rust build dep) for the common case of using httpbin as a test library. It also blocks packaging httpbin for distros that can't ship flasgger's bundled, pre-minified JS (e.g. Fedora). Move flasgger from the hard dependencies to the existing "mainapp" extra (so the published Docker image, built with --extra mainapp, keeps the fancy homepage) and guard all the Swagger setup behind its availability. When flasgger is absent, the static legacy landing page is served at / and a warning is logged. Adds a subprocess-based regression test that blocks flasgger and asserts / falls back to the static page with a 200 instead of erroring. Closes #26. Supersedes #32 and #44. Co-Authored-By: Adam Williamson Co-Authored-By: Michał Górny --- httpbin/core.py | 155 +++++++++++++++++++++++------------------- pyproject.toml | 2 +- tests/test_httpbin.py | 27 ++++++++ 3 files changed, 112 insertions(+), 72 deletions(-) diff --git a/httpbin/core.py b/httpbin/core.py index e23cc86b..6cfeb0a1 100644 --- a/httpbin/core.py +++ b/httpbin/core.py @@ -33,7 +33,10 @@ except ImportError: # werkzeug < 2.1 from werkzeug.wrappers import BaseResponse as Response -from flasgger import Swagger, NO_SANITIZER +try: + from flasgger import Swagger, NO_SANITIZER +except ImportError: + Swagger = False from . import filters from .helpers import ( @@ -95,77 +98,83 @@ def jsonify(*args, **kwargs): app.config["SWAGGER"] = {"title": "httpbin.org", "uiversion": 3} -template = { - "swagger": "2.0", - "info": { - "title": "httpbin.org", - "description": ( - "A simple HTTP Request & Response Service." - "
A Kenneth Reitz project." - "

Run locally:
" - "$ docker pull ghcr.io/psf/httpbin
" - "$ docker run -p 80:8080 ghcr.io/psf/httpbin" - ), - "contact": { - "responsibleOrganization": "Python Software Foundation", - "responsibleDeveloper": "Kenneth Reitz", - "url": "https://github.com/psf/httpbin/", - }, - # "termsOfService": "http://me.com/terms", - "version": version, - }, - "host": "httpbin.org", # overrides localhost:5000 - "basePath": "/", # base bash for blueprint registration - "schemes": ["https"], - "protocol": "https", - "tags": [ - { - "name": "HTTP Methods", - "description": "Testing different HTTP verbs", - # 'externalDocs': {'description': 'Learn more', 'url': 'https://www.w3.org/Protocols/rfc2616/rfc2616-sec9.html'} - }, - {"name": "Auth", "description": "Auth methods"}, - { - "name": "Status codes", - "description": "Generates responses with given status code", - }, - {"name": "Request inspection", "description": "Inspect the request data"}, - { - "name": "Response inspection", - "description": "Inspect the response data like caching and headers", - }, - { - "name": "Response formats", - "description": "Returns responses in different data formats", - }, - {"name": "Dynamic data", "description": "Generates random and dynamic data"}, - {"name": "Cookies", "description": "Creates, reads and deletes Cookies"}, - {"name": "Images", "description": "Returns different image formats"}, - {"name": "Redirects", "description": "Returns different redirect responses"}, - { - "name": "Anything", - "description": "Returns anything that is passed to request", +if Swagger: + template = { + "swagger": "2.0", + "info": { + "title": "httpbin.org", + "description": ( + "A simple HTTP Request & Response Service." + "
A Kenneth Reitz project." + "

Run locally:
" + "$ docker pull ghcr.io/psf/httpbin
" + "$ docker run -p 80:8080 ghcr.io/psf/httpbin" + ), + "contact": { + "responsibleOrganization": "Python Software Foundation", + "responsibleDeveloper": "Kenneth Reitz", + "url": "https://github.com/psf/httpbin/", + }, + # "termsOfService": "http://me.com/terms", + "version": version, }, - ], -} - -swagger_config = { - "headers": [], - "specs": [ - { - "endpoint": "spec", - "route": "/spec.json", - "rule_filter": lambda rule: True, # all in - "model_filter": lambda tag: True, # all in - } - ], - "static_url_path": "/flasgger_static", - # "static_folder": "static", # must be set by user - "swagger_ui": True, - "specs_route": "/", -} + "host": "httpbin.org", # overrides localhost:5000 + "basePath": "/", # base bash for blueprint registration + "schemes": ["https"], + "protocol": "https", + "tags": [ + { + "name": "HTTP Methods", + "description": "Testing different HTTP verbs", + # 'externalDocs': {'description': 'Learn more', 'url': 'https://www.w3.org/Protocols/rfc2616/rfc2616-sec9.html'} + }, + {"name": "Auth", "description": "Auth methods"}, + { + "name": "Status codes", + "description": "Generates responses with given status code", + }, + {"name": "Request inspection", "description": "Inspect the request data"}, + { + "name": "Response inspection", + "description": "Inspect the response data like caching and headers", + }, + { + "name": "Response formats", + "description": "Returns responses in different data formats", + }, + {"name": "Dynamic data", "description": "Generates random and dynamic data"}, + {"name": "Cookies", "description": "Creates, reads and deletes Cookies"}, + {"name": "Images", "description": "Returns different image formats"}, + {"name": "Redirects", "description": "Returns different redirect responses"}, + { + "name": "Anything", + "description": "Returns anything that is passed to request", + }, + ], + } + + swagger_config = { + "headers": [], + "specs": [ + { + "endpoint": "spec", + "route": "/spec.json", + "rule_filter": lambda rule: True, # all in + "model_filter": lambda tag: True, # all in + } + ], + "static_url_path": "/flasgger_static", + # "static_folder": "static", # must be set by user + "swagger_ui": True, + "specs_route": "/", + } -swagger = Swagger(app, sanitizer=NO_SANITIZER, template=template, config=swagger_config) + swagger = Swagger(app, sanitizer=NO_SANITIZER, template=template, config=swagger_config) +else: + app.logger.warning( + "flasgger is not installed; serving the static landing page at / " + "and skipping the Swagger UI and /spec.json." + ) # Set up Bugsnag exception tracking, if desired. To use Bugsnag, install the # Bugsnag Python client with the command "pip install bugsnag", and set the @@ -243,8 +252,12 @@ def set_cors_headers(response): # Routes # ------ +if Swagger: + staticroute = "/legacy" +else: + staticroute = "/" -@app.route("/legacy") +@app.route(staticroute) def view_landing_page(): """Generates Landing Page in legacy layout.""" return render_template("index.html") diff --git a/pyproject.toml b/pyproject.toml index 6c7a58ca..d5cc9f0e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,7 +34,6 @@ classifiers = [ dependencies = [ "brotlicffi", "decorator", - "flasgger", "flask >= 2.2.4", 'greenlet < 3.0; python_version<"3.12"', 'greenlet >= 3.0.0a1; python_version>="3.12.0rc0"', @@ -46,6 +45,7 @@ dependencies = [ [project.optional-dependencies] test = ["pytest", "tox"] mainapp = [ + "flasgger", "gunicorn", "gevent", ] diff --git a/tests/test_httpbin.py b/tests/test_httpbin.py index 9d995b93..eb1d315c 100755 --- a/tests/test_httpbin.py +++ b/tests/test_httpbin.py @@ -1,7 +1,9 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- import os +import sys import base64 +import subprocess import unittest import contextlib import json @@ -796,6 +798,31 @@ def test_etag_with_no_headers(self): self.assertEqual(response.status_code, 200) self.assertEqual(response.headers.get('ETag'), 'abc') + def test_index_falls_back_to_static_page_without_flasgger(self): + """When flasgger isn't installed, / serves the static legacy landing + page (HTTP 200) instead of the Swagger UI, rather than 500ing. + + flasgger is imported at module load, so this runs in a subprocess that + poisons sys.modules['flasgger'] before httpbin is imported. + """ + code = ( + "import sys\n" + "sys.modules['flasgger'] = None\n" # makes `import flasgger` raise ImportError + "import httpbin\n" + "assert httpbin.core.Swagger is False, repr(httpbin.core.Swagger)\n" + "r = httpbin.app.test_client().get('/')\n" + "assert r.status_code == 200, r.status_code\n" + "body = r.get_data(as_text=True)\n" + "assert 'httpbin(1): HTTP Client Testing Service' in body, 'legacy page not served'\n" + "assert 'swagger-ui' not in body, 'swagger UI unexpectedly rendered'\n" + "print('OK')\n" + ) + result = subprocess.run( + [sys.executable, "-c", code], capture_output=True, text=True + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("OK", result.stdout) + def test_parse_multi_value_header(self): self.assertEqual(parse_multi_value_header('xyzzy'), [ "xyzzy" ]) self.assertEqual(parse_multi_value_header('"xyzzy"'), [ "xyzzy" ])