API Versioning¶
AppKernel supports evolving REST APIs through URL path versioning, view model
projection, and deprecation signalling. This tutorial walks through a
complete example: a User resource that gains a structured address
field between v1 and v2.
The core tension¶
AppKernel’s Model class serves two roles: it is both the MongoDB
persistence schema and the API response shape. These two concerns evolve
at different speeds and for different reasons, so a clean versioning strategy
separates them deliberately:
One persistence model — the
MongoRepositorymodel tracks what is stored in MongoDB. It absorbs all schema changes over time.Per-version view models — thin
Modelsubclasses (no repository mixin) that project the exact shape each API version exposes to clients.Per-version service classes —
@resource/@actioncontrollers that load from the persistence model and return the correct view model.
This keeps the database schema as a single source of truth while letting API contracts evolve independently.
URL versioning¶
The url_base parameter on register() is
the primary versioning mechanism. It prefixes every route generated for that
service:
kernel.register(User, url_base='/v1/', methods=['GET', 'POST'])
# → GET /v1/users/, POST /v1/users/, GET /v1/users/{object_id}, …
kernel.register(UserV2Service(), url_base='/v2/', tags=['v2'])
# → GET /v2/users/{user_id}
Clients pin to a specific version prefix and are unaffected by changes to other versions. Old versions can be removed by simply stopping their registration.
Complete example¶
Step 2 — V1 view model and service¶
The v1 view exposes only id and name. The from_user factory
method converts the persistence model to the v1 shape:
class UserV1(Model):
id: str | None = None
name: str | None = None
@classmethod
def from_user(cls, user: User) -> 'UserV1':
return cls(id=user.id, name=user.name)
class UserV1Service:
@resource(
method='GET',
path='./<user_id>',
summary='Get a user (v1)',
response_model=UserV1,
)
def get_user(self, user_id) -> UserV1:
user = User.where(User.id == user_id).find_one()
return UserV1.from_user(user)
@resource(
method='POST',
summary='Create a user (v1)',
request_model=UserV1,
response_model=UserV1,
)
def create_user(self, payload) -> UserV1:
user = User(name=payload.get('name'))
user.save()
return UserV1.from_user(user)
Step 3 — V2 view model and service¶
The v2 view adds the address field. The service class is registered at a
different URL prefix:
class UserV2(Model):
id: str | None = None
name: str | None = None
address: Address | None = None
@classmethod
def from_user(cls, user: User) -> 'UserV2':
return cls(id=user.id, name=user.name, address=user.address)
class UserV2Service:
@resource(
method='GET',
path='./<user_id>',
summary='Get a user (v2)',
response_model=UserV2,
)
def get_user(self, user_id) -> UserV2:
user = User.where(User.id == user_id).find_one()
return UserV2.from_user(user)
@resource(
method='POST',
summary='Create a user (v2)',
request_model=UserV2,
response_model=UserV2,
)
def create_user(self, payload) -> UserV2:
user = User(name=payload.get('name'), address=payload.get('address'))
user.save()
return UserV2.from_user(user)
Deprecating a version¶
Mark individual endpoints as deprecated using the deprecated=True kwarg.
This has no effect on runtime behaviour — it signals to API consumers (via
the OpenAPI spec and Swagger UI) that the endpoint will be removed:
class UserV1Service:
@resource(
method='GET',
path='./<user_id>',
deprecated=True,
summary='Deprecated — use GET /v2/users/{user_id} instead',
response_model=UserV1,
)
def get_user(self, user_id) -> UserV1:
...
In the generated OpenAPI spec the operation gains "deprecated": true,
which Swagger UI renders with a strikethrough.
To retire a version completely, remove its register() call. The routes
will no longer be registered and all endpoints vanish from the spec.
MongoDB schema evolution¶
Because MongoDB is schemaless, adding a field to the persistence model is
safe: old documents simply return None for the new field. Use
Default to supply a sensible fallback:
# Before v2
class User(Model, MongoRepository):
name: Annotated[str | None, Required()] = None
# After v2 — old documents have address=None, new ones carry an Address
class User(Model, MongoRepository):
name: Annotated[str | None, Required()] = None
address: Annotated[Address | None, Default(None)] = None
For type changes (e.g. address was a plain string, now it is an embedded
object), implement a custom Marshaller that handles both
formats on read:
from appkernel import Marshaller, Marshal
from typing import Annotated
class AddressUpgradeMarshaller(Marshaller):
def from_wire(self, value):
if isinstance(value, str):
# Legacy document — promote bare string to Address object
return Address(street=value, city='Unknown')
return value # already an Address dict from a v2 document
class User(Model, MongoRepository):
address: Annotated[Address | None, Marshal(AddressUpgradeMarshaller)] = None
This avoids bulk migration scripts for simple type widening. Documents are upgraded transparently on the first read after deployment.
What not to do¶
Don’t give each version its own MongoRepository model. UserV1 and
UserV2 as separate MongoRepository subclasses means two MongoDB
collections that diverge forever and require data duplication.
Don’t use query-parameter versioning (/users/?version=2). It
requires middleware changes, cannot be cleanly represented in OpenAPI, and
makes URL bookmarking unreliable.
Don’t version for additive-only changes. Adding a nullable field or a
new optional endpoint is backward compatible. Reserve version bumps for
breaking changes: removed fields, renamed fields, or changed types that
cannot be handled by a Default or Marshaller.