Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 37 additions & 10 deletions docs/guides/registration.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ The simplest approach with auto-detected hostname:
from servicekit.api import BaseServiceBuilder, ServiceInfo

app = (
BaseServiceBuilder(info=ServiceInfo(display_name="My Service"))
BaseServiceBuilder(info=ServiceInfo(id="my-service", display_name="My Service"))
.with_registration() # Reads SERVICEKIT_ORCHESTRATOR_URL from environment
.build()
)
Expand Down Expand Up @@ -61,6 +61,7 @@ class CustomServiceInfo(ServiceInfo):
app = (
BaseServiceBuilder(
info=CustomServiceInfo(
id="my-service",
display_name="My Service",
version="1.0.0",
deployment_env="staging",
Expand Down Expand Up @@ -99,6 +100,28 @@ The `.with_registration()` method accepts these parameters:
)
```

### ServiceInfo ID Field

The `id` field is **required** on `ServiceInfo` and must follow slug format:

- Lowercase letters, numbers, and hyphens only
- Must start with a letter
- No consecutive hyphens
- No trailing or leading hyphens

**Valid examples:** `my-service`, `chap-ewars`, `prediction-service`, `service1`

**Invalid examples:** `My-Service` (uppercase), `my_service` (underscore), `1-service` (starts with number)

```python
# Valid
ServiceInfo(id="my-service", display_name="My Service")

# Invalid - will raise ValidationError
ServiceInfo(id="My-Service", display_name="My Service") # uppercase
ServiceInfo(id="my_service", display_name="My Service") # underscore
```

### Parameters

- **orchestrator_url** (`str | None`): Orchestrator registration endpoint URL. If None, reads from environment variable.
Expand Down Expand Up @@ -182,8 +205,10 @@ The service sends this payload to the orchestrator:

```json
{
"id": "my-service",
"url": "http://my-service:8000",
"info": {
"id": "my-service",
"display_name": "My Service",
"version": "1.0.0",
"summary": "Service description",
Expand All @@ -196,8 +221,10 @@ For custom ServiceInfo subclasses:

```json
{
"id": "ml-service",
"url": "http://ml-service:8000",
"info": {
"id": "ml-service",
"display_name": "ML Service",
"version": "2.0.0",
"deployment_env": "production",
Expand All @@ -214,21 +241,21 @@ The orchestrator responds with registration details, including the ping endpoint

```json
{
"id": "01K83B5V85PQZ1HTH4DQ7NC9JM",
"id": "my-service",
"status": "registered",
"service_url": "http://my-service:8000",
"message": "Service registered successfully",
"ttl_seconds": 30,
"ping_url": "http://orchestrator:9000/services/01K83B5V85PQZ1HTH4DQ7NC9JM/$ping"
"ping_url": "http://orchestrator:9000/services/my-service/$ping"
}
```

**Key fields:**
- `id`: Unique ULID identifier assigned by orchestrator
- `id`: Service identifier (matches the `id` field from ServiceInfo)
- `ttl_seconds`: Time-to-live in seconds (service must ping within this window)
- `ping_url`: Endpoint for keepalive pings (automatically used by the service)

**Important**: The `ping_url` is provided by the orchestrator - services don't need to configure it. The service automatically uses this URL for keepalive pings when `enable_keepalive=True`.
**Important**: The service ID is defined by the service itself via `ServiceInfo.id`, not assigned by the orchestrator. This makes registration idempotent - re-registering the same service updates the existing entry rather than creating a new one.

### Hostname Resolution

Expand Down Expand Up @@ -261,7 +288,7 @@ Priority order:
from servicekit.api import BaseServiceBuilder, ServiceInfo

app = (
BaseServiceBuilder(info=ServiceInfo(display_name="Production Service"))
BaseServiceBuilder(info=ServiceInfo(id="production-service", display_name="Production Service"))
.with_logging()
.with_health()
.with_registration() # Reads from environment
Expand All @@ -284,7 +311,7 @@ services:

```python
app = (
BaseServiceBuilder(info=ServiceInfo(display_name="Test Service"))
BaseServiceBuilder(info=ServiceInfo(id="test-service", display_name="Test Service"))
.with_registration(
orchestrator_url="http://localhost:9000/services/$register",
host="test-service",
Expand All @@ -298,7 +325,7 @@ app = (

```python
app = (
BaseServiceBuilder(info=ServiceInfo(display_name="My Service"))
BaseServiceBuilder(info=ServiceInfo(id="my-service", display_name="My Service"))
.with_registration(
orchestrator_url_env="MY_APP_ORCHESTRATOR_URL",
host_env="MY_APP_HOST",
Expand All @@ -321,7 +348,7 @@ For critical services that must register:

```python
app = (
BaseServiceBuilder(info=ServiceInfo(display_name="Critical Service"))
BaseServiceBuilder(info=ServiceInfo(id="critical-service", display_name="Critical Service"))
.with_registration(
fail_on_error=True, # Abort startup if registration fails
max_retries=10,
Expand All @@ -335,7 +362,7 @@ app = (

```python
app = (
BaseServiceBuilder(info=ServiceInfo(display_name="My Service"))
BaseServiceBuilder(info=ServiceInfo(id="my-service", display_name="My Service"))
.with_registration(
max_retries=10, # More attempts
retry_delay=1.0, # Faster retries
Expand Down
1 change: 1 addition & 0 deletions examples/app_hosting/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
app = (
BaseServiceBuilder(
info=ServiceInfo(
id="app-hosting-demo",
display_name="App Hosting Demo",
version="1.0.0",
summary="Demonstrates hosting static web apps with servicekit",
Expand Down
1 change: 1 addition & 0 deletions examples/auth_basic/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
app = (
BaseServiceBuilder(
info=ServiceInfo(
id="auth-basic-example",
display_name="Authenticated API Example",
version="1.0.0",
summary="Basic API key authentication example",
Expand Down
1 change: 1 addition & 0 deletions examples/auth_custom_header/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
app = (
BaseServiceBuilder(
info=ServiceInfo(
id="auth-custom-header-example",
display_name="API with Custom Authentication Header",
version="1.0.0",
summary="API using custom header name for authentication",
Expand Down
1 change: 1 addition & 0 deletions examples/auth_docker_secrets/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
app = (
BaseServiceBuilder(
info=ServiceInfo(
id="auth-docker-secrets-example",
display_name="Secure API with Docker Secrets",
version="2.0.0",
summary="Production API using Docker secrets file for authentication",
Expand Down
1 change: 1 addition & 0 deletions examples/auth_envvar/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
app = (
BaseServiceBuilder(
info=ServiceInfo(
id="auth-envvar-example",
display_name="Production API with Environment Variable Auth",
version="2.0.0",
summary="Production-ready API using environment variables for authentication",
Expand Down
1 change: 1 addition & 0 deletions examples/core_api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ async def seed_users(app: FastAPI) -> None:
app = (
BaseServiceBuilder(
info=ServiceInfo(
id="core-user-service",
display_name="Core User Service",
version="1.0.0",
summary="User management API using core-only features",
Expand Down
1 change: 1 addition & 0 deletions examples/job_scheduler/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ async def get_computation_result( # pyright: ignore[reportUnusedFunction]


info = ServiceInfo(
id="job-scheduler-demo",
display_name="Job Scheduler Demo",
summary="Demonstrates async job scheduling with polling and SSE streaming",
version="1.0.0",
Expand Down
1 change: 1 addition & 0 deletions examples/monitoring/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
app = (
BaseServiceBuilder(
info=ServiceInfo(
id="monitoring-example",
display_name="Monitoring Example Service",
version="1.0.0",
summary="Service with OpenTelemetry monitoring and Prometheus metrics",
Expand Down
19 changes: 11 additions & 8 deletions examples/registration/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,23 +115,25 @@ docker compose down
5. **Registration Attempt**: Sends POST to orchestrator with payload:
```json
{
"id": "registration-example",
"url": "http://svca:8000",
"info": {
"id": "registration-example",
"display_name": "Registration Example Service",
"version": "1.0.0",
...
}
}
```
6. **ID Assignment**: Orchestrator assigns ULID and returns:
6. **Registration Confirmed**: Orchestrator returns:
```json
{
"id": "01K83B5V85PQZ1HTH4DQ7NC9JM",
"id": "registration-example",
"status": "registered",
"service_url": "http://svca:8000",
"message": "...",
"ttl_seconds": 30,
"ping_url": "http://orchestrator:9000/services/01K83B5V85PQZ1HTH4DQ7NC9JM/$ping"
"ping_url": "http://orchestrator:9000/services/registration-example/$ping"
}
```
7. **Keepalive Started**: Background task starts sending pings every 10 seconds to `ping_url`
Expand Down Expand Up @@ -243,7 +245,7 @@ services:

```python
app = (
BaseServiceBuilder(info=ServiceInfo(display_name="My Service"))
BaseServiceBuilder(info=ServiceInfo(id="my-service", display_name="My Service"))
.with_registration(
orchestrator_url="http://orchestrator:9000/services/$register",
service_key="my-secret-key",
Expand All @@ -256,7 +258,7 @@ app = (

```python
app = (
BaseServiceBuilder(info=ServiceInfo(display_name="My Service"))
BaseServiceBuilder(info=ServiceInfo(id="my-service", display_name="My Service"))
.with_registration(
service_key_env="MY_APP_REGISTRATION_KEY",
)
Expand All @@ -277,7 +279,7 @@ The service key is included in:
from servicekit.api import BaseServiceBuilder, ServiceInfo

app = (
BaseServiceBuilder(info=ServiceInfo(display_name="My Service"))
BaseServiceBuilder(info=ServiceInfo(id="my-service", display_name="My Service"))
.with_logging()
.with_health()
.with_registration() # Auto-detect hostname
Expand All @@ -297,6 +299,7 @@ class CustomServiceInfo(ServiceInfo):
app = (
BaseServiceBuilder(
info=CustomServiceInfo(
id="custom-service",
display_name="Custom Service",
deployment_env="staging",
team="data-science",
Expand All @@ -313,7 +316,7 @@ app = (

```python
app = (
BaseServiceBuilder(info=ServiceInfo(display_name="My Service"))
BaseServiceBuilder(info=ServiceInfo(id="my-service", display_name="My Service"))
.with_registration(
orchestrator_url_env="MY_APP_ORCHESTRATOR_URL",
host_env="MY_APP_HOST",
Expand All @@ -327,7 +330,7 @@ app = (

```python
app = (
BaseServiceBuilder(info=ServiceInfo(display_name="My Service"))
BaseServiceBuilder(info=ServiceInfo(id="my-service", display_name="My Service"))
.with_registration(
orchestrator_url="http://orchestrator:9000/register",
host="my-service",
Expand Down
1 change: 1 addition & 0 deletions examples/registration/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
app = (
BaseServiceBuilder(
info=ServiceInfo(
id="registration-example",
display_name="Registration Example Service",
version="1.0.0",
summary="Demonstrates automatic service registration with orchestrator",
Expand Down
1 change: 1 addition & 0 deletions examples/registration/main_custom.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ class CustomServiceInfo(ServiceInfo):
app = (
BaseServiceBuilder(
info=CustomServiceInfo(
id="custom-metadata-service",
display_name="Custom Metadata Service",
version="2.0.0",
summary="Demonstrates custom ServiceInfo with additional metadata",
Expand Down
1 change: 1 addition & 0 deletions examples/registration/orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,7 @@ async def lifespan(app: FastAPI):
app = (
BaseServiceBuilder(
info=ServiceInfo(
id="mock-orchestrator",
display_name="Mock Orchestrator",
version="1.0.0",
summary="Simple orchestrator for testing service registration",
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "servicekit"
version = "0.7.0"
version = "0.8.0"
description = "Async SQLAlchemy framework with FastAPI integration - reusable foundation for building data services"
readme = "README.md"
authors = [{ name = "Morten Hansen", email = "[email protected]" }]
Expand Down
17 changes: 13 additions & 4 deletions src/servicekit/api/registration.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,8 +113,19 @@ async def register_service(
# Build service URL
service_url = f"http://{resolved_host}:{resolved_port}"

# Extract service ID from info (requires id attribute)
service_id = getattr(info, "id", None)
if not service_id:
error_msg = "ServiceInfo must have an 'id' attribute for registration"
logger.error("registration.missing_service_id")
if fail_on_error:
raise ValueError(error_msg)
logger.warning("registration.skipped", reason="missing service ID")
return None

# Build registration payload
payload: dict[str, Any] = {
"id": service_id,
"url": service_url,
"info": info.model_dump(mode="json"),
}
Expand Down Expand Up @@ -146,18 +157,16 @@ async def register_service(
)
response.raise_for_status()

# Parse response to extract service ID
# Parse response for additional info (ping_url, ttl)
response_data = response.json()
service_id = response_data.get("id")

log_context = {
"orchestrator_url": resolved_orchestrator_url,
"service_url": service_url,
"service_id": service_id,
"attempt": attempt,
"status_code": response.status_code,
}
if service_id:
log_context["service_id"] = service_id

# Store global references for keepalive
global _service_id, _ping_url
Expand Down
14 changes: 13 additions & 1 deletion src/servicekit/api/service_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from typing import Any, AsyncContextManager, AsyncIterator, Awaitable, Callable, Dict, List, Self

from fastapi import APIRouter, FastAPI
from pydantic import BaseModel, ConfigDict
from pydantic import BaseModel, ConfigDict, field_validator
from sqlalchemy import text

from servicekit import Database, SqliteDatabase
Expand Down Expand Up @@ -100,6 +100,7 @@ class _RegistrationOptions:
class ServiceInfo(BaseModel):
"""Service metadata for FastAPI application."""

id: str
display_name: str
version: str = "1.0.0"
summary: str | None = None
Expand All @@ -109,6 +110,17 @@ class ServiceInfo(BaseModel):

model_config = ConfigDict(extra="forbid")

@field_validator("id")
@classmethod
def validate_id(cls, v: str) -> str:
"""Validate service ID follows slug format."""
if not re.match(r"^[a-z][a-z0-9]*(-[a-z0-9]+)*$", v):
raise ValueError(
"Service ID must be slug format: lowercase letters, numbers, "
"and hyphens (e.g., 'my-service', 'chap-ewars')"
)
return v


class BaseServiceBuilder:
"""Base service builder providing core FastAPI functionality without module dependencies."""
Expand Down
Loading