Part 4: API Interface Design and Schema Definition¶
In the previous part, we designed data models and serializers. Now we will create complete API endpoints, learn Unfazed's parameter annotation system, define request/response schemas, and see how OpenAPI documentation is generated automatically.
API Interface Planning¶
Based on our enrollment system's requirements, we need the following endpoints:
| Endpoint Path | Method | Description | Parameter Source |
|---|---|---|---|
/api/enroll/student-list |
GET | Get student list | Query (pagination) |
/api/enroll/course-list |
GET | Get course list | Query (pagination) |
/api/enroll/bind |
POST | Student enrolls course | JSON body |
Note on API conventions: Different teams have different design standards. In this tutorial, we follow a convention where only GET/POST methods are used, URLs follow the pattern
/api/{app}/{resource-action}, and all responses use HTTP status 200 with acodefield in the body (0 = success).
Schema Definition¶
Schemas define the data contracts for your API — the shapes of requests and responses. They are Pydantic models that also drive OpenAPI documentation generation. See OpenAPI.
Creating Schema Models¶
Edit enroll/schema.py:
# enroll/schema.py
import typing as t
from pydantic import BaseModel, Field
from .serializers import StudentSerializer, CourseSerializer
class BaseResponse[T](BaseModel):
"""Unified response wrapper"""
code: int = Field(0, description="Response code, 0 = success")
message: str = Field("", description="Response message")
data: T = Field(description="Response data")
class StudentListResponse(BaseResponse[t.List[StudentSerializer]]):
pass
class CourseListResponse(BaseResponse[t.List[CourseSerializer]]):
pass
class BindRequest(BaseModel):
student_id: int = Field(description="Student ID", gt=0)
course_id: int = Field(description="Course ID", gt=0)
class BindResponse(BaseResponse[t.Dict]):
pass
Note that StudentSerializer and CourseSerializer can be used directly as response models since they extend Pydantic's BaseModel.
Endpoint Implementation¶
Understanding Parameter Annotations¶
Unfazed uses typing.Annotated with param markers to declare where each parameter comes from. See Endpoint for the full reference.
import typing as t
from unfazed.route import params as p
# Query parameters (from URL query string: ?page=1&size=10)
page: t.Annotated[int, p.Query(default=1)]
# Path parameters (from URL path: /users/{user_id})
user_id: t.Annotated[int, p.Path()]
# JSON body (from request body)
data: t.Annotated[CreateUser, p.Json()]
# Response spec (for OpenAPI documentation)
-> t.Annotated[JsonResponse, p.ResponseSpec(model=UserResponse)]
Available param markers: Path, Query, Header, Cookie, Json, Form, File. All extend Pydantic's FieldInfo and accept the same keyword arguments (default, description, ge, le, etc.).
Writing the Endpoints¶
Edit enroll/endpoints.py:
# enroll/endpoints.py
import typing as t
from unfazed.http import HttpRequest, JsonResponse, PlainTextResponse
from unfazed.route import params as p
from . import schema as s
async def hello(request: HttpRequest) -> PlainTextResponse:
"""Hello World endpoint"""
return PlainTextResponse("Hello, World!")
async def list_student(
request: HttpRequest,
page: t.Annotated[int, p.Query(default=1, description="Page number", ge=1)],
size: t.Annotated[int, p.Query(default=10, description="Items per page", ge=1, le=100)],
) -> t.Annotated[JsonResponse, p.ResponseSpec(model=s.StudentListResponse)]:
"""Get paginated student list"""
return JsonResponse({
"code": 0,
"message": "success",
"data": [],
})
async def list_course(
request: HttpRequest,
page: t.Annotated[int, p.Query(default=1, description="Page number", ge=1)],
size: t.Annotated[int, p.Query(default=10, description="Items per page", ge=1, le=100)],
) -> t.Annotated[JsonResponse, p.ResponseSpec(model=s.CourseListResponse)]:
"""Get paginated course list"""
return JsonResponse({
"code": 0,
"message": "success",
"data": [],
})
async def bind(
request: HttpRequest,
ctx: t.Annotated[s.BindRequest, p.Json()],
) -> t.Annotated[JsonResponse, p.ResponseSpec(model=s.BindResponse)]:
"""Bind a student to a course"""
return JsonResponse({
"code": 0,
"message": f"Student {ctx.student_id} enrolled in course {ctx.course_id}",
"data": {
"student_id": ctx.student_id,
"course_id": ctx.course_id,
},
})
Key points:
- Every non-
HttpRequestparameter must declare its source viaAnnotated[Type, p.Source()]. p.Query(default=1, ge=1)provides a default value and validation constraint.p.Json()extracts the parameter from the JSON request body.p.ResponseSpec(model=...)tells OpenAPI what the response looks like — it does not affect runtime behavior.- No bare default values — use
p.Query(default=...)instead ofpage: int = 1. See Endpoint — Gotchas.
Route Configuration¶
Edit enroll/routes.py:
# enroll/routes.py
from unfazed.route import path
from .endpoints import hello, list_student, list_course, bind
patterns = [
path("/hello", endpoint=hello, name="hello"),
path("/student-list", endpoint=list_student, name="list_students"),
path("/course-list", endpoint=list_course, name="list_courses"),
path("/bind", endpoint=bind, methods=["POST"], name="bind_course"),
]
Automatic API Documentation¶
OpenAPI Configuration¶
To enable the documentation UI, add the OPENAPI setting:
# entry/settings/__init__.py (add to UNFAZED_SETTINGS)
UNFAZED_SETTINGS = {
# ... existing settings ...
"OPENAPI": {
"info": {
"title": "Tutorial Project API",
"version": "1.0.0",
"description": "Student Course Enrollment System API",
},
"servers": [
{"url": "http://127.0.0.1:9527", "description": "Local dev"},
],
"allow_public": True,
},
}
Browse the Documentation¶
After starting the server, visit:
- Swagger UI (interactive):
http://127.0.0.1:9527/openapi/docs - ReDoc (readable):
http://127.0.0.1:9527/openapi/redoc - Raw JSON schema:
http://127.0.0.1:9527/openapi/openapi.json
Unfazed generates the OpenAPI 3.1 schema from your endpoint type hints automatically — parameter types, defaults, validation rules, and response models are all included. See OpenAPI.
Testing the Endpoints¶
Using curl¶
# Student list (with default pagination)
curl "http://127.0.0.1:9527/api/enroll/student-list"
# Student list (with custom pagination)
curl "http://127.0.0.1:9527/api/enroll/student-list?page=1&size=5"
# Course list
curl "http://127.0.0.1:9527/api/enroll/course-list"
# Course binding
curl -X POST "http://127.0.0.1:9527/api/enroll/bind" \
-H "Content-Type: application/json" \
-d '{"student_id": 1, "course_id": 1}'
Using Python¶
import requests
base_url = "http://127.0.0.1:9527/api/enroll"
# Test student list
resp = requests.get(f"{base_url}/student-list", params={"page": 1, "size": 10})
print("Student list:", resp.json())
# Test binding
resp = requests.post(
f"{base_url}/bind",
json={"student_id": 1, "course_id": 1},
)
print("Bind result:", resp.json())
Next Steps¶
You have designed complete API endpoints with typed parameter annotations and automatic OpenAPI documentation. In the next part, we will:
- Implement the business logic in the Services layer
- Connect endpoints to the database via serializers
- Handle validation errors and edge cases
Continue to Part 5: Business Logic Implementation.