Skip to content
Merged
81 changes: 81 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## What This Is

A FastAPI-based Python library implementing the [OCPI protocol](https://evroaming.org/ocpi-background/) (Open Charge Point Interface) for EV charging networks. Users integrate it by implementing abstract base classes and calling `get_application()`.

## Commands

All commands use `uv`:

```bash
uv sync --all-extras # Install all dependencies including dev/docs
uv run pytest # Run all tests
uv run pytest tests/path/to/test_file.py::TestClass::test_method # Run single test
uv run pytest --cov=ocpi # Run tests with coverage
uv run ruff check . # Lint
uv run ruff format . # Format
uv run ruff format --check . # Check formatting without modifying
uv run mypy ocpi # Type check
uv run mkdocs serve # Local docs server (requires: uv sync --extra docs)
uv run pre-commit install # Install pre-commit hooks
```

## Architecture

### Entry Point

`get_application()` in `ocpi/main.py` (re-exported from `ocpi/__init__.py`) is the sole public API for creating a FastAPI instance. It wires together routers, middleware, and dependency injection based on the provided versions, roles, CRUD implementation, and authenticator.

### What Users Must Implement

Two abstract base classes define the integration contract:

1. **`ocpi/core/crud.py` — `Crud`**: Six async methods (`list`, `get`, `create`, `update`, `delete`, `do`). Each receives `module`, `role`, `version` as kwargs so one implementation handles all OCPI modules/versions/roles.

2. **`ocpi/core/authentication/authenticator.py` — `Authenticator`**: Must implement `get_valid_token_c()` (Token C for CPO authentication) and `get_valid_token_a()` (Token A for EMSP credentials exchange). The base class provides `authenticate()` and `authenticate_credentials()` using these.

Optional: subclass `ocpi/core/adapter.py` — `Adapter` to transform your data models into OCPI schemas.

### Module Organization

`ocpi/modules/` contains 12 OCPI modules (locations, sessions, cdrs, tokens, tariffs, commands, credentials, versions, chargingprofiles, hubclientinfo, payments, bookings). Each module folder typically has:
- `v_X_X_X/` subfolders for version-specific schemas and routers
- CPO and EMSP router variants

`ocpi/core/routers/` aggregates module routers by version (v_2_1_1, v_2_2_1, v_2_3_0).

### Configuration

`ocpi/core/config.py` uses Pydantic Settings. Key env vars:
- `ENVIRONMENT`: production/development/testing
- `NO_AUTH`: disable all authentication
- `VERSIONS_REQUIRE_AUTH`: whether version/details endpoints require auth (default `True`)
- `OCPI_HOST`, `OCPI_PREFIX`, `PROTOCOL`: URL construction
- `COUNTRY_CODE`, `PARTY_ID`: OCPI party identifiers

### Authentication Flow

`ocpi/core/authentication/verifier.py` handles token verification. Supports both plain tokens and base64-encoded `Token <value>` format. Used via FastAPI dependency injection in `ocpi/core/dependencies.py`.

### Response Format

All endpoints return `OCPIResponse` (defined in `ocpi/core/schemas.py`) wrapping data with OCPI status codes from `ocpi/core/status.py`.

## Testing Conventions

Tests are organized by OCPI version and role:
- `tests/test_modules/test_v_2_1_1/`, `test_v_2_2_1/`, `test_v_2_3_0/`
- Within each: per-module directories with `test_cpo.py` and `test_emsp.py`
- Shared mock implementations: `tests/test_modules/utils.py` (`MockCrud`, `ClientAuthenticator`)
- Each test folder has a `conftest.py` setting up the test FastAPI app via `get_application()`

When adding a new module or endpoint, follow the pattern of existing module tests.

## Supported OCPI Versions

2.3.0, 2.2.1, 2.1.1

Roles: CPO, EMSP, HUB, NAP, NSP, SCSP, PTP (defined in `ocpi/core/enums.py` as `RoleEnum`).
22 changes: 16 additions & 6 deletions ocpi/core/logs.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,20 +29,30 @@ def format(self, record):
return formatter.format(record)


_ENV_ALIASES: dict[str, str] = {
"prod": EnvironmentType.production.value,
"production": EnvironmentType.production.value,
"dev": EnvironmentType.development.value,
"development": EnvironmentType.development.value,
"staging": EnvironmentType.development.value,
"test": EnvironmentType.testing.value,
"testing": EnvironmentType.testing.value,
}


class LoggingConfig:
def __init__(self, environment: str, logger) -> None:
self.environment = environment
self.logger = logger

def configure_logger(self):
if self.environment == EnvironmentType.production.value:
normalized = _ENV_ALIASES.get(self.environment.lower())
if normalized is None:
raise ValueError("Invalid environment")
if normalized == EnvironmentType.production.value:
self.logger.setLevel(logging.INFO)
elif self.environment == EnvironmentType.development.value:
self.logger.setLevel(logging.DEBUG)
elif self.environment == EnvironmentType.testing.value:
self.logger.setLevel(logging.DEBUG)
else:
raise ValueError("Invalid environment")
self.logger.setLevel(logging.DEBUG)


logger = logging.getLogger("OCPI-Logger")
Expand Down
24 changes: 22 additions & 2 deletions ocpi/main.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from typing import Any
from uuid import uuid4

from fastapi import FastAPI, Request
from fastapi import status as fastapistatus
Expand Down Expand Up @@ -38,6 +39,24 @@
from ocpi.modules.versions.schemas import Version


class HubRequestIdMiddleware(BaseHTTPMiddleware):
"""Echo X-Request-ID and X-Correlation-ID headers per the OCPI spec.

Every request receives a unique X-Request-ID in the response.
If the client provides X-Correlation-ID it is echoed back unchanged.
"""

async def dispatch(self, request: Request, call_next: RequestResponseEndpoint):
request_id = request.headers.get("X-Request-ID", str(uuid4()))
correlation_id = request.headers.get("X-Correlation-ID")

response = await call_next(request)
response.headers["X-Request-ID"] = request_id
if correlation_id:
response.headers["X-Correlation-ID"] = correlation_id
return response


class ExceptionHandlerMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint):
logger.debug(f"{request.method}: {request.url}")
Expand Down Expand Up @@ -161,6 +180,7 @@ def get_application(
allow_headers=["*"],
)
_app.add_middleware(ExceptionHandlerMiddleware)
_app.add_middleware(HubRequestIdMiddleware)

