The release
The previous entry told where the idea came from: a global repository of schemas at Lyft, where what a service offered existed as an artifact in its own right, and the goal of bringing the same mechanism to a platform whose daily surface was HTTP. This entry walks the release that answered it, version 0.1.01. The code arrives with the entries: the koba-public repository holds exactly the releases the blog has walked so far, and grows one release per entry. It is small: an app, an endpoint derivation, a router over the standard library and one test file. Its opening sentence states the whole idea:
A service is a set of functions. This module serves them over HTTP without asking the functions to know it: an API is an ordinary Go value, and every exported method of that value is an endpoint.
Two rules follow from it, and the rest of the release is their consequence: an API is a value whose exported methods are endpoints, and the HTTP method follows the signature.
An API is a value
The public surface is one interface and one constructor:
// App serves APIs over HTTP. An API is any value; each of its exported methods
// becomes an endpoint at the route named after the method.
type App interface {
// AddApi registers api: each exported method becomes an endpoint. It
// panics when a method has a shape no endpoint can serve.
AddApi(api any)
// Run serves the endpoints on addr until the listener fails.
Run(addr string) error
}
// MakeRawApp returns an app with the router and nothing else.
func MakeRawApp() AppAn API is whatever value the application already has. Nothing is implemented, embedded or annotated. The service that runs through this series is a preference poll with cryptographic guarantees, introduced in the previous entry, and this is as much of it as the first release can express:
// Polls declares polls, takes ballots into their logs and closes them.
type Polls struct{}
func (*Polls) ListPolls() []Poll { ... }
func (*Polls) DeclarePoll(request DeclarePollRequest) Poll { ... }
func (*Polls) Cast(request CastRequest) Receipt { ... }
func (*Polls) ReadLog(request ReadLogRequest) LogPage { ... }
func (*Polls) ClosePoll(request ClosePollRequest) { ... }
func main() {
app := MakeRawApp()
app.AddApi(&Polls{})
log.Fatal(app.Run(":8080"))
}A Poll has a question and its options. A CastRequest carries the poll, a nullifier, the value that makes a ballot count once without saying whose it is, and the sealed preference. A Receipt says that a ballot is in the poll's log without saying what it chose. A LogPage is a stretch of the log with the root over all of it. The types are the application's own; none of them mentions the library.
AddApi walks the value's exported methods with reflection, in the order reflection reports them, and turns each one into an endpoint. The domain code above knows nothing about HTTP, and that was the point: the move from Lyft's arrangement is that the interface is not an artifact maintained apart from the implementation. The signature is the interface, so the two cannot drift.
The method follows the signature
Everything about HTTP is derived from the method's shape, never declared. The derivation has three parts.
The shape is checked first, when the API is registered, so a method nobody could call is noticed before the first client tries it:
// checkShape says whether an endpoint can serve method. The receiver is the
// method's first parameter; the request, when there is one, is the second.
func checkShape(method reflect.Method) error {
if method.Type.NumIn() > 2 {
return fmt.Errorf("... takes %d parameters; an endpoint supplies at most one, the request", ...)
}
if method.Type.NumIn() == 2 && method.Type.In(1).Kind() == reflect.Pointer {
return fmt.Errorf("... takes a pointer; an endpoint decodes the request into a value", ...)
}
if method.Type.NumOut() > 1 {
return fmt.Errorf("... returns %d values; an endpoint responds with at most one", ...)
}
return nil
}Then the HTTP method is read off the signature. A method that takes a request receives it as the body, so it is POST; a method that takes nothing is GET:
func httpMethodOf(method reflect.Method) string {
if method.Type.NumIn() > 1 {
return http.MethodPost
}
return http.MethodGet
}And the route is the method's name in lower case with a dash between words, so ListPolls is served at GET /list-polls, Cast at POST /cast and ClosePoll, which takes a request and returns nothing, at POST /close-poll answering 204. The rule has a cost that is visible already: ReadLog takes a request, the poll and a page, so it is a POST, when a read should be a GET. The signature decides, and the signature cannot say that reading is safe. The real service, three layers later, serves the same function at GET /polls/{id}/log, because by then the wiring decides and the function has nothing to say about it. An API that wants other routes implements WithRoutesAPI and names them; Routes itself is not an endpoint.
The response follows the same logic. A method that returns a value answers 200 with its JSON encoding; a method that returns nothing answers 204. A body that does not decode into the request value is a 400. A method that panics is caught and answered with a 500, and both failures carry the same JSON body: the status, its name in lower case, and the message.
// invoke calls the method and reports a panic as the failure instead of
// letting it escape.
func invoke(call reflect.Value, arguments []reflect.Value) (results []reflect.Value, failure any) {
defer func() {
if recovered := recover(); recovered != nil {
failure = recovered
}
}()
return call.Call(arguments), nil
}The same service in gRPC
gRPC is the other answer to the same observation, so it is the right comparison. There, the interface is an artifact first, the way it was at Lyft, and the code is generated from it. The Polls service would start as a .proto file:
service Polls {
rpc ListPolls(google.protobuf.Empty) returns (PollList);
rpc Cast(CastRequest) returns (Receipt);
rpc ClosePoll(ClosePollRequest) returns (google.protobuf.Empty);
}and the generator would hand the application an interface to implement:
type PollsServer interface {
ListPolls(context.Context, *emptypb.Empty) (*PollList, error)
Cast(context.Context, *CastRequest) (*Receipt, error)
ClosePoll(context.Context, *ClosePollRequest) (*emptypb.Empty, error)
mustEmbedUnimplementedPollsServer()
}The two designs agree on one thing and disagree on everything else.
They agree that the address is derived from names. gRPC serves Cast at /polls.Polls/Cast; this release serves it at /cast. Neither asks the author to choose a path. gRPC also has only one verb, every call is a POST over HTTP/2, so there is nothing for the signature to decide; here the signature decides between GET and POST.
They disagree on where the interface lives and which way the dependency points. In gRPC the .proto is the source and the Go is derived from it, so the implementation cannot drift from the interface, but the interface belongs to the transport's own description language, and the domain function is shaped by it: a context, a pointer to a generated message, a generated message and an error. The function knows gRPC exists, and so do its types. In this release the direction is inverted: the Go signature is the source and HTTP is derived from it. The function takes the application's own CastRequest by value and returns the application's own Receipt, and it cannot drift from the interface because it is the interface. A method that takes nothing takes nothing, and a method that returns nothing returns nothing; gRPC needs an Empty message to say either.
They disagree on failure, and both are poor at it. gRPC returns an error with a status code and a message; this release turns a panic into a 500 with a message. Neither lets the operation declare which failures a caller must handle. That gap stayed open in both for a long time.
And gRPC does several things this release does not attempt: streaming, generated clients in a dozen languages, and rules for evolving a schema without breaking its consumers. The point of the comparison is not that a reflective Go library beats gRPC. It is that gRPC fixes the drift by making the artifact the source and generating the code, and this release fixes it by making the code the artifact. The second choice keeps the domain ignorant of the wire, and that is the property the rest of the project is built on. gRPC did not go away; today it is one of the wires that project a Koba operation, next to HTTP, and the signature it generates is one more projection of the same function.
What the tests record
The test file reads as the list of behaviors the release commits to, and its names are the rules:
TestExportedMethodsAreEndpointsTestTheHTTPMethodFollowsTheSignatureTestRouteOfTestAnAPIChoosesItsRoutesTestABodyThatDoesNotDecodeIsRejectedTestAFailingMethodIsAnsweredWithTheFailureTestRegistrationRefusesShapesNoEndpointCanServe
The last one is the one I would point at. Refusing a shape at registration is the first time the library said no to the application on the application's behalf, and that gesture, a boundary that is checked rather than documented, is what the rest of the project kept enlarging.
The conclusion that matters
Look again at Polls. It imports nothing from the library, mentions no route, no verb, no status code and no JSON. Every fact about HTTP lives on the other side of AddApi, in the library. That gives the release its real result: the transport can change independently of the domain logic, and the domain logic can change independently of the transport.
Change the derivation, say the routes, the status for a missing return, the shape of an error body, and Polls is untouched; nothing in it knew those facts. Add a method to Polls, or change what Cast takes and returns, and the HTTP surface follows by itself; nothing in the library was written for Polls. The two subjects can be worked on by different people, versioned on different schedules and tested on their own. A unit test of Cast is a function call. A test of the library is a request against any value with exported methods.
This is the separation the first entry described as the founding insight, the domain and its transport are different subjects, and here it is not a convention someone has to remember. It is a property of the code: the dependency points one way, from the library to the value, never back. Everything the project did afterwards, replacing HTTP with a second wire, making the contract explicit, generating documents from it, depends on this one arrow staying pointed in that direction.
What this release did not solve
The signature is the whole contract, and a signature says very little. Hold the polls service against the guarantees it owes, and the gaps line up. A nullifier seen twice must refuse the ballot, but the only way Cast can refuse is to panic, so uniqueness is a 500 with a message. Eligibility asks who is calling, and the function receives no context: it cannot see the caller, carry a deadline, or log under the request that reached it. A well-formed ballot has bounded preferences that sum to one, and the only validation is that the body decodes. The poll's log should be public and append-only, and a request that answers once has no way to say so. A method cannot return an error, so every failure is a defect. And HTTP is the only wire. The arrow points the right way, from the library to the domain, but the library at the other end knows only HTTP, so changing the transport means changing this library rather than choosing another. The independence is real on the domain’s side and still single-valued on the transport’s.
Each of those gaps is a later release, and each guarantee of the polls service will ask the framework for one specific thing. Domain functions with a base contract; that was the whole seed.
Where the real service stands
The real service exists, on today's Koba, and its first increment is exactly this part: the log. Holding it against the cut above shows what three layers of releases bought, guarantee by guarantee.
- Uniqueness is a declared outcome.
CastreportsNullifierSeen, with the index of the earlier ballot, and the contract lists it besidePollClosedandUnknownPoll; HTTP shows the first two as409and the last as404, because the wiring says so, and an error outside the three is a defect. - Well-formedness is a rule on a value. A question must arrive and not be blank; the options must arrive, not be null, and hold two to twenty distinct entries; a page bound sits between zero and five hundred. Each rule is a value the service names once, the plan rebuilds and the document publishes.
- Eligibility has a place. Declaring and closing a poll require the scope
polls:manage, declared in the contract and verified before the function runs; casting, reading and verifying are public, because the log is public by design and the proof that will say who may cast is carried, not yet verified. - Reading is a
GET. The same function that this release would have served as aPOSTis reached atGET /polls/{id}/log, with its page in the query, because how an operation is reached is the wiring's decision. - Nothing is written twice. The OpenAPI document, the documentation for people, the static plan and the lock are generated from the contract and the wiring, committed, and checked in CI against what the declaration produces now.
What the increment still does not do is the rest of the specification: verify the proof, draw a sample, aggregate, keep anything past the process. Each is a later increment, and each will have its entry.
Where this blog goes
The next entry walks the release where serving stopped deriving from the value and started consuming a description: the API described, planned and generated, no longer routed.
Notes
-
Tag
v0.1.0of roxiegrillo/koba-public, dated 12 January 2022. The quotes, the code and the rules in this entry are from that release;app.go,endpoints.goandapp_test.goare the files walked here. ↩