Define a Service
This page explains how to define a devedge-sdk service from a Protocol Buffer (proto) definition.
You author a proto, run buf generate, and receive a service scaffold, a repository, and an authz
rule table. The proto is the single source of truth for your service’s API, storage, and
authorization.
Use this page when you are adding a new resource to an existing service or customizing the scaffold
that devedge-sdk new service generates.
devedge-sdk new service <name> --resource <Resource> --backend gorm instead (see the
Quickstart). It generates
a building, authz-gated, persisting project with no hand-edits. This page explains what that
scaffold produces.Author the proto
Declare your resource, the RPCs, the HTTP mappings, and the authz rule per method. The example
below is the apikey fixture
shipped with the SDK. For the resource message itself — which field types persist, the
framework-managed fields (etag, delete_time, account_id, …), constraints, and relationships —
see Model a Resource.
syntax = "proto3";
package apikey.v1;
option go_package = "github.com/infobloxopen/devedge-sdk/testdata/apikey/apikeyv1;apikeyv1";
import "google/api/annotations.proto";
import "infoblox/authz/v1/authz.proto";
import "infoblox/field/v1/field.proto";
message APIKey {
string id = 1;
string name = 2;
string account_id = 3;
string key_value = 4 [(infoblox.field.v1.opts) = {secret: true}];
string key_prefix = 5;
}
service APIKeyService {
rpc CreateAPIKey(CreateAPIKeyRequest) returns (APIKey) {
option (google.api.http) = {post: "/v1/apikeys", body: "api_key"};
option (infoblox.authz.v1.rule) = {verb: "create", resource: "api_keys"};
}
rpc GetAPIKey(GetAPIKeyRequest) returns (APIKey) {
option (google.api.http) = {get: "/v1/apikeys/{id}"};
option (infoblox.authz.v1.rule) = {verb: "read", resource: "api_keys"};
}
rpc ListAPIKeys(ListAPIKeysRequest) returns (ListAPIKeysResponse) {
option (google.api.http) = {get: "/v1/apikeys"};
option (infoblox.authz.v1.rule) = {verb: "read", resource: "api_keys"};
}
rpc DeleteAPIKey(DeleteAPIKeyRequest) returns (DeleteAPIKeyResponse) {
option (google.api.http) = {delete: "/v1/apikeys/{id}"};
option (infoblox.authz.v1.rule) = {verb: "delete", resource: "api_keys"};
}
}
message CreateAPIKeyRequest { APIKey api_key = 1; }
message GetAPIKeyRequest { string id = 1; }
message ListAPIKeysRequest { int32 page_size = 1; string page_token = 2; }
message ListAPIKeysResponse { repeated APIKey api_keys = 1; string next_page_token = 2; }
message DeleteAPIKeyRequest { string id = 1; }
message DeleteAPIKeyResponse {}Configure buf
A buf.gen.yaml lists every plugin in the order they run. The SDK’s plugins generate after the
base protoc-gen-go / protoc-gen-go-grpc:
version: v2
inputs:
- directory: .
plugins:
- local: protoc-gen-go
out: .
opt: paths=source_relative
- local: protoc-gen-go-grpc
out: .
opt: paths=source_relative
- local: protoc-gen-devedge-authz # → apikey.authz.go (APIKeyServiceAuthzRules)
out: .
opt: paths=source_relative
- local: protoc-gen-svc # → apikey.svc.go (service scaffold)
out: .
opt: paths=source_relative
- local: protoc-gen-storage # → apikey.storage.go (GORM Repository)
out: .
opt: paths=source_relative
- local: protoc-gen-grpc-gateway # → apikey.pb.gw.go (HTTP/JSON gateway)
out: .
opt: paths=source_relative
- local: protoc-gen-ent # → ent/schema/api_key.go (ent schema)
out: .protoc-gen-storage for GORM, protoc-gen-ent for ent) pull in
gorm.io/gorm / entgo.io/ent. Generate them into a module that has those deps so they
never enter the SDK’s own go.mod. The SDK’s apikey fixture does this — it lives in its own
module under testdata/apikey/.Consumer module setup
The buf.gen.yaml above is the SDK’s in-repo setup. In your own service module you also need a
buf.yaml that resolves the non-local imports — google/api/annotations.proto and the two
infoblox/... annotation protos — which do not live in your repo by default.
Follow these steps:
Vendor the annotation protos. The
infoblox/authz/v1/authz.protoandinfoblox/field/v1/field.protoschemas are released viaapxin the canonicalinfobloxopen/apismodule, but that module ships only the generated Go bindings (which you pull in step 4) — it does not publish the.protosource forbuf export, and there is no public BSR module to export from. The SDK ships a byte-identical mirror of both files under its module atproto/infoblox/; vendor from there. Because you copy from the SDK version yourgo.modalready pins, the protos stay in lock-step with the bindings you compile against:# Pin the SDK, then copy its mirrored annotation protos out of the module cache. go get github.com/infobloxopen/devedge-sdk@latest SDK=$(go list -m -f '{{.Dir}}' github.com/infobloxopen/devedge-sdk) mkdir -p proto/infoblox cp -R "$SDK/proto/infoblox/." proto/infoblox/Keep the vendored imports in a directory separate from your own protos (buf v2 module roots must not overlap):
your-service/ ├── api/ # your protos → one buf module │ └── notes.proto └── proto/ # vendored imports → a second buf module └── infoblox/{authz,field}/v1/*.protobuf.yaml— declare both roots plus the googleapis dep, then runbuf dep update(this writesbuf.lock; it is required before the firstbuf generate):buf.yamlversion: v2 modules: - path: api - path: proto deps: - buf.build/googleapis/googleapis # provides google/api/*.protobuf dep updatebuf.gen.yaml— generate only your module (inputs: api) and usemodule=output so the generated Go lands at your import path:buf.gen.yamlversion: v2 inputs: - directory: api plugins: - {local: protoc-gen-go, out: ., opt: module=github.com/example/notes} - {local: protoc-gen-go-grpc, out: ., opt: module=github.com/example/notes} - {local: protoc-gen-devedge-authz, out: ., opt: module=github.com/example/notes} - {local: protoc-gen-svc, out: ., opt: module=github.com/example/notes} - {local: protoc-gen-grpc-gateway, out: ., opt: module=github.com/example/notes}go.mod— the generated code imports the canonical annotation bindings:go get github.com/infobloxopen/apis/proto/infoblox/authz@latest go get github.com/infobloxopen/apis/proto/infoblox/field@latest
Generate
buf generateYou now have:
| File | From | Contains |
|---|---|---|
apikey.pb.go, apikey_grpc.pb.go | base plugins | message types + gRPC stubs |
apikey.authz.go | protoc-gen-devedge-authz | APIKeyServiceAuthzRules []authz.MethodRule |
apikey.svc.go | protoc-gen-svc | the generated default CRUD handler APIKeyServiceCRUDHandler + NewAPIKeyServiceHandler / RegisterAPIKeyServiceWithRepository / RegisterAPIKeyService (no hand-written handler for pure CRUD) |
apikey.storage.go | protoc-gen-storage | APIKeyModel + APIKeyRepository (GORM) |
apikey.pb.gw.go | gateway plugin | HTTP/JSON gateway registration |
ent/schema/api_key.go | protoc-gen-ent | the ent schema (run go generate ./ent to build the client) |
Wire the server
A scaffolded service runs on the servicekit host. protoc-gen-svc emits an importable
<Service>Module, and the scaffold’s cmd/<svc>/main.go hands it to servicekit.Run, which builds
the one shared server and serves under the fail-closed boot gate. For the runtime these pieces form,
see the servicekit reference.
For a pure-CRUD service there is no hand-written handler and no hand-listed rules. The generated
module wires the generated CRUD handler over your repository, and Run hosts it:
// Build the generated repository, wrap it in the generated module, and host it.
repo := apikeyv1.NewAPIKeyRepository(db, enc) // generated GORM repo (enc for the secret field)
err := servicekit.Run(servicekit.HostConfig{
Modules: []servicekit.Module{apikeyv1.APIKeyServiceModule(apikeyv1.APIKeyServiceModuleOptions{Repo: repo})},
GRPCAddr: ":9090",
HTTPAddr: ":8080",
// No Rules to pass: the generated module carries APIKeyServiceAuthzRules and
// contributes them when it registers.
Authorizer: authz.NewDevAuthorizer(/* grants */),
// Derive the principal from request metadata in dev so grants can match;
// swap for a verified-token PrincipalFunc in production. See server reference.
PrincipalFunc: grpcauthz.DevPrincipalFunc(),
Context: ctx,
})The generated module’s Register calls RegisterAPIKeyServiceWithRepository on the shared server,
which constructs the generated CRUD handler, registers it on gRPC and the HTTP gateway, and
contributes the service’s authz rules. The boot-time completeness gate runs when Run calls
server.Serve.
Custom or non-CRUD logic? Replace the generated module with a hand-written servicekit.Module:
embed the generated APIKeyServiceCRUDHandler, override the methods you change (and implement any
custom RPCs the generator left Unimplemented), then register your handler with
RegisterAPIKeyService(app.Server, h). The
Add a custom method or second resource how-to walks the full recipe; see
codegen → protoc-gen-svc for the override mechanics.
The generated rules feed both the authz interceptor and the field-mask validator. See the servicekit reference and the server reference.
Call the HTTP API
The gateway maps each RPC to the HTTP route in its google.api.http option. CreateAPIKey sets
body: "api_key", so the JSON request body is the APIKey resource, sent at the top level — not
wrapped in the request field name:
curl -X POST localhost:8080/v1/apikeys \
-H 'Content-Type: application/json' \
-d '{"label": "ci-token", "key_prefix": "ak_live"}'The tenant comes from request metadata, not the body, and the server generates the id (see
Model a Resource → Resource name). Send only the
resource’s own writable fields.
DiscardUnknown: true, so a body shaped like {"api_key": {...}} — the resource wrapped in the
request field name — parses as an empty APIKey and creates a resource with no fields (HTTP 200,
empty result). Match body: "<field>" and send the resource fields at the top level. This is stock
grpc-gateway behavior; the SDK installs no custom marshaler.The same care applies to Update. A generated Update<Resource> maps
{patch: "/v1/<plural>/{<resource>.id}", body: "<resource>"}, so the id is in the path and the body is
again the bare resource — but the set of fields to change rides in an update_mask query parameter,
never the body.
update_mask in the body is ignored, not applied. With a body: "<field>" mapping the body
binds only to that resource field, so an update_mask (or updateMask) placed in the JSON body is
discarded — the update changes nothing and still returns HTTP 200 with a fresh etag. update_mask is a
repeated string: send one repeated query parameter per field in proto snake_case
(?update_mask=name&update_mask=label). Comma-joining (?update_mask=name,label) or camelCase
(?update_mask=displayName) parses as a single unknown path and also no-ops. See
persistence → update_mask encoding over the gateway
for the full encoding table and worked curls.Error handling
The ErrorMapperUnary interceptor, installed automatically by server.New, converts persistence
sentinels to gRPC status codes so your handlers do not need to map them manually:
| Sentinel | gRPC code |
|---|---|
persistence.ErrNotFound | codes.NotFound |
persistence.ErrConflict | codes.AlreadyExists |
persistence.ErrPreconditionFailed | codes.FailedPrecondition |
persistence.ConstraintError(err) (unique/FK violation) | codes.AlreadyExists or codes.FailedPrecondition |
*persistence.FieldViolationError | codes.InvalidArgument (with field detail) |
Return the error from the repository call directly. The interceptor handles the translation.
Hand-calling status.Error(codes.NotFound, …) is redundant and risks diverging from the
framework’s AIP-193 error detail format.
func (s *apiKeyServer) GetAPIKey(ctx context.Context, req *apikeyv1.GetAPIKeyRequest) (*apikeyv1.APIKey, error) {
k, err := s.repo.Get(ctx, req.Id)
if err != nil {
return nil, err // ErrNotFound → codes.NotFound; other sentinels mapped automatically
}
return k, nil
}See middleware → Error mapper and persistence → Errors.
Secret fields
key_value is annotated secret, so the generated APIKeyRepository requires an Encryptor. Its
constructor is NewAPIKeyRepository(db *gorm.DB, enc secret.Encryptor). See
Secret fields.
Governing the public API locally
The proto is the public, apx-governed contract. Run the same gates locally that CI runs before you
push. A service scaffolded with devedge-sdk new service ships Makefile targets for each:
make api-lint # STANDARD lint of the public proto (apx lint)
make api-breaking # backward-compatibility check vs the last committed proto (apx breaking --against HEAD)
make api-release # prepare a versioned release (apx release prepare … --lifecycle experimental)Or call apx directly: apx lint, apx breaking --against HEAD, apx release prepare proto/<svc>/v1 --version v1.0.0-alpha.1 --lifecycle experimental --dry-run.
api-breakingcatches an accidental breaking change before it lands. It requires an initialized git repo with at least one commit — rungit init && git add . && git commit -m "initial"first if this is a fresh scaffold. After that, with no prior API tag, it passes. Once released, compare against the released tag instead.api-releaseon the ent scaffold prints a non-fatalgo_packagemismatch warning —got "<module>/gen/<svc>v1", expected "<module>/proto/<svc>/v1". This is expected and harmless: the generated Go must be a single directory segment undergen/so the sibling generatedent/package compiles, which is not the<module>/<api-id>layout apx derives by default. The command exits 0 — do not realign thego_package(it breaks the ent build) and do not pass--strict(that turns the warning fatal). See codegen → buf.gen.yaml.
Next steps
- Storage shapes — GORM vs ent.
- API Key Manager tutorial — the same proto, end to end.