Skip to content

Commit

Permalink
Merge pull request #1 from CameronSima/feature/validation
Browse files Browse the repository at this point in the history
Feature/validation
  • Loading branch information
CameronSima authored Sep 4, 2024
2 parents 8a2f3cd + efd4c2b commit a64ba24
Show file tree
Hide file tree
Showing 17 changed files with 499 additions and 81 deletions.
36 changes: 36 additions & 0 deletions notes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
1. Out-of-the-box Real-time Capabilities
WebSocket & Server-Sent Events (SSE) Support: Provide built-in support for WebSocket and SSE endpoints with seamless integration into the framework’s routing and middleware systems.
Event Broadcasting and Pub/Sub: Integrate a simple pub/sub or event broadcasting system (using Redis or another broker) for real-time features like chat applications, live updates, or collaborative tools.

2. First-class Support for Background Tasks and Workers
Integrated Worker Queue: Include a built-in task queue system (similar to Celery) for background processing with async task support. Tasks could be defined in the same codebase with decorators and could run in separate worker processes.
Job Scheduling: Native support for scheduling tasks with a cron-like syntax.

3. Intuitive Error Handling and Debugging
Error Tracing and Categorization: Introduce a built-in error-handling mechanism that categorizes errors, traces them to their origin, and provides detailed context in logs or error responses.
Middleware for Debugging: Provide middleware that allows inspecting the internal state, dependencies, and the request lifecycle in real-time.

4. Security Enhancements
Built-in Security Middleware: Include robust security middleware by default for features like CSRF protection, CORS handling, rate limiting, and DDoS protection.
Advanced Auth Integration: Built-in support for advanced authentication methods, including OAuth2, SAML, JWT, and OpenID Connect, with easy configuration.

5. Optimized for Serverless and Edge Deployments
Edge-optimized and Serverless-first: Design the framework to be easily deployable on serverless platforms (like AWS Lambda or Vercel) or edge networks, with minimal configuration and optimized cold-start times.
Pre-rendering and Edge Caching: Integrate strategies for pre-rendering content and edge caching, making it more performant for static content delivery.

6. Automatic API Documentation with Extensions
Customizable and Dynamic Documentation: Provide a dynamic documentation system like Swagger or Redoc but with more customization options (themes, plugins) and interactive features, such as example requests/responses based on schema.
API Versioning and Deprecation Notices: Automatically handle API versioning and provide built-in tools for deprecating old versions gracefully.

7. Declarative Data Validation and Transformation
Advanced Validation Framework: Go beyond Pydantic models by offering a more declarative approach for complex data validation, transformation, and coercion, including custom validation rules and error messages.

8. GraphQL and gRPC Integration
GraphQL Server with Subscriptions: Provide first-class support for building GraphQL servers with built-in support for subscriptions.
gRPC Support: Offer built-in support for gRPC endpoints to enable high-performance RPCs for microservices.

Schema-first Approach: Focus on a design-first approach where API definitions (using OpenAPI or GraphQL schema) drive the development process.
Automatic Code Generation: Enable automatic code generation for client SDKs, server stubs, and documentation based on the schema.

9. Data Streaming and Real-time Data Sync
Built-in Data Streaming: Provide support for data streaming (using WebSockets or HTTP/2) and real-time data synchronization, which is particularly useful for collaborative applications.
8 changes: 4 additions & 4 deletions src/ziplineio/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@


