Build a Mock Simpro REST API Service
Build a FastAPI mock Simpro API with OAuth2, PostgreSQL, and Docker.
Introduction
30 Second Summary
When your team is building against a third-party API, waiting for live credentials can stall development for days. Every feature that depends on the external service sits blocked until credentials arrive.
In this project, you will build a Docker-containerised mock Simpro API service that your Sprint 1 simpro_client can talk to without any live Simpro access. You will spin up the service with docker compose up, obtain an OAuth2 token from its mock endpoint, and query companies, customers, jobs, and quotes through Simpro-pattern URLs.
What You'll Build
You will run docker compose up and watch your simpro_client authenticate against the mock, then query four resource endpoint groups and receive paginated, PascalCase JSON responses identical to what Simpro's live API returns.
By the end of this project, you'll have:
- Point your simpro_client at the mock's OAuth2 token endpoint, authenticate, and receive a Bearer token without needing live Simpro credentials.
- Query four resource endpoint groups (companies, customers, jobs, quotes) through Simpro's exact URL patterns and receive paginated, PascalCase JSON responses your client's existing code handles unchanged.
- Spin up a PostgreSQL database with Alembic-managed migrations and seed data that gives every endpoint realistic records to return from the very first request.
- Secret Mission: an optional challenge to push your skills further.
Are there any prerequisites?
This project picks up from CLIVE Phase 3 Sprint 1, so you need the existing simpro_client package in your CLIVE repo. You also need Docker, Docker Compose, and Python 3.12 installed, along with working familiarity with FastAPI and SQLAlchemy.
Before We Start
Before writing any code, let's lock in what you're about to build and why it matters for CLIVE's development velocity.
Scaffold the Mock Service
The simpro_client you built in Sprint 1 can authenticate, paginate, and fetch resources. But it has nowhere to send those requests. Without a running service at the other end, every integration path is untestable.
In this step, you'll create the full project skeleton for a new FastAPI service called simpro_mock. You'll set up its pinned dependencies, configuration module, database connection layer, and Docker files so the service builds cleanly as part of the CLIVE stack.
In this step, get ready to:
- Create the services/simpro_mock/ directory with its package structure, dependencies, and configuration modules.
- Write the Dockerfile and Docker Compose file to containerise the service with a PostgreSQL database.
- Build the Docker image to confirm all dependencies install correctly.
Create the project directory and core modules
Every service in the CLIVE stack lives under services/. The mock needs its own directory with a Python package inside it, plus placeholder directories that the Dockerfile will copy later.
- From the root of your CLIVE repository, create the project directories and placeholder files by running these commands:
mkdir -p services/simpro_mock/simpro_mock
mkdir -p services/simpro_mock/alembic
touch services/simpro_mock/simpro_mock/__init__.py
touch services/simpro_mock/alembic.ini
- Confirm the structure exists by running:
ls services/simpro_mock/
You should see alembic, alembic.ini, and simpro_mock listed.
Why the empty alembic files?
The Dockerfile you'll write shortly copies both alembic.ini and the alembic/ directory into the container image. These files are empty placeholders for now. You'll fill them with real migration content in Step 3.
- Create a file called pyproject.toml inside services/simpro_mock/ and paste in the following:
[project]
name = "simpro-mock"
version = "0.1.0"
description = "Mock Simpro REST API service for CLIVE development"
requires-python = ">=3.12"
dependencies = [
"fastapi==0.141.1",
"uvicorn==0.52.4",
"sqlalchemy==2.0.52",
"psycopg2-binary==2.9.12",
"alembic==1.19.1",
"pydantic-settings==2.15.0",
"python-multipart==0.0.27",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0.0",
"httpx>=0.27.0",
]
What are these dependencies for?
- fastapi and uvicorn provide the web framework and ASGI server.
- sqlalchemy and psycopg2-binary handle ORM modelling and the PostgreSQL driver.
- alembic manages database schema migrations.
- pydantic-settings loads configuration from environment variables.
- python-multipart is required by FastAPI's Form() parameter parsing, which you'll use for the OAuth2 token endpoint.
- Save pyproject.toml.
The settings module is the single place every other module reads its configuration from. It pulls values from environment variables with a consistent prefix.
- Create config.py inside services/simpro_mock/simpro_mock/ and paste in the following:
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
database_url: str = (
"postgresql://clive:clive@simpro-mock-db:5432/simpro_mock"
)
mock_client_id: str = "mock-client-id"
mock_client_secret: str = "mock-client-secret"
mock_access_token: str = "mock-access-token-simpro"
token_expires_in: int = 3600
model_config = {"env_prefix": "SIMPRO_MOCK_"}
settings = Settings()
What does this code do?
Settings inherits from BaseSettings, which means every field can be overridden by an environment variable. The env_prefix of SIMPRO_MOCK_ means the environment variable SIMPRO_MOCK_DATABASE_URL maps to the database_url field.
The defaults point at the Docker Compose service names you'll create shortly. The mock_access_token is the static token the mock will issue and validate. It's hardcoded because this is a local dev tool, not a production service.
- Save config.py.
- Verify both files exist by running:
ls services/simpro_mock/simpro_mock/
You should see __init__.py and config.py listed.
The database module sets up the SQLAlchemy engine, session factory, and a base class that all ORM models will inherit from.
- Create database.py inside services/simpro_mock/simpro_mock/ and paste in the following:
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, sessionmaker
from simpro_mock.config import settings
engine = create_engine(settings.database_url, echo=False)
SessionLocal = sessionmaker(bind=engine)
class Base(DeclarativeBase):
pass
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
What does this code do?
- create_engine opens a connection pool to the PostgreSQL database using the URL from settings.
- SessionLocal is a session factory. Each call to it creates a new database session for a single request.
- Base is the declarative base class that all ORM models inherit from. SQLAlchemy uses it to track table definitions.
- get_db() is a FastAPI dependency that yields a session and closes it when the request finishes.
- Save database.py.
Now the service needs a Dockerfile so Docker can build it into a container image.
- Create a file called Dockerfile inside services/simpro_mock/ (at the same level as pyproject.toml) and paste in the following:
FROM python:3.12-slim
WORKDIR /app
COPY pyproject.toml .
RUN pip install --no-cache-dir .
COPY alembic.ini .
COPY alembic/ alembic/
COPY simpro_mock/ simpro_mock/
EXPOSE 8000
CMD ["sh", "-c", "alembic upgrade head && python -m simpro_mock.seed && uvicorn simpro_mock.main:app --host 0.0.0.0 --port 8000"]
What does this Dockerfile do?
- It copies pyproject.toml first and runs pip install before copying the rest of the source. This way Docker caches the dependency layer and only re-installs when dependencies change.
- The CMD runs three things in sequence at container startup: Alembic migrations, seed data, then the uvicorn server. The migrations and seed script don't exist yet, so the container won't start until Step 3. The image will still build successfully.
- Save Dockerfile.
- Verify all the files are in place by running:
ls services/simpro_mock/
You should see Dockerfile, alembic, alembic.ini, pyproject.toml, and simpro_mock.
Missing a file?
Make sure you created Dockerfile (no extension) at services/simpro_mock/Dockerfile, not inside the inner simpro_mock/ package directory. The pyproject.toml and Dockerfile sit alongside the simpro_mock/ directory, not inside it.
help me debug my simpro_mock project structure
- Create a configuration.md file inside services/simpro_mock/ documenting all five SIMPRO_MOCK_* environment variables and their default values. Use the field names and defaults from config.py as your reference.
Write the Docker Compose file
The mock service needs two containers: the FastAPI app itself and a PostgreSQL database for it to query. Docker Compose defines both in a single file so you can start the entire stack with one command.
- Create a file called docker-compose.simpro-mock.yml in the root of your CLIVE repository (not inside services/) and paste in the following:
services:
simpro-mock-db:
image: postgres:16-alpine
environment:
POSTGRES_USER: clive
POSTGRES_PASSWORD: clive
POSTGRES_DB: simpro_mock
volumes:
- simpro-mock-db-data:/var/lib/postgresql/data
ports:
- "5433:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U clive -d simpro_mock"]
interval: 5s
timeout: 3s
retries: 5
simpro-mock:
build:
context: ./services/simpro_mock
dockerfile: Dockerfile
ports:
- "8100:8000"
environment:
SIMPRO_MOCK_DATABASE_URL: postgresql://clive:clive@simpro-mock-db:5432/simpro_mock
depends_on:
simpro-mock-db:
condition: service_healthy
volumes:
simpro-mock-db-data:
What does this compose file do?
- simpro-mock-db runs postgres:16-alpine with a healthcheck. The database is exposed on host port 5433 to avoid conflicting with any existing PostgreSQL instance on 5432.
- simpro-mock builds from the Dockerfile you just created. It maps host port 8100 to container port 8000 and waits for the database to pass its healthcheck before starting.
- The SIMPRO_MOCK_DATABASE_URL environment variable overrides the default in config.py, pointing the app at the compose-internal database hostname simpro-mock-db.
- Save docker-compose.simpro-mock.yml.
✔️ Awesome, I've got everything!
Great. Double check you've saved the file in the repository root, not inside services/.
ⓧ I'd like to double check the full code
services:
simpro-mock-db:
image: postgres:16-alpine
environment:
POSTGRES_USER: clive
POSTGRES_PASSWORD: clive
POSTGRES_DB: simpro_mock
volumes:
- simpro-mock-db-data:/var/lib/postgresql/data
ports:
- "5433:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U clive -d simpro_mock"]
interval: 5s
timeout: 3s
retries: 5
simpro-mock:
build:
context: ./services/simpro_mock
dockerfile: Dockerfile
ports:
- "8100:8000"
environment:
SIMPRO_MOCK_DATABASE_URL: postgresql://clive:clive@simpro-mock-db:5432/simpro_mock
depends_on:
simpro-mock-db:
condition: service_healthy
volumes:
simpro-mock-db-data:
Create the FastAPI app and build the image
The Dockerfile's CMD starts uvicorn simpro_mock.main:app, so the package needs a main.py with an app object. For now it's an empty FastAPI instance with no routers.
- Create main.py inside services/simpro_mock/simpro_mock/ and paste in the following:
from fastapi import FastAPI
app = FastAPI(title="Simpro Mock API", version="0.1.0")
- Save main.py.
Every file the Dockerfile needs is now in place. Before you run this next command, what do you think will happen? Will the build succeed or fail, and why?
- Build the Docker image by running:
docker compose -f docker-compose.simpro-mock.yml build simpro-mock
The build pulls the python:3.12-slim base image, installs all seven dependencies from pyproject.toml, and copies your source files. You should see it complete with no errors. The final output line shows the image was built successfully.
Build failing?
- If you see a COPY failed error mentioning alembic.ini or alembic/, make sure you ran the touch and mkdir commands from the first chunk. Those placeholder files must exist in the build context.
- If pip install fails to resolve a dependency, double check that your version pins in pyproject.toml match exactly. A single typo in a version number causes resolution failures.
- If Docker itself is not running, you'll see a connection error. Make sure Docker Desktop is running before retrying.
help me debug my simpro-mock Docker build
The skeleton is in place and the image builds cleanly. Next up, you'll add the first real endpoints: a mock OAuth2 token endpoint and a health check that prove the service is alive and responding.
Mock OAuth2 Token Endpoint and Health Check
Your mock service skeleton is built and the Docker image compiles cleanly.
The simpro_client from Sprint 1 always starts by POSTing to a token URL for an OAuth2 Bearer token. Without a working token endpoint at that URL, the client cannot authenticate and every subsequent API call fails before it even reaches a resource route. In this step, you'll stand up two foundational endpoints: a health check and a mock OAuth2 token issuer.
In this step, get ready to:
- Create Pydantic response schemas for the token and health responses.
- Build the health check and OAuth2 token routers.
- Start the Docker stack and verify both endpoints respond.
Create the Pydantic response schemas
FastAPI uses Pydantic models to validate request data and serialise responses into JSON. Defining these models first gives the routers a clear contract for what each endpoint returns.
- Create a new file called schemas.py inside services/simpro_mock/simpro_mock/ and add this code:
from pydantic import BaseModel
# Response shape for the mock OAuth2 token endpoint
class TokenResponse(BaseModel):
access_token: str
token_type: str = "Bearer"
expires_in: int
# Response shape for the health check endpoint
class HealthResponse(BaseModel):
status: str
service: str
version: str
What does this code do?
TokenResponse defines the JSON shape that the OAuth2 token endpoint returns. The token_type field defaults to "Bearer" because that is what the Simpro API uses for its OAuth2 flow.
HealthResponse defines the shape for a simple health check. It reports the service name and version so you can confirm which container is responding.
- Save schemas.py.
- Confirm the file exists by running:
ls services/simpro_mock/simpro_mock/schemas.py
You should see the file path printed back in the output.
Build the health and token routers
FastAPI organises endpoints into routers. You need two: one for the health check that confirms the service is alive, and one for the OAuth2 token endpoint that the simpro_client posts to before making any API calls.
- Create a new file called routers.py inside services/simpro_mock/simpro_mock/ and add the imports, router declarations, and health check endpoint:
from fastapi import APIRouter, Form
from simpro_mock.config import settings
from simpro_mock.schemas import HealthResponse, TokenResponse
# Create separate routers for health and token endpoints
health_router = APIRouter()
token_router = APIRouter()
@health_router.get("/health", response_model=HealthResponse)
def health_check():
return HealthResponse(
status="ok", service="simpro-mock", version="0.1.0"
)
What does this code do?
- health_router and token_router are separate APIRouter instances. FastAPI lets you group related endpoints into routers and register them on the main app.
- GET /health returns a fixed JSON response confirming the service name and version. This is the first thing you'll test after starting the container.
You'll test this endpoint after adding the token router below.
- Add the OAuth2 token endpoint below the health_check function in routers.py:
# Accept form-encoded OAuth2 client credentials and return a static mock token
@token_router.post("/oauth2/token", response_model=TokenResponse)
def issue_token(
grant_type: str = Form("client_credentials"),
client_id: str = Form(""),
client_secret: str = Form(""),
):
return TokenResponse(
access_token=settings.mock_access_token,
token_type="Bearer",
expires_in=settings.token_expires_in,
)
Why Form() instead of JSON body?
The OAuth2 Client Credentials specification requires the token request to use form-encoded parameters, not JSON. FastAPI's Form() function reads values from the request body as application/x-www-form-urlencoded data. This is why the project depends on the python-multipart package.
The endpoint ignores the actual credential values and always returns the static token from settings.mock_access_token. This means the simpro_client's auth module works without needing real Simpro credentials.
- Save routers.py.
✔️ Awesome, I've got everything!
Great. Double check you've saved routers.py before moving on.
ⓧ I'd like to double check the full code
Here is the complete routers.py with both endpoints together.
from fastapi import APIRouter, Form
from simpro_mock.config import settings
from simpro_mock.schemas import HealthResponse, TokenResponse
health_router = APIRouter()
token_router = APIRouter()
@health_router.get("/health", response_model=HealthResponse)
def health_check():
return HealthResponse(
status="ok", service="simpro-mock", version="0.1.0"
)
@token_router.post("/oauth2/token", response_model=TokenResponse)
def issue_token(
grant_type: str = Form("client_credentials"),
client_id: str = Form(""),
client_secret: str = Form(""),
):
return TokenResponse(
access_token=settings.mock_access_token,
token_type="Bearer",
expires_in=settings.token_expires_in,
)
The routers exist but are not connected to the FastAPI app yet. You need to register them in main.py.
- Open services/simpro_mock/simpro_mock/main.py.
- Replace the entire contents with this updated version:
from fastapi import FastAPI
from simpro_mock.routers import health_router, token_router
app = FastAPI(title="Simpro Mock API", version="0.1.0")
# Register the health and token routers on the app
app.include_router(health_router)
app.include_router(token_router)
What does this change?
app.include_router() mounts each router's endpoints onto the FastAPI application. Without this step, the /health and /oauth2/token paths would not be reachable.
- Save main.py.
Start the stack and verify both endpoints
All three files are in place. Time to rebuild the Docker image with your new code and bring the full stack up.
- Build and start the Docker stack by running:
docker compose -f docker-compose.simpro-mock.yml up -d --build
What does this command do?
- --build rebuilds the simpro-mock image so it includes your new schemas.py, routers.py, and updated main.py.
- -d runs the containers in detached mode so your terminal stays free for curl commands.
- The simpro-mock container waits for simpro-mock-db to pass its pg_isready health check before starting.
- Wait about 10 seconds for the database to pass its health check and for uvicorn to start.
- Confirm both containers are running by running:
docker compose -f docker-compose.simpro-mock.yml ps
You should see both simpro-mock and simpro-mock-db listed with a status of Up.
Container exiting or restarting?
- Check the logs with docker compose -f docker-compose.simpro-mock.yml logs simpro-mock to see the error message.
- If you see an import error mentioning routers or schemas, make sure both files are inside the simpro_mock/ package directory (next to __init__.py), not in the outer services/simpro_mock/ directory.
- If the database container shows unhealthy, give it another 15 seconds. The pg_isready health check retries up to 5 times.
Still stuck? Help me debug why my simpro-mock container keeps restarting. Here are the logs: [paste logs]
Before running the next command, what JSON fields do you expect the health endpoint to return?
- Test the health endpoint by running:
curl http://localhost:8100/health
You should see:
{"status":"ok","service":"simpro-mock","version":"0.1.0"}
Now for the main event. The simpro_client sends a form-encoded POST to the token URL. Do you think a bare GET request to /oauth2/token would work, or does it need to be a POST?
- Test the token endpoint by running:
curl -X POST http://localhost:8100/oauth2/token -d "grant_type=client_credentials&client_id=test&client_secret=test"
You should see:
{"access_token":"mock-access-token-simpro","token_type":"Bearer","expires_in":3600}
Why does the token value matter?
The access token value mock-access-token-simpro comes from the mock_access_token field in config.py. In a later step, the auth middleware will check incoming API requests for this exact token. Any request without it gets rejected with a 401.
Seeing 'Connection refused' from curl?
- Make sure the containers are running with docker compose -f docker-compose.simpro-mock.yml ps.
- Verify you are using port 8100 (the host-mapped port), not 8000 (the container-internal port).
- For the token endpoint, make sure you are using -X POST and -d to send the form data. A GET request to that path returns a 405 Method Not Allowed.
Need more help? Help me debug my curl request to the mock Simpro token endpoint
Both endpoints are live. The last task for this step is to document the architectural decision behind building a mock service.
- Create a file called adr-mock-simpro-api.md in the root of your CLIVE repository.
- Document three things: why you chose to build a mock instead of waiting for live Simpro API access, what the mock covers (endpoints, auth, pagination), and what it does not cover (rate limiting, webhook callbacks, real credential validation).
- Save the file.
What is an ADR?
An Architecture Decision Record captures the context, decision, and consequences of a significant technical choice. Writing one now means anyone joining the project later understands why the mock exists and what its boundaries are.
Your mock service can now issue tokens and report its health. Next up, you'll give it a real database with SQLAlchemy models, Alembic migrations, and seed data so the API endpoints have something to query.
Build the API Endpoints
In the last step, you built a real database layer with Alembic migrations and seed data. Your mock now has tables full of companies, customers, jobs, and quotes ready to query.
The simpro_client from Sprint 1 constructs request URLs matching Simpro's /api/v1.0/companies/{companyID}/... pattern. This step adds database-backed GET endpoints at those exact paths so the client's requests hit working routes.
In this step, get ready to:
- Add Pydantic response schemas for all four resources.
- Wire up eight GET endpoints mirroring Simpro's URL structure.
- Rebuild and test the endpoints with curl.
Add response schemas for each resource
Each endpoint needs a Pydantic response model that defines the shape of the JSON it returns. The mock has four resources to expose: companies, customers, jobs, and quotes.
- In schemas.py, add these four response models below the existing HealthResponse class:
class CompanyResponse(BaseModel):
id: int
name: str
class CustomerResponse(BaseModel):
id: int
company_id: int
given_name: str
family_name: str
email: str | None = None
phone: str | None = None
class JobResponse(BaseModel):
id: int
company_id: int
name: str
status: str
date_issued: str | None = None
total: float
class QuoteResponse(BaseModel):
id: int
company_id: int
customer_id: int | None = None
name: str
status: str
total: float
What do these models define?
Each model mirrors the columns on its matching database table. Fields like email and customer_id are marked None because they are nullable in the schema.
Notice the field names use Python-convention snake_case (given_name, company_id). This is the natural choice for a Python project. You will revisit this decision later when the simpro_client connects.
- Save schemas.py.
Wire up the API routes
With the response shapes defined, you can build the endpoints. Each resource gets a list endpoint (returns all records with pagination) and a detail endpoint (returns one record by ID or 404). All routes sit under the /api/v1.0 prefix to match Simpro's URL pattern.
- In routers.py, find the imports at the top of the file:
from fastapi import APIRouter, Form
from simpro_mock.config import settings
from simpro_mock.schemas import HealthResponse, TokenResponse
- Replace those imports with the following expanded block:
from fastapi import APIRouter, Depends, Form, HTTPException, Query, Response
from sqlalchemy.orm import Session
from simpro_mock.config import settings
from simpro_mock.database import get_db
from simpro_mock.models import Company, Customer, Job, Quote
from simpro_mock.schemas import (
CompanyResponse, CustomerResponse, HealthResponse,
JobResponse, QuoteResponse, TokenResponse,
)
What are the new imports for?
- Depends and get_db inject a database session into each route function.
- HTTPException lets detail endpoints return a 404 when a record is not found.
- Query adds validation constraints to the page and pageSize pagination parameters.
- Still in routers.py, add the api_router and companies endpoints below the issue_token function:
api_router = APIRouter(prefix="/api/v1.0")
@api_router.get("/companies/", response_model=list[CompanyResponse])
def list_companies(
page: int = Query(1, ge=1),
pageSize: int = Query(30, ge=1, le=250),
db: Session = Depends(get_db),
):
# Query companies with offset/limit pagination
items = db.query(Company).offset((page - 1) * pageSize).limit(pageSize).all()
return [CompanyResponse(id=c.id, name=c.name) for c in items]
@api_router.get("/companies/{company_id}", response_model=CompanyResponse)
def get_company(company_id: int, db: Session = Depends(get_db)):
company = db.query(Company).filter(Company.id == company_id).first()
if not company:
raise HTTPException(status_code=404, detail="Company not found")
return CompanyResponse(id=company.id, name=company.name)
How does pagination work here?
List endpoints accept page (default 1) and pageSize (default 30, max 250) as query parameters. These match Simpro's documented pagination contract.
The detail endpoint returns a 404 with a JSON body when the requested ID does not exist. FastAPI converts HTTPException into the correct HTTP response automatically.
- Save routers.py.
The new api_router exists in routers.py but main.py does not import or register it yet.
- In main.py, find this import line:
from simpro_mock.routers import health_router, token_router
- Add api_router to the import, so the line reads:
from simpro_mock.routers import api_router, health_router, token_router
- Below the existing app.include_router(token_router) line, add:
app.include_router(api_router)
- Save main.py.
- Rebuild and start the stack by running:
docker compose -f docker-compose.simpro-mock.yml up -d --build
- Test the companies list endpoint by running:
curl http://localhost:8100/api/v1.0/companies/
You should see a JSON array containing the CVC Test Company record.
Seeing an empty array or connection error?
- Run docker compose -f docker-compose.simpro-mock.yml logs simpro-mock and check for import errors. A typo in the import block is the most common cause.
- Confirm main.py includes app.include_router(api_router). Without this line, the endpoints exist in code but are not mounted on the app.
- If the database was cleared, run the seed again: docker compose -f docker-compose.simpro-mock.yml exec simpro-mock python -m simpro_mock.seed.
Still stuck? Help me debug why my companies endpoint returns an empty array or errors.
Now add the remaining three resource groups. Each follows the same pattern: a list endpoint filtered by company_id and a detail endpoint that returns 404 for missing IDs.
- In routers.py, add the customers endpoints below the get_company function:
@api_router.get("/companies/{company_id}/customers/", response_model=list[CustomerResponse])
def list_customers(
company_id: int,
page: int = Query(1, ge=1),
pageSize: int = Query(30, ge=1, le=250),
db: Session = Depends(get_db),
):
# Filter customers by the company they belong to
items = db.query(Customer).filter(Customer.company_id == company_id).offset((page - 1) * pageSize).limit(pageSize).all()
return [CustomerResponse(id=c.id, company_id=c.company_id, given_name=c.given_name, family_name=c.family_name, email=c.email, phone=c.phone) for c in items]
@api_router.get("/companies/{company_id}/customers/{customer_id}", response_model=CustomerResponse)
def get_customer(company_id: int, customer_id: int, db: Session = Depends(get_db)):
customer = db.query(Customer).filter(Customer.id == customer_id, Customer.company_id == company_id).first()
if not customer:
raise HTTPException(status_code=404, detail="Customer not found")
return CustomerResponse(id=customer.id, company_id=customer.company_id, given_name=customer.given_name, family_name=customer.family_name, email=customer.email, phone=customer.phone)
Why filter by company_id?
Simpro scopes every resource under a company. The URL pattern /companies/{company_id}/customers/ means 'customers belonging to this company.' The .filter(Customer.company_id == company_id) clause ensures the query respects that scoping.
- Add the jobs endpoints below the customers section:
@api_router.get("/companies/{company_id}/jobs/", response_model=list[JobResponse])
def list_jobs(
company_id: int,
page: int = Query(1, ge=1),
pageSize: int = Query(30, ge=1, le=250),
db: Session = Depends(get_db),
):
items = db.query(Job).filter(Job.company_id == company_id).offset((page - 1) * pageSize).limit(pageSize).all()
# Convert date_issued to ISO format string for JSON serialisation
return [JobResponse(id=j.id, company_id=j.company_id, name=j.name, status=j.status, date_issued=j.date_issued.isoformat() if j.date_issued else None, total=j.total) for j in items]
@api_router.get("/companies/{company_id}/jobs/{job_id}", response_model=JobResponse)
def get_job(company_id: int, job_id: int, db: Session = Depends(get_db)):
job = db.query(Job).filter(Job.id == job_id, Job.company_id == company_id).first()
if not job:
raise HTTPException(status_code=404, detail="Job not found")
return JobResponse(id=job.id, company_id=job.company_id, name=job.name, status=job.status, date_issued=job.date_issued.isoformat() if job.date_issued else None, total=job.total)
What does the date_issued conversion do?
The date_issued column is a Python date object. Calling .isoformat() converts it to a YYYY-MM-DD string, matching Simpro's date format.
- Add the quotes endpoints below the jobs section:
@api_router.get("/companies/{company_id}/quotes/", response_model=list[QuoteResponse])
def list_quotes(
company_id: int,
page: int = Query(1, ge=1),
pageSize: int = Query(30, ge=1, le=250),
db: Session = Depends(get_db),
):
items = db.query(Quote).filter(Quote.company_id == company_id).offset((page - 1) * pageSize).limit(pageSize).all()
return [QuoteResponse(id=q.id, company_id=q.company_id, customer_id=q.customer_id, name=q.name, status=q.status, total=q.total) for q in items]
@api_router.get("/companies/{company_id}/quotes/{quote_id}", response_model=QuoteResponse)
def get_quote(company_id: int, quote_id: int, db: Session = Depends(get_db)):
quote = db.query(Quote).filter(Quote.id == quote_id, Quote.company_id == company_id).first()
if not quote:
raise HTTPException(status_code=404, detail="Quote not found")
return QuoteResponse(id=quote.id, company_id=quote.company_id, customer_id=quote.customer_id, name=quote.name, status=quote.status, total=quote.total)
- Save routers.py.
- Rebuild and restart the stack by running:
docker compose -f docker-compose.simpro-mock.yml up -d --build
- Test the customers endpoint by running:
curl http://localhost:8100/api/v1.0/companies/0/customers/
You should see a JSON array with three customer objects (John Smith, Jane Doe, Bob Wilson).
- Test the jobs endpoint by running:
curl http://localhost:8100/api/v1.0/companies/0/jobs/
You should see three job records with names like Kitchen Renovation, Bathroom Refit, and Office Fit-Out.
Seeing an internal server error?
- Check the container logs with docker compose -f docker-compose.simpro-mock.yml logs simpro-mock. A traceback will point to the exact line causing the error.
- Verify that each response model field name matches the corresponding SQLAlchemy model attribute exactly. A typo like givenname instead of given_name will cause an AttributeError.
Help me debug my FastAPI endpoint error.
Verify the full endpoint suite
All eight endpoints should now be live. Before running the final checks, think about what you expect to see.
When you request a company ID that does not exist, what HTTP status code do you think the mock will return?
- Test the 404 behaviour by requesting a non-existent company:
curl -s -w "\nHTTP Status: %{http_code}\n" http://localhost:8100/api/v1.0/companies/999
You should see {"detail":"Company not found"} with HTTP Status 404.
- Verify the quotes endpoint by running:
curl http://localhost:8100/api/v1.0/companies/0/quotes/
You should see three quote records. Look closely at the field names in the response. You will notice they use snake_case: id, company_id, given_name. The simpro_client expects PascalCase (ID, CompanyID, GivenName). You will fix that mismatch in the next step.
✔️ Awesome, I've got everything!
Make sure you have saved both schemas.py, routers.py, and main.py.
ⓧ I'd like to double check the full code
The full files are large at this point. Scroll up and confirm each section is present in order.
schemas.py should contain, in order:
- The import and existing TokenResponse and HealthResponse classes.
- CompanyResponse with id and name fields.
- CustomerResponse with 6 fields including given_name and family_name.
- JobResponse with 6 fields including date_issued and total.
- QuoteResponse with 6 fields including customer_id.
routers.py should contain, in order:
- The expanded imports (Depends, HTTPException, Query, Response, models, schemas).
- health_router, token_router, and their existing functions.
- api_router = APIRouter(prefix="/api/v1.0").
- list_companies and get_company.
- list_customers and get_customer.
- list_jobs and get_job.
- list_quotes and get_quote.
main.py should contain:
from fastapi import FastAPI
from simpro_mock.routers import api_router, health_router, token_router
app = FastAPI(title="Simpro Mock API", version="0.1.0")
app.include_router(health_router)
app.include_router(token_router)
app.include_router(api_router)
All eight endpoints are serving database-backed JSON at Simpro-matching URLs. Next up, you will connect the simpro_client and discover that those snake_case field names need to change to match Simpro's PascalCase response format.
Achieve Client Integration and Response Fidelity
In Step 4, you built eight API endpoints that serve company, customer, job, and quote data straight from the database. The mock now responds to Simpro-pattern URLs and returns real, queryable results.
There is a fidelity problem hiding in plain sight. The real Simpro API returns PascalCase field names like ID and GivenName. Your mock currently returns Python-convention snake_case names like id and given_name. The responses also lack the pagination headers and auth checks that Sprint 1's simpro_client depends on.
In this step, get ready to:
- Fix response schemas to match Simpro's PascalCase field name format.
- Add Bearer token middleware and Simpro-compatible pagination response headers.
- Run an integration test proving the full auth-then-query loop works end to end.
Spot the field name gap and fix response schemas
The simpro_client from Sprint 1 deserialises responses against Simpro's documented API schema. Before you run the next curl, think about what field names the client expects in the JSON response.
- Query the customers endpoint to see what the mock currently returns:
curl -s http://localhost:8100/api/v1.0/companies/0/customers/ | python3 -m json.tool
You see fields like id, company_id, given_name, and family_name. The real Simpro API returns ID, CompanyID, GivenName, and FamilyName. The simpro_client maps against those PascalCase names, so every snake_case field silently resolves to None or triggers a validation error.
Why does field name fidelity matter in a mock?
A mock service's job is to behave exactly like the real API so your client code cannot tell the difference. If field names, casing, or response shapes diverge, bugs hide until you hit the live API in production.
- In schemas.py, find the four response model classes starting with CompanyResponse and replace all four with these PascalCase versions:
class CompanyResponse(BaseModel):
ID: int
Name: str
class CustomerResponse(BaseModel):
ID: int
CompanyID: int
GivenName: str
FamilyName: str
Email: str | None = None
Phone: str | None = None
class JobResponse(BaseModel):
ID: int
CompanyID: int
Name: str
Status: str
DateIssued: str | None = None
Total: float
class QuoteResponse(BaseModel):
ID: int
CompanyID: int
CustomerID: int | None = None
Name: str
Status: str
Total: float
What changed in these models?
Every field name switched from Python snake_case to Simpro's PascalCase convention. id became ID, company_id became CompanyID, given_name became GivenName, and so on. Pydantic serialises these field names directly into the JSON response, so the output now matches what Simpro's real API returns.
Now the schemas expect PascalCase, but every router function still passes snake_case keyword arguments. You need to update the constructor calls to match.
- In routers.py, find the get_company function's return line:
return CompanyResponse(id=company.id, name=company.name)
- Replace the keyword arguments with PascalCase field names so the line reads:
return CompanyResponse(ID=company.id, Name=company.name)
- Apply the same PascalCase keyword argument pattern to all eight endpoint functions in routers.py. Use the mapping in the callout below.
Full PascalCase keyword mapping for each model
- CompanyResponse: id= becomes ID=, name= becomes Name=.
- CustomerResponse: id= to ID=, company_id= to CompanyID=, given_name= to GivenName=, family_name= to FamilyName=, email= to Email=, phone= to Phone=.
- JobResponse: id= to ID=, company_id= to CompanyID=, name= to Name=, status= to Status=, date_issued= to DateIssued=, total= to Total=.
- QuoteResponse: id= to ID=, company_id= to CompanyID=, customer_id= to CustomerID=, name= to Name=, status= to Status=, total= to Total=.
- Save schemas.py and routers.py.
- Rebuild the Docker stack by running:
docker compose -f docker-compose.simpro-mock.yml up -d --build
- Verify the field names are now PascalCase by running:
curl -s http://localhost:8100/api/v1.0/companies/0/customers/ | python3 -m json.tool
You should now see ID, CompanyID, GivenName, and FamilyName in the JSON output instead of the old snake_case names.
Seeing a validation error or 500?
- Make sure you updated the constructor keyword arguments in every router function, not just the schema field names. Pydantic rejects keyword arguments that don't match the model's field names.
- Check that the casing matches exactly. CompanyId is not the same as CompanyID (capital D).
Why are my PascalCase response fields causing errors?
Add Bearer token middleware and pagination headers
The field names are fixed, but two more fidelity gaps remain. The mock accepts any request without authentication, and list responses have no pagination headers. Sprint 1's simpro_client sends a Bearer token on every request and reads Result-Total, Result-Count, and Result-Pages from the response headers to drive pagination.
- Create a new file simpro_mock/middleware.py and add the imports and auth middleware class:
import math
from fastapi import Response
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import JSONResponse
from simpro_mock.config import settings
class BearerAuthMiddleware(BaseHTTPMiddleware):
"""Validates Bearer tokens on API routes, exempting health and token endpoints."""
EXEMPT_PATHS = {
"/health",
"/oauth2/token",
"/docs",
"/openapi.json",
"/redoc",
}
- Add the dispatch method inside the class, directly below the EXEMPT_PATHS set:
async def dispatch(self, request: Request, call_next):
if request.url.path in self.EXEMPT_PATHS:
return await call_next(request)
auth_header = request.headers.get("Authorization", "")
if not auth_header.startswith("Bearer "):
return JSONResponse(
status_code=401,
content={"detail": "Missing Bearer token"},
)
token = auth_header.removeprefix("Bearer ")
if token != settings.mock_access_token:
return JSONResponse(
status_code=401,
content={"detail": "Invalid access token"},
)
return await call_next(request)
What does this middleware do?
- EXEMPT_PATHS lets /health, /oauth2/token, and the docs endpoints pass through without a token.
- For all other paths, it checks the Authorization header for a Bearer token and validates it against settings.mock_access_token.
- The middleware returns a JSONResponse for 401 errors directly, because Starlette's BaseHTTPMiddleware does not propagate HTTPException to FastAPI's exception handlers.
- Below the class (outside it, at the module level), add the two pagination helper functions:
def paginate_query(query, page: int, page_size: int):
"""Apply pagination to a SQLAlchemy query. Returns (items, total, total_pages)."""
total = query.count()
total_pages = math.ceil(total / page_size) if total > 0 else 1
offset = (page - 1) * page_size
items = query.offset(offset).limit(page_size).all()
return items, total, total_pages
def set_pagination_headers(
response: Response, total: int, count: int, total_pages: int
):
"""Set Simpro-compatible pagination response headers."""
response.headers["Result-Total"] = str(total)
response.headers["Result-Count"] = str(count)
response.headers["Result-Pages"] = str(total_pages)
What do these helpers do?
- paginate_query() replaces the manual .offset().limit() logic in your list endpoints. It counts total rows, calculates total pages, and returns the sliced items.
- set_pagination_headers() adds three response headers that Simpro's real API provides: Result-Total, Result-Count, and Result-Pages.
- Save middleware.py.
- Replace the full contents of main.py with the following to register the middleware:
from fastapi import FastAPI
from simpro_mock.middleware import BearerAuthMiddleware
from simpro_mock.routers import api_router, health_router, token_router
app = FastAPI(title="Simpro Mock API", version="0.1.0")
app.add_middleware(BearerAuthMiddleware)
app.include_router(health_router)
app.include_router(token_router)
app.include_router(api_router)
What changed in main.py?
Two lines were added. The import brings in BearerAuthMiddleware, and app.add_middleware(BearerAuthMiddleware) attaches it to every request. FastAPI now runs every incoming request through the middleware's dispatch method before it reaches any router.
- Save main.py and rebuild the stack:
docker compose -f docker-compose.simpro-mock.yml up -d --build
- Test that the middleware blocks unauthenticated requests by running:
curl -s http://localhost:8100/api/v1.0/companies/ | python3 -m json.tool
You should see {"detail": "Missing Bearer token"} with no company data. The middleware is doing its job.
- Confirm that an authenticated request still returns data by running:
curl -s -H "Authorization: Bearer mock-access-token-simpro" http://localhost:8100/api/v1.0/companies/ | python3 -m json.tool
You should see the company array with PascalCase fields. The token endpoint and health check still work without a token because they are in the exempt paths.
Getting 401 on every request including /health?
- Make sure EXEMPT_PATHS contains exactly /health and /oauth2/token (with leading slashes, no trailing slashes).
- Check that app.add_middleware(BearerAuthMiddleware) appears BEFORE the app.include_router lines in main.py.
Why is my BearerAuthMiddleware blocking all requests?
Now wire the pagination helpers into the list endpoints. The list functions currently use manual .offset().limit() logic. You will replace each list function with a version that uses paginate_query() and set_pagination_headers().
- In routers.py, find the import section and add this line after the get_db import:
from simpro_mock.middleware import paginate_query, set_pagination_headers
- Replace the entire list_companies function with this version:
@api_router.get("/companies/", response_model=list[CompanyResponse])
def list_companies(
response: Response,
page: int = Query(1, ge=1),
pageSize: int = Query(30, ge=1, le=250),
db: Session = Depends(get_db),
):
query = db.query(Company)
items, total, total_pages = paginate_query(query, page, pageSize)
results = [CompanyResponse(ID=c.id, Name=c.name) for c in items]
set_pagination_headers(response, total, len(results), total_pages)
return results
What changed in this function?
- response: Response was added to the function signature so the helpers can set headers on the response object.
- paginate_query() replaces the manual offset/limit chain and also returns total and total_pages.
- set_pagination_headers() adds the three Simpro-compatible response headers.
- Replace the list_customers function with this version (same pattern, more fields):
@api_router.get(
"/companies/{company_id}/customers/",
response_model=list[CustomerResponse],
)
def list_customers(
company_id: int,
response: Response,
page: int = Query(1, ge=1),
pageSize: int = Query(30, ge=1, le=250),
db: Session = Depends(get_db),
):
query = db.query(Customer).filter(Customer.company_id == company_id)
items, total, total_pages = paginate_query(query, page, pageSize)
results = [
CustomerResponse(
ID=c.id,
CompanyID=c.company_id,
GivenName=c.given_name,
FamilyName=c.family_name,
Email=c.email,
Phone=c.phone,
)
for c in items
]
set_pagination_headers(response, total, len(results), total_pages)
return results
- Apply the same pattern to list_jobs and list_quotes. Each function needs: response: Response in the signature, paginate_query() replacing the manual offset/limit, PascalCase constructors, and set_pagination_headers() before the return.
- Save routers.py and rebuild the stack:
docker compose -f docker-compose.simpro-mock.yml up -d --build
- Verify the pagination headers appear on a list response by running:
curl -sD - -H "Authorization: Bearer mock-access-token-simpro" http://localhost:8100/api/v1.0/companies/0/customers/
In the response headers, you should see result-total: 3, result-count: 3, and result-pages: 1. The JSON body shows PascalCase customer objects.
Not seeing pagination headers?
- Make sure you added response: Response to each list function's parameter list. Without it, set_pagination_headers() has no response object to write to.
- Check that the from simpro_mock.middleware import paginate_query, set_pagination_headers line is present at the top of routers.py.
Why aren't my Simpro pagination headers showing up?
Verify the integration loop and document the sprint
All three fidelity fixes are in place: PascalCase field names, Bearer token validation, and pagination headers. Time to prove the full auth-then-query loop works as a single automated check.
- Install httpx locally so the test script can make HTTP requests:
pip install httpx
- Create a file called test_simpro_mock.py in your project root and add this integration test:
"""Integration test: auth + query + response format."""
import httpx
BASE = "http://localhost:8100"
# Get a token from the mock OAuth2 endpoint
r = httpx.post(f"{BASE}/oauth2/token", data={
"grant_type": "client_credentials",
"client_id": "test",
"client_secret": "test",
})
assert r.status_code == 200, f"Token failed: {r.status_code}"
token = r.json()["access_token"]
print(f"Token: {token}")
# Query companies with Bearer auth
headers = {"Authorization": f"Bearer {token}"}
r = httpx.get(f"{BASE}/api/v1.0/companies/", headers=headers)
assert r.status_code == 200, f"Companies failed: {r.status_code}"
data = r.json()
assert "ID" in data[0], f"Expected PascalCase, got: {list(data[0].keys())}"
assert "Result-Total" in r.headers, "Missing Result-Total header"
print(f"Companies: {data}")
print(f"Headers: Total={r.headers['Result-Total']}, Count={r.headers['Result-Count']}, Pages={r.headers['Result-Pages']}")
# Verify 401 without auth
r = httpx.get(f"{BASE}/api/v1.0/companies/")
assert r.status_code == 401, f"Expected 401, got: {r.status_code}"
print(f"No-auth: {r.status_code} (expected 401)")
print("\nAll integration checks passed!")
What does this test cover?
- It POSTs to the mock token endpoint and obtains a Bearer token.
- It GETs /api/v1.0/companies/ with the token and asserts PascalCase keys and pagination headers.
- It sends an unauthenticated request and asserts HTTP 401.
Before running the test, predict whether all three assertions will pass. Did you wire up the token endpoint, PascalCase schemas, auth middleware, and pagination headers correctly?
- Run the integration test by running:
python test_simpro_mock.py
You should see each check print its result followed by "All integration checks passed!" at the end.
Test assertion failing?
- If the token request fails, make sure the simpro-mock container is running. Check with docker compose -f docker-compose.simpro-mock.yml ps.
- If the PascalCase assertion fails, the response keys still show snake_case. Double-check that you updated both schemas.py field names and the constructor keyword arguments in routers.py.
- If the 401 assertion fails (you get 200 without a token), the middleware is not registered. Check that main.py has the app.add_middleware(BearerAuthMiddleware) line.
My simpro-mock integration test is failing. Help me debug.
- Create phase3.md in your project root documenting Sprint 2 deliverables: the mock service, how to start it, what endpoints it provides, and the integration test results.
- Commit the sprint by running:
git add .
git commit -m "Sprint 2: simpro-mock service with OAuth2, PascalCase responses, pagination headers"
✔️ Awesome, I've got everything!
Great. Double-check you have saved all four changed files and that your integration test passes cleanly.
ⓧ I'd like to double check the full code
Scroll up and confirm each of these chunks is present and in order:
- schemas.py: TokenResponse and HealthResponse unchanged at the top, then four PascalCase response models (CompanyResponse, CustomerResponse, JobResponse, QuoteResponse) with fields like ID, CompanyID, GivenName.
- middleware.py: imports, then BearerAuthMiddleware class with EXEMPT_PATHS and dispatch method, then paginate_query() and set_pagination_headers() at the module level.
- main.py: imports BearerAuthMiddleware, calls app.add_middleware(BearerAuthMiddleware) before the router registrations.
- routers.py: imports paginate_query and set_pagination_headers from middleware. All four list functions have response: Response in their signature and use the pagination helpers. All eight endpoint functions use PascalCase constructor kwargs.
Secret mission
Add Search and Filtering
The mock service handles basic CRUD, but Simpro's real API supports field-level filtering with operators like gt(), lt(), between(), and in(). Can you build a filtering module that parses these operator expressions from query parameters, translates them to SQLAlchemy filters, and supports AND/OR search modes?
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally using Docker containers, so there are no ongoing cloud costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- Docker container: simpro-mock (FastAPI app).
- Docker container: simpro-mock-db (PostgreSQL database).
- Docker volume: simpro-mock-db-data (persistent database storage).
Keep everything running
No action needed. Choose this if you are still actively developing against the mock service.
- The simpro-mock and simpro-mock-db containers stay running and ready for requests.
- Your seed data persists across container restarts via the simpro-mock-db-data Docker volume.
- There are no ongoing costs since everything runs locally.
Pause - I'll come back to this later
Stop the running containers to free up system memory and CPU without losing your database data.
- Stop both containers by running:
docker compose -f docker-compose.simpro-mock.yml stop
- Your database volume is preserved. When you are ready to resume, start the stack again by running:
docker compose -f docker-compose.simpro-mock.yml up -d
Delete - I don't want to use this again
Remove all Docker containers and the database volume to start completely fresh.
- Stop and remove the containers, network, and the simpro-mock-db-data volume by running:
docker compose -f docker-compose.simpro-mock.yml down -v
- Confirm the containers are gone by running:
docker ps -a --filter name=simpro-mock
- You should see no containers listed in the output.
- Your source files under services/simpro_mock/ and docker-compose.simpro-mock.yml remain in your CLIVE repository. You can remove them with a git commit if you no longer need them.
Nice Work!
Nice Work!
Nice work! You have built a fully functional mock Simpro API service that lets any developer on the CLIVE team develop and test integrations without waiting for live Simpro credentials.
You learned how to:
- Build a FastAPI mock service with a mock OAuth2 token endpoint, Bearer auth middleware, and PascalCase response schemas that mirror Simpro's exact API format and URL patterns.
- Create a PostgreSQL-backed data layer with SQLAlchemy ORM models, an Alembic migration, and idempotent seed data across four resource types.
- Integrate the mock into the CLIVE Docker Compose stack with pagination response headers (Result-Total, Result-Count, Result-Pages) and a verified end-to-end auth-then-query integration loop.
- Secret Mission: Added Simpro-style query parameter filtering with operator parsing (gt, lt, between, in) and search mode support (AND/OR).
Ready to quiz yourself?