Skip to main content

A simple HTTP server for test environments

Project description

https://github.com/lexsca/spoof/actions/workflows/checks.yml/badge.svg https://img.shields.io/pypi/v/spoof.svg https://img.shields.io/pypi/pyversions/spoof.svg https://img.shields.io/github/license/lexsca/spoof.svg https://img.shields.io/badge/code%20style-black-000000.svg

Spoof is a simple HTTP server for test environments.

>>> import requests
... import spoof
...
... with spoof.http() as http:
...     http.responses.append([200, [], "This is Spoof 👻👋"])
...     requests.get(http.url).text
...
'This is Spoof 👻👋'

A test interface for HTTP

Spoof lets you easily create HTTP servers on real network sockets. Designed for test environments, what responses to send can be configured anytime, including while a server is running. Requests can be inspected live or after a response is sent.

Unlike a conventional HTTP server, where specific methods and paths are configured in advance, Spoof accepts and records all requests, sending whatever responses are queued, or a default response if the queue is empty.

Why would I want this?

Have you ever wanted mock for HTTP? Ever wanted to refactor a client library, but had no way to verify behavior apart from doing live integration testing? If so, Spoof might be for you. Some key features:

  • Decoupled requests and responses

  • Concurrent servers

  • SSL/TLS with post-quantum cryptography

  • Proxy tunneling

  • Live request debugging

  • IPv6

Installation and Compatibility

Spoof is available on PyPI:

$ python -m pip install spoof

Spoof is tested on Python 3.10 to 3.14, uses the http.server module in the standard library, and has no external Python dependencies.

Multiple Spoof servers can be run concurrently, and by default, the port number is the next available port. With OpenSSL installed, Spoof can provide SSL/TLS connectivity. HTTP proxying and IPv6 are also supported.

Response syntax

Spoof expects responses to have the following syntax:

[httpStatus, [(headerName1, value1), (headerName2, value2)], content]

# no content (Content-Length header is *not* sent if content is None)
[200, [], None]

# utf-8 content
[200, [], "This is Spoof 👻👋"]

# bytes content
[200, [("Content-Type", "application/json")], b'{"success": true }']

# responses can also be a callback, with request as the only argument
def callback(request):
    return [200, [], request.path]

Response precedence

Spoof determines what response to send to incoming requests based on the following precedence, highest to lowest:

  1. Oldest response queued in .responses using first-in, first-out (FIFO) order

  2. Response stored in .defaultResponse if no responses are queued

  3. Response stored in .errorResponse if .defaultResponse is None

By default, Spoof will respond with an HTTP 503 Service Unavailable error, because newly created Spoof instances have no responses queued and no default response set. This requires non-error HTTP responses to be explicitly specified.

Response queue

Spoof will always try to send a response from .responses first, before falling back to .defaultResponse if the queue is empty. Backed by a deque instance, the .responses queue supports adding items via .responses.append() and .responses.extend(), similar to a regular list.

Spoof HTTP servers run in a single background thread, so response order should be predictable. Tests using Spoof should be able to use the same fixtures, in the same order, and get the same results. Example queueing multiple responses, verifying content, and request paths:

import requests
import spoof

with spoof.http() as http:
    http.responses.extend([
        [200, [("Content-Type", "application/json")], b'{"id": 1111}'],
        [200, [("Content-Type", "application/json")], b'{"id": 2222}'],
    ])
    http.defaultResponse = [404, [], "Not found"]

    assert requests.get(http.url + "/path").json() == {"id": 1111}
    assert requests.get(http.url + "/alt/path").json() == {"id": 2222}
    assert requests.get(http.url + "/oops").status_code == 404
    assert [r.path for r in http.requests] == ["/path", "/alt/path", "/oops"]

Response default

Spoof will always try to send a response from .responses first, before falling back to .defaultResponse if the queue is empty. Example setting a callback as a default response:

import requests
import spoof