from ziplineio.exception import NotFoundHttpException
from ziplineio.middleware import middleware, run_middleware_stack
from ziplineio.middleware import run_middleware_stack
from ziplineio.dependency_injector import inject, injector, DependencyInjector
from ziplineio import settings
from ziplineio.handler import Handler
Expand Down Expand Up @@ -89,12 +89,12 @@ async def _get_and_call_handler(

if handler is not None:
# If a handler is found, call it with the request
return await call_handler(handler, req)
return await call_handler(handler, req=req)

# If no handler was found, attempt to run middlewares.
# (If a handler was found, middlewares will be run by `call_handler`)
req, ctx, res = await run_middleware_stack(
self._router._router_level_middelwares, req
self._router._router_level_middelwares, req=req
)

# If middleware does not provide a response, return a 404 Not Found
Expand All @@ -103,7 +103,7 @@ async def _get_and_call_handler(
return res

if self._router._not_found_handler:
response = await call_handler(self._router._not_found_handler, req)
response = await call_handler(self._router._not_found_handler, req=req)
headers = isinstance(response, Response) and response._headers or {}
return NotFoundResponse(response, headers)

Expand Down
2 changes: 1 addition & 1 deletion src/ziplineio/html/jinja.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ async def wrapped_handler(req, **kwargs):

# Filter kwargs to only pass those that the handler expects
# filtered_kwargs = {k: v for k, v in kwargs.items() if k in sig.parameters}
context = await call_handler(handler, req, **kwargs)
context = await call_handler(handler, req=req, **kwargs)
rendered = template.render(context)
return JinjaResponse(rendered)

Expand Down
15 changes: 8 additions & 7 deletions src/ziplineio/middleware.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import inspect
from typing import List, Callable, Tuple
from ziplineio import response

from ziplineio.handler import Handler
from ziplineio.request import Request
from ziplineio.response import Response
Expand All @@ -15,7 +14,9 @@ async def wrapped_handler(req: Request, **kwargs):
kwargs.setdefault("ctx", {})
# Run the middleware stack

req, kwargs, res = await run_middleware_stack(middlewares, req, **kwargs)
req, kwargs, res = await run_middleware_stack(
middlewares, req=req, **kwargs
)

if res is not None:
return res
Expand All @@ -28,15 +29,15 @@ async def wrapped_handler(req: Request, **kwargs):


async def run_middleware_stack(
middlewares: list[Handler], request: Request, **kwargs
middlewares: list[Handler], req: Request, **kwargs
) -> Tuple[Request, dict, bytes | str | dict | Response | None]:
for middleware in middlewares:
# if the middleware func takes params, pass them in. Otherwise, just pass req

if "ctx" not in kwargs:
kwargs["ctx"] = {}

_res = await call_handler(middleware, request, **kwargs)
_res = await call_handler(middleware, req=req, **kwargs)

# regular handlers return a response, but middleware can return a tuple
if not isinstance(_res, tuple):
Expand All @@ -51,6 +52,6 @@ async def run_middleware_stack(

if not isinstance(req, Request):
response = req
return request, kwargs, response
return req, kwargs, response

return request, kwargs, None
return req, kwargs, None
7 changes: 5 additions & 2 deletions src/ziplineio/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
import inspect
import asyncio
from typing import Dict

from httpx import request
from ziplineio.request import Request
from ziplineio.response import Response
from ziplineio.handler import Handler
Expand All @@ -18,11 +20,12 @@ def get_class_params(cls):

async def call_handler(
handler: Handler,
req: Request,
**kwargs,
) -> bytes | str | dict | Response | Exception:
try:
kwargs = {"req": req, **kwargs}
if "req" not in kwargs:
raise ValueError("Request object not found in kwargs")

params = inspect.signature(handler).parameters
kwargs = {k: v for k, v in kwargs.items() if k in params}
if not inspect.iscoroutinefunction(handler):
Expand Down
3 changes: 3 additions & 0 deletions src/ziplineio/validation/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from .body import BodyParam, validate_body
from .query import QueryParam, validate_query
from .index import validate
46 changes: 46 additions & 0 deletions src/ziplineio/validation/body.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
from functools import wraps
from ziplineio.request import Request
from pydantic import ValidationError

from ziplineio.validation.query import QueryParam


class BodyParam(QueryParam):
"""Alias Query param."""

pass


def validate_body(*body_params):
"""
Decorator to validate body parameters.
"""

def decorator(handler):
@wraps(handler)
async def wrapper(req: Request, *args, **kwargs):
errors = {}
validated_body = {}

for param in body_params:
param_name = param.param
value = req.body.get(param_name)
if value is None:
if param.required:
errors[param_name] = "Missing required body parameter"
else:
try:
validated_body[param_name] = param.validate(value)
except (ValueError, ValidationError) as e:
errors[param_name] = str(e)

if errors:
return {"errors": errors}, 400

# Attach validated body parameters to the request
req.body = validated_body
return await handler(req, *args, **kwargs)

return wrapper

return decorator if body_params else lambda req: decorator(lambda *_: None)(req)
45 changes: 45 additions & 0 deletions src/ziplineio/validation/index.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
from ziplineio.request import Request


def validate(*validators):
def decorator(handler):
async def wrapper(req: Request, ctx: dict):
errors = {}
validated_body = {}

for validator in validators:
validation_result = validator(req)
validated_body.update(validation_result.get("validated", {}))
errors.update(validation_result.get("errors", {}))

if errors:
return {"errors": errors}, 400

req.body = validated_body
return await handler(req, ctx)

return wrapper

return decorator


# # Example Pydantic model
# class UserModel(BaseModel):
# username: str
# age: int


# # Usage example with Pydantic model
# @app.post("/")
# @validate_body(BodyParam("user", UserModel))
# async def create_user_handler(req: Request, ctx: dict):
# user = req.validated_body.get("user")
# return {"user": user.dict()} # Access the validated Pydantic model instance


# # Usage example with simple type validation
# @app.get("/")
# @validate_query(QueryParam("bar", str))
# async def test_handler(req: Request, ctx: dict):
# bar = req.validated_query.get("bar")
# return {"bar": bar}
86 changes: 86 additions & 0 deletions src/ziplineio/validation/query.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
from functools import wraps
import inspect
from typing import Type, Union
from pydantic import BaseModel, ValidationError
from ziplineio.request import Request


class QueryParam:
def __init__(
self, param: str, type: Union[Type, BaseModel] = str, required: bool = True
):
self.param = param
self.type = type
self.required = required

def validate(self, value):
if isinstance(self.type, type) and issubclass(self.type, BaseModel):
try:
# Use Pydantic model for validation
model_instance = self.type.parse_obj(value)
return model_instance
except ValidationError as e:
raise ValueError(f"Validation error for {self.param}: {e}")

try:
# Basic type casting
return self.type(value)
except ValueError:
raise ValueError(
f"Invalid type for {self.param}, expected {self.type.__name__}"
)


def validate_query(*query_params):
"""
Decorator to validate query parameters.
"""

def decorator(handler):
# Use inspect to get the function signature and find the request parameter name
signature = inspect.signature(handler)
request_param_name = None

# Find the parameter annotated with the Request type
for param_name, param in signature.parameters.items():
if param.annotation == Request or isinstance(
param_name, Request
): # Look for the parameter with type `Request`
request_param_name = param_name
break

if request_param_name is None:
raise ValueError(
"Handler function must have a parameter of type `Request`."
)

@wraps(handler)
async def wrapper(*args, **kwargs):
req = kwargs.get(request_param_name)

errors = {}
validated_query = {}

for param in query_params:
param_name = param.param
value = req.query_params.get(param_name)
if value is None:
if param.required:
errors[param_name] = "Missing required query parameter"
else:
try:
validated_query[param_name] = param.validate(value)
except (ValueError, ValidationError) as e:
errors[param_name] = str(e)

if errors:
return {"errors": errors}, 400

# Attach validated query parameters to the request
req.query_params = validated_query

return await handler(*args, **kwargs)

return wrapper

return decorator if query_params else lambda req: decorator(lambda *_: None)(req)
17 changes: 17 additions & 0 deletions test/test_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,23 @@ async def test_handler(req: Request, ctx: dict):
# Assertions
self.assertEqual(response["message"], "Hello, world!")

async def test_query_params(self):
# Mock request data
req = Request(method="GET", path="/")
req.query_params = {"bar": "baz"}

# Define a simple handler for testing
@self.app.get("/")
async def test_handler(req: Request, ctx: dict):
return {"message": req.query_params["bar"]}

# Call the route
handler, params = self.app._router.get_handler("GET", "/")
response = await handler(req, {})

# Assertions
self.assertEqual(response["message"], "baz")

async def test_middleware_injection(self):
# Mock request data
req = Request(method="GET", path="/with-middleware")
Expand Down
2 changes: 1 addition & 1 deletion test/test_dependency_injection.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,6 @@ async def test_handler(req, service1: Service1):
# Call the handler
handler, params = self.ziplineio.get_handler("GET", "/")

response = await call_handler(handler, {})
response = await call_handler(handler, req={})

self.assertEqual(response["message"], "Service 1")
15 changes: 13 additions & 2 deletions test/test_e2e.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
import requests
import uvicorn
import unittest
import unittest.async_case


from ziplineio.app import App
Expand Down Expand Up @@ -81,7 +80,19 @@ async def asyncSetUp(self):
"""Bring server up."""
self.proc = Process(target=run_server, args=(), daemon=False)
self.proc.start()
await asyncio.sleep(0.2) # time for the server to start

# Wait for the server to be up
await self.wait_for_server()

async def wait_for_server(self):
"""Wait for the server to be ready."""
while True:
try:
response = requests.get("http://localhost:5050/bytes")
if response.status_code == 200:
break
except requests.ConnectionError:
await asyncio.sleep(0.1) # Short sleep between retries

async def test_handler_returns_bytes(self):
response = requests.get("http://localhost:5050/bytes")
Expand Down
Loading

0 comments on commit a64ba24

Please sign in to comment.