Skip to main content

PostgREST-inspired SQLite HTTP REST API using Python stdlib

Project description

restqlite
=========

PostgREST-inspired HTTP REST API for SQLite, Python stdlib only.
No dependencies. Python 3.9+, compatible with 3.14 free-threaded.


INSTALL
-------

pip install restqlite


CLI
---

python -m restqlite mydb.sqlite3
python -m restqlite mydb.sqlite3 --port 5000
python -m restqlite mydb.sqlite3 --host 127.0.0.1 --read-only
python -m restqlite mydb.sqlite3 --log-level WARNING


USAGE
-----

import restqlite

# blocking
restqlite.serve('mydb.sqlite3', host='', port=8000)

# custom server (e.g. alongside other handlers)
import http.server
handler = restqlite.make_handler('mydb.sqlite3')
with http.server.ThreadingHTTPServer(('', 8000), handler) as srv:
srv.serve_forever()


serve(db_path, host='', port=8000, read_only=False,
before_request=None, routes=None,
allowed_tables=None, max_rows=None, log_level='INFO')

make_handler(db_path, read_only=False,
before_request=None, routes=None,
allowed_tables=None, max_rows=None, log_level='INFO')

db_path Path to SQLite file, or ':memory:'.
read_only If True, only GET and HEAD are accepted.
before_request Callable(handler) run before every request; return False
to signal the response has already been sent.
routes Dict mapping path or 'METHOD /path' to a callable.
Matched before restqlite routing. Example:
{'GET /health': lambda h: _send(h, 200, {'ok': True})}
allowed_tables Collection of table/view names to expose, or a
Callable(handler) returning one. None exposes all (default).
max_rows Maximum rows returned per GET request. Clamps both explicit
?limit= values and requests with no limit. None = no cap.
log_level Logging level string ('DEBUG', 'INFO', 'WARNING', …).
Sets the 'restqlite' logger level. None = don't change.
Default 'INFO'.


API REFERENCE
-------------

All tables and views are auto-discovered from the database schema.
Requests and responses use JSON by default. Column types are inferred
at query time (SQLite dynamic typing).


GET /
List tables and views.

Response:
{"tables": ["orders", "users"], "views": ["active_users"]}

When allowed_tables is set, only listed tables/views are returned.


GET /metrics
Prometheus exposition format metrics:
restqlite_requests_total{method="GET"} 42
restqlite_db_bytes 16384
Falls through to a table named "metrics" if one exists.
Use with Prometheus + grafana_dashboard.json (included in repo).


GET /<table>
Fetch rows from a table or view.

Query parameters:
select=col1,col2 return only named columns
col=op.value filter rows (see OPERATORS)
order=col.asc sort; append .desc for descending
order=col1.asc,col2.desc multi-column sort
order=col.asc.nullsfirst nullsfirst/nullslast suffix accepted (ignored)
limit=N page size
offset=N skip N rows
Prefer: count=none skip COUNT(*); omits Content-Range header

Response header:
Content-Range: 0-9/42 (first-last/total; omitted with count=none)
Content-Range: */0 (empty result set)

Example:
GET /orders?status=eq.open&order=created.desc&limit=20

HEAD /<table>
Same as GET but returns headers only (no body).
Content-Range header is still included.


POST /<table>
Insert one row (JSON object) or many rows (JSON array).

Headers:
Prefer: return=representation return inserted row(s) in response

Response (without Prefer header):
{"count": 1}

Response (with Prefer: return=representation):
{inserted row object} for a single row
[{...}, {...}] for multiple rows

Example:
POST /orders
{"product": "widget", "qty": 3, "status": "open"}


PUT /<table>
Upsert one row (JSON object) or many rows (JSON array).
Uses INSERT OR REPLACE semantics: existing rows matched by primary key
are replaced; new rows are inserted.

Headers:
Prefer: return=representation return upserted row(s) in response

Response (without Prefer header):
{"count": 1}

Response (with Prefer: return=representation):
{row object} for a single row
[{...}, {...}] for multiple rows

Example:
PUT /orders
{"id": 42, "product": "widget", "qty": 5, "status": "open"}


PATCH /<table>[?filters]
Update columns on matching rows. Body is a JSON object of columns
to set. Filters follow the same syntax as GET.

Headers:
Prefer: return=representation return updated rows in response

Response (without Prefer header):
{"count": 2}

Response (with Prefer: return=representation):
[{updated row}, ...]

Example:
PATCH /orders?status=eq.open
{"status": "closed"}


DELETE /<table>[?filters]
Delete matching rows. No filters deletes all rows.

Headers:
Prefer: return=representation return deleted rows in response

Response (without Prefer header):
{"count": 5}

Response (with Prefer: return=representation):
[{deleted row}, ...]

Example:
DELETE /orders?status=eq.closed


OPERATORS
---------

Filters take the form ?column=op.value.
Prefix with not. to negate: ?col=not.eq.foo

eq equal ?name=eq.alice
neq not equal ?name=neq.bob
lt less than ?price=lt.100
lte less than or equal ?price=lte.100
gt greater than ?price=gt.0
gte greater than/equal ?price=gte.0
like pattern match ?name=like.*ali* (* is wildcard; case-sensitive)
ilike case-insensitive ?name=ilike.*ALI* (ASCII only)
is null/bool check ?deleted=is.null
?active=is.true
?active=is.false
?col=is.unknown (alias for null)
in set membership ?id=in.(1,2,3) (parentheses required)
fts FTS5 full-text ?body=fts.hello+world (requires FTS5 table)
FTS5 binary NOT: ?body=fts.hello+NOT+world