with spoof.http() as http:
    http.defaultResponse = lambda request: [200, [], request.path]

    assert requests.get(http.url + "/alt").text == "/alt"

Request history

Spoof records each request and appends it to the .requests property, which is backed by a deque instance, the same as the .responses property. Think of it like a structured access log. Example using request history:

>>> import requests
... import spoof
...
... with spoof.http() as http:
...     http.defaultResponse = [200, [], None]
...
...     [requests.get(http.url + path) for path in ["/a", "/b", "/c"]]
...     [f"{r.method} {r.path} {r.protocol}" for r in http.requests]
...
[<Response [200]>, <Response [200]>, <Response [200]>]
['GET /a HTTP/1.1', 'GET /b HTTP/1.1', 'GET /c HTTP/1.1']

Request properties

SpoofRequestEnv instances have the following properties:

Property

Description

content

bytes object of request content

contentEncoding

Value of Content-Encoding header, if present

contentLength

Value of Content-Length header, if present

contentType

Value of Content-Type header, if present

headers

http.client.HTTPMessage object of headers

json()

Convenience to call json.loads on content

method

Request method (e.g. GET, POST, HEAD)

path

Decoded URI path, without query string

protocol

Protocol version (e.g. HTTP/1.0)

queryString

Anything in URI after ?

serverName

Host name of HTTP server

serverPort

Port number of HTTP server

uri

Raw URI path and query string, if present

SSL/TLS Mode

Spoof can support SSL/TLS when the ssl=True argument is given, which depends on the OpenSSL command line tools. This generates a self-signed certificate suitable for use with localhost connections. For other use-cases, spoof.ssl() can provide more configuration options:

import requests
import spoof

with spoof.http(ssl=True) as http:
    http.responses.append([200, [], "No self-signed cert warning!"])

    response = requests.get(http.url, verify=http.ssl.certFile)
    assert response.text == "No self-signed cert warning!"

If setting the verify option in requests isn’t workable, the REQUESTS_CA_BUNDLE or CURL_CA_BUNDLE environment variables can be set to the path of the self-signed certificate to silence SSL/TLS errors:

import os
import requests
import spoof

with spoof.http(ssl=True) as http:
    http.responses.append([200, [], "No self-signed cert warning!"])

    os.environ["REQUESTS_CA_BUNDLE"] = http.ssl.certFile
    response = requests.get(http.url)
    assert response.text == "No self-signed cert warning!"

If OpenSSL 3.5.0 or later is installed, Post-Quantum Cryptography (PQC) key algorithms can be used:

import requests
import spoof

with spoof.ssl(keyAlgorithm="mldsa65") as ssl:
    with spoof.http(ssl=ssl) as http:
        http.responses.append([200, [], "TLS with PQC Key Algorithm"])

        response = requests.get(http.url, verify=ssl.certFile)
        assert response.text == "TLS with PQC Key Algorithm"

Proxy Mode

Spoof supports proxying by forwarding CONNECT requests to a separate upstream Spoof instance when the proxy=True argument is given. Unlike a real proxy server, Spoof won’t try to connect to external services. Example usage:

import requests
import spoof

with spoof.ssl(commonName="example.spoof") as ssl:
    with spoof.http(ssl=ssl, proxy=True) as proxy:
        proxy.upstream.defaultResponse = [200, [], "I'm here!"]

        response = requests.get(
            "https://example.spoof/ayt",
            proxies={"https": proxy.url},
            verify=ssl.certFile
        )
        assert proxy.requests[0].method == "CONNECT"
        assert proxy.requests[0].path == "example.spoof:443"
        assert proxy.upstream.requests[0].method == "GET"
        assert proxy.upstream.requests[0].path == "/ayt"
        assert response.text == "I'm here!"

If setting the proxies option in requests isn’t workable, the https_proxy environment variable can be set to the URL of the proxy:

import os
import requests
import spoof

