The cart's API

The cart’s endpoint package: its bodies, the Store and the Catalog it is served on, its paths and parameters, its six operations on openapi/v3, the answers each error becomes, the API that serves them, and the document.

The bodies

Start the endpoint package with what the API reads and writes. Its tags become the document’s schemas:

type Cart struct {
	CartID    uuid.UUID  `json:"cartId" required:"true" format:"uuid"`
	Items     []CartItem `json:"items" required:"true"`
	CreatedAt time.Time  `json:"createdAt" required:"true"`
	UpdatedAt time.Time  `json:"updatedAt" required:"true"`
}

type CartItem struct {
	ItemID    uuid.UUID `json:"itemId" required:"true" format:"uuid"`
	CartID    uuid.UUID `json:"cartId" required:"true" format:"uuid"`
	ProductID string    `json:"productId" required:"true"`
	Quantity  int       `json:"quantity" required:"true" minimum:"1"`
	UnitPrice float64   `json:"unitPrice" required:"true" minimum:"0"`
	CreatedAt time.Time `json:"createdAt" required:"true"`
	UpdatedAt time.Time `json:"updatedAt" required:"true"`
}

type AddItemRequest struct {
	ProductID string `json:"productId" required:"true" minLength:"1"`
	Quantity  int    `json:"quantity" required:"true" minimum:"1"`
}
type UpdateItemRequest struct {
	Quantity int `json:"quantity" required:"true" minimum:"1"`
}

An item’s request has no price. The price comes from the catalog, so a client can’t set its own.

The Store and the Catalog

The operations are served on two interfaces. Every Store method takes the cart’s owner:

type NewItem struct {
	ProductID string
	Quantity  int
	UnitPrice float64
}
type Store interface {
	CreateCart(ctx context.Context, owner string) (Cart, error)
	GetCart(ctx context.Context, owner string, cartID uuid.UUID) (Cart, error)
	DeleteCart(ctx context.Context, owner string, cartID uuid.UUID) error
	AddItem(ctx context.Context, owner string, cartID uuid.UUID, item NewItem) (CartItem, error)
	UpdateItem(ctx context.Context, owner string, cartID, itemID uuid.UUID, quantity int) (CartItem, error)
	RemoveItem(ctx context.Context, owner string, cartID, itemID uuid.UUID) error
}

type Catalog interface {
	Price(ctx context.Context, productID string) (float64, error)
}

What they return, besides a result, is one of these errors, or another that wraps none of them:

var (
	ErrCartNotFound        = errors.New("cart not found")
	ErrItemNotFound        = errors.New("item not found")
	ErrProductNotFound     = errors.New("product not found")
	ErrCatalogUnavailable  = errors.New("the catalog is unavailable")
	ErrDatabaseUnavailable = errors.New("the database is unavailable")
)

The answers

Three answers are problem details of the API’s own, and anything else is a 500 that says nothing of the failure:

type NotFound struct{ problem.Details }

type Invalid struct{ problem.Details }

type Unavailable struct{ problem.Details }

func InternalError(err error) problem.Details {
	return problem.Details{
		Type:   "about:blank",
		Title:  http.StatusText(http.StatusInternalServerError),
		Status: http.StatusInternalServerError,
		Detail: "The server failed to answer the request.",
	}
}

Each is made with its status and a detail:

func notFound(detail string) NotFound {
	return NotFound{problem.Details{
		Type:   "about:blank",
		Title:  http.StatusText(http.StatusNotFound),
		Status: http.StatusNotFound,
		Detail: detail,
	}}
}

answer turns the Store’s and the Catalog’s errors into them. A dependency that is down is a 503, never a 500, and its detail says which:

func answer(err error, cartID, itemID uuid.UUID) error {
	switch {
	case errors.Is(err, ErrCartNotFound):
		return notFound(fmt.Sprintf("There is no cart %s.", cartID))
	case errors.Is(err, ErrItemNotFound):
		return notFound(fmt.Sprintf("Cart %s has no item %s.", cartID, itemID))
	case errors.Is(err, ErrDatabaseUnavailable):
		return unavailable("The cart can't reach its database right now. Try again later.")
	case errors.Is(err, ErrCatalogUnavailable) && errors.Is(err, circuit.ErrRejected):
		return unavailable("The cart isn't calling the catalog right now, since it has been failing. Try again later.")
	case errors.Is(err, ErrCatalogUnavailable):
		return unavailable("The cart can't reach the catalog right now. Try again later.")
	default:
		return err
	}
}

The paths

The paths are built from typed parts. Both IDs are UUIDs:

var uuidPattern = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`)

var (
	cartID = openapi.PathParam[string]("cartId").Description("The cart's identifier, a UUID.").Pattern(uuidPattern)
	itemID = openapi.PathParam[string]("itemId").Description("The item's identifier, a UUID.").Pattern(uuidPattern)
)

var (
	carts = openapi.Root().Lit("carts")
	cart  = carts.Param(cartID)
	items = cart.Lit("items")
	item  = items.Param(itemID)
)

The pattern is checked before a handler runs, so a handler parses an ID that is already well formed:

func uuidFrom[B any](req openapi.Request[B], p openapi.PathParameter[string]) (uuid.UUID, error) {
	return uuid.Parse(openapi.ParamFrom(req, p))
}

The operations

Carts holds what the operations are served on, and declares the six of them:

type Carts struct {
	Store   Store
	Catalog Catalog
	Bearer  auth.Scheme[Claims]
}

func (c Carts) Operations() []openapi.Operation {
	write := openapi.Security(auth.RequireScopes(c.Bearer, ScopeWrite))
	return []openapi.Operation{
		openapi.Post(carts, c.createCart, openapi.EmptyDecoder, jsoncodec.Encoder,
			openapi.OperationID("createCart"),
			write,
			openapi.Summary("Create a new shopping cart"),
			openapi.Status(http.StatusCreated),
			tags,
		),
		openapi.Get(cart, c.getCart, jsoncodec.Encoder,
			openapi.OperationID("getCart"),
			openapi.Summary("Get a shopping cart and its items"),
			tags,
		),
		openapi.Delete(cart, c.deleteCart, openapi.EmptyEncoder,
			openapi.OperationID("deleteCart"),
			write,
			openapi.Summary("Delete a shopping cart and all its items"),
			tags,
		),
		openapi.Post(items, c.addCartItem, jsoncodec.Decoder, jsoncodec.Encoder,
			openapi.OperationID("addCartItem"),
			write,
			openapi.Summary("Add an item to a shopping cart"),
			openapi.Status(http.StatusCreated),
			invalidBody,
			tags,
		),
		openapi.Patch(item, c.updateCartItem, jsoncodec.Decoder, jsoncodec.Encoder,
			openapi.OperationID("updateCartItem"),
			write,
			openapi.Summary("Update the quantity of an item in a shopping cart"),
			invalidBody,
			tags,
		),
		openapi.Delete(item, c.removeCartItem, openapi.EmptyEncoder,
			openapi.OperationID("removeCartItem"),
			write,
			openapi.Summary("Remove an item from a shopping cart"),
			tags,
		),
	}
}

The two with a body answer one it won’t accept with an Invalid:

var (
	tags        = openapi.Tags("carts")
	invalidBody = openapi.Error[Invalid](http.StatusUnprocessableEntity, jsoncodec.Problem)
)

Who may do what builds write and the bearer scheme.

A handler

Each handler is a plain function from a Request to its answer. Adding an item checks the body, asks the catalog for the price, and stores the item with it:

func (c Carts) addCartItem(ctx context.Context, req openapi.Request[AddItemRequest]) (CartItem, error) {
	owner, err := c.owner(ctx)
	if err != nil {
		return CartItem{}, err
	}
	id, err := uuidFrom(req, cartID)
	if err != nil {
		return CartItem{}, err
	}
	body := req.Body()
	switch {
	case body.ProductID == "":
		return CartItem{}, invalid("productId is required")
	case body.Quantity < 1:
		return CartItem{}, invalid("quantity must be at least 1")
	}
	price, err := c.Catalog.Price(ctx, body.ProductID)
	if errors.Is(err, ErrProductNotFound) {
		return CartItem{}, invalid(fmt.Sprintf("There is no product %s in the catalog.", body.ProductID))
	}
	if err != nil {
		return CartItem{}, answer(err, id, uuid.Nil)
	}
	added, err := c.Store.AddItem(ctx, owner, id, NewItem{ProductID: body.ProductID, Quantity: body.Quantity, UnitPrice: price})
	if err != nil {
		return CartItem{}, answer(err, id, uuid.Nil)
	}
	return added, nil
}

The item keeps the price it was added at, so a later change of price doesn’t change a cart.

A cart with no items answers "items": [], never null:

func withItems(c Cart) Cart {
	if c.Items == nil {
		c.Items = []CartItem{}
	}
	return c
}

The API

The cart’s Init builds the API from the operations, the document, and the answers every operation shares:

api, err := openapi.NewAPI(s,
	openapi.Info{
		Title:       "Shopping Cart API",
		Version:     "1.0.0",
		Description: "API for managing shopping carts on an e-commerce platform.",
	},
	openapi.Unmatched{
		NotFound:         problem.Handler(http.StatusNotFound, jsoncodec.Problem),
		MethodNotAllowed: problem.Handler(http.StatusMethodNotAllowed, jsoncodec.Problem),
	},
	openapi.RequestErrors(problem.FromRequestError, jsoncodec.Problem),
	openapi.CatchAll(http.StatusInternalServerError, endpoint.InternalError, jsoncodec.Problem),
	append(endpoint.Carts{Store: store, Catalog: catalog, Bearer: bearer}.Operations(),
		openapi.Document(openapi.Root().Lit("openapi.json"), openapi.Security(auth.Disabled())),
	),
	openapi.Error[endpoint.NotFound](http.StatusNotFound, jsoncodec.Problem),
	openapi.Security(auth.Require(bearer)),
	openapi.Error[endpoint.Unavailable](http.StatusServiceUnavailable, jsoncodec.Problem),
)
if err != nil {
	return nil, err
}

It is served behind http.MaxBytesHandler, so a body over 64 KiB is a 413:

mux.Handle("/", http.MaxBytesHandler(api, maxBodyBytes))
const maxBodyBytes = 64 << 10
OperationPathAnswers
createCartPOST /carts201, 401, 403, 404, 500, 503
getCartGET /carts/{cartId}200, 400, 401, 404, 500, 503
deleteCartDELETE /carts/{cartId}204, 400, 401, 403, 404, 500, 503
addCartItemPOST /carts/{cartId}/items201, 400, 401, 403, 404, 413, 415, 422, 500, 503
updateCartItemPATCH /carts/{cartId}/items/{itemId}200, 400, 401, 403, 404, 413, 415, 422, 500, 503
removeCartItemDELETE /carts/{cartId}/items/{itemId}204, 400, 401, 403, 404, 500, 503

A 400, a 413 and a 415 are RequestErrors’: a parameter, or a body, the API couldn’t read. A path or a method the API doesn’t serve is Unmatched’s 404 or 405.

The document

The document is served at GET /openapi.json, to anyone. The cart’s tests keep it as a golden file, app/testdata/openapi.json, so a change to any declaration shows up in review as a change to the document.