Adding a service¶
Adding a GCP service to drongo is intentionally mechanical - the same shape moto
uses. A service lives in src/drongo/services/<name>/ and is three files plus an
__init__.py.
We'll sketch a fictional widgets service. Use the existing storage and
secretmanager services as real references.
1. models.py - in-memory state¶
from drongo.core.backend import BaseBackend, BackendDict
class WidgetsBackend(BaseBackend):
def setup(self) -> None:
# Called on creation and on every reset. Initialise empty state.
self.widgets: dict[str, dict] = {}
def create_widget(self, name: str) -> dict:
widget = {"name": f"projects/{self.project}/widgets/{name}"}
self.widgets[name] = widget
return widget
widgets_backends: BackendDict[WidgetsBackend] = BackendDict(WidgetsBackend, "widgets")
- Backends are project-scoped:
self.projectis set for you. - Use
global_namespace=Trueif the resource namespace is global (like GCS buckets), so every project shares one backend.
2. responses.py - request handlers¶
from drongo.core.responses import BaseResponse, HttpResponse, Request, json_response
from drongo.services.widgets.models import widgets_backends
class WidgetsResponse(BaseResponse):
def backend_for(self, request: Request) -> "WidgetsBackend":
return widgets_backends[request.path_params["project"]]
def create_widget(self, request: Request) -> HttpResponse:
widget = self.backend_for(request).create_widget(request.param("widgetId"))
return json_response(widget)
Handlers receive a decoded Request (.path_params, .param(), .header(),
.json(), .body) and return (status, headers, body) - use json_response
for JSON. Raise drongo.core.exceptions.not_found(...) etc. for errors; they're
serialized into the GCP error envelope automatically.
3. urls.py - routing¶
from drongo.services.widgets.responses import WidgetsResponse
url_bases = [r"https?://widgets\.googleapis\.com"]
url_paths = {
r"POST /v1/projects/(?P<project>[^/]+)/widgets": WidgetsResponse.create_widget,
r"GET /v1/projects/(?P<project>[^/]+)/widgets/(?P<widget>[^/]+)": (
WidgetsResponse.get_widget
),
}
- Keys are
"<METHOD> <path-regex>"; the regex isfullmatched against the URL path. Named groups land inrequest.path_params. - Order matters - list more specific paths first.
4. __init__.py - register the service¶
from drongo.core.registry import ServiceDefinition, register_service
from drongo.services.widgets import urls
from drongo.services.widgets.models import widgets_backends
from drongo.services.widgets.responses import WidgetsResponse
register_service(
ServiceDefinition(
name="widgets",
backends=widgets_backends,
response=WidgetsResponse(urls.url_bases, urls.url_paths),
)
)
Finally, import your package in src/drongo/services/__init__.py so it registers
on load.
5. Tests¶
Add tests under tests/<name>/ that drive the real google-cloud client
through mock_gcp (or the drongo fixture). Assert on client-observable behavior
first, and optionally on raw backend state via
get_backend("widgets")["project"].
gRPC-first services (no REST transport)¶
Some services (Pub/Sub, Firestore) default to gRPC and cannot be intercepted via
HTTP. Instead of responses.py + urls.py, add an emulator.py with a
BaseEmulator (see drongo/services/pubsub/emulator.py): it starts an
in-process gRPC server backed by your models.py and redirects the client with
the service's *_EMULATOR_HOST env var. Build the server from generic gRPC
handlers using the client library's own proto-plus (de)serializers, so no
generated servicer classes are needed. Register the service with
ServiceDefinition(name=..., backends=..., emulator=...).
Tips¶
- Find the exact URLs/JSON shapes by capturing a request: register a
responsescallback and printrequest.url/request.body(see how the storage multipart parser was reverse-engineered). For gRPC, capture the method paths and message types the same way against a stub server. - Get the HTTP status codes right - the client maps them to
google.api_core.exceptionssubclasses. For gRPC, map togrpc.StatusCode. - Run
make checkbefore opening a PR.