with spoof.ssl(commonName="example.spoof") as ssl:
    with spoof.http(ssl=ssl, proxy=True) as proxy:
        proxy.upstream.defaultResponse = [200, [], "I'm here!"]

        os.environ["https_proxy"] = proxy.url
        os.environ["REQUESTS_CA_BUNDLE"] = ssl.certFile

        response = requests.get("https://example.spoof/ayt")
        assert proxy.requests[0].method == "CONNECT"
        assert proxy.requests[0].path == "example.spoof:443"
        assert proxy.upstream.requests[0].method == "GET"
        assert proxy.upstream.requests[0].path == "/ayt"
        assert response.text == "I'm here!"

IPv6 Mode

Setting the host attribute to an IPv6 address will work as expected. There is also an IPv6-only spoof.http6 class that can be used if needed to only listen on IPv6 sockets.

>>> import requests
... import spoof
...
... with spoof.http(host="::1") as http:
...     http.responses.append([200, [], "This is Spoof on IPv6 👀"])
...     requests.get(http.url).text
...     http.url
...
'This is Spoof on IPv6 👀'
'http://[::1]:51324'
>>> import requests
... import spoof
...
... with spoof.http6(host="localhost") as http:
...     http.responses.append([200, [], "This is also Spoof on IPv6 👀"])
...     requests.get(http.url).text
...     http.url
...
'This is also Spoof on IPv6 👀'
'http://[::1]:54296'

Debug mode

Setting a callback with a breakpoint() can allow for live HTTP request debugging, including setting custom responses and inspecting requests. Note that callbacks can also be queued.

>>> import requests
... import spoof
...
... def debugCallback(request):
...     response = [200, [], ""]
...     breakpoint()
...     return response
...
... with spoof.http() as http:
...     http.defaultResponse = debugCallback
...     requests.get(http.url).text
...
> <python-input-0>(6)debugCallback()
(Pdb) request
SpoofRequestEnv(content=None, contentEncoding=None, contentLength=0, contentType=None, headers=<http.client.HTTPMessage object at 0x10e16bd90>, method='GET', path='/', protocol='HTTP/1.1', queryString=None, serverName='localhost', serverPort=51612, uri='/')
(Pdb) response[2] = "content set from pdb"
(Pdb) c
'content set from pdb'

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

spoof-2.4.1.tar.gz (26.1 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

spoof-2.4.1-py3-none-any.whl (12.7 kB view details)

Uploaded Python 3

File details

Details for the file spoof-2.4.1.tar.gz.

File metadata

  • Download URL: spoof-2.4.1.tar.gz
  • Upload date:
  • Size: 26.1 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.13

File hashes

Hashes for spoof-2.4.1.tar.gz
Algorithm Hash digest
SHA256 6a582e59a96ff35703e0d9d6d0f343bd885fb5aece77d50a5f258d6e90a2e5f9
MD5 251c8952eab0420513c5db2191ce872c
BLAKE2b-256 3ea699fe4224782e74c463560eb4a8ad37a69f7cd39ed52cbdf7627ea75f00e4

See more details on using hashes here.

Provenance

The following attestation bundles were made for spoof-2.4.1.tar.gz:

Publisher: release.yml on lexsca/spoof

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file spoof-2.4.1-py3-none-any.whl.

File metadata

  • Download URL: spoof-2.4.1-py3-none-any.whl
  • Upload date:
  • Size: 12.7 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.13

File hashes

Hashes for spoof-2.4.1-py3-none-any.whl
Algorithm Hash digest
SHA256 f501db77d39d88b66173fdb23e60fcfb35690fa1d67025afceee4dd469cc4a2a
MD5 e1f29c5b60fcbf848cafc3fefc35696f
BLAKE2b-256 e79492fd41750cad67c125f5e638635e12e34c36c8722a9a3a3a54c4dcb89a94

See more details on using hashes here.

Provenance

The following attestation bundles were made for spoof-2.4.1-py3-none-any.whl:

Publisher: release.yml on lexsca/spoof

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page