diff --git a/docs/configuration.md b/docs/configuration.md index 0e80d87..5cb3d35 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -6,6 +6,7 @@ JsWeb applications are configured using a `config.py` file in your project's roo - [Config File Structure](#config-file-structure) - [Core Configuration Options](#core-configuration-options) +- [OpenAPI & API Documentation Configuration](#openapi--api-documentation-configuration) - [Database Configuration](#database-configuration) - [Security Settings](#security-settings) - [Development vs Production](#development-vs-production) @@ -22,7 +23,11 @@ import os # Get the base directory of the project BASE_DIR = os.path.abspath(os.path.dirname(__file__)) -# Secret key for signing sessions and other security-related things +# Application info +APP_NAME = "myproject" +VERSION = "0.1.0" + +# Security SECRET_KEY = "your-secret-key" # Database configuration @@ -35,6 +40,19 @@ STATIC_DIR = os.path.join(BASE_DIR, "static") # Template files configuration TEMPLATE_FOLDER = "templates" + +# Server configuration +HOST = "127.0.0.1" +PORT = 8000 + +# OpenAPI / API Documentation (Automatic!) +ENABLE_OPENAPI_DOCS = True +API_TITLE = "myproject API" +API_VERSION = "1.0.0" +API_DESCRIPTION = "API documentation" +OPENAPI_DOCS_URL = "/docs" +OPENAPI_REDOC_URL = "/redoc" +OPENAPI_JSON_URL = "/openapi.json" ``` ## Core Configuration Options @@ -52,6 +70,108 @@ Here are the most important configuration options: | `STATIC_DIR` | String | Directory path for static files | | `TEMPLATE_FOLDER` | String | Directory for templates | | `MAX_CONTENT_LENGTH` | Integer | Maximum upload file size in bytes | +| `BASE_DIR` | String | Absolute path to project root directory | +| `HOST` | String | Server host address (default: `127.0.0.1`) | +| `PORT` | Integer | Server port (default: `8000`) | +| `APP_NAME` | String | Application name | +| `VERSION` | String | Application version | + +## OpenAPI & API Documentation Configuration + +JsWeb automatically generates OpenAPI documentation with Swagger UI and ReDoc. Configure these settings to customize your API documentation: + +```python +# OpenAPI / Swagger Documentation Configuration +ENABLE_OPENAPI_DOCS = True # Enable/disable automatic API documentation +API_TITLE = "My App API" # API title shown in documentation +API_VERSION = "1.0.0" # API version +API_DESCRIPTION = "API for my app" # API description (supports Markdown!) +OPENAPI_DOCS_URL = "/docs" # Swagger UI endpoint +OPENAPI_REDOC_URL = "/redoc" # ReDoc UI endpoint +OPENAPI_JSON_URL = "/openapi.json" # OpenAPI specification JSON endpoint +``` + +### API Documentation Settings + +| Setting | Type | Default | Description | +|---------|------|---------|-------------| +| `ENABLE_OPENAPI_DOCS` | Boolean | `True` | Enable/disable automatic OpenAPI documentation | +| `API_TITLE` | String | `"jsweb API"` | Title displayed in API documentation | +| `API_VERSION` | String | `"1.0.0"` | Version of your API | +| `API_DESCRIPTION` | String | `""` | Description of your API (supports Markdown) | +| `OPENAPI_DOCS_URL` | String | `"/docs"` | URL endpoint for Swagger UI | +| `OPENAPI_REDOC_URL` | String | `"/redoc"` | URL endpoint for ReDoc UI | +| `OPENAPI_JSON_URL` | String | `"/openapi.json"` | URL endpoint for OpenAPI JSON specification | + +### Using Markdown in API Description + +The `API_DESCRIPTION` supports Markdown formatting for rich documentation: + +```python +API_DESCRIPTION = """ +# My Awesome API + +This is a **production-ready** API built with JsWeb. + +## Features + +- Fast ASGI framework +- Automatic OpenAPI documentation +- Built-in authentication +- Database ORM support + +## Base URL + +`https://api.example.com` +""" +``` + +### Example: Complete API Documentation Setup + +```python +# config.py +import os + +# Core settings +APP_NAME = "Task Manager" +VERSION = "2.1.0" +SECRET_KEY = os.getenv('SECRET_KEY', 'dev-key') +DEBUG = os.getenv('DEBUG', False) + +# Database +SQLALCHEMY_DATABASE_URI = "sqlite:///app.db" + +# API Documentation +ENABLE_OPENAPI_DOCS = True +API_TITLE = "Task Manager API" +API_VERSION = "2.1.0" +API_DESCRIPTION = """ +# Task Manager API + +A powerful REST API for managing tasks and projects. + +## Authentication + +All endpoints require Bearer token authentication. + +## Error Handling + +Errors are returned as JSON with appropriate HTTP status codes. +""" +OPENAPI_DOCS_URL = "/api/docs" +OPENAPI_REDOC_URL = "/api/redoc" +OPENAPI_JSON_URL = "/api/spec.json" +``` + +### Disabling Documentation + +To disable automatic API documentation: + +```python +ENABLE_OPENAPI_DOCS = False +``` + +When disabled, the documentation endpoints will not be registered. ## Database Configuration diff --git a/docs/database.md b/docs/database.md index dfbf9d0..6cb9d2d 100644 --- a/docs/database.md +++ b/docs/database.md @@ -7,6 +7,7 @@ JsWeb provides a simple and powerful way to work with databases using [SQLAlchem - [Configuration](#configuration) - [Defining Models](#defining-models) - [Model Fields](#model-fields) +- [Return Types](#return-types) - [Querying the Database](#querying-the-database) - [Database Migrations](#database-migrations) - [Relationships](#relationships) @@ -98,78 +99,148 @@ class Product(ModelBase): category = Column(String(50), index=True) ``` +## Return Types + +ModelBase provides CRUD (Create, Read, Update, Delete) operations with the following return types: + +### ModelBase Methods Return Types + +| Method | Return Type | Description | +|--------|------------|-------------| +| `Model.create(**kwargs)` | `Model` | Creates and saves instance | +| `instance.save()` | `None` | Saves changes to database | +| `instance.update(**kwargs)` | `None` | Updates fields and saves | +| `instance.delete()` | `None` | Deletes instance from database | +| `Model.query.get(id)` | `Optional[Model]` | Get by primary key or None | +| `Model.query.first()` | `Optional[Model]` | Get first result or None | +| `Model.query.all()` | `List[Model]` | Get all results as list | +| `Model.query.filter_by()` | `Query` | Query builder | + +### Example with Type Hints + +```python +from typing import Optional, List +from jsweb.database import ModelBase + +class User(ModelBase): + __tablename__ = 'users' + id = Column(Integer, primary_key=True) + username = Column(String(80), unique=True) + email = Column(String(120)) + +# Create - returns User instance +user: User = User.create(username="alice", email="alice@example.com") + +# Read - returns Optional[User] +user: Optional[User] = User.query.get(1) +user: Optional[User] = User.query.filter_by(username="bob").first() +users: List[User] = User.query.all() + +# Update - returns None +user.email = "newemail@example.com" +user.save() # -> None + +# Update with update() - returns None +user.update(email="another@example.com") # -> None + +# Delete - returns None +user.delete() # -> None +``` + ## Querying the Database ### Get All Records ```python +from typing import List from .models import User +from jsweb.response import render, HTMLResponse @app.route("/users") -async def user_list(req): - users = User.query.all() +async def user_list(req) -> HTMLResponse: + users: List[User] = User.query.all() return render(req, "users.html", {"users": users}) ``` ### Get a Single Record by ID ```python +from typing import Optional +from jsweb.response import render, json, HTMLResponse, JSONResponse + @app.route("/user/") -async def user_detail(req, user_id): - user = User.query.get(user_id) +async def user_detail(req, user_id: int) -> HTMLResponse: + user: Optional[User] = User.query.get(user_id) if user is None: - return "User not found", 404 + return render(req, "404.html", {}), 404 return render(req, "user.html", {"user": user}) + +# JSON API version +@app.route("/api/user/") +async def api_user_detail(req, user_id: int) -> JSONResponse: + user: Optional[User] = User.query.get(user_id) + if user is None: + return json({"error": "User not found"}, status_code=404) + return json(user.to_dict()) ``` ### Filter Records ```python -# Get user by username -user = User.query.filter_by(username="alice").first() +from typing import List, Optional +from sqlalchemy import or_ -# Get all admin users -admins = User.query.filter_by(role="admin").all() +# Get user by username - returns Optional[User] +user: Optional[User] = User.query.filter_by(username="alice").first() -# Using filter() for more complex conditions -from sqlalchemy import or_ +# Get all admin users - returns List[User] +admins: List[User] = User.query.filter_by(role="admin").all() -users = User.query.filter( +# Using filter() for more complex conditions - returns List[User] +users: List[User] = User.query.filter( or_(User.username == "alice", User.email == "alice@example.com") ).all() ``` !!! tip "first() vs all()" - - `first()` returns the first result or `None` - - `all()` returns a list of all results - - `get(id)` returns the record with that primary key or `None` + - `first()` returns `Optional[Model]` (first result or `None`) + - `all()` returns `List[Model]` (all results) + - `get(id)` returns `Optional[Model]` (by primary key or `None`) ### Creating Records ```python -# Method 1: Using create() -new_user = User.create(username="john", email="john@example.com") +# Method 1: Using create() - returns Model instance +new_user: User = User.create(username="john", email="john@example.com") -# Method 2: Creating and saving manually +# Method 2: Creating and saving manually - returns None user = User(username="jane", email="jane@example.com") -db.session.add(user) -db.session.commit() +user.save() # -> None ``` ### Updating Records ```python -user = User.query.get(1) -user.email = "newemail@example.com" -db.session.commit() +# Method 1: Manual update - returns None +user: Optional[User] = User.query.get(1) +if user: + user.email = "newemail@example.com" + user.save() # -> None + +# Method 2: Using update() - returns None +user.update(email="another@example.com") # -> None ``` ### Deleting Records ```python -user = User.query.get(1) -db.session.delete(user) -db.session.commit() +# Method 1: Delete instance - returns None +user: Optional[User] = User.query.get(1) +if user: + user.delete() # -> None + +# Method 2: Query and delete +User.query.filter_by(username="inactive").delete() ``` ## Database Migrations diff --git a/docs/dto-models.md b/docs/dto-models.md new file mode 100644 index 0000000..7ae5447 --- /dev/null +++ b/docs/dto-models.md @@ -0,0 +1,562 @@ +# DTOs & Data Models + +JsWeb provides a powerful Data Transfer Object (DTO) system built on Pydantic v2 for automatic validation, serialization, and API documentation. DTOs are essential for building robust APIs with proper data validation. + +## Table of Contents + +- [What are DTOs?](#what-are-dtos) +- [JswebBaseModel](#jswebbasemodel) +- [Defining DTOs](#defining-dtos) +- [Field Types](#field-types) +- [Validation](#validation) +- [Serialization](#serialization) +- [OpenAPI Integration](#openapi-integration) +- [Practical Examples](#practical-examples) +- [Best Practices](#best-practices) + +## What are DTOs? + +Data Transfer Objects (DTOs) are lightweight classes that define the structure and validation rules for data flowing in and out of your API. They provide: + +- **Type Safety**: Explicit type definitions +- **Validation**: Automatic validation of input data +- **Documentation**: Auto-generated API schemas +- **Serialization**: Easy conversion to/from JSON +- **IDE Support**: Full autocomplete and type checking + +## JswebBaseModel + +The `JswebBaseModel` is the base class for all DTOs in JsWeb. It's built on Pydantic v2 and provides a clean API for data handling. + +```python +from jsweb.dto import JswebBaseModel, Field + +class UserDto(JswebBaseModel): + """DTO for user data""" + name: str = Field(description="User's full name", max_length=100) + email: str = Field(description="Email address") + age: int = Field(ge=0, le=150, description="User age") +``` + +### Return Type: `Type[JswebBaseModel]` + +When you define a DTO class, it becomes a subclass of `JswebBaseModel` with all the inherited methods. + +## Defining DTOs + +### Basic DTO + +```python +from jsweb.dto import JswebBaseModel, Field +from typing import Optional + +class CreateUserRequest(JswebBaseModel): + """Request DTO for creating a user""" + username: str = Field(min_length=3, max_length=50) + email: str = Field(pattern=r'^[\w\.-]+@[\w\.-]+\.\w+$') + password: str = Field(min_length=8) + full_name: Optional[str] = None +``` + +### With Defaults + +```python +from jsweb.dto import JswebBaseModel, Field +from datetime import datetime + +class BlogPostDto(JswebBaseModel): + title: str = Field(min_length=1, max_length=200) + content: str + published: bool = False + created_at: datetime = Field(default_factory=datetime.now) + tags: list[str] = Field(default_factory=list) +``` + +### Nested DTOs + +```python +from jsweb.dto import JswebBaseModel, Field +from typing import List + +class AddressDto(JswebBaseModel): + street: str + city: str + country: str + postal_code: str + +class UserWithAddressDto(JswebBaseModel): + name: str + email: str + address: AddressDto # Nested DTO +``` + +## Field Types + +JsWeb supports all Python type annotations with Pydantic validation: + +### Basic Types + +```python +from jsweb.dto import JswebBaseModel, Field +from typing import Optional + +class BasicTypesDto(JswebBaseModel): + # String + name: str = Field(min_length=1, max_length=100) + + # Integer + age: int = Field(ge=0, le=150) + + # Float + price: float = Field(gt=0) + + # Boolean + active: bool = True + + # Optional (can be None) + nickname: Optional[str] = None +``` + +### Complex Types + +```python +from jsweb.dto import JswebBaseModel, Field +from datetime import datetime, date +from typing import List, Dict + +class ComplexTypesDto(JswebBaseModel): + # Lists + tags: List[str] = Field(default_factory=list) + scores: List[int] + + # Dictionary + metadata: Dict[str, str] = Field(default_factory=dict) + + # Date/Time + birth_date: date + created_at: datetime + updated_at: Optional[datetime] = None +``` + +### Field Constraints + +```python +from jsweb.dto import JswebBaseModel, Field + +class ConstrainedFieldsDto(JswebBaseModel): + # String constraints + username: str = Field(min_length=3, max_length=20, pattern=r'^[a-zA-Z0-9_]+$') + + # Numeric constraints + age: int = Field(ge=0, le=150) + rating: float = Field(ge=0, le=5) + + # List constraints + tags: list[str] = Field(min_length=1, max_length=10) + + # With description (for OpenAPI) + email: str = Field(description="User's email address") +``` + +## Validation + +### Automatic Validation + +Validation happens automatically when creating instances: + +```python +from jsweb.dto import JswebBaseModel, Field +from pydantic import ValidationError + +class UserDto(JswebBaseModel): + name: str = Field(min_length=1) + age: int = Field(ge=0, le=150) + +# ✓ Valid +user = UserDto(name="Alice", age=30) + +# ✗ Raises ValidationError +try: + invalid = UserDto(name="", age=200) # Empty name, age out of range +except ValidationError as e: + print(e.errors()) +``` + +### Custom Validators + +```python +from jsweb.dto import JswebBaseModel, Field, validator +from datetime import date + +class PersonDto(JswebBaseModel): + name: str + birth_date: date + + @validator('name') + def name_must_not_be_empty(cls, v): + if not v or not v.strip(): + raise ValueError('Name cannot be empty') + return v.strip() + + @validator('birth_date') + def birth_date_must_be_past(cls, v): + if v > date.today(): + raise ValueError('Birth date cannot be in the future') + return v +``` + +### Root Validators + +Validate multiple fields together: + +```python +from jsweb.dto import JswebBaseModel, root_validator + +class PasswordChangeDto(JswebBaseModel): + old_password: str + new_password: str + confirm_password: str + + @root_validator() + def passwords_match(cls, values): + new_pass = values.get('new_password') + confirm_pass = values.get('confirm_password') + + if new_pass != confirm_pass: + raise ValueError('Passwords do not match') + + return values +``` + +## Serialization + +### `to_dict()` → `Dict[str, Any]` + +Convert DTO instance to dictionary: + +```python +user = UserDto(name="Alice", email="alice@example.com", age=30) + +# Basic conversion +user_dict = user.to_dict() +# Output: {'name': 'Alice', 'email': 'alice@example.com', 'age': 30} + +# Exclude None values +user_dict = user.to_dict(exclude_none=True) + +# Use field aliases +user_dict = user.to_dict(by_alias=True) +``` + +### `to_json()` → `str` + +Convert DTO instance to JSON string: + +```python +user = UserDto(name="Alice", email="alice@example.com", age=30) + +# Basic conversion +json_str = user.to_json() +# Output: '{"name":"Alice","email":"alice@example.com","age":30}' + +# Pretty-printed +json_str = user.to_json(indent=2) + +# Exclude None values +json_str = user.to_json(exclude_none=True) +``` + +### `from_dict()` → `Type[JswebBaseModel]` + +Create DTO instance from dictionary: + +```python +data = { + "name": "Bob", + "email": "bob@example.com", + "age": 25 +} + +user = UserDto.from_dict(data) +# Automatically validates! +``` + +### `model_validate()` → `Type[JswebBaseModel]` + +Parse and validate data: + +```python +json_data = '{"name": "Alice", "email": "alice@example.com", "age": 30}' + +# From JSON string +import json +data = json.loads(json_data) +user = UserDto.model_validate(data) + +# With validation errors +try: + invalid = UserDto.model_validate({"name": "", "age": 200}) +except ValidationError as e: + print(e) +``` + +## OpenAPI Integration + +### `openapi_schema()` → `Dict[str, Any]` + +Generate OpenAPI 3.0 schema automatically: + +```python +class UserDto(JswebBaseModel): + """A user in the system""" + name: str = Field(description="User's full name") + email: str = Field(description="Email address") + age: int = Field(ge=0, le=150, description="User age") + +# Generate schema +schema = UserDto.openapi_schema() + +# Output: +# { +# "type": "object", +# "properties": { +# "name": {"type": "string", "description": "User's full name"}, +# "email": {"type": "string", "description": "Email address"}, +# "age": {"type": "integer", "minimum": 0, "maximum": 150, ...} +# }, +# "required": ["name", "email", "age"] +# } +``` + +### Using in API Documentation + +```python +from jsweb import JsWebApp +from jsweb.response import json +from jsweb.dto import JswebBaseModel, Field + +class CreateUserRequest(JswebBaseModel): + """Request body for creating a user""" + username: str = Field(min_length=3, description="Username") + email: str = Field(description="Email address") + password: str = Field(min_length=8, description="Password") + +class UserResponse(JswebBaseModel): + """User response DTO""" + id: int + username: str + email: str + +@app.route("/api/users", methods=["POST"]) +async def create_user(req) -> json: + """ + Create a new user + + Request body schema: + {schema} + + Response schema: + {response_schema} + """ + # Schema is auto-documented in API + return json({ + "id": 1, + "username": "john", + "email": "john@example.com" + }) +``` + +## Practical Examples + +### User Registration + +```python +from jsweb.dto import JswebBaseModel, Field, validator +from jsweb.response import json, JSONResponse +import re + +class RegisterRequest(JswebBaseModel): + """User registration request""" + username: str = Field(min_length=3, max_length=50) + email: str + password: str = Field(min_length=8) + password_confirm: str + + @validator('username') + def username_alphanumeric(cls, v): + if not re.match(r'^[a-zA-Z0-9_]+$', v): + raise ValueError('Username must be alphanumeric') + return v + + @validator('email') + def email_valid(cls, v): + if '@' not in v: + raise ValueError('Invalid email') + return v + +class UserResponse(JswebBaseModel): + """Registered user response""" + id: int + username: str + email: str + +@app.route("/api/register", methods=["POST"]) +async def register(req) -> JSONResponse: + try: + # Parse and validate request + req_data = await req.json() + register_req = RegisterRequest.from_dict(req_data) + + # Check password confirmation + if register_req.password != register_req.password_confirm: + return json( + {"error": "Passwords don't match"}, + status_code=400 + ) + + # Create user + user = User.create( + username=register_req.username, + email=register_req.email, + password_hash=hash_password(register_req.password) + ) + + # Return response + response = UserResponse(id=user.id, username=user.username, email=user.email) + return json(response.to_dict(), status_code=201) + + except ValidationError as e: + return json( + {"errors": e.errors()}, + status_code=422 + ) +``` + +### API Response Wrapper + +```python +from jsweb.dto import JswebBaseModel, Field +from typing import Generic, TypeVar, Optional + +T = TypeVar('T') + +class ApiResponse(JswebBaseModel, Generic[T]): + """Standard API response wrapper""" + success: bool + data: Optional[dict] = None + error: Optional[str] = None + status_code: int = 200 + +@app.route("/api/data") +async def get_data(req) -> json: + try: + data = fetch_data() + response = ApiResponse( + success=True, + data=data, + status_code=200 + ) + return json(response.to_dict()) + except Exception as e: + response = ApiResponse( + success=False, + error=str(e), + status_code=500 + ) + return json(response.to_dict(), status_code=500) +``` + +## Best Practices + +### 1. Use Type Hints Consistently + +```python +# ✓ Good +class UserDto(JswebBaseModel): + name: str = Field(min_length=1) + age: int = Field(ge=0) + email: str + +# ✗ Bad +class UserDto(JswebBaseModel): + name = Field(min_length=1) + age = Field(ge=0) +``` + +### 2. Provide Field Descriptions + +```python +# ✓ Good - helps with API documentation +class ProductDto(JswebBaseModel): + name: str = Field(description="Product name") + price: float = Field(gt=0, description="Product price in USD") + stock: int = Field(ge=0, description="Available stock count") + +# ✗ Less helpful +class ProductDto(JswebBaseModel): + name: str + price: float + stock: int +``` + +### 3. Separate Request and Response DTOs + +```python +# ✓ Good +class CreateUserRequest(JswebBaseModel): + username: str + email: str + password: str + +class UserResponse(JswebBaseModel): + id: int + username: str + email: str + created_at: datetime + +# ✗ Less clear +class UserDto(JswebBaseModel): + username: str + email: str + password: Optional[str] = None + id: Optional[int] = None + created_at: Optional[datetime] = None +``` + +### 4. Validate Business Logic + +```python +# ✓ Good +class TransferRequest(JswebBaseModel): + from_account: int + to_account: int + amount: float = Field(gt=0) + + @root_validator() + def accounts_different(cls, values): + if values.get('from_account') == values.get('to_account'): + raise ValueError('Cannot transfer to the same account') + return values + +# ✗ Less safe +class TransferRequest(JswebBaseModel): + from_account: int + to_account: int + amount: float +``` + +### 5. Use Optional for Nullable Fields + +```python +# ✓ Good +from typing import Optional + +class UpdateUserRequest(JswebBaseModel): + name: Optional[str] = None + email: Optional[str] = None + +# ✗ Wrong +class UpdateUserRequest(JswebBaseModel): + name: str = None # Missing type hint + email: str = None +``` diff --git a/docs/forms.md b/docs/forms.md index 40491b7..16883f4 100644 --- a/docs/forms.md +++ b/docs/forms.md @@ -6,6 +6,7 @@ JsWeb provides a powerful and easy-to-use form library that simplifies the proce - [Creating a Form](#creating-a-form) - [Form Fields](#form-fields) +- [Return Types](#return-types) - [Validators](#validators) - [Rendering Forms](#rendering-forms) - [Form Validation](#form-validation) @@ -30,44 +31,83 @@ class LoginForm(Form): ## Form Fields -JsWeb provides a variety of form fields for different input types: +JsWeb provides a variety of form fields for different input types. + +### Return Types for Field Operations + +| Operation | Return Type | Description | +|-----------|------------|-------------| +| `field()` or `field.render()` | `Markup` (str) | HTML rendering of field | +| `field.label` | `Label` | Form label object | +| `field.validate(form)` | `bool` | Validation result | +| `field.data` | `Any` | Field's current value | +| `field.errors` | `List[str]` | List of validation errors | ### Text Fields -| Field | Description | Example | -|-------|-------------|---------| -| `StringField` | Single-line text input | `StringField("Name")` | -| `PasswordField` | Password input (hidden) | `PasswordField("Password")` | -| `TextAreaField` | Multi-line text input | `TextAreaField("Comments")` | -| `EmailField` | Email input | `EmailField("Email")` | -| `URLField` | URL input | `URLField("Website")` | -| `SearchField` | Search input | `SearchField("Search")` | +| Field | Description | Example | Renders As | +|-------|-------------|---------|-----------| +| `StringField` | Single-line text input | `StringField("Name")` | `` | +| `PasswordField` | Password input (hidden) | `PasswordField("Password")` | `` | +| `TextAreaField` | Multi-line text input | `TextAreaField("Comments")` | `