Skip to content

REST API code generation for Go

Marcelo Cantos edited this page Sep 29, 2019 · 20 revisions

From a testability perspective, here’s a somewhat radical idea around fully encapsulating the business logic relative to 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 /txs/{}
        Backend2 <- FetchTrans
        return <: set of Transaction

Put simply, one endpoint depends on two other endpoints.

Clients

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)
}

Server

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(…))().

Generating the clients

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, url.Parse("/txs/"+url.PathEscape(crn)))
    return
}

Wiring up the server

The generated frontend TransactionService logic will instantiate code-generated Backend[12] clients and pass their bounds methods to the delegated TransactionServiceServer interface. Here’s a hypothetical generated API frontend:

func HandleTransactionService(
    r chi.Router,
    ts TransactionServiceServer,
    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)
        }
    })
}

The developer writes the implementation

With all the machinery in place, the developer can now implement the core business logic.

Sequential backend calls

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 {
    ⋮
}

Parallel backend calls

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
}

Parallel backend calls processed as they arrive

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
}

Parallel backend calls processed in parallel.

We can be still more aggressive by processing each response in its own goroutine.

// 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)

    var aResult, bResult SetOfTransaction
    errors := make(chan error)

    getTransactions := func(running <-chan func() (SetOfTx, error), result *SetOfTransaction) {
        go func() {
            txes, err := (<-running)()
            if err == nil {
                *result = txes.Transform(TxToTransaction).(SetOfTransaction)
            }
            errors <- err
        }()
    })
    getTransactions(Backend1GetTxs(cctx, crn), &aResult)
    getTransactions(Backend2FechTrans(cctx, "crn:"+crn), &bResult)

    for i := 0; i < 2; i++ {
        if err := <-errors; err != nil {
            cancel()
            return nil, err
        }
    }

    // Exclude b TxIDs already in a.
    return aResult.Union(bResult.ButNot(aResult.Project("TxID"))), nil
}

Cleaner with Go 2

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() }

    var aResult, bResult SetOfTransaction
    errors := make(chan error)

    getTransactions := func(running <-chan func() (SetOfTx, error), result *SetOfTransaction) {
        go func() {
            defer func() { errors <- err }
            *result = check (<-running)()
            aResult = a.Transform(TxToTransaction).(SetOfTransaction)
        }()
    }
    getTransactions(Backend1GetTxs(cctx, crn), &aResult)
    getTransactions(Backend2FechTrans(cctx, "crn:"+crn), &bResult)

    check <-errors
    check <-errors

    // 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 handling errors.

Further ideas into implementation strategies may be found in Lazy Sets.