ALLOWED TABLES
--------------

allowed_tables controls which tables and views are exposed. Tables not
in the allowed set return 404 (not 403, to avoid leaking schema info).

Static list:
restqlite.serve('mydb.sqlite3', allowed_tables=['orders', 'products'])

Callable (dynamic, e.g. DB-stored permissions):
def tables(handler):
# return a collection or None to allow all
role = handler.headers.get('X-Role', 'guest')
if role == 'admin':
return None # unrestricted
return ['products', 'categories']

restqlite.serve('mydb.sqlite3', allowed_tables=tables)

None (default) exposes all tables and views.


EXTENSIBILITY
-------------

before_request -- run before every request:

def auth_check(handler):
if handler.headers.get('Authorization') != 'Bearer secret':
handler.send_response(401)
handler.send_header('Content-Length', '0')
handler.end_headers()
return False # response already sent; restqlite must not respond

restqlite.serve('mydb.sqlite3', before_request=auth_check)

routes -- custom endpoints (bypass restqlite routing entirely):

import json

def health(handler):
body = json.dumps({'status': 'ok'}).encode()
handler.send_response(200)
handler.send_header('Content-Type', 'application/json')
handler.send_header('Content-Length', str(len(body)))
handler.end_headers()
handler.wfile.write(body)

restqlite.serve('mydb.sqlite3', routes={
'GET /health': health,
'/ping': health, # matches any method
})


MULTI-TENANCY
-------------

Isolate tenants with separate SQLite database files (one file per tenant).
Use routes or before_request to select the appropriate db_path and
dispatch to a per-tenant handler class.


ERROR RESPONSES
---------------

All errors return JSON:
{"error": "description"}

Status codes:
400 bad request (invalid filter, unknown column, SQLite error)
404 table or view not found (also returned for allowed_tables misses)
405 method not allowed (write on read-only server, or unsupported method)


THREADING
---------

Uses ThreadingHTTPServer (one thread per connection). HTTP/1.1 keep-alive
allows a single thread to serve multiple sequential requests on one
connection, and each thread holds its own SQLite connection via
threading.local. WAL journal mode is enabled for concurrent reads.

The sqlite3 module has threadsafety level 1 (threads may share the
module but not connections). Per-thread connections satisfy this.
Safe under Python 3.14 free-threaded (no-GIL) builds because no
mutable state is shared between threads.


SCHEMA NOTES
------------

No column types in CREATE TABLE statements (SQLite dynamic typing):

CREATE TABLE orders (
id INTEGER PRIMARY KEY,
product,
qty,
status,
created
);

PRAGMA foreign_keys=ON is set on every connection.


MAX ROWS
--------

Prevent unbounded queries by setting a server-level row cap:

restqlite.serve('mydb.sqlite3', max_rows=1000)

The cap clamps both explicit ?limit= and queries with no limit parameter.
Clients cannot exceed it regardless of what they request.


LOGGING
-------

restqlite emits structured JSON logs to stdout via Python's logging module.
The logger is named 'restqlite'. Each log record is a JSON object:

{"timestamp": "...", "level": "INFO", "message": "...", "logger": "restqlite"}

Access logs (one per request) include additional fields:

{"timestamp": "...", "level": "INFO", "message": "GET /orders 200",
"logger": "restqlite",
"client_ip": "127.0.0.1", "method": "GET", "path": "/orders",
"status": 200, "duration_ms": 1.23}

A startup log is emitted when serve() binds the socket:

{"message": "restqlite listening on 0.0.0.0:8000", ...,
"db": "mydb.sqlite3", "host": "0.0.0.0", "port": 8000}

To suppress access logs, set log_level='WARNING'. To integrate with an
existing logging setup, configure logging.getLogger('restqlite') directly
and pass log_level=None to avoid overriding the level.


LIMITATIONS
-----------

- No bulk PATCH with per-row values
- Single SQLite file per server instance (use separate instances for tenants)

Project details


Download files

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

Source Distribution

restqlite-0.1.2.tar.gz (34.4 kB view details)

Uploaded Source

Built Distribution

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

restqlite-0.1.2-py3-none-any.whl (19.0 kB view details)

Uploaded Python 3

File details

Details for the file restqlite-0.1.2.tar.gz.

File metadata

  • Download URL: restqlite-0.1.2.tar.gz
  • Upload date:
  • Size: 34.4 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.11.26 {"installer":{"name":"uv","version":"0.11.26","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

File hashes

Hashes for restqlite-0.1.2.tar.gz
Algorithm Hash digest
SHA256 24f04079eced59266aabaa9884c41a8cb8d0c4d34e50e5f00541ffbd0561b393
MD5 e59481e894d2fa1ac9dc9afcfd47715b
BLAKE2b-256 54d93ab1abcf935f07c007a6b561238394df91e46d81d68275debbd924e5890c

See more details on using hashes here.

File details

Details for the file restqlite-0.1.2-py3-none-any.whl.

File metadata

  • Download URL: restqlite-0.1.2-py3-none-any.whl
  • Upload date:
  • Size: 19.0 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.11.26 {"installer":{"name":"uv","version":"0.11.26","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

File hashes

Hashes for restqlite-0.1.2-py3-none-any.whl
Algorithm Hash digest
SHA256 314f029164588a3efaba6ebf3ab9e95ac0f03cc36f8fe8fc79ff06f2d0e8857f
MD5 6239e084b9876bb5e742a2a7d229b401
BLAKE2b-256 6d0845f33b9d670d2450d020048baa98228050110b73663919f07cd80d5e402b

See more details on using hashes here.

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