-
Notifications
You must be signed in to change notification settings - Fork 42
REST API code generation for Go
From a testability perspective, here’s a somewhat radical idea around fully encapsulating the business logic from both its dependants and its dependencies. Consider the following Sysl design:
Backend1:
GET /txs/{crn<:string}:
return <: set of Tx
Backend2:
FetchTrans(custid<:string):
return <: set of Tx
TransactionService:
GET /transactions/{crn<:string}:
Backend1 <- GET /tx
Backend2 <- FetchTrans
return <: set of Transaction
The generated interfaces for Backend[12] and TransactionService would be fairly straightforward, something like:
type Backend1 interface {
GetTxs(ctx context.Context, crn string) (SetOfTx, error)
}
type Backend2 interface {
FetchTrans(ctx context.Context, custid string) (SetOfTx, error)
}
type TransactionService interface {
GetTransactions(ctx context.Context, crn string) (SetOfTransaction, error)
}
The TransactionService, however, would have an additional interface, the purpose of which is to define the protocol between the front-end service that implements the endpoint and the implementation of the endpoint:
type TransactionServiceServer interface {
GetTransactions(
ctx context.Context,
crn string,
Backend1GetTxs func(ctx context.Context, crn string) <-chan func() (SetOfTx, error),
Backend2FetchTrans func(ctx context.Context, custid string) <-chan func() (SetOfTx, error),
) (SetOfTransaction, error)
}
The only difference between TransactionServiceServer
and TransactionService
is the two additional parameters, Backend1GetTxs
and Backend2FetchTrans
. These are simply the endpoints GetTransactions
depends on according to the Sysl definition. They are supplied by some outer context.
These endpoints are defined to return <-chan func() (SetOfTx, error)
(instead of simply (SetOfTx, error)
) in order to support simpler asynchronous usage. Implementation code can call both functions to start invocation and then select
on the returned channels in order to respond to whichever call is ready first. This is illustrated below. Note that, in the simple case of just wanting the result upfront, calls can be wrapped in an odd but simple construct: result, err := (<-Backend1GetTxs(…))()
.
Client-side logic will usually be generated:
type backend1Client struct {
client *http.Client
}
func NewBackend1Client(client *http.Client) Backend1 {
return &backend1Client{client}
}
func (c *backend1Client) GetTxs(ctx context.Context, crn string) (result SetOfTx, err error) {
err = internal.Get(ctx, c.client, &result, "/txs/"+url.PathEscape(crn))
return
}
The generated frontend TransactionService logic will instantiate code-generated Backend[12] clients and pass their bounds methods to the delegated TransactionService interface. Here’s a hypothetical generated API frontend:
func HandleTransactionService(
r chi.Router,
ts TransactionServiceProtocol,
backend1 Backend1,
backend2 Backend2,
) {
r.Get("/transactions/{crn}", func(w http.ResponseWriter, r *http.Request) {
crn := chi.URLParam(r, "crn")
err, result := ts.GetTransactions(
r.Context(),
crn,
func(ctx context.Context, crn string) <-chan func() (SetOfTx, error) {
if err := internal.ContextPassThrough(r, ctx); err != nil {
panic(err)
}
ready := make(<-chan func() (SetOfTx, error))
go func() {
result, err := backend1.GetTxs(ctx, crn)
ready <- func() (SetOfTx, error) { return result, err }
}
return ready
},
func(ctx context.Context, custid string) <-chan func() (SetOfTx, error) {
if err := internal.ContextPassThrough(r, ctx); err != nil {
panic(err)
}
ready := make(<-chan func() (SetOfTx, error))
go func() {
result, err := backend2.FetchTrans(ctx, custid)
ready <- func() (SetOfTx, error) { return result, err }
}
return ready
},
backend2.FetchTrans,
)
if err != nil {
http.Error(w, http.StatusText(500) + ": " + err.Error(), 500)
} else {
json.NewEncoder(w).Encode(result)
}
})
}
With all the machinery in place, the developer can now implement the core business logic.
type TransactionServiceServerImpl struct {}
// GetTransactions calls Backend1/2 and combines the results into a single set.
// Only one of each transaction will appear in the result.
func (*TransactionServiceServerImpl) GetTransactions(
ctx context.Context,
crn string,
Backend1GetTxs func(ctx context.Context, crn string) <-chan func() (SetOfTx, error),
Backend2FetchTrans func(ctx context.Context, custid string) <-chan func() (SetOfTx, error),
) (SetOfTransaction, error) {
a, err := (<-Backend1GetTxs(ctx, crn))()
if err != nil {
return nil, err
}
aResult := a.Transform(TxToTransaction).(SetOfTransaction)
b, err := (<-Backend2FechTrans(ctx, "crn:"+crn))()
if err != nil {
return nil, err
}
bResult := b.Transform(TxToTransaction).(SetOfTransaction)
// Exclude b TxIDs already in a.
return aResult.Union(bResult.ButNot(aResult.Project("TxID"))), nil
}
func TxToTransaction(tx Tx) Transaction {
⋮
}
If you want to execute the backends in parallel, things get only slightly more complicated.
// GetTransactions calls Backend1/2 and combines the results into a single set.
// Only one of each transaction will appear in the result. If either backend
// call fails, the entire operation fails.
func (*TransactionServiceServerImpl) GetTransactions(
ctx context.Context,
crn string,
Backend1GetTxs func(ctx context.Context, crn string) <-chan func() (SetOfTx, error),
Backend2FetchTrans func(ctx context.Context, custid string) <-chan func() (SetOfTx, error),
) (SetOfTransaction, error) {
aRunning := Backend1GetTxs(cctx, crn)
bRunning := Backend2FechTrans(cctx, "crn:"+crn)
a, err := (<-aRunning)()
if err != nil {
return err
}
aResult := a.Transform(TxToTransaction).(SetOfTransaction)
b, err := (<-bRunning)()
if err != nil {
return err
}
bResult := b.Transform(TxToTransaction).(SetOfTransaction)
// Exclude b TxIDs already in a.
return aResult.Union(bResult.ButNot(aResult.Project("TxID"))), nil
}
Things get even more complicated if you want to immediately process whichever result arrives first and cancel the other in-flight backend call when one fails:
// GetTransactions calls Backend1/2 and combines the results into a single set.
// Only one of each transaction will appear in the result. If either backend
// call fails, the other call is cancelled and the entire operation fails.
func (*TransactionServiceServerImpl) GetTransactions(
ctx context.Context,
crn string,
Backend1GetTxs func(ctx context.Context, crn string) <-chan func() (SetOfTx, error),
Backend2FetchTrans func(ctx context.Context, custid string) <-chan func() (SetOfTx, error),
) (SetOfTransaction, error) {
cctx, cancel := context.WithCancel(ctx)
aRunning := Backend1GetTxs(cctx, crn)
bRunning := Backend2FechTrans(cctx, "crn:"+crn)
var aResult, bResult SetOfTransaction
for i := 0; i < 2; i++ {
select {
case aReady := <-aRunning:
a, err := aReady()
if err != nil {
cancel()
return nil, err
}
aResult = a.Transform(TxToTransaction).(SetOfTransaction)
case bReady := <-bRunning:
b, err := bReady()
if err != nil {
cancel()
return nil, err
}
bResult = b.Transform(TxToTransaction).(SetOfTransaction)
}
// Exclude b TxIDs already in a.
return aResult.Union(bResult.ButNot(aResult.Project("TxID"))), nil
}
Go 2's error handling and generics will support a cleaner design:
func (*TransactionServiceServerImpl) GetTransactions(
ctx context.Context,
crn string,
Backend1GetTxs func(ctx context.Context, crn string) <-chan func() (SetOfTx, error),
Backend2FetchTrans func(ctx context.Context, custid string) <-chan func() (SetOfTx, error),
) (SetOfTransaction, error) {
cctx, cancel := context.WithCancel(ctx)
handle err { cancel() }
a := Backend1GetTxs(cctx, crn)
b := Backend2FechTrans(cctx, "crn:"+crn)
var aResult, bResult SetOfTransaction
select {
case a := <-a:
aResult = (check a()).Transform(TxToTransaction)
bResult = (check (<-b)()).Transform(TxToTransaction)
case b := <-b:
bResult = (check b()).Transform(TxToTransaction)
aResult = (check (<-a)()).Transform(TxToTransaction)
}
// Exclude b TxIDs already in a.
return aResult.Union(bResult.ButNot(aResult.Project("TxID"))), nil
}
Note that the clumsy for-loop is gone due to the ease of finalising both calls within each case
.