From 1497c17c00518af66f1ebc36360768c9fc9b7b85 Mon Sep 17 00:00:00 2001 From: Terrence Meiczinger Date: Thu, 26 Feb 2026 23:13:44 -0500 Subject: [PATCH 1/5] update --- example/__init__.py | 18 ++- example/__main__.py | 163 ++++++++++++++------- example/resources/__init__.py | 13 +- example/resources/contact.py | 76 ++++++++++ example/resources/files.py | 117 +++++++++++++++ example/resources/form.py | 39 ----- example/resources/health.py | 52 +++++++ example/resources/media.py | 53 ------- example/resources/servers.py | 129 ++++++++++++----- example/resources/users.py | 258 ++++++++++++++++++++++++---------- src/reliqua/api.py | 5 +- tests/test_auth.py | 4 +- 12 files changed, 668 insertions(+), 259 deletions(-) create mode 100644 example/resources/contact.py create mode 100644 example/resources/files.py delete mode 100644 example/resources/form.py create mode 100644 example/resources/health.py delete mode 100644 example/resources/media.py diff --git a/example/__init__.py b/example/__init__.py index 991652e..8c2a385 100644 --- a/example/__init__.py +++ b/example/__init__.py @@ -1,5 +1,21 @@ """ -Reliqua Framework. +Reliqua Example Application. + +A comprehensive example demonstrating all major features of the Reliqua framework: + +- Resource auto-discovery and routing (single and multiple routes with suffixes) +- Docstring-driven OpenAPI 3.1 documentation generation +- Parameter types: query, path, body, and form parameters +- Type coercion: str, int, float, bool, list, list[int], list[str], object +- Parameter options: required, default, enum, min, max +- Response schemas with component references +- Content type negotiation (JSON, XML, YAML, text, binary, gzip) +- Authentication: BasicAuthentication, CookieAuthentication, MultiAuthentication +- Authorization: AccessResource with per-resource __auth__ role mapping +- Public resources with no_auth = True +- Custom resource attributes and app config passthrough +- Status codes and exception handling +- Operator parsing in query parameters (e.g., ?age__gt=25) Copyright 2016-2024. """ diff --git a/example/__main__.py b/example/__main__.py index e8cc020..1cec8b2 100644 --- a/example/__main__.py +++ b/example/__main__.py @@ -1,12 +1,19 @@ """ -Reliqua Framework. +Reliqua Example Application. + +Demonstrates application setup with: +- Multiple authentication backends (Basic + Cookie) via MultiAuthentication +- Per-resource authorization via AccessResource (deny-by-default) +- CLI argument parsing with config file fallback +- Custom resource attributes and app config passthrough +- OpenAPI metadata and server configuration +- Gunicorn worker configuration Copyright 2016-2024. """ import argparse import os -import sys from reliqua import Application, load_config from reliqua.auth import ( @@ -17,63 +24,106 @@ CookieAuthentication, ) +# --------------------------------------------------------------------------- +# Authentication callbacks +# --------------------------------------------------------------------------- +# Each callback receives credentials and returns an AuthenticationContext +# on success or None on failure. The context carries identity and role info +# that the authorization layer uses for access decisions. +# --------------------------------------------------------------------------- -def check_user(username, _password): - """Return if user is authenticated.""" - if username == "ted": - return AuthenticationContext(user="ted", role="admin") - return None +def validate_basic_credentials(username, _password): + """Validate HTTP Basic credentials. + BasicAuthentication calls this with (username, password) extracted from + the Authorization header. Return an AuthenticationContext on success. -def check_api_key(api_key): - """Return if user is authenticated.""" - if api_key == "abc123": - return AuthenticationContext(name="ted", role="admin") + Demonstrates: + - Returning different roles based on identity + - Setting arbitrary attributes (user, role) on the context + """ + known_users = { + "admin": "admin", + "viewer": "viewer", + } + role = known_users.get(username) + if role: + return AuthenticationContext(user=username, role=role) return None -def main(): - """Execute main method.""" - bind_address = "127.0.0.1" - bind_port = 8000 - workers = 2 - parser = argparse.ArgumentParser() - resource_path = os.path.abspath(os.path.dirname(sys.modules[__name__].__file__)) + "/resources" - - parser.add_argument( - "--address", - help="API bind address to listen for requests", - default=bind_address, - ) - parser.add_argument("--port", help="Bind port to listen for requests", default=bind_port) - parser.add_argument("--ui-url", help="OpenAPI UI URL (ie Swagger index) default is address:port") - parser.add_argument("--docs", help="Docs", default="/docs") - parser.add_argument("--server", help="Additional server(s) usable in the API docs", nargs="*") - parser.add_argument("--resource-path", help="Path to API resource modules", default=resource_path) - parser.add_argument("--workers", help="Number of worker threads", default=workers) - parser.add_argument("--config", help="Configuration file", default=None) - - basic_auth = BasicAuthentication( - validation=check_user, - ) - cookie_auth = CookieAuthentication( - "api_key", - validation=check_api_key, - ) +def validate_cookie_api_key(api_key): + """Validate an API key from a cookie. - auth = AuthMiddleware([basic_auth, cookie_auth], control=AccessResource(default_mode="deny")) + CookieAuthentication calls this with the cookie value. + Return an AuthenticationContext on success. + Demonstrates: + - Token-based authentication + - Setting name and role on the context + """ + valid_keys = { + "abc123": AuthenticationContext(name="api-service", role="admin"), + "read-only": AuthenticationContext(name="reader", role="viewer"), + } + return valid_keys.get(api_key) + + +# --------------------------------------------------------------------------- +# Application entry point +# --------------------------------------------------------------------------- + + +def main(): + """Start the example application.""" + parser = argparse.ArgumentParser(description="Reliqua Example API Server") + resource_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "resources") + + parser.add_argument("--address", help="Bind address", default="127.0.0.1") + parser.add_argument("--port", help="Bind port", type=int, default=8000) + parser.add_argument("--ui-url", help="Public URL for OpenAPI UI (e.g., behind a proxy)") + parser.add_argument("--docs", help="Docs URL path", default="/docs") + parser.add_argument("--server", help="Additional OpenAPI server URLs", nargs="*") + parser.add_argument("--resource-path", help="Path to resource modules", default=resource_path) + parser.add_argument("--workers", help="Number of Gunicorn workers", type=int, default=2) + parser.add_argument("--config", help="INI configuration file path", default=None) args = parser.parse_args() - servers = [{"url": x, "description": ""} for x in args.server] if args.server else [] - middleware = [auth] + # ----------------------------------------------------------------------- + # Optional: load config file and merge with CLI args + # ----------------------------------------------------------------------- + # Demonstrates load_config() which reads a [config] section from an INI file. if args.config: - config = load_config(args.config) - for k, v in config.items(): - if getattr(args, k, None): - setattr(args, k, v) + file_config = load_config(args.config) + for key, value in file_config.items(): + if getattr(args, key, None): + setattr(args, key, value) + + # ----------------------------------------------------------------------- + # Authentication setup + # ----------------------------------------------------------------------- + # Demonstrates: + # BasicAuthentication — HTTP Basic (Authorization header) + # CookieAuthentication — API key stored in a cookie + # AuthMiddleware — tries authenticators in order (first success wins) + # AccessResource — reads __auth__ on each resource for role-based access; + # default_mode="deny" means everything requires auth + # unless the resource sets no_auth=True or __auth__ + # grants access via wildcard roles. + basic_auth = BasicAuthentication(validation=validate_basic_credentials) + cookie_auth = CookieAuthentication("api_key", validation=validate_cookie_api_key) + + auth_middleware = AuthMiddleware( + [basic_auth, cookie_auth], + control=AccessResource(default_mode="deny"), + ) + + # ----------------------------------------------------------------------- + # Gunicorn, info, and OpenAPI configuration + # ----------------------------------------------------------------------- + servers = [{"url": url, "description": ""} for url in args.server] if args.server else [] gunicorn = { "bind": f"{args.address}:{args.port}", @@ -83,11 +133,11 @@ def main(): } info = { - "title": "Reliqua Example", + "title": "Reliqua Example API", "version": "1.0.0", - "description": "Example API", - "license": "3-Clause BSD License", - "license_url": "https://opensource.org/license/bsd-3-clause", + "description": "Comprehensive example demonstrating all Reliqua features", + "license": "MIT License", + "license_url": "https://opensource.org/license/mit", "contact_name": "Terrence Meiczinger", } @@ -95,14 +145,23 @@ def main(): "highlight": True, "sort": "alpha", "ui_url": args.ui_url, + "docs": args.docs, "servers": servers, } + # ----------------------------------------------------------------------- + # Create and run the application + # ----------------------------------------------------------------------- + # Demonstrates: + # resource_path — auto-discovers all Resource subclasses in this directory + # middleware — auth middleware is applied to every request + # config — accessible in resources via self.app_config + # resource_attributes — every resource gets self.version set automatically app = Application( resource_path=args.resource_path, - middleware=middleware, + middleware=[auth_middleware], config=vars(args), - resource_attributes={"random": "example"}, + resource_attributes={"version": "1.0.0"}, info=info, openapi=openapi, gunicorn=gunicorn, diff --git a/example/resources/__init__.py b/example/resources/__init__.py index 991652e..72a67a7 100644 --- a/example/resources/__init__.py +++ b/example/resources/__init__.py @@ -1,5 +1,16 @@ """ -Reliqua Framework. +Example resource modules. + +Each module demonstrates different Reliqua features: + +- users.py — CRUD with multiple routes, suffixes, body/query/path params, + typed lists, component schemas, role-based __auth__, get_params() +- servers.py — Multiple routes per resource, path param type coercion, + query list params, operator parsing +- contact.py — Multipart form parameters (in=form), no_auth public resource +- files.py — Binary and gzip streaming responses, content type negotiation +- health.py — Minimal public endpoint, resource attributes, app config access, + status code helpers, accepts/return content types Copyright 2016-2024. """ diff --git a/example/resources/contact.py b/example/resources/contact.py new file mode 100644 index 0000000..5386253 --- /dev/null +++ b/example/resources/contact.py @@ -0,0 +1,76 @@ +""" +Contact resource — demonstrates multipart form data handling. + +Features shown: +- Form parameters (in=form) for multipart file/field uploads +- Required form fields with validation +- no_auth = True for public endpoints +- :accepts form: content type declaration + +Copyright 2016-2024. +""" + +from reliqua.resources.base import Resource + +# --------------------------------------------------------------------------- +# In-memory store for submitted messages +# --------------------------------------------------------------------------- + +messages = [] + + +class Contact(Resource): + """Contact form submission. + + Demonstrates: + - Multipart form data (in=form) — used for HTML form submissions + and file uploads. The ProcessParams middleware automatically + extracts form fields into req.params. + - Public endpoint via no_auth = True + - :accepts form: tells OpenAPI this endpoint expects multipart/form-data + """ + + __routes__ = {"/contact": {}} + + __tags__ = ["contact"] + + # Public — no authentication required + no_auth = True + + def on_post(self, req, resp): + """ + Submit a contact message. + + Accepts multipart form data with subject, email, and message fields. + All fields are required. + + :param str subject: [in=form required] Message subject + :param str email: [in=form required] Sender email address + :param str message: [in=form required] Message body + + :response 200: Message was submitted + :response 400: Missing required fields + + :accepts form: + :return json: + """ + p = req.params + entry = { + "subject": p.get("subject"), + "email": p.get("email"), + "message": p.get("message"), + } + messages.append(entry) + resp.media = {"success": True, "id": len(messages) - 1} + + def on_get(self, _req, resp): + """ + List submitted messages. + + Returns all previously submitted contact messages. + + :response 200: Messages were retrieved + + :return json: + """ + resp.media = {"messages": messages, "count": len(messages)} diff --git a/example/resources/files.py b/example/resources/files.py new file mode 100644 index 0000000..d150c9e --- /dev/null +++ b/example/resources/files.py @@ -0,0 +1,117 @@ +""" +Files resource — demonstrates binary and streaming responses. + +Features shown: +- Binary response content type (:return binary:) +- Gzip response content type (:return gzip:) +- Streaming responses via resp.data and resp.stream +- Content-Disposition headers for file downloads +- Path parameter for dynamic filenames +- Text/plain response content type (:return text:) +- YAML response content type (:return yaml:) + +Copyright 2016-2024. +""" + +import yaml + +from reliqua.resources.base import Resource + + +class Download(Resource): + """File download endpoint. + + Demonstrates: + - Binary/gzip streaming response using resp.data + - Setting Content-Disposition for file download + - :return gzip: content type declaration + """ + + __routes__ = {"/files/download": {}} + + __tags__ = ["files"] + + __auth__ = { + "GET": ["admin", "viewer"], + } + + def on_get(self, _req, resp): + """ + Download a sample file. + + Returns a gzip-compressed file as a download attachment. + In a real application this would read from disk or object storage. + + :response 200: File data returned + :return gzip: + """ + # Simulate gzip file content + content = b"This is a sample file for download demonstration." + resp.data = content + resp.content_type = "application/gzip" + resp.append_header("Content-Disposition", "attachment; filename=sample.gz") + + +class TextFile(Resource): + """Plain text response endpoint. + + Demonstrates: + - :return text: content type (text/plain) + - Direct resp.text for text responses + """ + + __routes__ = {"/files/text": {}} + + __tags__ = ["files"] + + __auth__ = { + "GET": ["admin", "viewer"], + } + + def on_get(self, _req, resp): + """ + Get a plain text response. + + Returns data as plain text instead of JSON. + + :response 200: Text content returned + :return text: + """ + resp.content_type = "text/plain; charset=utf-8" + resp.text = "Hello from the Reliqua example application!\nThis is plain text." + + +class YamlFile(Resource): + """YAML response endpoint. + + Demonstrates: + - :return yaml: content type (application/yaml) + - YAML media handler serialization + """ + + __routes__ = {"/files/yaml": {}} + + __tags__ = ["files"] + + __auth__ = { + "GET": ["admin", "viewer"], + } + + version = None + + def on_get(self, _req, resp): + """ + Get a YAML response. + + Returns data serialized as YAML using the built-in YAMLHandler. + + :response 200: YAML content returned + :return yaml: + """ + data = { + "application": "reliqua-example", + "features": ["routing", "auth", "openapi", "yaml"], + "version": self.version, + } + resp.content_type = "application/yaml" + resp.data = yaml.dump(data, default_flow_style=False).encode("utf-8") diff --git a/example/resources/form.py b/example/resources/form.py deleted file mode 100644 index 3dc7c96..0000000 --- a/example/resources/form.py +++ /dev/null @@ -1,39 +0,0 @@ -""" -Reliqua Framework. - -Copyright 2016-2024. -""" - -from reliqua.exceptions import HTTPBadRequest -from reliqua.resources.base import Resource - - -class Contact(Resource): - """User resource.""" - - __routes__ = { - "/contact": {}, - } - - __tags__ = [ - "contact", - ] - - no_auth = True - - def on_post(self, req, resp): - """ - Send contact message. - - :param str subject: [in=form required=True] Subject - :param str email: [in=form required=True] Sender email - :param str message: [in=form required=True] Message contents - - :accepts form: - :return json: - """ - p = req.params - if p.get("subject"): - resp.media = {"success": True} - else: - raise HTTPBadRequest diff --git a/example/resources/health.py b/example/resources/health.py new file mode 100644 index 0000000..64648b1 --- /dev/null +++ b/example/resources/health.py @@ -0,0 +1,52 @@ +""" +Health resource — demonstrates a minimal public status endpoint. + +Features shown: +- no_auth = True for unauthenticated public access +- Accessing resource_attributes (self.version) set in Application constructor +- Accessing app_config (self.app_config) passed from Application config +- status_codes helper for HTTP status strings +- Simple JSON response with no parameters + +Copyright 2016-2024. +""" + +from reliqua import status_codes as status +from reliqua.resources.base import Resource + + +class Health(Resource): + """Health check endpoint. + + Demonstrates: + - Minimal resource with no parameters + - no_auth = True: publicly accessible without credentials + - self.version: a custom resource_attribute set in Application() + - self.app_config: the config dict passed to Application() + - status_codes.http() for building status strings + """ + + __routes__ = {"/health": {}} + + __tags__ = ["health"] + + # Public — no authentication required + no_auth = True + version = None # This will be set from Application constructor + + def on_get(self, _req, resp): + """ + Health check. + + Returns the current health status of the API. This endpoint + requires no authentication and accepts no parameters. + + :response 200: Service is healthy + + :return json: + """ + resp.media = { + "status": "ok", + "version": self.version, + "status_code": status.http(200), + } diff --git a/example/resources/media.py b/example/resources/media.py deleted file mode 100644 index 82c611c..0000000 --- a/example/resources/media.py +++ /dev/null @@ -1,53 +0,0 @@ -""" -Reliqua Framework. - -Copyright 2016-2024. -""" - -import os - -from reliqua.resources.base import Resource - - -class Gzip(Resource): - """GZIP resource.""" - - __routes__ = { - "/gzip": {}, - } - - def on_get(self, _req, resp): - """ - Send contact message. - - :accepts json: Accepts JSON - :response 200 binary: All good - :return gzip: Return data - """ - with open("/tmp/hello.txt.gz", "rb") as fh: - resp.append_header("Content-Disposition", "attachment; filename=hello.txt.gz") - resp.content_type = "application/gzip" - resp.content_encoding = "gzip" - resp.data = fh.read() - - -class Binary(Resource): - """Binary resource.""" - - __routes__ = { - "/bin/{filename}": {}, - } - - def on_get(self, _req, resp, filename): - """ - Send contact message. - - :param str filename: [in=path] Filename - :response 200 binary: All good - :return binary: Return data - """ - path = f"/tmp/{filename}" - resp.stream = open(path, "rb") - resp.content_type = "application/gzip" - resp.content_encoding = "gzip" - resp.content_length = os.path.getsize(path) diff --git a/example/resources/servers.py b/example/resources/servers.py index 80f9bd6..0c8b19a 100644 --- a/example/resources/servers.py +++ b/example/resources/servers.py @@ -1,5 +1,14 @@ """ -Reliqua Framework. +Servers resource — demonstrates multi-route resources and path param coercion. + +Features shown: +- Multiple routes on one class with suffix routing +- Integer path parameter type coercion (int cpus in=path) +- Query list parameters (list labs in=query) +- Float parameters with min/max constraints +- __auth__ with wildcard method (*) granting access to all verbs +- Multi-line docstring descriptions for OpenAPI +- Raising HTTPNotFound exceptions Copyright 2016-2024. """ @@ -7,81 +16,133 @@ from reliqua.exceptions import HTTPNotFound from reliqua.resources.base import Resource +# --------------------------------------------------------------------------- +# In-memory data store +# --------------------------------------------------------------------------- + servers = [ - "romeo", - "juliet", + {"name": "romeo", "cpus": 4, "memory_gb": 16.0, "location": "us-east"}, + {"name": "juliet", "cpus": 8, "memory_gb": 32.0, "location": "us-west"}, + {"name": "hamlet", "cpus": 16, "memory_gb": 64.0, "location": "eu-west"}, + {"name": "ophelia", "cpus": 2, "memory_gb": 8.0, "location": "us-east"}, ] +LOCATIONS = ["us-east", "us-west", "eu-west", "ap-south"] + class Server(Resource): - """Server resource.""" + """Single server operations by ID. + + Demonstrates: + - Suffix routing for /servers/{id} + - __auth__ with wildcard: all methods require admin role + - Integer path parameter + """ __routes__ = { "/servers/{id}": {"suffix": "by_id"}, } - __tags__ = [ - "servers", - ] + __tags__ = ["servers"] + + __auth__ = { + "*": ["admin"], + } def on_get_by_id(self, _req, resp, id=None): """ - Retrieve a server. + Retrieve a server by ID. - Retrieve a server by its ID. + Returns detailed information about a specific server. - :param str id: [in=path, required] Server ID + :param int id: [in=path required] Server ID (zero-indexed) - :response 200: Server was retrieved - :response 404: Server not found + :response 200: Server was retrieved + :response 404: Server not found :return json: """ try: resp.media = servers[int(id)] - except (IndexError, ValueError): - raise HTTPNotFound("Invalid ID") + except (IndexError, ValueError, TypeError) as exc: + raise HTTPNotFound("Server not found") from exc class Servers(Resource): - """Servers resource.""" + """Server collection and filtered queries. + + Demonstrates: + - Two routes on one class: /servers and /servers/cpus/{cpus} + - Suffix routing for the /cpus/{cpus} sub-route + - Query list params for filtering + - Enum parameter referencing a class attribute + - Float parameter with min/max constraints + - Integer path parameter with type coercion + """ - __routes__ = {"/servers": {}, "/servers/cpus/{cpus}": {"suffix": "by_cpu"}} + __routes__ = { + "/servers": {}, + "/servers/cpus/{cpus}": {"suffix": "by_cpu"}, + } - __tags__ = [ - "servers", - ] + __tags__ = ["servers"] + + __auth__ = { + "GET": ["admin", "viewer"], + } + + # Enum values referenced by [enum=locations] + locations = LOCATIONS def on_get(self, req, resp): """ - Retrieve servers. + List servers. - Retrieve a list of servers in the lab. + Returns all servers, optionally filtered by location or + minimum memory. Supports query list parameters and + float constraints. - :param list labs: [in=query] The labs servers are located + :param list location: [in=query enum=locations] Filter by datacenter location(s) + :param float min_memory: [in=query min=0 max=1024] Minimum memory in GB - :response 200: Servers were retrieved - :response 400: Invalid query parameter + :response 200: Servers were retrieved + :response 400: Invalid query parameter :return json: """ - labs = req.params.get("labs") - resp.media = labs + p = req.params + results = servers + + if p.get("location"): + locs = p["location"] if isinstance(p["location"], list) else [p["location"]] + results = [s for s in results if s["location"] in locs] + + if p.get("min_memory") is not None: + results = [s for s in results if s["memory_gb"] >= p["min_memory"]] + + resp.media = {"results": results, "count": len(results)} def on_get_by_cpu(self, _req, resp, cpus=1): """ - Retrieve a server by CPU. + List servers by CPU count. - Retrieve server information by CPU. + Returns servers that have at least the specified number of CPUs. + The cpus path parameter is automatically coerced to int. - :param int cpus: [in=path required] Number of CPUs for server + :param int cpus: [in=path required] Minimum number of CPUs - :response 200: Server was retrieved - :response 404: Server not found + :response 200: Servers were retrieved + :response 404: No servers found :return json: """ try: - resp.media = servers[int(cpus)] - except (IndexError, ValueError): - raise HTTPNotFound("Invalid ID") + min_cpus = int(cpus) + except (ValueError, TypeError) as exc: + raise HTTPNotFound("Invalid CPU count") from exc + + results = [s for s in servers if s["cpus"] >= min_cpus] + if not results: + raise HTTPNotFound("No servers found with that CPU count") + + resp.media = {"results": results, "count": len(results)} diff --git a/example/resources/users.py b/example/resources/users.py index b7bea7a..98dc8f6 100644 --- a/example/resources/users.py +++ b/example/resources/users.py @@ -1,5 +1,22 @@ """ -Reliqua Framework. +Users resource — demonstrates the most common Reliqua features. + +Features shown: +- Multiple routes on separate classes with suffix routing +- __routes__ dict with suffix option for multi-route resources +- __tags__ for OpenAPI grouping +- __auth__ for per-method role-based authorization +- no_auth = True to bypass authentication entirely +- Component schemas (user, users) referenced in :response docstrings +- Enum attributes referenced from docstring [enum=] +- Path parameters (in=path, required) +- Query parameters (in=query) with default values +- Typed list parameters: list[int], list[str], list[dict] +- Body parameters (in=body) with required, default, object types +- Multiple content types via :accepts and :return +- get_params() helper for extracting request parameters +- Resource attributes (self.version) and app config (self.app_config) +- Exception handling with HTTPNotFound Copyright 2016-2024. """ @@ -7,143 +24,236 @@ from reliqua.exceptions import HTTPNotFound from reliqua.resources.base import Resource +# --------------------------------------------------------------------------- +# In-memory data store +# --------------------------------------------------------------------------- + users = [ - { - "username": "ted", - "email": "ted@nowhere.com", - }, - { - "username": "bob", - "email": "bob@nowhere.com", - }, + {"username": "admin", "email": "admin@example.com", "role": "admin"}, + {"username": "alice", "email": "alice@example.com", "role": "viewer"}, + {"username": "bob", "email": "bob@example.com", "role": "viewer"}, ] -phones = ["603-555-1234", "603-555-5678"] +# --------------------------------------------------------------------------- +# OpenAPI component schemas +# --------------------------------------------------------------------------- +# These are referenced by name in :response docstrings (e.g., :response 200 user:) +# and automatically added to the OpenAPI components/schemas section. USER = { "type": "object", "properties": { - "username": { - "type": "string", - "examples": ["billy"], - } + "username": {"type": "string", "examples": ["alice"]}, + "email": {"type": "string", "examples": ["alice@example.com"]}, + "role": {"type": "string", "examples": ["viewer"]}, }, - "required": ["username"], + "required": ["username", "email"], } USERS = {"type": "array", "items": {"$ref": "#/components/schemas/user"}} +# --------------------------------------------------------------------------- +# Enum values referenced by enum= option in docstrings +# --------------------------------------------------------------------------- + +SORT_FIELDS = ["username", "email", "role"] +ROLES = ["admin", "viewer"] + class User(Resource): - """User resource.""" + """Single user operations (get, update, delete by ID). + + Demonstrates: + - Suffix routing: __routes__ maps /users/{id} with suffix="by_id", + so handlers are named on_get_by_id, on_put_by_id, on_delete_by_id + - __auth__ restricts PUT and DELETE to admin role only; + GET has no restriction so the AccessResource default_mode applies + - Path parameters with [in=path required] + - Component schema reference in :response (user) + - Multiple :accepts and :return content types + """ __routes__ = { "/users/{id}": {"suffix": "by_id"}, } - __tags__ = [ - "users", - ] + __tags__ = ["users"] __auth__ = { - "put": ["admin"], + "PUT": ["admin"], + "DELETE": ["admin"], } + # Schema attributes referenced by :response docstrings user = USER - phones = phones def on_get_by_id(self, _req, resp, id=None): """ - Return a user. + Retrieve a user by ID. + + Returns a single user object matching the given ID. + + :param int id: [in=path required] User ID (zero-indexed) + + :response 200 user: User was retrieved + :response 404: User not found - :param str id: [in=path required] User ID - :response 200 user: User was retrieved - :response 400: Invalid query parameter - :accepts [json,xml]: Accept types - :return [json,xml]: Return content type + :return [json yaml]: """ try: resp.media = users[int(id)] - except IndexError as exc: - raise HTTPNotFound("User not found", description="Please provide a valid user ID.") from exc + except (IndexError, ValueError, TypeError) as exc: + raise HTTPNotFound( + "User not found", + description="Please provide a valid user ID.", + ) from exc + + def on_put_by_id(self, req, resp, id=None): + """ + Update a user by ID. + + Replaces the user at the given index. Requires admin role. + + :param int id: [in=path required] User ID + :param str username: [in=body required] Username + :param str email: [in=body required] Email address + :param str role: [in=body default=viewer] User role + + :response 200 user: User was updated + :response 404: User not found + + :accepts json: + :return json: + """ + try: + idx = int(id) + p = req.params + users[idx] = { + "username": p["username"], + "email": p["email"], + "role": p.get("role", "viewer"), + } + resp.media = users[idx] + except (IndexError, ValueError, TypeError) as exc: + raise HTTPNotFound("User not found") from exc def on_delete_by_id(self, _req, resp, id=None): """ - Delete a user. + Delete a user by ID. - :param str id: [in=path] User Id + Removes the user at the given index. Requires admin role. + + :param int id: [in=path required] User ID + + :response 200: User was deleted + :response 404: User not found :return json: """ try: - users.pop(int(id)) - resp.media = {"success": True} - except IndexError as exc: - raise HTTPNotFound("User not found", description="Please provide a valid user ID.") from exc + removed = users.pop(int(id)) + resp.media = {"deleted": removed["username"]} + except (IndexError, ValueError, TypeError) as exc: + raise HTTPNotFound("User not found") from exc class Users(Resource): - """Users resource.""" + """Collection-level user operations (list and create). - __routes__ = { - "/users": {}, - } + Demonstrates: + - no_auth = True: this resource bypasses authentication entirely, + regardless of the AccessResource default_mode + - Query parameters with defaults, typed lists, and enum references + - Body parameters with required, default, object, and typed list types + - get_params() helper for extracting a subset of request parameters + - Accessing self.app_config and self.version (resource_attributes) + - Component schema reference for collection response (users) + """ - __tags__ = [ - "users", - ] + __routes__ = {"/users": {}} - __auth__ = { - "GET": ["admin"], - "POST": ["admin"], - "DELETE": ["admin"], - } + __tags__ = ["users"] + # Public endpoint — no authentication required no_auth = True + # Schema attributes users = USERS + # Enum attributes referenced by [enum=sort_fields] and [enum=roles] + sort_fields = SORT_FIELDS + roles = ROLES + + version = None + def on_get(self, req, resp): """ - Return users. + List users. + + Returns all users, optionally filtered by query parameters. + This endpoint is public (no_auth = True). - :param str username: [in=query] Username - :param str email: [in=query default=ted@nowhere.com] Email - :param list[int] ids: [in=query] List of IDs + :param str username: [in=query] Filter by username + :param str email: [in=query default=] Filter by email + :param list[int] ids: [in=query] Filter by list of user IDs + :param str sort: [in=query enum=sort_fields default=username] Sort field + :param str role: [in=query enum=roles] Filter by role - :response 200 users: Users were retrieved - :response 401: Invalid Authorization - :accepts [json,xml]: Accept types - :return [json xml]: Return JSON of users + :response 200 users: Users were retrieved + + :accepts [json yaml]: + :return [json yaml]: """ - results = [] p = req.params + results = users + + # Filter by username + if p.get("username"): + results = [u for u in results if u["username"] == p["username"]] + + # Filter by email + if p.get("email"): + results = [u for u in results if u["email"] == p["email"]] - if any(p.values()): - for user in users: - if user["username"] == p.get("username", None): - results.append(user) - elif user["email"] == p.get("email", None): - results.append(user) - else: - results = users + # Filter by IDs + if p.get("ids"): + results = [users[i] for i in p["ids"] if i < len(users)] - resp.media = {"results": results, "config": self.app_config, "random": self.random} + # Filter by role + if p.get("role"): + results = [u for u in results if u["role"] == p["role"]] + + resp.media = { + "results": results, + "count": len(results), + "sort": p.get("sort", "username"), + "version": self.version, + } def on_post(self, req, resp): """ Create a new user. - :param str username: [in=body required=true] Username - :param str email: [in=body required=true] Email - :param list[dict] data: [in=body] Extra Data - :param list[str] names: [in=body] Names - :param bool valid: [in=body default=False] Valid - :param object config: [in=body] Configuration data + Accepts a JSON body with user details. This endpoint is public. - :accepts [json xml]: The body content type + :param str username: [in=body required] Username + :param str email: [in=body required] Email address + :param str role: [in=body default=viewer] User role + :param list[str] tags: [in=body] Optional tags for the user + :param list[dict] metadata: [in=body] Optional metadata entries + :param bool active: [in=body default=true] Whether user is active + :param object preferences: [in=body] Arbitrary JSON preferences + + :response 201: User was created + :response 400: Invalid request body + + :accepts json: :return json: """ - p = req.params + p = self.get_params(req, exclude=["tags", "metadata", "preferences"]) users.append(p) - resp.media = len(users) - 1 + resp.status = "201 Created" + resp.media = { + "id": len(users) - 1, + "user": p, + } diff --git a/src/reliqua/api.py b/src/reliqua/api.py index e8e715f..859af38 100644 --- a/src/reliqua/api.py +++ b/src/reliqua/api.py @@ -98,7 +98,6 @@ def __init__( if not resource_path: resource_path = path + "/resources" - self.req_options.auto_parse_form_urlencoded = True self.resource_path = resource_path self._add_handlers() @@ -116,8 +115,8 @@ def _add_handlers(self): "application/json": JSONHandler(), } - self.req_options.media_handlers.update(extra_handlers) - self.resp_options.media_handlers.update(extra_handlers) + self.req_options.media_handlers.update(extra_handlers) # pyright: ignore[reportAttributeAccessIssue] + self.resp_options.media_handlers.update(extra_handlers) # pyright: ignore[reportAttributeAccessIssue] def _load_resources(self): """Load resource classes from the specified resource path.""" diff --git a/tests/test_auth.py b/tests/test_auth.py index e9df57f..03130c8 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -41,8 +41,8 @@ def test_default_role_is_none(self): def test_kwargs_set_attributes(self): ctx = AuthenticationContext(user="alice", role="admin") - assert ctx.user == "alice" - assert ctx.role == "admin" + assert getattr(ctx, "user", None) == "alice" + assert getattr(ctx, "role", None) == "admin" class TestAccessList: From 676418bab518bfa24a7553b8aa5119cc7146a6b7 Mon Sep 17 00:00:00 2001 From: Terrence Meiczinger Date: Thu, 26 Feb 2026 23:44:09 -0500 Subject: [PATCH 2/5] update --- pyproject.toml | 4 +- src/reliqua/api.py | 31 +- src/reliqua/app.py | 37 ++- src/reliqua/auth.py | 46 ++- src/reliqua/database.py | 13 +- src/reliqua/exceptions.py | 566 +++------------------------------- src/reliqua/media_handlers.py | 6 + src/reliqua/middleware.py | 11 +- src/reliqua/status_codes.py | 8 +- tests/test_app.py | 60 +++- tests/test_auth.py | 6 +- tests/test_middleware.py | 14 +- tests/test_sphinx_parser.py | 6 +- tests/test_status_codes.py | 16 + 14 files changed, 238 insertions(+), 586 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 173c940..176ff7e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ name = "reliqua" version = "0.0.13" description = "Simple, efficient, intuitive API Framework" readme = "README.md" -requires-python = ">=3.8" +requires-python = ">=3.10" keywords = ["utilities"] license = { text = "MIT" } authors = [ @@ -106,7 +106,7 @@ py_version = 310 include_trailing_comma = true [tool.ruff] -target-version = "py38" +target-version = "py310" line-length = 120 output-format = "full" exclude = [".git", ".venv", ".tox", ".dist", "doc", "*egg,build", "*.pyc"] diff --git a/src/reliqua/api.py b/src/reliqua/api.py index 859af38..771d7f0 100644 --- a/src/reliqua/api.py +++ b/src/reliqua/api.py @@ -25,6 +25,7 @@ import glob import importlib import inspect +import logging import os import re import sys @@ -41,6 +42,8 @@ from .sphinx_parser import SphinxParser from .swagger import Swagger +logger = logging.getLogger(__name__) + class Api(falcon.App): """Add auto route and documentation.""" @@ -53,7 +56,7 @@ def __init__( resource_attributes=None, info=None, openapi=None, - **_kwargs, + cors_options=None, ): """ Create an API instance. @@ -80,6 +83,7 @@ def __init__( self.openapi["spec"] = f"{openapi_path}/openapi.json" self.openapi["static"] = f"{openapi_path}/static" self.resources = [] + self._parser = SphinxParser() self.auth = [x for x in middleware if isinstance(x, AuthMiddleware)] self.config = config or {} self.resource_attributes = resource_attributes or {} @@ -90,7 +94,12 @@ def __init__( self.openapi["static_url"] = f"{self.url}{self.openapi['static']}" middleware = middleware or [] - cors = CORS(allow_all_origins=True, allow_all_methods=True, allow_all_headers=True) + cors_options = cors_options or { + "allow_all_origins": True, + "allow_all_methods": True, + "allow_all_headers": True, + } + cors = CORS(**cors_options) middleware.append(cors.middleware) super().__init__(middleware=middleware) @@ -122,11 +131,11 @@ def _load_resources(self): """Load resource classes from the specified resource path.""" resources = [] path = f"{self.resource_path}/*.py" - print(f"searching {path}") + logger.info("searching %s", path) files = glob.glob(path) for file in files: - print(f"loading {file}") + logger.info("loading %s", file) classes = self._get_classes(file) resources.extend(classes) @@ -145,12 +154,14 @@ def _is_route_method(self, name, suffix): def _parse_methods(self, resource, route, methods): """Parse methods of a resource for a given route.""" - parser = SphinxParser() for name in methods: operation_id = f"{resource.__class__.__name__}.{name}" - action = re.search(r"on_(delete|get|patch|post|put)", name).group(1) + match = re.search(r"on_(delete|get|patch|post|put)", name) + if not match: + continue + action = match.group(1) method = getattr(resource, name) - resource.__data__[route][action] = parser.parse(method, operation_id=operation_id) + resource.__data__[route][action] = self._parser.parse(method, operation_id=operation_id) def _parse_resource(self, resource): """Parse a resource to extract routes and methods.""" @@ -175,7 +186,7 @@ def _get_classes(self, filename): try: spec.loader.exec_module(module) except (ImportError, FileNotFoundError, SyntaxError, TypeError, AttributeError) as e: - print(f"Error loading module {module_name}: {e}") + logger.error("Error loading module %s: %s", module_name, e) return classes for _, c in inspect.getmembers(module, inspect.isclass): @@ -202,8 +213,8 @@ def _add_docs(self): openapi = OpenApi(**self.info, auth=self.auth, servers=self.servers) openapi.process_resources(self.resources) schema = openapi.schema() - print(f"adding static route {self.openapi['docs']} {self.openapi['file_path']}") + logger.info("adding static route %s %s", self.openapi["docs"], self.openapi["file_path"]) self.add_static_route(self.openapi["static"], self.openapi["file_path"]) self.add_route(self.openapi["docs"], swagger) - print(f"adding openapi file {self.openapi['spec']}") + logger.info("adding openapi file %s", self.openapi["spec"]) self.add_route(self.openapi["spec"], Docs(schema)) diff --git a/src/reliqua/app.py b/src/reliqua/app.py index 495c05c..7050eec 100644 --- a/src/reliqua/app.py +++ b/src/reliqua/app.py @@ -5,6 +5,7 @@ """ import configparser +import logging from gunicorn.app.base import BaseApplication @@ -41,26 +42,18 @@ } -def update_dict(a, b): +def update_dict(overrides, defaults): """ - Update dictionary a with values from dictionary b. + Merge user overrides into a defaults dictionary. - Update dictionary a with values from dictionary b. Remove - keys from a that are not in b. + Return a new dictionary containing only keys defined in `defaults`, + with values from `overrides` taking precedence when present. - :param dict a: Dictionary to update - :param dict b: Dictionary with new values - :return dict: Updated dictionary + :param dict overrides: User-supplied values + :param dict defaults: Default values (also defines valid keys) + :return dict: Merged dictionary """ - # Create a new dictionary with keys from a that are also in b - updated_dict = {key: a[key] for key in a if key in b} - - # Add keys from b that are not in a - for key in b: - if key not in updated_dict: - updated_dict[key] = b[key] - - return updated_dict + return {key: overrides.get(key, defaults[key]) for key in defaults} def load_config(config_file): @@ -80,8 +73,7 @@ def load_config(config_file): for option in config.options(section): params[option] = config.get(section, option) except (TypeError, configparser.Error) as e: - # Log the error or handle it appropriately - print(f"Error loading config file: {e}") + logging.getLogger(__name__).error("Error loading config file: %s", e) return params @@ -98,7 +90,7 @@ def __init__( info=None, openapi=None, gunicorn=None, - **_kwargs, + cors_options=None, ): """ Create Application instance. @@ -110,6 +102,7 @@ def __init__( :param str info: Application information :param str openapi: OpenAPI configuration options :param dict gunicorn: Gunicorn configuration options + :param dict cors_options: CORS configuration options for falcon-cors :return: Application instance """ @@ -118,6 +111,11 @@ def __init__( openapi = openapi or {} info = info or {} + logging.basicConfig( + level=logging.INFO, + format="%(message)s", + ) + resource_path = resource_path or "" resource_attributes = resource_attributes or {} @@ -142,6 +140,7 @@ def __init__( resource_attributes=resource_attributes, openapi=openapi, info=info, + cors_options=cors_options, ) super().__init__() diff --git a/src/reliqua/auth.py b/src/reliqua/auth.py index 8cd0fde..40ac324 100644 --- a/src/reliqua/auth.py +++ b/src/reliqua/auth.py @@ -9,6 +9,23 @@ import falcon +__all__ = [ + "AccessCallback", + "AccessControl", + "AccessList", + "AccessMap", + "AccessResource", + "ApiAuthentication", + "AuthMiddleware", + "AuthenticationContext", + "BasicAuthentication", + "BearerAuthentication", + "CookieAuthentication", + "HeaderAuthentication", + "MultiAuthentication", + "QueryAuthentication", +] + def is_base64(data): """ @@ -55,7 +72,7 @@ class AccessControl: The `authorized` method returns whether the client is authorized to execute the call. The authorized takes an `AuthenticationContext` - which may have implentation specific details. + which may have implementation specific details. """ def authorized(self, context, _route, _method, _resource): @@ -138,8 +155,20 @@ def __init__(self, routes=None, methods=None, default_mode="allow"): self.default_mode = default_mode def authorized(self, context, _route, _method, _resource): - """Return whether client is allowed to access resource.""" - raise NotImplementedError("authorized method not implemented") + """ + Return whether client is allowed to access resource. + + AccessList does not perform role-based authorization. + If the request reached this point, it passed authentication, + so access is granted. + + :param object context: Authentication context + :param str _route: Route being called + :param str _method: HTTP method invoked + :param Resource _resource: Route resource + :return bool: Always True (authorization is implicit) + """ + return True def authentication_required(self, route, method, _resource): """ @@ -153,7 +182,6 @@ def authentication_required(self, route, method, _resource): :return bool: True if authentication is required """ matched = False - required = self.default_mode != "allowed" if route.lower() in self.routes or method.lower() in self.methods: matched = True @@ -174,7 +202,7 @@ class AccessMap(AccessControl): Access Map. Access control is checked by the routes and/or method specified in the - the dictionary. Only items defined will be checked and everything else will + dictionary. Only items defined will be checked and everything else will be denied. Therefore, this must be a complete map. The access map follows the form: @@ -197,7 +225,7 @@ class AccessMap(AccessControl): def __init__(self, access_map): """ - Create the AccessList instance. + Create the AccessMap instance. :param list access_map: Dictionary of rules :return: @@ -274,7 +302,7 @@ class AccessResource(AccessControl): def __init__(self, default_mode="deny", raise_on_undefined=False): """ - Create the AccessList instance. + Create the AccessResource instance. :param str default_mode: Default mode (allow|deny) :param bool raise_on_undefined: If a resource has undefined auth attributes, raise exception @@ -351,7 +379,7 @@ class ApiAuthentication(Authentication): An abstract base class for API key type authentications. """ - location = " any" + location = "any" def __init__(self, name, description=None, validation=None): """ @@ -710,7 +738,7 @@ def process_resource(self, req, resp, resource, _params): # authenticate user auth = self.authenticate(req, resp, resource) - # if an auth contecxt is returned check if authorized + # if an auth context is returned check if authorized if auth: authorized = self.control.authorized(auth, req.uri_template, req.method, resource) if not authorized: diff --git a/src/reliqua/database.py b/src/reliqua/database.py index 08a03e1..99b0b7b 100644 --- a/src/reliqua/database.py +++ b/src/reliqua/database.py @@ -16,6 +16,15 @@ SqliteDatabase, ) +__all__ = [ + "BaseModel", + "DatabaseConnection", + "db", + "mysql_connect", + "psql_connect", + "sqlite_connect", +] + db = Proxy() @@ -111,8 +120,8 @@ def psql_connect(host=None, database=None, user=None, password=None, port=5432): """ if is_b64(password): password = b64decode(password) - mysql = PostgresqlDatabase(database, user=user, password=password, host=host, port=port) - db.initialize(mysql) + psql = PostgresqlDatabase(database, user=user, password=password, host=host, port=port) + db.initialize(psql) return db diff --git a/src/reliqua/exceptions.py b/src/reliqua/exceptions.py index 4004cd4..ea4e36d 100644 --- a/src/reliqua/exceptions.py +++ b/src/reliqua/exceptions.py @@ -22,548 +22,60 @@ SOFTWARE. """ -from falcon.http_error import HTTPError - - -class HTTPBadRequest(HTTPError): - """Bad Request.""" - - def __init__(self, title=None, description=None, headers=None): - """Initialize.""" - status = "400 Bad Request" - title = title or status - - super().__init__( - status=status, - title=title, - description=description, - headers=headers, - ) - - -class HTTPUnauthorized(HTTPError): - """Unauthorized.""" - - def __init__(self, title=None, description=None, headers=None): - """Initialize.""" - status = "401 Unauthorized" - title = title or status - - super().__init__( - status=status, - title=title, - description=description, - headers=headers, - ) - - -class HTTPPaymentRequired(HTTPError): - """Payment Required.""" - - def __init__(self, title=None, description=None, headers=None): - """Initialize.""" - status = "402 Payment Required" - title = title or status - - super().__init__( - status=status, - title=title, - description=description, - headers=headers, - ) - - -class HTTPForbidden(HTTPError): - """Forbidden.""" - - def __init__(self, title=None, description=None, headers=None): - """Initialize.""" - status = "403 Forbidden" - title = title or status - - super().__init__( - status=status, - title=title, - description=description, - headers=headers, - ) - - -class HTTPNotFound(HTTPError): - """Not Found.""" - - def __init__(self, title=None, description=None, headers=None): - """Initialize.""" - status = "404 Not Found" - title = title or status - - super().__init__( - status=status, - title=title, - description=description, - headers=headers, - ) - - -class HTTPMethodNotAllowed(HTTPError): - """Method Not Allowed.""" - - def __init__(self, title=None, description=None, headers=None): - """Initialize.""" - status = "405 Method Not Allowed" - title = title or status - - super().__init__( - status=status, - title=title, - description=description, - headers=headers, - ) - - -class HTTPNotAcceptable(HTTPError): - """Not Acceptable.""" - - def __init__(self, title=None, description=None, headers=None): - """Initialize.""" - status = "406 Not Acceptable" - title = title or status - - super().__init__( - status=status, - title=title, - description=description, - headers=headers, - ) - - -class HTTPProxyAuthenticationRequired(HTTPError): - """Proxy Authentication Required.""" - - def __init__(self, title=None, description=None, headers=None): - """Initialize.""" - status = "407 Proxy Authentication Required" - title = title or status - - super().__init__( - status=status, - title=title, - description=description, - headers=headers, - ) - - -class HTTPRequestTimeout(HTTPError): - """Request Timeout.""" - - def __init__(self, title=None, description=None, headers=None): - """Initialize.""" - status = "408 Request Timeout" - title = title or status - - super().__init__( - status=status, - title=title, - description=description, - headers=headers, - ) - - -class HTTPConflict(HTTPError): - """Conflict.""" - - def __init__(self, title=None, description=None, headers=None): - """Initialize.""" - status = "409 Conflict" - title = title or status - - super().__init__( - status=status, - title=title, - description=description, - headers=headers, - ) - - -class HTTPGone(HTTPError): - """Gone.""" - - def __init__(self, title=None, description=None, headers=None): - """Initialize.""" - status = "410 Gone" - title = title or status - - super().__init__( - status=status, - title=title, - description=description, - headers=headers, - ) - - -class HTTPLengthRequired(HTTPError): - """Length Required.""" - - def __init__(self, title=None, description=None, headers=None): - """Initialize.""" - status = "411 Length Required" - title = title or status - - super().__init__( - status=status, - title=title, - description=description, - headers=headers, - ) - - -class HTTPPreconditionFailed(HTTPError): - """Precondition Failed.""" - - def __init__(self, title=None, description=None, headers=None): - """Initialize.""" - status = "412 Precondition Failed" - title = title or status - - super().__init__( - status=status, - title=title, - description=description, - headers=headers, - ) - - -class HTTPPayloadTooLarge(HTTPError): - """Payload Too Large.""" - - def __init__(self, title=None, description=None, headers=None): - """Initialize.""" - status = "413 Payload Too Large" - title = title or status - - super().__init__( - status=status, - title=title, - description=description, - headers=headers, - ) - - -class HTTPUnsupportedMediaType(HTTPError): - """Unsupported Media Type.""" - - def __init__(self, title=None, description=None, headers=None): - """Initialize.""" - status = "415 Unsupported Media Type" - title = title or status - - super().__init__( - status=status, - title=title, - description=description, - headers=headers, - ) - - -class HTTPRangeNotSatisfiable(HTTPError): - """Range Not Satisfiable.""" - - def __init__(self, title=None, description=None, headers=None): - """Initialize.""" - status = "416 Range Not Satisfiable" - title = title or status - - super().__init__( - status=status, - title=title, - description=description, - headers=headers, - ) +import re +from falcon.http_error import HTTPError -class HTTPExpectationFailed(HTTPError): - """Expectation Failed.""" - - def __init__(self, title=None, description=None, headers=None): - """Initialize.""" - status = "417 Expectation Failed" - title = title or status - - super().__init__( - status=status, - title=title, - description=description, - headers=headers, - ) - - -class HTTPUnprocessableEntity(HTTPError): - """Unprocessable Entity.""" - - def __init__(self, title=None, description=None, headers=None): - """Initialize.""" - status = "422 Unprocessable Entity" - title = title or status - - super().__init__( - status=status, - title=title, - description=description, - headers=headers, - ) - - -class HTTPLocked(HTTPError): - """Locked.""" - - def __init__(self, title=None, description=None, headers=None): - """Initialize.""" - status = "423 Locked" - title = title or status - - super().__init__( - status=status, - title=title, - description=description, - headers=headers, - ) - - -class HTTPFailedDependency(HTTPError): - """Failed Dependency.""" - - def __init__(self, title=None, description=None, headers=None): - """Initialize.""" - status = "424 Failed Dependency" - title = title or status - - super().__init__( - status=status, - title=title, - description=description, - headers=headers, - ) - - -class HTTPUpgradeRequired(HTTPError): - """Upgrade Required.""" - - def __init__(self, title=None, description=None, headers=None): - """Initialize.""" - status = "426 Upgrade Required" - title = title or status - - super().__init__( - status=status, - title=title, - description=description, - headers=headers, - ) - - -class HTTPPreconditionRequired(HTTPError): - """Precondition Required.""" - - def __init__(self, title=None, description=None, headers=None): - """Initialize.""" - status = "428 Precondition Required" - title = title or status - - super().__init__( - status=status, - title=title, - description=description, - headers=headers, - ) - - -class HTTPTooManyRequests(HTTPError): - """Too Many Requests.""" - - def __init__(self, title=None, description=None, headers=None): - """Initialize.""" - status = "429 Too Many Requests" - title = title or status - - super().__init__( - status=status, - title=title, - description=description, - headers=headers, - ) - - -class HTTPRequestHeaderFieldsTooLarge(HTTPError): - """Request Header Fields Too Large.""" - - def __init__(self, title=None, description=None, headers=None): - """Initialize.""" - status = "431 Request Header Fields Too Large" - title = title or status - - super().__init__( - status=status, - title=title, - description=description, - headers=headers, - ) - - -class HTTPUnavailableForLegalReasons(HTTPError): - """Unavailable For Legal Reasons.""" - - def __init__(self, title=None, description=None, headers=None): - """Initialize.""" - status = "451 Unavailable For Legal Reasons" - title = title or status - - super().__init__( - status=status, - title=title, - description=description, - headers=headers, - ) - - -class HTTPInternalServerError(HTTPError): - """Internal Server Error.""" - - def __init__(self, title=None, description=None, headers=None): - """Initialize.""" - status = "500 Internal Server Error" - title = title or status - - super().__init__( - status=status, - title=title, - description=description, - headers=headers, - ) - - -class HTTPNotImplemented(HTTPError): - """Not Implemented.""" - - def __init__(self, title=None, description=None, headers=None): - """Initialize.""" - status = "501 Not Implemented" - title = title or status - - super().__init__( - status=status, - title=title, - description=description, - headers=headers, - ) - - -class HTTPBadGateway(HTTPError): - """Bad Gateway.""" - - def __init__(self, title=None, description=None, headers=None): - """Initialize.""" - status = "502 Bad Gateway" - title = title or status - - super().__init__( - status=status, - title=title, - description=description, - headers=headers, - ) - - -class HTTPServiceUnavailable(HTTPError): - """Service Unavailable.""" - - def __init__(self, title=None, description=None, headers=None): - """Initialize.""" - status = "503 Service Unavailable" - title = title or status - - super().__init__( - status=status, - title=title, - description=description, - headers=headers, - ) - - -class HTTPGatewayTimeout(HTTPError): - """Gateway Timeout.""" - - def __init__(self, title=None, description=None, headers=None): - """Initialize.""" - status = "504 Gateway Timeout" - title = title or status - - super().__init__( - status=status, - title=title, - description=description, - headers=headers, - ) - - -class HTTPVersionNotSupported(HTTPError): - """HTTP Version Not Supported.""" - - def __init__(self, title=None, description=None, headers=None): - """Initialize.""" - status = "505 HTTP Version Not Supported" - title = title or status - - super().__init__( - status=status, - title=title, - description=description, - headers=headers, - ) +from .status_codes import CODES +# Map of HTTP status codes (4xx and 5xx) to exception class names. +# +# Each entry generates a class like: +# +# class HTTPNotFound(HTTPError): +# """Not Found.""" +# def __init__(self, title=None, description=None, headers=None): +# ... +# +# The classes are added to this module's namespace and __all__, +# so they can be imported normally: +# +# from reliqua.exceptions import HTTPNotFound -class HTTPInsufficientStorage(HTTPError): - """Insufficient Storage.""" +_ERROR_CODES = {code: message for code, message in CODES.items() if int(code) >= 400} - def __init__(self, title=None, description=None, headers=None): - """Initialize.""" - status = "507 Insufficient Storage" - title = title or status - super().__init__( - status=status, - title=title, - description=description, - headers=headers, - ) +def _make_class_name(message): + """Convert an HTTP message to a PascalCase class name prefixed with HTTP.""" + cleaned = re.sub(r"[^a-zA-Z0-9 ]", "", message) + return "HTTP" + cleaned.replace(" ", "") -class HTTPLoopDetected(HTTPError): - """Loop Detected.""" +def _make_exception_class(code, message): + """Dynamically create an HTTPError subclass for the given status code.""" + status_string = f"{code} {message}" def __init__(self, title=None, description=None, headers=None): """Initialize.""" - status = "508 Loop Detected" - title = title or status - - super().__init__( - status=status, - title=title, + super(self.__class__, self).__init__( + status=status_string, + title=title or status_string, description=description, headers=headers, ) + return type( + _make_class_name(message), + (HTTPError,), + {"__doc__": f"{message}.", "__init__": __init__}, + ) -class HTTPNetworkAuthenticationRequired(HTTPError): - """Network Authentication Required.""" - def __init__(self, title=None, description=None, headers=None): - """Initialize.""" - status = "511 Network Authentication Required" - title = title or status +# Generate all exception classes and populate __all__ +__all__ = [] - super().__init__( - status=status, - title=title, - description=description, - headers=headers, - ) +for _code, _message in _ERROR_CODES.items(): + _cls_name = _make_class_name(_message) + _cls = _make_exception_class(_code, _message) + globals()[_cls_name] = _cls + __all__.append(_cls_name) diff --git a/src/reliqua/media_handlers.py b/src/reliqua/media_handlers.py index d91527e..016350e 100644 --- a/src/reliqua/media_handlers.py +++ b/src/reliqua/media_handlers.py @@ -14,6 +14,12 @@ from falcon.media import JSONHandler as FalconJSONHandler from yaml import SafeLoader +__all__ = [ + "JSONHandler", + "TextHandler", + "YAMLHandler", +] + class YAMLHandler(BaseHandler): """Media handler class for YAML.""" diff --git a/src/reliqua/middleware.py b/src/reliqua/middleware.py index 3089610..b7c9825 100644 --- a/src/reliqua/middleware.py +++ b/src/reliqua/middleware.py @@ -10,6 +10,13 @@ import falcon +__all__ = [ + "Converter", + "Parameter", + "ProcessParams", + "to_bool", +] + def to_bool(value): """ @@ -210,7 +217,6 @@ def convert(req, parameter, transform=None): converter = getattr(Converter, f"as_{parameter.datatype}", Converter.as_str) transform = TRANSFORMS.get(transform, str) - print(f"transform: {transform}") default = transform(parameter.default) if transform and parameter.default else None return converter( @@ -244,9 +250,6 @@ def _check_required(self, request, parameter): description=f"Missing parameter '{parameter.name}'", ) - def _convert(self, request, parameter): - pass - def _parse_operators(self, request): operators = {} name = "" diff --git a/src/reliqua/status_codes.py b/src/reliqua/status_codes.py index c535db1..6565537 100644 --- a/src/reliqua/status_codes.py +++ b/src/reliqua/status_codes.py @@ -27,8 +27,12 @@ def http(code): """Return HTTP as string.""" try: return f"{int(code)} {CODES[str(code)]}" - except ValueError: - return f"{MESSAGES[code.upper()]} {code}" + except (ValueError, KeyError): + key = str(code).upper().replace(" ", "_") + status_code = MESSAGES.get(key) + if status_code is None: + return "500 Internal Server Error" + return f"{status_code} {code}" HTTP = http diff --git a/tests/test_app.py b/tests/test_app.py index e304e45..7e648a6 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -2,7 +2,9 @@ import os import tempfile +from unittest.mock import MagicMock, patch +from reliqua.api import Api from reliqua.app import load_config, update_dict @@ -89,4 +91,60 @@ def test_load_config_missing_section(self): f.flush() result = load_config(f.name) os.unlink(f.name) - assert result == {} + assert not result + + +class TestCorsOptions: + """Tests for configurable CORS options passed to Api.""" + + _patches = ( + "reliqua.api.Api._add_docs", + "reliqua.api.Api._add_routes", + "reliqua.api.Api._parse_docstrings", + "reliqua.api.Api._load_resources", + "reliqua.api.Api._add_handlers", + "falcon.App.__init__", + ) + + @patch(_patches[0]) + @patch(_patches[1]) + @patch(_patches[2]) + @patch(_patches[3]) + @patch(_patches[4]) + @patch(_patches[5], return_value=None) + @patch("reliqua.api.CORS") + def test_default_cors_options(self, mock_cors, *_mocks): + """When no cors_options given, default allow-all should be used.""" + mock_cors.return_value.middleware = MagicMock() + Api( + resource_path="/tmp", + middleware=[], + openapi={"path": "/docs", "ui_url": "", "servers": []}, + ) + mock_cors.assert_called_once_with( + allow_all_origins=True, + allow_all_methods=True, + allow_all_headers=True, + ) + + @patch(_patches[0]) + @patch(_patches[1]) + @patch(_patches[2]) + @patch(_patches[3]) + @patch(_patches[4]) + @patch(_patches[5], return_value=None) + @patch("reliqua.api.CORS") + def test_custom_cors_options(self, mock_cors, *_mocks): + """Custom cors_options should be forwarded to CORS().""" + mock_cors.return_value.middleware = MagicMock() + custom = {"allow_all_origins": False, "allow_origins_list": ["https://example.com"]} + Api( + resource_path="/tmp", + middleware=[], + openapi={"path": "/docs", "ui_url": "", "servers": []}, + cors_options=custom, + ) + mock_cors.assert_called_once_with( + allow_all_origins=False, + allow_origins_list=["https://example.com"], + ) diff --git a/tests/test_auth.py b/tests/test_auth.py index 03130c8..7523004 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -84,10 +84,10 @@ def test_deny_mode_unmatched_route_requires_auth(self): ac = AccessList(routes=["/public"], methods=[], default_mode="deny") assert ac.authentication_required("/secret", "GET", None) is True - def test_authorized_not_implemented(self): + def test_authorized_always_true(self): + """AccessList grants access to any authenticated request.""" ac = AccessList() - with pytest.raises(NotImplementedError): - ac.authorized(None, None, None, None) + assert ac.authorized(None, None, None, None) is True class TestAccessCallback: diff --git a/tests/test_middleware.py b/tests/test_middleware.py index c67d4a6..75bc5ce 100644 --- a/tests/test_middleware.py +++ b/tests/test_middleware.py @@ -5,7 +5,13 @@ import falcon import pytest -from reliqua.middleware import Converter, Parameter, ProcessParams, python_type, to_bool +from reliqua.middleware import ( + Converter, + Parameter, + ProcessParams, + python_type, + to_bool, +) class TestToBool: @@ -73,8 +79,8 @@ def test_default_attributes(self): def test_kwargs_update(self): p = Parameter(name="test", datatype="str", required=True, default="hello") - assert p.name == "test" - assert p.datatype == "str" + assert p.name == "test" # pylint: disable=no-member + assert p.datatype == "str" # pylint: disable=no-member assert p.required is True assert p.default == "hello" @@ -143,7 +149,7 @@ def test_as_array_returns_value(self): assert result == ["a", "b"] -class TestProcessParams: +class TestProcessParams: # pylint: disable=protected-access """Tests for the ProcessParams middleware.""" def test_check_required_raises_on_missing(self): diff --git a/tests/test_sphinx_parser.py b/tests/test_sphinx_parser.py index a176006..4518399 100644 --- a/tests/test_sphinx_parser.py +++ b/tests/test_sphinx_parser.py @@ -6,7 +6,7 @@ def make_method(docstring): """Create a function with the given docstring for testing.""" - def on_get(self, _req, _resp): + def on_get(_self, _req, _resp): pass on_get.__doc__ = docstring @@ -174,7 +174,7 @@ def test_parse_operation_detection(self): for verb in ["get", "post", "put", "patch", "delete"]: - def method(self): + def method(_self): """Test.""" method.__qualname__ = f"Res.on_{verb}" @@ -185,7 +185,7 @@ def method(self): def test_parse_suffix_detection(self): parser = SphinxParser() - def method(self): + def method(_self): r"""Test.\n\n:return json:""" method.__qualname__ = "Res.on_get_by_id" diff --git a/tests/test_status_codes.py b/tests/test_status_codes.py index 9c84192..98ded94 100644 --- a/tests/test_status_codes.py +++ b/tests/test_status_codes.py @@ -60,3 +60,19 @@ def test_codes_has_common_entries(self): assert CODES["200"] == "OK" assert CODES["404"] == "Not Found" assert CODES["500"] == "Internal Server Error" + + +class TestHttpFallback: + """Tests for http() graceful fallback behavior.""" + + def test_unknown_numeric_code_returns_500(self): + """Unknown numeric codes should fall back to 500.""" + assert http(999) == "500 Internal Server Error" + + def test_unknown_string_code_returns_500(self): + """Unknown string codes should fall back to 500.""" + assert http("TOTALLY_BOGUS") == "500 Internal Server Error" + + def test_garbage_input_returns_500(self): + """Non-numeric, non-message strings should fall back to 500.""" + assert http("not a code") == "500 Internal Server Error" From acdb73736719ac9b03b691c2a5fe812c716aaf7d Mon Sep 17 00:00:00 2001 From: Terrence Meiczinger Date: Thu, 26 Feb 2026 23:50:16 -0500 Subject: [PATCH 3/5] update --- src/reliqua/app.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/reliqua/app.py b/src/reliqua/app.py index 7050eec..14c77a3 100644 --- a/src/reliqua/app.py +++ b/src/reliqua/app.py @@ -143,6 +143,9 @@ def __init__( cors_options=cors_options, ) + logger = logging.getLogger(__name__) + logger.info("listening on %s", bind) + super().__init__() def init(self, _parser, _opts, _args): From 92f930b913e59ead8b530fde3e33c9b55e11ef23 Mon Sep 17 00:00:00 2001 From: Terrence Meiczinger Date: Thu, 26 Feb 2026 23:50:42 -0500 Subject: [PATCH 4/5] update --- tests/test_exceptions.py | 67 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 tests/test_exceptions.py diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py new file mode 100644 index 0000000..07a6a46 --- /dev/null +++ b/tests/test_exceptions.py @@ -0,0 +1,67 @@ +"""Tests for reliqua.exceptions module (dynamic exception classes).""" + +# pylint: disable=no-member + +from falcon.http_error import HTTPError + +import reliqua.exceptions as exc + + +class TestDynamicExceptionClasses: + """Tests for dynamically generated HTTP exception classes.""" + + def test_common_classes_exist(self): + """Common exception classes should be importable.""" + assert hasattr(exc, "HTTPNotFound") + assert hasattr(exc, "HTTPBadRequest") + assert hasattr(exc, "HTTPUnauthorized") + assert hasattr(exc, "HTTPForbidden") + assert hasattr(exc, "HTTPInternalServerError") + + def test_all_exports_match_globals(self): + """__all__ should list every generated class.""" + for name in exc.__all__: + assert hasattr(exc, name), f"{name} in __all__ but not in module" + + def test_classes_are_subclasses_of_httperror(self): + """Each generated class should inherit from HTTPError.""" + for name in exc.__all__: + cls = getattr(exc, name) + assert issubclass(cls, HTTPError), f"{name} is not an HTTPError subclass" + + def test_instantiation_defaults(self): + """Instantiate HTTPNotFound with defaults and verify status/title.""" + error = exc.HTTPNotFound() + assert error.status == "404 Not Found" + assert error.title == "404 Not Found" + assert error.description is None + + def test_instantiation_custom_fields(self): + """Custom title and description should be set.""" + error = exc.HTTPBadRequest( + title="Validation Failed", + description="Name field is required", + ) + assert error.status == "400 Bad Request" + assert error.title == "Validation Failed" + assert error.description == "Name field is required" + + def test_instantiation_with_headers(self): + """Headers should be passed through.""" + error = exc.HTTPUnauthorized(headers={"WWW-Authenticate": "Bearer"}) + assert error.status == "401 Unauthorized" + + def test_only_4xx_and_5xx_generated(self): + """No 1xx, 2xx, or 3xx classes should be generated.""" + for name in exc.__all__: + cls = getattr(exc, name) + instance = cls() + code = int(instance.status.split()[0]) + assert code >= 400, f"{name} has unexpected status code {code}" + + def test_docstrings_are_set(self): + """Each class should have a docstring derived from the HTTP message.""" + for name in exc.__all__: + cls = getattr(exc, name) + assert cls.__doc__ is not None, f"{name} is missing a docstring" + assert cls.__doc__.endswith("."), f"{name} docstring should end with a period" From be86a77a9276784a592b590e2d642b04cff0d5c4 Mon Sep 17 00:00:00 2001 From: Terrence Meiczinger Date: Thu, 26 Feb 2026 23:56:39 -0500 Subject: [PATCH 5/5] update --- src/reliqua/exceptions.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/reliqua/exceptions.py b/src/reliqua/exceptions.py index ea4e36d..94b6091 100644 --- a/src/reliqua/exceptions.py +++ b/src/reliqua/exceptions.py @@ -55,7 +55,7 @@ def _make_exception_class(code, message): """Dynamically create an HTTPError subclass for the given status code.""" status_string = f"{code} {message}" - def __init__(self, title=None, description=None, headers=None): + def _init(self, title=None, description=None, headers=None): """Initialize.""" super(self.__class__, self).__init__( status=status_string, @@ -67,7 +67,7 @@ def __init__(self, title=None, description=None, headers=None): return type( _make_class_name(message), (HTTPError,), - {"__doc__": f"{message}.", "__init__": __init__}, + {"__doc__": f"{message}.", "__init__": _init}, ) @@ -78,4 +78,4 @@ def __init__(self, title=None, description=None, headers=None): _cls_name = _make_class_name(_message) _cls = _make_exception_class(_code, _message) globals()[_cls_name] = _cls - __all__.append(_cls_name) + __all__ += [_cls_name] # noqa: PLE0604