From eb845c5bf4a811add81e30c74d1fb98b0dae9085 Mon Sep 17 00:00:00 2001 From: Vishesh 'ironeagle' Bangotra Date: Tue, 16 Jun 2026 12:26:47 +0530 Subject: [PATCH] vet app --- openapi_first/templates/vet_app/__init__.py | 97 ++ openapi_first/templates/vet_app/data.py | 276 +++++ openapi_first/templates/vet_app/main.py | 35 + openapi_first/templates/vet_app/models.py | 104 ++ openapi_first/templates/vet_app/openapi.yaml | 1019 +++++++++++++++++ openapi_first/templates/vet_app/routes.py | 365 ++++++ .../templates/vet_app/test_vet_app.py | 151 +++ 7 files changed, 2047 insertions(+) create mode 100644 openapi_first/templates/vet_app/__init__.py create mode 100644 openapi_first/templates/vet_app/data.py create mode 100644 openapi_first/templates/vet_app/main.py create mode 100644 openapi_first/templates/vet_app/models.py create mode 100644 openapi_first/templates/vet_app/openapi.yaml create mode 100644 openapi_first/templates/vet_app/routes.py create mode 100644 openapi_first/templates/vet_app/test_vet_app.py diff --git a/openapi_first/templates/vet_app/__init__.py b/openapi_first/templates/vet_app/__init__.py new file mode 100644 index 0000000..35e79af --- /dev/null +++ b/openapi_first/templates/vet_app/__init__.py @@ -0,0 +1,97 @@ +""" +OpenAPI-first Veterinary Clinic application template. + +This package contains a complete, runnable example of an OpenAPI-first +veterinary clinic management service. It demonstrates all ``x-`` extension +fields consumed by the ``react-openapi`` admin panel renderer. + +The application manages five resources: + +- **Parents** — pet owners with contact details +- **Vets** — veterinarians with specializations +- **Treatments** — medical procedure catalog +- **Pets** — animals with species, age, weight, and photos +- **Appointments** — scheduled visits linking pets, vets, and treatments + +All HTTP routes, methods, schemas, and operation bindings are defined +in the OpenAPI specification (``openapi.yaml``). Every operation has an +explicit ``operationId`` that maps to a Python handler in ``routes.py``. + +This file is a copyable template. It is not part of the ``openapi_first`` +library API surface. + +---------------------------------------------------------------------- +OpenAPI x- extension fields demonstrated +---------------------------------------------------------------------- + +Schema-level extensions (mark a schema as a UI resource): + + ``x-resource`` (REQUIRED) Maps schema to URL path segment + ``x-primary-key`` (REQUIRED) Primary key property name + ``x-display-format`` (REQUIRED) Human-readable label template + ``x-list-columns`` (REQUIRED) Columns for the datatable + +Property-level extensions (control UI rendering): + + ``x-label`` (REQUIRED) Human-readable field label + ``x-order`` (REQUIRED) Field ordering in forms/detail + ``x-description`` (optional) Helper text below form fields + ``x-hidden`` (optional) Visibility in form / list / detail + ``x-filterable`` (optional) Allows column filtering + ``x-sortable`` (optional) Allows column sorting + ``x-fk`` (optional) Foreign key — renders as dropdown + ``x-fk.resource`` (REQUIRED for FK) Target resource name + ``x-fk.prefetch`` (optional) Preload all FK options on mount + ``x-ui-type`` (optional) Custom UI type (e.g. image upload) + ``x-upload-url`` (optional) Upload endpoint for binary fields + +---------------------------------------------------------------------- +Scaffolding via CLI +---------------------------------------------------------------------- + +Create a new vet clinic service using the bundled template: + + openapi-first vet_app + +Create the service in a custom directory: + + openapi-first vet_app my-vet-clinic + +---------------------------------------------------------------------- +Client Usage Example +---------------------------------------------------------------------- + + from openapi_first.loader import load_openapi + from openapi_first.client import OpenAPIClient + + spec = load_openapi("openapi.yaml") + client = OpenAPIClient(spec) + + # List pets with pagination + response = client.list_pets(query_params={"limit": 10, "offset": 0}) + + # Create a pet with FK references + response = client.create_pet( + body={"name": "Fido", "species": "dog", "parents": [1, 2]} + ) + + # Upload a pet photo + response = client.upload_pet_photo( + path_params={"id": 1}, + body={"file": open("photo.jpg", "rb")}, + ) + +---------------------------------------------------------------------- +Non-Goals +---------------------------------------------------------------------- + +This template is intentionally minimal and is NOT: +- production-ready +- persistent or concurrency-safe +- a reference architecture for data storage + +It exists solely as a copyable example for learning, testing, and +bootstrapping OpenAPI-first services. + +This package is not part of the ``openapi_first`` library API surface. +""" diff --git a/openapi_first/templates/vet_app/data.py b/openapi_first/templates/vet_app/data.py new file mode 100644 index 0000000..97ca9b3 --- /dev/null +++ b/openapi_first/templates/vet_app/data.py @@ -0,0 +1,276 @@ +""" +In-memory data store for the Veterinary Clinic example. + +This module is NOT thread-safe and is intended for demos and scaffolds only. + +It provides minimal, process-local data stores for the five veterinary +clinic entities. Each store exposes standard CRUD operations backed by +a simple dictionary. + +This module intentionally avoids: +- persistence +- concurrency guarantees +- transactional semantics +- validation beyond what Pydantic provides + +This module is not part of the ``openapi_first`` library API surface. +""" + +from datetime import datetime, timezone + +from models import ( + Parent, ParentCreate, + Vet, VetCreate, + Treatment, TreatmentCreate, + Pet, PetCreate, + Appointment, AppointmentCreate, +) + + +def _now(): + return datetime.now(timezone.utc).isoformat() + + +# --------------------------------------------------------------------------- +# Parents +# --------------------------------------------------------------------------- + +_parents: dict[int, Parent] = {} +_parents_next_id = 1 + + +def list_parents() -> list[Parent]: + return list(_parents.values()) + + +def get_parent(parent_id: int) -> Parent: + return _parents[parent_id] + + +def create_parent(payload: ParentCreate) -> Parent: + global _parents_next_id + now = _now() + parent = Parent( + id=_parents_next_id, + **payload.model_dump(exclude={"id"}), + metadata={"createdOn": now, "updatedOn": now} if payload.metadata else None, + ) + _parents[_parents_next_id] = parent + _parents_next_id += 1 + return parent + + +def update_parent(parent_id: int, payload: ParentCreate) -> Parent: + if parent_id not in _parents: + raise KeyError(parent_id) + now = _now() + parent = _parents[parent_id] + updated = parent.model_copy( + update={ + **payload.model_dump(exclude={"id", "metadata"}), + "metadata": {"createdOn": parent.metadata.get("createdOn", now), "updatedOn": now} + if parent.metadata else None, + } + ) + _parents[parent_id] = updated + return updated + + +def delete_parent(parent_id: int) -> None: + del _parents[parent_id] + + +# --------------------------------------------------------------------------- +# Vets +# --------------------------------------------------------------------------- + +_vets: dict[int, Vet] = {} +_vets_next_id = 1 + + +def list_vets() -> list[Vet]: + return list(_vets.values()) + + +def get_vet(vet_id: int) -> Vet: + return _vets[vet_id] + + +def create_vet(payload: VetCreate) -> Vet: + global _vets_next_id + now = _now() + vet = Vet( + id=_vets_next_id, + **payload.model_dump(exclude={"id"}), + metadata={"createdOn": now, "updatedOn": now} if payload.metadata else None, + ) + _vets[_vets_next_id] = vet + _vets_next_id += 1 + return vet + + +def update_vet(vet_id: int, payload: VetCreate) -> Vet: + if vet_id not in _vets: + raise KeyError(vet_id) + now = _now() + vet = _vets[vet_id] + updated = vet.model_copy( + update={ + **payload.model_dump(exclude={"id", "metadata"}), + "metadata": {"createdOn": vet.metadata.get("createdOn", now), "updatedOn": now} + if vet.metadata else None, + } + ) + _vets[vet_id] = updated + return updated + + +def delete_vet(vet_id: int) -> None: + del _vets[vet_id] + + +# --------------------------------------------------------------------------- +# Treatments +# --------------------------------------------------------------------------- + +_treatments: dict[int, Treatment] = {} +_treatments_next_id = 1 + + +def list_treatments() -> list[Treatment]: + return list(_treatments.values()) + + +def get_treatment(treatment_id: int) -> Treatment: + return _treatments[treatment_id] + + +def create_treatment(payload: TreatmentCreate) -> Treatment: + global _treatments_next_id + now = _now() + treatment = Treatment( + id=_treatments_next_id, + **payload.model_dump(exclude={"id"}), + metadata={"createdOn": now, "updatedOn": now} if payload.metadata else None, + ) + _treatments[_treatments_next_id] = treatment + _treatments_next_id += 1 + return treatment + + +def update_treatment(treatment_id: int, payload: TreatmentCreate) -> Treatment: + if treatment_id not in _treatments: + raise KeyError(treatment_id) + now = _now() + treatment = _treatments[treatment_id] + updated = treatment.model_copy( + update={ + **payload.model_dump(exclude={"id", "metadata"}), + "metadata": {"createdOn": treatment.metadata.get("createdOn", now), "updatedOn": now} + if treatment.metadata else None, + } + ) + _treatments[treatment_id] = updated + return updated + + +def delete_treatment(treatment_id: int) -> None: + del _treatments[treatment_id] + + +# --------------------------------------------------------------------------- +# Pets +# --------------------------------------------------------------------------- + +_pets: dict[int, Pet] = {} +_pets_next_id = 1 + + +def list_pets() -> list[Pet]: + return list(_pets.values()) + + +def get_pet(pet_id: int) -> Pet: + return _pets[pet_id] + + +def create_pet(payload: PetCreate) -> Pet: + global _pets_next_id + now = _now() + pet = Pet( + id=_pets_next_id, + **payload.model_dump(exclude={"id"}), + metadata={"createdOn": now, "updatedOn": now} if payload.metadata else None, + ) + _pets[_pets_next_id] = pet + _pets_next_id += 1 + return pet + + +def update_pet(pet_id: int, payload: PetCreate) -> Pet: + if pet_id not in _pets: + raise KeyError(pet_id) + now = _now() + pet = _pets[pet_id] + updated = pet.model_copy( + update={ + **payload.model_dump(exclude={"id", "metadata"}), + "metadata": {"createdOn": pet.metadata.get("createdOn", now), "updatedOn": now} + if pet.metadata else None, + } + ) + _pets[pet_id] = updated + return updated + + +def delete_pet(pet_id: int) -> None: + del _pets[pet_id] + + +# --------------------------------------------------------------------------- +# Appointments +# --------------------------------------------------------------------------- + +_appointments: dict[int, Appointment] = {} +_appointments_next_id = 1 + + +def list_appointments() -> list[Appointment]: + return list(_appointments.values()) + + +def get_appointment(appointment_id: int) -> Appointment: + return _appointments[appointment_id] + + +def create_appointment(payload: AppointmentCreate) -> Appointment: + global _appointments_next_id + now = _now() + appointment = Appointment( + id=_appointments_next_id, + **payload.model_dump(exclude={"id"}), + metadata={"createdOn": now, "updatedOn": now} if payload.metadata else None, + ) + _appointments[_appointments_next_id] = appointment + _appointments_next_id += 1 + return appointment + + +def update_appointment(appointment_id: int, payload: AppointmentCreate) -> Appointment: + if appointment_id not in _appointments: + raise KeyError(appointment_id) + now = _now() + appointment = _appointments[appointment_id] + updated = appointment.model_copy( + update={ + **payload.model_dump(exclude={"id", "metadata"}), + "metadata": {"createdOn": appointment.metadata.get("createdOn", now), "updatedOn": now} + if appointment.metadata else None, + } + ) + _appointments[appointment_id] = updated + return updated + + +def delete_appointment(appointment_id: int) -> None: + del _appointments[appointment_id] diff --git a/openapi_first/templates/vet_app/main.py b/openapi_first/templates/vet_app/main.py new file mode 100644 index 0000000..9f1515c --- /dev/null +++ b/openapi_first/templates/vet_app/main.py @@ -0,0 +1,35 @@ +""" +Application entry point for an OpenAPI-first Veterinary Clinic service. + +This module constructs a FastAPI application exclusively from an +OpenAPI specification and a handler namespace, without using +decorator-driven routing. + +All HTTP routes, methods, request/response schemas, and operation +bindings are defined in the OpenAPI document referenced by +``openapi_path``. Python callables defined in the ``routes`` module are +bound to OpenAPI operations strictly via ``operationId``. + +This module contains no routing logic, persistence concerns, or +framework configuration beyond application assembly. + +Design guarantees: +- OpenAPI is the single source of truth +- No undocumented routes can exist +- Every OpenAPI operationId must resolve to exactly one handler +- All contract violations fail at application startup + +This file is intended to be used as the ASGI entry point. + +Example: + uvicorn main:app +""" + +from openapi_first.app import OpenAPIFirstApp +import routes + +app = OpenAPIFirstApp( + openapi_path="openapi.yaml", + routes_module=routes, + title="Veterinary Clinic Service", +) diff --git a/openapi_first/templates/vet_app/models.py b/openapi_first/templates/vet_app/models.py new file mode 100644 index 0000000..96acb8d --- /dev/null +++ b/openapi_first/templates/vet_app/models.py @@ -0,0 +1,104 @@ +""" +Pydantic domain models for the Veterinary Clinic example. + +This module defines Pydantic models representing the five domain +entities used by the veterinary clinic service. These models are +referenced by the OpenAPI specification for request and response +schemas. + +The models are declarative and framework-agnostic. They contain no +persistence logic, validation beyond type constraints, or business +behavior. + +This module is not part of the ``openapi_first`` library API surface. +It exists solely to support the example application template. +""" + +from datetime import date, datetime +from pydantic import BaseModel + + +class Metadata(BaseModel): + createdOn: datetime | None = None + updatedOn: datetime | None = None + + +class ParentBase(BaseModel): + name: str + email: str + phone: str | None = None + metadata: Metadata | None = None + + +class ParentCreate(ParentBase): + pass + + +class Parent(ParentBase): + id: int + + +class VetBase(BaseModel): + name: str + specialty: str | None = None + email: str + phone: str | None = None + metadata: Metadata | None = None + + +class VetCreate(VetBase): + pass + + +class Vet(VetBase): + id: int + + +class TreatmentBase(BaseModel): + label: str + description: str | None = None + metadata: Metadata | None = None + + +class TreatmentCreate(TreatmentBase): + pass + + +class Treatment(TreatmentBase): + id: int + + +class PetBase(BaseModel): + name: str + species: str + age: int | None = None + weight: float | None = None + birthDate: date | None = None + photo: str | None = None + parent_ids: list[int] = [] + metadata: Metadata | None = None + + +class PetCreate(PetBase): + pass + + +class Pet(PetBase): + id: int + + +class AppointmentBase(BaseModel): + date: datetime + notes: str | None = None + pet_id: int + vet_id: int + treatment_id: int + metadata: Metadata | None = None + + +class AppointmentCreate(AppointmentBase): + pass + + +class Appointment(AppointmentBase): + id: int diff --git a/openapi_first/templates/vet_app/openapi.yaml b/openapi_first/templates/vet_app/openapi.yaml new file mode 100644 index 0000000..a3c0ddf --- /dev/null +++ b/openapi_first/templates/vet_app/openapi.yaml @@ -0,0 +1,1019 @@ +openapi: 3.1.0 + +info: + title: Veterinary Clinic API + version: 1.0.0 + description: | + Veterinary clinic management API demonstrating OpenAPI x- extension + fields for the react-openapi admin panel renderer. + +servers: + - url: https://api.example.com/v1 + +components: + schemas: + Metadata: + type: object + x-display-format: "created:{createdOn}|updated:{updatedOn}" + properties: + createdOn: + type: string + format: date-time + updatedOn: + type: string + format: date-time + + Parent: + type: object + x-resource: parents + x-primary-key: id + x-display-format: "{name}" + x-list-columns: [name, email, phone] + properties: + id: + type: integer + readOnly: true + x-order: 0 + x-hidden: { form: true, list: true } + x-label: "ID" + name: + type: string + x-order: 1 + x-label: "Name" + x-description: "Parent's full name" + x-filterable: true + x-sortable: true + email: + type: string + format: email + x-order: 2 + x-label: "Email" + x-description: "Email address" + x-filterable: true + phone: + type: string + x-order: 3 + x-label: "Phone" + x-description: "Contact phone number" + x-filterable: true + metadata: + $ref: '#/components/schemas/Metadata' + x-order: 4 + x-label: "Metadata" + required: [id, name, email] + + Vet: + type: object + x-resource: vets + x-primary-key: id + x-display-format: "Dr. {name}" + x-list-columns: [name, specialty, email, phone] + properties: + id: + type: integer + readOnly: true + x-order: 0 + x-hidden: { form: true, list: true } + x-label: "ID" + name: + type: string + x-order: 1 + x-label: "Name" + x-description: "Veterinarian's full name" + x-filterable: true + x-sortable: true + specialty: + type: string + x-order: 2 + x-label: "Specialty" + x-description: "Area of specialization" + x-filterable: true + email: + type: string + format: email + x-order: 3 + x-label: "Email" + x-description: "Email address" + x-filterable: true + phone: + type: string + x-order: 4 + x-label: "Phone" + x-description: "Contact phone number" + metadata: + $ref: '#/components/schemas/Metadata' + x-order: 4 + x-label: "Metadata" + required: [id, name] + + Treatment: + type: object + x-resource: treatments + x-primary-key: id + x-display-format: "{label}" + x-list-columns: [label, description] + properties: + id: + type: integer + readOnly: true + x-order: 0 + x-hidden: { form: true, list: true } + x-label: "ID" + label: + type: string + x-order: 1 + x-label: "Treatment" + x-description: "Name of the treatment" + x-filterable: true + x-sortable: true + description: + type: string + x-order: 2 + x-label: "Description" + x-description: "Detailed description of the treatment" + metadata: + $ref: '#/components/schemas/Metadata' + x-order: 4 + x-label: "Metadata" + required: [id, label] + + Pet: + type: object + x-resource: pets + x-primary-key: id + x-display-format: "{name} – #{id}" + x-list-columns: [name, species, age, weight, birthDate, parents] + properties: + id: + type: integer + readOnly: true + x-order: 0 + x-hidden: { form: true, list: true } + x-label: "ID" + name: + type: string + x-order: 1 + x-label: "Pet Name" + x-description: "Name of the pet" + x-filterable: true + x-sortable: true + species: + type: string + enum: [dog, cat, bird] + x-order: 2 + x-label: "Species" + x-description: "Type of animal" + x-filterable: true + age: + type: integer + x-order: 3 + x-label: "Age" + x-description: "Age in years" + x-filterable: true + x-sortable: true + weight: + type: number + format: float + x-order: 4 + x-label: "Weight" + x-description: "Weight in kilograms" + birthDate: + type: string + format: date + x-order: 5 + x-label: "Date of Birth" + x-description: "Pet's birth date" + x-filterable: true + photo: + type: string + format: binary + x-ui-type: image + x-upload-url: /pets/{id}/photo + x-order: 6 + x-label: "Photo" + x-description: "Upload a photo of the pet" + parents: + type: array + items: + $ref: '#/components/schemas/Parent' + x-fk: + resource: parents + x-order: 7 + x-label: "Parents" + x-description: "Pet's owners" + x-filterable: true + metadata: + $ref: '#/components/schemas/Metadata' + x-order: 4 + x-label: "Metadata" + required: [id, name, parents] + + Appointment: + type: object + x-resource: appointments + x-primary-key: id + x-display-format: "Appt #{id} – {date}" + x-list-columns: [date, pet, vet, treatment, notes] + properties: + id: + type: integer + readOnly: true + x-order: 0 + x-hidden: { form: true, list: true } + x-label: "ID" + date: + type: string + format: date-time + x-order: 1 + x-label: "Date & Time" + x-description: "Appointment date and time" + x-filterable: true + x-sortable: true + notes: + type: string + x-order: 2 + x-label: "Notes" + x-description: "Any additional notes" + pet: + $ref: '#/components/schemas/Pet' + x-fk: + resource: pets + x-order: 3 + x-label: "Pet" + x-description: "Select a pet" + x-filterable: true + vet: + $ref: '#/components/schemas/Vet' + x-fk: + resource: vets + prefetch: true + x-order: 4 + x-label: "Veterinarian" + x-description: "Select a veterinarian" + x-filterable: true + treatment: + $ref: '#/components/schemas/Treatment' + x-fk: + resource: treatments + prefetch: true + x-order: 5 + x-label: "Treatment" + x-description: "Select a treatment" + x-filterable: true + metadata: + $ref: '#/components/schemas/Metadata' + x-order: 4 + x-label: "Metadata" + required: [id, date, pet, vet, treatment] + + ErrorBody: + type: object + properties: + detail: + type: string + required: [detail] + + HTTPValidationError: + type: object + properties: + detail: + type: array + items: + $ref: '#/components/schemas/ValidationError' + + ValidationError: + type: object + properties: + loc: + type: array + items: + type: string + msg: + type: string + type: + type: string + required: [loc, msg, type] + + responses: + Unauthorized: + description: Not authenticated + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBody' + Forbidden: + description: Insufficient permissions + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBody' + NotFound: + description: Resource not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBody' + ValidationError: + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + InternalServerError: + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBody' + +paths: + /parents: + get: + summary: List parents (paginated) + operationId: list_parents + parameters: + - in: query + name: limit + schema: {type: integer, default: 20} + - in: query + name: offset + schema: {type: integer, default: 0} + responses: + '200': + description: Paginated list of parents + content: + application/json: + schema: + type: object + properties: + total: + type: integer + items: + type: array + items: + $ref: '#/components/schemas/Parent' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '422': + $ref: '#/components/responses/ValidationError' + '500': + $ref: '#/components/responses/InternalServerError' + post: + summary: Create a parent + operationId: create_parent + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Parent' + responses: + '201': + description: Parent created + content: + application/json: + schema: + $ref: '#/components/schemas/Parent' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '422': + $ref: '#/components/responses/ValidationError' + '500': + $ref: '#/components/responses/InternalServerError' + /parents/{id}: + get: + operationId: get_parent + parameters: + - name: id + in: path + required: true + schema: {type: integer} + responses: + '200': + description: Single parent + content: + application/json: + schema: + $ref: '#/components/schemas/Parent' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '422': + $ref: '#/components/responses/ValidationError' + '500': + $ref: '#/components/responses/InternalServerError' + put: + operationId: update_parent + parameters: + - name: id + in: path + required: true + schema: {type: integer} + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Parent' + responses: + '200': + description: Parent updated + content: + application/json: + schema: + $ref: '#/components/schemas/Parent' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '422': + $ref: '#/components/responses/ValidationError' + '500': + $ref: '#/components/responses/InternalServerError' + delete: + operationId: delete_parent + parameters: + - name: id + in: path + required: true + schema: {type: integer} + responses: + '204': + description: Parent deleted + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '422': + $ref: '#/components/responses/ValidationError' + '500': + $ref: '#/components/responses/InternalServerError' + + /vets: + get: + summary: List vets (paginated) + operationId: list_vets + parameters: + - in: query + name: limit + schema: {type: integer, default: 20} + - in: query + name: offset + schema: {type: integer, default: 0} + responses: + '200': + description: Paginated list of vets + content: + application/json: + schema: + type: object + properties: + total: + type: integer + items: + type: array + items: + $ref: '#/components/schemas/Vet' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '422': + $ref: '#/components/responses/ValidationError' + '500': + $ref: '#/components/responses/InternalServerError' + post: + summary: Create a vet + operationId: create_vet + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Vet' + responses: + '201': + description: Vet created + content: + application/json: + schema: + $ref: '#/components/schemas/Vet' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '422': + $ref: '#/components/responses/ValidationError' + '500': + $ref: '#/components/responses/InternalServerError' + /vets/{id}: + get: + operationId: get_vet + parameters: + - name: id + in: path + required: true + schema: {type: integer} + responses: + '200': + description: Single vet + content: + application/json: + schema: + $ref: '#/components/schemas/Vet' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '422': + $ref: '#/components/responses/ValidationError' + '500': + $ref: '#/components/responses/InternalServerError' + put: + operationId: update_vet + parameters: + - name: id + in: path + required: true + schema: {type: integer} + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Vet' + responses: + '200': + description: Vet updated + content: + application/json: + schema: + $ref: '#/components/schemas/Vet' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '422': + $ref: '#/components/responses/ValidationError' + '500': + $ref: '#/components/responses/InternalServerError' + delete: + operationId: delete_vet + parameters: + - name: id + in: path + required: true + schema: {type: integer} + responses: + '204': + description: Vet deleted + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '422': + $ref: '#/components/responses/ValidationError' + '500': + $ref: '#/components/responses/InternalServerError' + + /treatments: + get: + summary: List treatments (catalogue) + operationId: list_treatments + responses: + '200': + description: List of treatments + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Treatment' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '422': + $ref: '#/components/responses/ValidationError' + '500': + $ref: '#/components/responses/InternalServerError' + post: + summary: Add a treatment (admin only) + operationId: create_treatment + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Treatment' + responses: + '201': + description: Treatment added + content: + application/json: + schema: + $ref: '#/components/schemas/Treatment' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '422': + $ref: '#/components/responses/ValidationError' + '500': + $ref: '#/components/responses/InternalServerError' + /treatments/{id}: + get: + operationId: get_treatment + parameters: + - name: id + in: path + required: true + schema: {type: integer} + responses: + '200': + description: Single treatment + content: + application/json: + schema: + $ref: '#/components/schemas/Treatment' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '422': + $ref: '#/components/responses/ValidationError' + '500': + $ref: '#/components/responses/InternalServerError' + put: + operationId: update_treatment + parameters: + - name: id + in: path + required: true + schema: {type: integer} + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Treatment' + responses: + '200': + description: Treatment updated + content: + application/json: + schema: + $ref: '#/components/schemas/Treatment' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '422': + $ref: '#/components/responses/ValidationError' + '500': + $ref: '#/components/responses/InternalServerError' + delete: + operationId: delete_treatment + parameters: + - name: id + in: path + required: true + schema: {type: integer} + responses: + '204': + description: Treatment deleted + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '422': + $ref: '#/components/responses/ValidationError' + '500': + $ref: '#/components/responses/InternalServerError' + + /pets: + get: + summary: List pets (paginated) + operationId: list_pets + parameters: + - in: query + name: limit + schema: {type: integer, default: 20} + - in: query + name: offset + schema: {type: integer, default: 0} + responses: + '200': + description: Paginated list of pets + content: + application/json: + schema: + type: object + properties: + total: + type: integer + items: + type: array + items: + $ref: '#/components/schemas/Pet' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '422': + $ref: '#/components/responses/ValidationError' + '500': + $ref: '#/components/responses/InternalServerError' + post: + summary: Create a pet + operationId: create_pet + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + responses: + '201': + description: Pet created + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '422': + $ref: '#/components/responses/ValidationError' + '500': + $ref: '#/components/responses/InternalServerError' + /pets/{id}: + get: + operationId: get_pet + parameters: + - name: id + in: path + required: true + schema: {type: integer} + responses: + '200': + description: Single pet + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '422': + $ref: '#/components/responses/ValidationError' + '500': + $ref: '#/components/responses/InternalServerError' + put: + operationId: update_pet + parameters: + - name: id + in: path + required: true + schema: {type: integer} + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + responses: + '200': + description: Pet updated + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '422': + $ref: '#/components/responses/ValidationError' + '500': + $ref: '#/components/responses/InternalServerError' + delete: + operationId: delete_pet + parameters: + - name: id + in: path + required: true + schema: {type: integer} + responses: + '204': + description: Pet deleted + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '422': + $ref: '#/components/responses/ValidationError' + '500': + $ref: '#/components/responses/InternalServerError' + post: + summary: Upload pet photo + operationId: upload_pet_photo + parameters: + - name: id + in: path + required: true + schema: {type: integer} + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + properties: + file: + type: string + format: binary + responses: + '200': + description: Photo uploaded + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '422': + $ref: '#/components/responses/ValidationError' + '500': + $ref: '#/components/responses/InternalServerError' + + /appointments: + get: + summary: List appointments (paginated, filterable) + operationId: list_appointments + parameters: + - in: query + name: limit + schema: {type: integer, default: 20} + - in: query + name: offset + schema: {type: integer, default: 0} + - in: query + name: date + schema: {type: string, format: date} + - in: query + name: vet + schema: {type: integer} + - in: query + name: pet + schema: {type: integer} + responses: + '200': + description: Paginated list of appointments + content: + application/json: + schema: + type: object + properties: + total: + type: integer + items: + type: array + items: + $ref: '#/components/schemas/Appointment' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '422': + $ref: '#/components/responses/ValidationError' + '500': + $ref: '#/components/responses/InternalServerError' + post: + summary: Create an appointment + operationId: create_appointment + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Appointment' + responses: + '201': + description: Appointment created + content: + application/json: + schema: + $ref: '#/components/schemas/Appointment' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '422': + $ref: '#/components/responses/ValidationError' + '500': + $ref: '#/components/responses/InternalServerError' + /appointments/{id}: + get: + operationId: get_appointment + parameters: + - name: id + in: path + required: true + schema: {type: integer} + responses: + '200': + description: Single appointment + content: + application/json: + schema: + $ref: '#/components/schemas/Appointment' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '422': + $ref: '#/components/responses/ValidationError' + '500': + $ref: '#/components/responses/InternalServerError' + put: + operationId: update_appointment + parameters: + - name: id + in: path + required: true + schema: {type: integer} + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Appointment' + responses: + '200': + description: Appointment updated + content: + application/json: + schema: + $ref: '#/components/schemas/Appointment' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '422': + $ref: '#/components/responses/ValidationError' + '500': + $ref: '#/components/responses/InternalServerError' + delete: + operationId: delete_appointment + parameters: + - name: id + in: path + required: true + schema: {type: integer} + responses: + '204': + description: Appointment deleted + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '422': + $ref: '#/components/responses/ValidationError' + '500': + $ref: '#/components/responses/InternalServerError' diff --git a/openapi_first/templates/vet_app/routes.py b/openapi_first/templates/vet_app/routes.py new file mode 100644 index 0000000..4772da9 --- /dev/null +++ b/openapi_first/templates/vet_app/routes.py @@ -0,0 +1,365 @@ +""" +Veterinary Clinic route handlers bound via OpenAPI operationId. + +Handlers explicitly control HTTP response status codes to ensure runtime +behavior matches the OpenAPI contract. Domain models defined using +Pydantic are used for request and response payloads. + +No routing decorators, path definitions, or implicit framework behavior +appear in this module. All routing, HTTP methods, and schemas are defined +in the OpenAPI specification. +""" + +from fastapi import Response, HTTPException, UploadFile + +from models import ( + ParentCreate, + VetCreate, + TreatmentCreate, + PetCreate, + AppointmentCreate, +) +from data import ( + list_parents as _list_parents, + get_parent as _get_parent, + create_parent as _create_parent, + update_parent as _update_parent, + delete_parent as _delete_parent, + list_vets as _list_vets, + get_vet as _get_vet, + create_vet as _create_vet, + update_vet as _update_vet, + delete_vet as _delete_vet, + list_treatments as _list_treatments, + get_treatment as _get_treatment, + create_treatment as _create_treatment, + update_treatment as _update_treatment, + delete_treatment as _delete_treatment, + list_pets as _list_pets, + get_pet as _get_pet, + create_pet as _create_pet, + update_pet as _update_pet, + delete_pet as _delete_pet, + list_appointments as _list_appointments, + get_appointment as _get_appointment, + create_appointment as _create_appointment, + update_appointment as _update_appointment, + delete_appointment as _delete_appointment, +) + + +# --------------------------------------------------------------------------- +# Parents +# --------------------------------------------------------------------------- + + +def list_parents(limit: int = 20, offset: int = 0): + """List parents (paginated). + + Parameters + ---------- + limit : int + Maximum number of records to return. + offset : int + Number of records to skip. + + Returns + ------- + dict + Paginated response with ``total`` and ``items``. + """ + items = _list_parents() + return {"total": len(items), "items": items[offset:offset + limit]} + + +def create_parent(payload: ParentCreate, response: Response): + """Create a parent. + + Parameters + ---------- + payload : ParentCreate + Parent data excluding the ``id`` field. + + Returns + ------- + Parent + The newly created parent. + """ + parent = _create_parent(payload) + response.status_code = 201 + return parent + + +def get_parent(id: int): + """Retrieve a single parent by ID. + + Parameters + ---------- + id : int + Identifier of the parent. + + Returns + ------- + Parent + The requested parent. + + Raises + ------ + HTTPException + 404 if the parent does not exist. + """ + try: + return _get_parent(id) + except KeyError: + raise HTTPException(status_code=404, detail="Parent not found") + + +def update_parent(id: int, payload: ParentCreate): + """Update an existing parent. + + Parameters + ---------- + id : int + Identifier of the parent. + payload : ParentCreate + Updated parent data. + + Returns + ------- + Parent + The updated parent. + + Raises + ------ + HTTPException + 404 if the parent does not exist. + """ + try: + return _update_parent(id, payload) + except KeyError: + raise HTTPException(status_code=404, detail="Parent not found") + + +def delete_parent(id: int, response: Response): + """Delete an existing parent. + + Parameters + ---------- + id : int + Identifier of the parent. + + Raises + ------ + HTTPException + 404 if the parent does not exist. + """ + try: + _delete_parent(id) + except KeyError: + raise HTTPException(status_code=404, detail="Parent not found") + response.status_code = 204 + + +# --------------------------------------------------------------------------- +# Vets +# --------------------------------------------------------------------------- + + +def list_vets(limit: int = 20, offset: int = 0): + """List vets (paginated).""" + items = _list_vets() + return {"total": len(items), "items": items[offset:offset + limit]} + + +def create_vet(payload: VetCreate, response: Response): + """Create a vet.""" + vet = _create_vet(payload) + response.status_code = 201 + return vet + + +def get_vet(id: int): + """Retrieve a single vet by ID.""" + try: + return _get_vet(id) + except KeyError: + raise HTTPException(status_code=404, detail="Vet not found") + + +def update_vet(id: int, payload: VetCreate): + """Update an existing vet.""" + try: + return _update_vet(id, payload) + except KeyError: + raise HTTPException(status_code=404, detail="Vet not found") + + +def delete_vet(id: int, response: Response): + """Delete an existing vet.""" + try: + _delete_vet(id) + except KeyError: + raise HTTPException(status_code=404, detail="Vet not found") + response.status_code = 204 + + +# --------------------------------------------------------------------------- +# Treatments +# --------------------------------------------------------------------------- + + +def list_treatments(): + """List treatments (catalogue). + + Returns + ------- + list[Treatment] + A list of treatment domain objects. + """ + return _list_treatments() + + +def create_treatment(payload: TreatmentCreate, response: Response): + """Add a treatment (admin only).""" + treatment = _create_treatment(payload) + response.status_code = 201 + return treatment + + +def get_treatment(id: int): + """Retrieve a single treatment by ID.""" + try: + return _get_treatment(id) + except KeyError: + raise HTTPException(status_code=404, detail="Treatment not found") + + +def update_treatment(id: int, payload: TreatmentCreate): + """Update an existing treatment.""" + try: + return _update_treatment(id, payload) + except KeyError: + raise HTTPException(status_code=404, detail="Treatment not found") + + +def delete_treatment(id: int, response: Response): + """Delete an existing treatment.""" + try: + _delete_treatment(id) + except KeyError: + raise HTTPException(status_code=404, detail="Treatment not found") + response.status_code = 204 + + +# --------------------------------------------------------------------------- +# Pets +# --------------------------------------------------------------------------- + + +def list_pets(limit: int = 20, offset: int = 0): + """List pets (paginated).""" + items = _list_pets() + return {"total": len(items), "items": items[offset:offset + limit]} + + +def create_pet(payload: PetCreate, response: Response): + """Create a pet.""" + pet = _create_pet(payload) + response.status_code = 201 + return pet + + +def get_pet(id: int): + """Retrieve a single pet by ID.""" + try: + return _get_pet(id) + except KeyError: + raise HTTPException(status_code=404, detail="Pet not found") + + +def update_pet(id: int, payload: PetCreate): + """Update an existing pet.""" + try: + return _update_pet(id, payload) + except KeyError: + raise HTTPException(status_code=404, detail="Pet not found") + + +def delete_pet(id: int, response: Response): + """Delete an existing pet.""" + try: + _delete_pet(id) + except KeyError: + raise HTTPException(status_code=404, detail="Pet not found") + response.status_code = 204 + + +def upload_pet_photo(id: int, file: UploadFile): + """Upload a pet photo. + + Parameters + ---------- + id : int + Identifier of the pet. + file : UploadFile + Image file to upload. + + Returns + ------- + dict + A confirmation with the pet ID. + """ + _ = file # In a real app, save to disk / object store + return {"id": id, "status": "photo_uploaded"} + + +# --------------------------------------------------------------------------- +# Appointments +# --------------------------------------------------------------------------- + + +def list_appointments(limit: int = 20, offset: int = 0, date: str = None, vet: int = None, pet: int = None): + """List appointments (paginated, filterable).""" + items = _list_appointments() + + # Basic in-memory filtering + if date: + items = [a for a in items if a.date.startswith(date)] + if vet is not None: + items = [a for a in items if a.vet_id == vet] + if pet is not None: + items = [a for a in items if a.pet_id == pet] + + return {"total": len(items), "items": items[offset:offset + limit]} + + +def create_appointment(payload: AppointmentCreate, response: Response): + """Create an appointment.""" + appointment = _create_appointment(payload) + response.status_code = 201 + return appointment + + +def get_appointment(id: int): + """Retrieve a single appointment by ID.""" + try: + return _get_appointment(id) + except KeyError: + raise HTTPException(status_code=404, detail="Appointment not found") + + +def update_appointment(id: int, payload: AppointmentCreate): + """Update an existing appointment.""" + try: + return _update_appointment(id, payload) + except KeyError: + raise HTTPException(status_code=404, detail="Appointment not found") + + +def delete_appointment(id: int, response: Response): + """Delete an existing appointment.""" + try: + _delete_appointment(id) + except KeyError: + raise HTTPException(status_code=404, detail="Appointment not found") + response.status_code = 204 diff --git a/openapi_first/templates/vet_app/test_vet_app.py b/openapi_first/templates/vet_app/test_vet_app.py new file mode 100644 index 0000000..e7392ea --- /dev/null +++ b/openapi_first/templates/vet_app/test_vet_app.py @@ -0,0 +1,151 @@ +""" +End-to-end tests for the OpenAPI-first Veterinary Clinic example app. + +These tests validate that all CRUD operations behave correctly +against the in-memory mock data store using Pydantic models. +""" + +from fastapi.testclient import TestClient + +from main import app +from openapi_first.loader import load_openapi +from openapi_first.client import OpenAPIClient + + +test_client = TestClient(app) +spec = load_openapi("openapi.yaml") +client = OpenAPIClient( + spec=spec, + base_url="http://testserver", + client=test_client, +) + + +def test_list_parents(): + """List parents returns paginated response.""" + response = client.list_parents(query_params={"limit": 10, "offset": 0}) + assert response.status_code == 200 + data = response.json() + assert "total" in data + assert "items" in data + + +def test_create_parent(): + """Creating a parent returns 201 with the created entity.""" + payload = {"name": "Alice", "email": "alice@example.com"} + response = client.create_parent(body=payload) + assert response.status_code == 201 + parent = response.json() + assert parent["name"] == "Alice" + assert "id" in parent + + +def test_get_parent(): + """Get parent by ID returns the entity.""" + parent = client.create_parent(body={"name": "Bob", "email": "bob@example.com"}).json() + response = client.get_parent(path_params={"id": parent["id"]}) + assert response.status_code == 200 + assert response.json()["name"] == "Bob" + + +def test_update_parent(): + """Update parent replaces its values.""" + parent = client.create_parent(body={"name": "Carol", "email": "carol@example.com"}).json() + payload = {"name": "Carol Smith", "email": "carol.smith@example.com"} + response = client.update_parent(path_params={"id": parent["id"]}, body=payload) + assert response.status_code == 200 + assert response.json()["name"] == "Carol Smith" + + +def test_delete_parent(): + """Delete parent returns 204 and removes the entity.""" + parent = client.create_parent(body={"name": "Dave", "email": "dave@example.com"}).json() + response = client.delete_parent(path_params={"id": parent["id"]}) + assert response.status_code == 204 + + +def test_list_vets(): + """List vets returns paginated response.""" + response = client.list_vets(query_params={"limit": 10, "offset": 0}) + assert response.status_code == 200 + data = response.json() + assert "total" in data + assert "items" in data + + +def test_create_vet(): + """Creating a vet returns 201.""" + payload = {"name": "Dr. Smith", "specialty": "Surgery", "email": "smith@clinic.com"} + response = client.create_vet(body=payload) + assert response.status_code == 201 + assert response.json()["name"] == "Dr. Smith" + + +def test_list_treatments(): + """List treatments returns an array.""" + response = client.list_treatments() + assert response.status_code == 200 + assert isinstance(response.json(), list) + + +def test_create_treatment(): + """Creating a treatment returns 201.""" + payload = {"label": "Vaccination", "description": "Annual vaccination"} + response = client.create_treatment(body=payload) + assert response.status_code == 201 + assert response.json()["label"] == "Vaccination" + + +def test_create_pet(): + """Creating a pet links FK references.""" + parent = client.create_parent(body={"name": "Owner", "email": "owner@example.com"}).json() + payload = {"name": "Fido", "species": "dog", "parent_ids": [parent["id"]]} + response = client.create_pet(body=payload) + assert response.status_code == 201 + assert response.json()["name"] == "Fido" + + +def test_upload_pet_photo(): + """Upload pet photo returns 200.""" + pet = client.create_pet(body={"name": "PhotoPet", "species": "cat", "parent_ids": []}).json() + response = client.upload_pet_photo( + path_params={"id": pet["id"]}, + body={}, + ) + assert response.status_code == 200 + + +def test_list_appointments(): + """List appointments returns paginated response with filter params.""" + response = client.list_appointments(query_params={"limit": 10, "offset": 0}) + assert response.status_code == 200 + data = response.json() + assert "total" in data + assert "items" in data + + +def test_full_appointment_lifecycle(): + """Create a parent, vet, treatment, pet, then an appointment.""" + parent = client.create_parent(body={"name": "Eve", "email": "eve@example.com"}).json() + vet = client.create_vet(body={"name": "Dr. Jones", "specialty": "Dentistry", "email": "jones@clinic.com"}).json() + treatment = client.create_treatment(body={"label": "Cleaning", "description": "Teeth cleaning"}).json() + pet = client.create_pet(body={"name": "Max", "species": "dog", "parent_ids": [parent["id"]]}).json() + + payload = { + "date": "2025-06-01T10:00:00", + "pet_id": pet["id"], + "vet_id": vet["id"], + "treatment_id": treatment["id"], + } + response = client.create_appointment(body=payload) + assert response.status_code == 201 + appointment = response.json() + assert appointment["pet_id"] == pet["id"] + + # Fetch it back + get_resp = client.get_appointment(path_params={"id": appointment["id"]}) + assert get_resp.status_code == 200 + + # Delete it + del_resp = client.delete_appointment(path_params={"id": appointment["id"]}) + assert del_resp.status_code == 204