_app.include_router(
versions_router,
Expand Down Expand Up @@ -263,11 +283,11 @@ def override_get_endpoints():
def override_get_modules():
return modules

_app.dependency_overrides[get_modules] = override_get_modules()
_app.dependency_overrides[get_modules] = override_get_modules

def override_get_authenticator():
return authenticator

_app.dependency_overrides[get_authenticator] = override_get_authenticator()
_app.dependency_overrides[get_authenticator] = override_get_authenticator

return _app
10 changes: 1 addition & 9 deletions ocpi/modules/commands/v_2_1_1/api/cpo.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,8 @@ async def apply_pydantic_schema(command: str, data: dict):
data = StartSession(**data) # type: ignore
elif command == CommandType.stop_session:
data = StopSession(**data) # type: ignore
elif command == CommandType.unlock_connector:
data = UnlockConnector(**data) # type: ignore
else:
raise NotImplementedError
data = UnlockConnector(**data) # type: ignore
return data


Expand Down Expand Up @@ -149,12 +147,6 @@ async def receive_command(
status_code=fastapistatus.HTTP_422_UNPROCESSABLE_ENTITY,
content={"detail": jsonable_encoder(exc.errors())},
)
except NotImplementedError:
logger.debug("NotImplementedError on applying pydantic schema to command")
return JSONResponse(
status_code=fastapistatus.HTTP_422_UNPROCESSABLE_ENTITY,
content={"detail": "Not implemented"},
)

try:
if hasattr(command_data, "location_id"):
Expand Down
3 changes: 1 addition & 2 deletions ocpi/modules/credentials/v_2_2_1/api/cpo.py
Original file line number Diff line number Diff line change
Expand Up @@ -263,8 +263,7 @@ async def update_credentials(
ModuleID.credentials_and_registration,
RoleEnum.cpo,
{"credentials": credentials.model_dump(), "endpoints": endpoints},
# TODO check credential_id
id="",
id=auth_token,
auth_token=auth_token,
version=VersionNumber.v_2_2_1,
)
Expand Down
3 changes: 1 addition & 2 deletions ocpi/modules/credentials/v_2_2_1/api/emsp.py
Original file line number Diff line number Diff line change
Expand Up @@ -263,8 +263,7 @@ async def update_credentials(
ModuleID.credentials_and_registration,
RoleEnum.emsp,
{"credentials": credentials.model_dump(), "endpoints": endpoints},
# TODO check credential_id
id="",
id=auth_token,
auth_token=auth_token,
version=VersionNumber.v_2_2_1,
)
Expand Down
3 changes: 1 addition & 2 deletions ocpi/modules/credentials/v_2_3_0/api/cpo.py
Original file line number Diff line number Diff line change
Expand Up @@ -265,8 +265,7 @@ async def update_credentials(
ModuleID.credentials_and_registration,
RoleEnum.cpo,
{"credentials": credentials.model_dump(), "endpoints": endpoints},
# TODO check credential_id
id="",
id=auth_token,
auth_token=auth_token,
version=VersionNumber.v_2_3_0,
)
Expand Down
3 changes: 1 addition & 2 deletions ocpi/modules/credentials/v_2_3_0/api/emsp.py
Original file line number Diff line number Diff line change
Expand Up @@ -265,8 +265,7 @@ async def update_credentials(
ModuleID.credentials_and_registration,
RoleEnum.emsp,
{"credentials": credentials.model_dump(), "endpoints": endpoints},
# TODO check credential_id
id="",
id=auth_token,
auth_token=auth_token,
version=VersionNumber.v_2_3_0,
)
Expand Down
3 changes: 2 additions & 1 deletion ocpi/modules/payments/v_2_3_0/api/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""API routers for payments module v2.3.0."""

from ocpi.modules.payments.v_2_3_0.api.cpo import router as cpo_router
from ocpi.modules.payments.v_2_3_0.api.emsp import router as emsp_router
from ocpi.modules.payments.v_2_3_0.api.ptp import router as ptp_router

__all__ = ["cpo_router", "ptp_router"]
__all__ = ["cpo_router", "emsp_router", "ptp_router"]
Loading
Loading