Fix bug that made the index method not properly recieve path params. Added URL shortener example to demonstrate this.

Commit 7882338 · patx · 2025-08-19T01:19:55-04:00

Changeset
78823389d5271f6f947a426bc691618bb23cf3e7
Parents
d0d09bf9868614bedb60bb1f6ab3738076a0e487

View source at this commit

Comments

No comments yet.

Log in to comment

Diff

diff --git a/README.md b/README.md
index 395e0c4..4d28fa4 100644
--- a/README.md
+++ b/README.md
@@ -21,7 +21,6 @@
 ### **Useful Links**
 - **Homepage**: [patx.github.io/micropie](https://patx.github.io/micropie)
 - **Official Documentation**: [micropie.readthedocs.io](https://micropie.readthedocs.io/)
-- **Quick API Reference**: [README.md#api-documentation](https://github.com/patx/micropie/blob/main/README.md#api-documentation)
 - **PyPI Page**: [pypi.org/project/MicroPie](https://pypi.org/project/MicroPie/)
 - **GitHub Project**: [github.com/patx/micropie](https://github.com/patx/micropie)
 - **File Issue/Request**: [github.com/patx/micropie/issues](https://github.com/patx/micropie/issues)
@@ -335,172 +334,4 @@ We welcome suggestions, bug reports, and pull requests!
 - File issues or feature requests [here](https://github.com/patx/micropie/issues).
 - Security issues that should not be public, email `harrisonerd [at] gmail.com`.
 
-# **API Documentation**
-
-## Session Backend Abstraction
-
-MicroPie provides an abstraction for session backends, allowing you to define custom session storage mechanisms.
-
-### `SessionBackend` Class
-
-#### Methods
-- `load(session_id: str) -> Dict[str, Any]`
-  - Abstract method to load session data given a session ID.
-- `save(session_id: str, data: Dict[str, Any], timeout: int) -> None`
-  - Abstract method to save session data.
-
-### `InMemorySessionBackend` Class
-
-An in-memory implementation of the `SessionBackend`.
-
-#### Methods
-- `__init__()`
-  - Initializes the in-memory session backend.
-- `load(session_id: str) -> Dict[str, Any]`
-  - Loads session data for the given session ID.
-- `save(session_id: str, data: Dict[str, Any], timeout: int) -> None`
-  - Saves session data for the given session ID.
-
-## Middleware Abstraction
-
-MicroPie allows you to create pluggable middleware to hook into the request lifecycle for both HTTP and WebSocket requests.
-
-### `HttpMiddleware` Class
-
-#### Methods
-- `before_request(request: Request) -> Optional[Dict]`
-  - Abstract method called before the HTTP request is processed. Returns an optional dictionary with response details (status_code, body, headers) to short-circuit the request, or None to continue processing.
-- `after_request(request: Request, status_code: int, response_body: Any, extra_headers: List[Tuple[str, str]]) -> Optional[Dict]`
-  - Abstract method called after the HTTP request is processed but before the final response is sent. Returns an optional dictionary with updated response details (status_code, body, headers), or None to use defaults.
-
-### `WebSocketMiddleware` Class
-
-#### Methods
-- `before_websocket(request: WebSocketRequest) -> Optional[Dict]`
-  - Abstract method called before the WebSocket handler is invoked. Returns an optional dictionary with close details (code, reason) to reject the connection, or None to continue processing.
-- `after_websocket(request: WebSocketRequest) -> None`
-  - Abstract method called after the WebSocket handler completes.
-
-## Request Objects
-
-### `Request` Class
-
-Represents an HTTP request in the MicroPie framework.
-
-#### Attributes
-- `scope`: The ASGI scope dictionary for the request.
-- `method`: The HTTP method of the request.
-- `path_params`: List of path parameters.
-- `query_params`: Dictionary of query parameters.
-- `body_params`: Dictionary of body parameters.
-- `get_json`: JSON request body object.
-- `session`: Dictionary of session data.
-- `files`: Dictionary of multipart data/streamed content.
-- `headers`: Dictionary of headers.
-
-### `WebSocketRequest` Class
-
-Represents a WebSocket request in the MicroPie framework, inheriting from `Request`.
-
-#### Attributes
-- Inherits all attributes from `Request`.
-
-## WebSocket Management
-
-### `WebSocket` Class
-
-Manages WebSocket communication in the MicroPie framework.
-
-#### Methods
-- `__init__(receive: Callable[[], Awaitable[Dict[str, Any]]], send: Callable[[Dict[str, Any]], Awaitable[None]]) -> None`
-  - Initializes a WebSocket instance with ASGI receive and send callables.
-- `accept(subprotocol: Optional[str] = None, session_id: Optional[str] = None) -> None`
-  - Accepts the WebSocket connection, optionally specifying a subprotocol and session ID for setting a cookie during the handshake.
-- `receive_text() -> str`
-  - Receives a text message from the WebSocket. Raises `ConnectionClosed` if the connection is closed or `ValueError` for unexpected message types.
-- `receive_bytes() -> bytes`
-  - Receives a binary message from the WebSocket. Raises `ConnectionClosed` if the connection is closed or `ValueError` for unexpected message types.
-- `send_text(data: str) -> None`
-  - Sends a text message over the WebSocket. Raises `RuntimeError` if the connection is not accepted.
-- `send_bytes(data: bytes) -> None`
-  - Sends a binary message over the WebSocket. Raises `RuntimeError` if the connection is not accepted.
-- `close(code: int = 1000, reason: Optional[str] = None) -> None`
-  - Closes the WebSocket connection with the specified code and optional reason.
-
-#### Attributes
-- `accepted`: Boolean indicating if the WebSocket connection is accepted.
-- `session_id`: Optional string storing the session ID set during the handshake.
-
-### `ConnectionClosed` Class
-
-An exception raised when a WebSocket connection is closed.
-
-## Application Base
-
-### `App` Class
-
-The main ASGI application class for handling HTTP, WebSocket, and lifespan events in MicroPie.
-
-#### Attributes
-- `middlewares`: List of `HttpMiddleware` instances for HTTP request processing.
-- `ws_middlewares`: List of `WebSocketMiddleware` instances for WebSocket request processing.
-- `startup_handlers`: List of async callables to run during `lifespan.startup`.
-- `shutdown_handlers`: List of async callables to run during `lifespan.shutdown`.
-- `_started`: Boolean indicating whether the application has completed startup.
-
-#### Methods
-- `__init__(session_backend: Optional[SessionBackend] = None) -> None`
-  - Initializes the application with an optional session backend, empty middleware lists, and empty lifecycle handler lists.
-- `request(self) -> Request`
-  - Accessor for the current request object. Returns the current request from the context variable.
-- `__call__(scope: Dict[str, Any], receive: Callable[[], Awaitable[Dict[str, Any]]], send: Callable[[Dict[str, Any]], Awaitable[None]]) -> None`
-  - ASGI callable interface for the server. Dispatches to HTTP, WebSocket, or lifespan event handling based on `scope` type.
-- `_asgi_app_http(scope: Dict[str, Any], receive: Callable[[], Awaitable[Dict[str, Any]]], send: Callable[[Dict[str, Any]], Awaitable[None]]) -> None`
-  - ASGI application entry point for handling HTTP requests.
-- `_asgi_app_websocket(scope: Dict[str, Any], receive: Callable[[], Awaitable[Dict[str, Any]]], send: Callable[[Dict[str, Any]], Awaitable[None]]) -> None`
-  - ASGI application entry point for handling WebSocket requests. Routes to methods prefixed with `ws_` (e.g., `ws_chat` for `/chat`).
-- `_asgi_app_lifespan(receive: Callable[[], Awaitable[Dict[str, Any]]], send: Callable[[Dict[str, Any]], Awaitable[None]]) -> None`
-  - ASGI application entry point for handling lifespan events. Executes registered startup and shutdown handlers.
-- `_parse_cookies(cookie_header: str) -> Dict[str, str]`
-  - Parses the Cookie header and returns a dictionary of cookie names and values.
-- `_parse_multipart(reader: asyncio.StreamReader, boundary: bytes) -> Tuple[Dict[str, List[str]], Dict[str, Dict[str, Any]]]`
-  - Asynchronously parses multipart/form-data from the given reader using the specified boundary. Returns a tuple of two dictionaries: `form_data` (text fields as key-value pairs) and `files` (file fields with metadata). Each file entry in `files` contains:
-    - `filename`: The original filename of the uploaded file.
-    - `content_type`: The MIME type of the file (defaults to `application/octet-stream`).
-    - `content`: An `asyncio.Queue` containing chunks of file data as bytes, with a `None` sentinel signaling the end of the stream.
-  - Handlers can consume the file data by iterating over the queue (e.g., using `await queue.get()`).
-  - *Requires:* `multipart`
-- `_send_response(send: Callable[[Dict[str, Any]], Awaitable[None]], status_code: int, body: Any, extra_headers: Optional[List[Tuple[str, str]]] = None) -> None`
-  - Sends an HTTP response using the ASGI send callable.
-- `_send_websocket_close(send: Callable[[Dict[str, Any]], Awaitable[None]], code: int, reason: str) -> None`
-  - Sends a WebSocket close message with the specified code and reason.
-- `_redirect(location: str) -> Tuple[int, str]`
-  - Generates an HTTP redirect response.
-- `_render_template(name: str, **kwargs: Any) -> str`
-  - Renders a template asynchronously using Jinja2.
-  - *Requires*: `jinja2`
-
-The `App` class is the main entry point for creating MicroPie applications. It implements the ASGI interface and handles HTTP, WebSocket, and lifespan events.
-
-## Response Formats
-
-Handlers can return responses in the following formats:
-
-1. String, bytes, or JSON-serializable object
-2. Tuple of (status_code, body)
-3. Tuple of (status_code, body, headers)
-4. Async or sync generator for streaming responses
-
-## Error Handling
-
-MicroPie provides built-in error handling for common HTTP status codes:
-
-- `404 Not Found`: Automatically returned for non-existent routes
-- `400 Bad Request`: Returned for missing required parameters
-- `500 Internal Server Error`: Returned for unhandled exceptions
-
-Custom error handling can be implemented through middleware.
-
----
-
 © 2025 Harrison Erd
diff --git a/examples/blog/app.py b/examples/blog/app.py
new file mode 100644
index 0000000..50de51f
--- /dev/null
+++ b/examples/blog/app.py
@@ -0,0 +1,97 @@
+from micropie import App
+from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
+from sqlalchemy.orm import sessionmaker, DeclarativeBase
+from sqlalchemy import Column, Integer, String, Text, DateTime
+from sqlalchemy.sql import select
+from datetime import datetime
+import markdown
+from jinja2 import Environment, FileSystemLoader
+import asyncio
+import os
+
+# Initialize Jinja2 environment for templates
+env = Environment(loader=FileSystemLoader('templates'))
+
+# Convert Markdown to HTML with GFM support
+def markdown_to_html(text):
+    return markdown.markdown(text, extensions=['extra', 'tables', 'fenced_code'])
+
+# SQLAlchemy setup
+class Base(DeclarativeBase):
+    pass
+
+class Post(Base):
+    __tablename__ = 'posts'
+    id = Column(Integer, primary_key=True)
+    title = Column(String(100), nullable=False)
+    content = Column(Text, nullable=False)
+    created_at = Column(DateTime, nullable=False, default=datetime.utcnow)
+
+# SQLite async engine and session (will be initialized in startup handler)
+engine = None
+AsyncSessionLocal = None
+
+# Lifecycle event handlers
+async def setup_db():
+    global engine, AsyncSessionLocal
+    print("Setting up SQLite database...")
+    engine = create_async_engine('sqlite+aiosqlite:///blog.db', echo=False)
+    AsyncSessionLocal = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
+    async with engine.begin() as conn:
+        await conn.run_sync(Base.metadata.create_all)
+    print("SQLite database setup complete.")
+
+async def close_db():
+    global engine
+    print("Closing SQLite database...")
+    if engine:
+        await engine.dispose()
+    print("SQLite database closed.")
+
+class BlogApp(App):
+    def __init__(self):
+        super().__init__()
+        # Register lifecycle handlers
+        self.startup_handlers.append(setup_db)
+        self.shutdown_handlers.append(close_db)
+
+    async def index(self):
+        # Fetch all posts, sorted by creation date (descending)
+        async with AsyncSessionLocal() as session:
+            posts = await session.execute(
+                select(Post).order_by(Post.created_at.desc())
+            )
+            posts = posts.scalars().all()
+            for post in posts:
+                post.html_content = markdown_to_html(post.content)
+        return await self._render_template('index.html', posts=posts)
+
+    async def new(self, title=None, content=None):
+        if self.request.method == 'POST':
+            # Handle form submission
+            title = self.request.body_params.get('title', [None])[0]
+            content = self.request.body_params.get('content', [None])[0]
+            if title and content:
+                # Insert new post into SQLite
+                async with AsyncSessionLocal() as session:
+                    new_post = Post(title=title, content=content)
+                    session.add(new_post)
+                    await session.commit()
+                return self._redirect('/')
+        return await self._render_template('new_post.html')
+
+    async def post(self, id):
+        # Fetch a single post by ID
+        async with AsyncSessionLocal() as session:
+            post = await session.get(Post, int(id))
+            if not post:
+                return 404, "Post not found"
+            post.html_content = markdown_to_html(post.content)
+        return await self._render_template('post.html', post=post)
+
+# Create the app instance
+app = BlogApp()
+
+if __name__ == '__main__':
+    import uvicorn
+    uvicorn.run(app, host='127.0.0.1', port=8080)
diff --git a/examples/blog/blog.db b/examples/blog/blog.db
new file mode 100644
index 0000000..fd53bcf
Binary files /dev/null and b/examples/blog/blog.db differ
diff --git a/examples/blog/templates/index.html b/examples/blog/templates/index.html
new file mode 100644
index 0000000..3be686e
--- /dev/null
+++ b/examples/blog/templates/index.html
@@ -0,0 +1,35 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>MicroPie Blog</title>
+    <script src="https://cdn.tailwindcss.com"></script>
+</head>
+<body class="bg-gray-100 font-sans">
+    <header class="bg-blue-600 text-white p-4">
+        <div class="container mx-auto flex justify-between items-center">
+            <h1 class="text-2xl font-bold">MicroPie Blog</h1>
+            <nav>
+                <a href="/" class="px-4 hover:underline" aria-label="Home">Home</a>
+                <a href="/new" class="px-4 hover:underline" aria-label="Create New Post">New Post</a>
+            </nav>
+        </div>
+    </header>
+    <main class="container mx-auto p-6">
+        <div class="grid gap-6">
+            {% for post in posts %}
+                <article class="bg-white p-6 rounded-lg shadow-md" role="article">
+                    <h2 class="text-xl font-semibold">
+                        <a href="/post/{{ post.id }}" class="text-blue-600 hover:underline">{{ post.title }}</a>
+                    </h2>
+                    <p class="text-gray-500 text-sm">Posted on {{ post.created_at.strftime('%Y-%m-%d %H:%M:%S') }}</p>
+                    <div class="prose max-w-none mt-4">{{ post.html_content | safe }}</div>
+                </article>
+            {% else %}
+                <p class="text-gray-600">No posts yet. <a href="/new" class="text-blue-600 hover:underline">Create one!</a></p>
+            {% endfor %}
+        </div>
+    </main>
+</body>
+</html>
diff --git a/examples/blog/templates/new_post.html b/examples/blog/templates/new_post.html
new file mode 100644
index 0000000..fcb3f57
--- /dev/null
+++ b/examples/blog/templates/new_post.html
@@ -0,0 +1,56 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>Create New Post - MicroPie Blog</title>
+    <script src="https://cdn.tailwindcss.com"></script>
+</head>
+<body class="bg-gray-100 font-sans">
+    <header class="bg-blue-600 text-white p-4">
+        <div class="container mx-auto flex justify-between items-center">
+            <h1 class="text-2xl font-bold">MicroPie Blog</h1>
+            <nav>
+                <a href="/" class="px-4 hover:underline" aria-label="Home">Home</a>
+                <a href="/new" class="px-4 hover:underline" aria-label="Create New Post">New Post</a>
+            </nav>
+        </div>
+    </header>
+    <main class="container mx-auto p-6">
+        <div class="bg-white p-6 rounded-lg shadow-md max-w-2xl mx-auto">
+            <h2 class="text-2xl font-semibold mb-4">Create New Post</h2>
+            <div>
+                <input type="text" id="title" name="title" placeholder="Post Title" required
+                       class="w-full p-2 mb-4 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
+                       aria-label="Post Title">
+                <textarea id="content" name="content" placeholder="Write your Markdown content here..." required
+                          class="w-full p-2 mb-4 border rounded-md h-64 focus:outline-none focus:ring-2 focus:ring-blue-500"
+                          aria-label="Post Content"></textarea>
+                <button onclick="submitForm()"
+                        class="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700"
+                        aria-label="Submit Post">Submit</button>
+            </div>
+            <p class="mt-4">
+                <a href="/" class="text-blue-600 hover:underline" aria-label="Back to Home">Back to Home</a>
+            </p>
+        </div>
+        <script>
+            function submitForm() {
+                const title = document.getElementById('title').value;
+                const content = document.getElementById('content').value;
+                if (!title || !content) {
+                    alert('Please fill in both title and content.');
+                    return;
+                }
+                const formData = new FormData();
+                formData.append('title', title);
+                formData.append('content', content);
+                fetch('/new', {
+                    method: 'POST',
+                    body: formData
+                }).then(() => window.location.href = '/');
+            }
+        </script>
+    </main>
+</body>
+</html>
diff --git a/examples/blog/templates/post.html b/examples/blog/templates/post.html
new file mode 100644
index 0000000..d3fb6b9
--- /dev/null
+++ b/examples/blog/templates/post.html
@@ -0,0 +1,30 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>{{ post.title }} - MicroPie Blog</title>
+    <script src="https://cdn.tailwindcss.com"></script>
+</head>
+<body class="bg-gray-100 font-sans">
+    <header class="bg-blue-600 text-white p-4">
+        <div class="container mx-auto flex justify-between items-center">
+            <h1 class="text-2xl font-bold">MicroPie Blog</h1>
+            <nav>
+                <a href="/" class="px-4 hover:underline" aria-label="Home">Home</a>
+                <a href="/new" class="px-4 hover:underline" aria-label="Create New Post">New Post</a>
+            </nav>
+        </div>
+    </header>
+    <main class="container mx-auto p-6">
+        <article class="bg-white p-6 rounded-lg shadow-md max-w-2xl mx-auto" role="article">
+            <h1 class="text-2xl font-semibold mb-2">{{ post.title }}</h1>
+            <p class="text-gray-500 text-sm mb-4">Posted on {{ post.created_at.strftime('%Y-%m-%d %H:%M:%S') }}</p>
+            <div class="prose max-w-none">{{ post.html_content | safe }}</div>
+            <p class="mt-4">
+                <a href="/" class="text-blue-600 hover:underline" aria-label="Back to Home">Back to Home</a>
+            </p>
+        </article>
+    </main>
+</body>
+</html>
diff --git a/examples/url_shortener/app.py b/examples/url_shortener/app.py
new file mode 100644
index 0000000..e5cd501
--- /dev/null
+++ b/examples/url_shortener/app.py
@@ -0,0 +1,69 @@
+# shorty_index_only.py
+# URL shortener on MicroPie using ONLY the `index` method for both create & redirect.
+# No middleware. Path params are mapped to `index(id=...)` automatically by MicroPie.
+# Run: uvicorn shorty_index_only:app --reload
+
+import re
+import secrets
+import string
+from typing import Optional
+from urllib.parse import urlparse
+
+from micropie import App
+from pickledb import PickleDB
+
+# ---- storage ----
+db = PickleDB("shorty.json")  # persisted JSON file
+
+# ---- config ----
+ALPHABET = string.ascii_letters + string.digits
+SLUG_LEN = 6
+
+# ---- helpers ----
+def _rand_slug(n: int = SLUG_LEN) -> str:
+    return "".join(secrets.choice(ALPHABET) for _ in range(n))
+
+def _is_valid_url(url: str) -> bool:
+    """Basic URL validation."""
+    if not url:
+        return False
+    # Simple regex for URL validation (http(s) scheme, domain, optional path)
+    pattern = r'^https?://[a-zA-Z0-9-._~:/?#[\]@!$&\'()*+,;=]+[^/]$'
+    return bool(re.match(pattern, url))
+
+class Root(App):
+
+    async def index(self, id: Optional[str] = None):
+        if self.request.method == "POST":
+            # Use body_params for form data instead of query_params
+            url = self.request.body_params.get('url', [''])[0]
+            if not _is_valid_url(url):
+                return 400, "Invalid URL provided"
+            slug = _rand_slug()
+            while db.get(slug):
+                slug = _rand_slug()
+            db.set(slug, url)
+            db.save()
+            short = f"http://127.0.0.1:8000/{slug}"
+            return f"Your short URL is <a href='{short}'>{short}</a>"
+        elif self.request.method == "GET":
+            if id:
+                dest = db.get(id)
+                if not dest:
+                    return 404, "Not Found"
+                # Ensure destination URL is valid before redirecting
+                if not _is_valid_url(dest):
+                    return 400, "Invalid destination URL"
+                return self._redirect(dest)
+            else:
+                return (
+                    """
+                    <h1>Shorty</h1>
+                    <form method="post" enctype="application/x-www-form-urlencoded">
+                      <input type="url" name="url" placeholder="https://example.com" required style="width:28rem">
+                      <button type="submit">Shorten</button>
+                    </form>
+                    """
+                )
+
+app = Root()
diff --git a/micropie.py b/micropie.py
index dded65d..e6ed820 100644
--- a/micropie.py
+++ b/micropie.py
@@ -532,9 +532,23 @@ class App:
                 await self._send_response(send, 404, "404 Not Found")
                 return
 
+            # Initialize func_args early to avoid UnboundLocalError
+            func_args: List[Any] = []
+
+            # Check if index handler accepts parameters (for non-root paths)
+            if handler == getattr(self, "index", None) and path and path != "index":
+                sig = inspect.signature(handler)
+                accepts_params = any(
+                    param.kind in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.VAR_POSITIONAL)
+                    for param in sig.parameters.values() if param.name != "self"
+                )
+                if not accepts_params:
+                    await self._send_response(send, 404, "404 Not Found")
+                    return
+                request.path_params = parts  # Pass all path parts to index handler
+
             # Build handler args (query/body/files/session)
             sig = inspect.signature(handler)
-            func_args: List[Any] = []
             path_params_copy = request.path_params[:]
 
             for param in sig.parameters.values():
@@ -577,10 +591,6 @@ class App:
 
                 func_args.append(param_value)
 
-            if handler == getattr(self, "index", None) and not func_args and path and path != "index":
-                await self._send_response(send, 404, "404 Not Found")
-                return
-
             # Execute handler
             try:
                 result = await handler(*func_args) if inspect.iscoroutinefunction(handler) else handler(*func_args)