Skip to content

Commit 6ce2a14

Browse files
sonnesRavi Atluri
andauthored
Add xapi package with type-safe HTTP API framework (#64)
* Add xapi package with type-safe HTTP API framework - Introduced xapi package for building type-safe HTTP APIs in Go. - Implemented Endpoint, Middleware, and Error handling structures. - Added example tests demonstrating usage of endpoints with middleware and custom error handling. - Created go.mod for module management. * Add Extracter, Validator, StatusSetter, and RawWriter interfaces to xapi package - Introduced interfaces for extracting request data, validating requests, setting custom status codes, and writing raw responses. - Updated Endpoint handler to utilize these new interfaces for improved request handling and response customization. - Updatedexample tests to demonstrate usage of the new features, including custom response writing and request validation. * Add unit tests * Update Makefile and go.mod/go.sum * Update xapi package documentation * Remove XML coverage report generation from test workflow and Makefile * Update Codecov action to version 5 in test workflow --------- Co-authored-by: Ravi Atluri <[email protected]>
1 parent bfd090e commit 6ce2a14

File tree

11 files changed

+1052
-30
lines changed

11 files changed

+1052
-30
lines changed

.github/workflows/test.yml

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,8 @@ jobs:
3232
run: make test
3333
- name: Coverage
3434
run: make test-cov
35-
- name: Generate coverage report in XML format
36-
run: make test-xml
3735
- name: Upload coverage to Codecov
38-
uses: codecov/codecov-action@v2
36+
uses: codecov/codecov-action@v5
3937
with:
4038
token: ${{ secrets.CODECOV_TOKEN }}
4139
files: ./coverage.xml

Makefile

Lines changed: 4 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,7 @@ vet:
1515
@$(call run-go-mod-dir,go vet ./...,"go vet")
1616

1717
lint: golangci-lint
18-
$(GOLANGCI_LINT) run --timeout=10m -v
19-
18+
$(GOLANGCI_LINT) run --timeout=10m -v --fix
2019

2120
.PHONY: tidy
2221
tidy:
@@ -32,21 +31,11 @@ generate: mockery protoc
3231
test:
3332
@$(call run-go-mod-dir,go test -race -covermode=atomic -coverprofile=coverage.out ./...,"go test")
3433

35-
test-cov: gocov
36-
@$(call run-go-mod-dir-exclude,$(GOCOV) convert coverage.out > coverage.json,$(EXCLUDE_GO_MOD_DIRS),"gocov convert")
37-
@$(call run-go-mod-dir-exclude,$(GOCOV) convert coverage.out | $(GOCOV) report,$(EXCLUDE_GO_MOD_DIRS),"gocov report")
38-
39-
test-xml: test-cov gocov-xml
40-
@jq -n '{ Packages: [ inputs.Packages ] | add }' $(shell find . -type f -name 'coverage.json' | sort) | $(GOCOVXML) > coverage.xml
41-
42-
.PHONY: test-html
43-
44-
test-html: test-cov gocov-html
45-
@jq -n '{ Packages: [ inputs.Packages ] | add }' $(shell find . -type f -name 'coverage.json' | sort) | $(GOCOVHTML) -t kit -r > coverage.html
46-
@open coverage.html
34+
test-cov:
35+
@$(call run-go-mod-dir-exclude,go tool cover -func=coverage.out,$(EXCLUDE_GO_MOD_DIRS),"go tool cover")
4736

4837
.PHONY: check
49-
check: fmt vet lint
38+
check: tidy fmt vet lint
5039
@git diff --quiet || test $$(git diff --name-only | grep -v -e 'go.mod$$' -e 'go.sum$$' | wc -l) -eq 0 || ( echo "The following changes (result of code generators and code checks) have been detected:" && git --no-pager diff && false ) # fail if Git working tree is dirty
5140

5241
# ========= Helpers ===========
@@ -55,18 +44,6 @@ GOLANGCI_LINT = $(BIN_DIR)/golangci-lint
5544
golangci-lint:
5645
$(call go-get-tool,$(GOLANGCI_LINT),github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest)
5746

58-
GOCOV = $(BIN_DIR)/gocov
59-
gocov:
60-
$(call go-get-tool,$(GOCOV),github.com/axw/gocov/[email protected])
61-
62-
GOCOVXML = $(BIN_DIR)/gocov-xml
63-
gocov-xml:
64-
$(call go-get-tool,$(GOCOVXML),github.com/AlekSi/[email protected])
65-
66-
GOCOVHTML = $(BIN_DIR)/gocov-html
67-
gocov-html:
68-
$(call go-get-tool,$(GOCOVHTML),github.com/matm/gocov-html/cmd/[email protected])
69-
7047
MOCKERY = $(BIN_DIR)/mockery
7148
mockery:
7249
$(call go-get-tool,$(MOCKERY),github.com/vektra/mockery/[email protected])

xapi/doc.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
// Package xapi provides a type-safe lightweight HTTP API framework for Go.
2+
//
3+
// Most HTTP handlers follow the same pattern - decode JSON, extract headers/params,
4+
// validate, call business logic, encode response. xapi codifies that pattern using
5+
// generics, so you write less but get more type safety. Your request and response
6+
// types define the API contract. The optional interfaces provide flexibility when needed.
7+
//
8+
// The result: handlers that are mostly business logic, with HTTP operations abstracted
9+
// away into a lightweight framework. You can use it with your existing HTTP router and
10+
// server, keeping all existing middlewares and error handling.
11+
//
12+
// # Core Types
13+
//
14+
// [Endpoint] is the main type that wraps your [EndpointHandler] and applies middleware
15+
// and error handling. Create endpoints using [NewEndpoint] with your handler and optional
16+
// configuration via [EndpointOption] values.
17+
//
18+
// [EndpointFunc] is a function type that implements [EndpointHandler], providing a
19+
// convenient way to create handlers from functions.
20+
//
21+
// # Optional Interfaces
22+
//
23+
// xapi defines four optional interfaces. Implement them on request and response types
24+
// only when needed:
25+
//
26+
// [Validator] runs after JSON decoding to validate the request. You can use any validation
27+
// library here.
28+
//
29+
// [Extracter] pulls data from the HTTP request that isn't in the JSON body, such as headers,
30+
// route path params, or query strings.
31+
//
32+
// [StatusSetter] controls the HTTP status code. The default is 200, but you can override it
33+
// to return 201 for creation, 204 for no content, etc.
34+
//
35+
// [RawWriter] bypasses JSON encoding entirely for HTML, or binary responses.
36+
// Use this when you need full control over the response format.
37+
//
38+
// # Middleware
39+
//
40+
// Middleware works exactly like standard http.Handler middleware. Any middleware you're
41+
// already using will work. Stack them in the order you need using [WithMiddleware]. They
42+
// wrap the endpoint cleanly, keeping auth, logging, and metrics separate from your
43+
// business logic. Use [MiddlewareFunc] to convert functions to middleware, or implement
44+
// [MiddlewareHandler] for custom middleware types.
45+
//
46+
// # Error Handling
47+
//
48+
// Default behavior is a 500 status with the error text. Customize this using
49+
// [WithErrorHandler] to distinguish validation errors from auth failures, map them to
50+
// appropriate status codes, and format them consistently. Implement the [ErrorHandler]
51+
// interface or use [ErrorFunc] for simple function-based handlers. The default error
52+
// handling is provided by [DefaultErrorHandler].
53+
package xapi

xapi/endpoint.go

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
package xapi
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"net/http"
7+
)
8+
9+
// EndpointHandler defines the interface for handling endpoint requests.
10+
type EndpointHandler[TReq, TRes any] interface {
11+
Handle(ctx context.Context, req *TReq) (*TRes, error)
12+
}
13+
14+
// EndpointFunc is a function type that implements EndpointHandler.
15+
type EndpointFunc[TReq, TRes any] func(ctx context.Context, req *TReq) (*TRes, error)
16+
17+
// Handle implements the EndpointHandler interface.
18+
func (e EndpointFunc[TReq, TRes]) Handle(ctx context.Context, req *TReq) (*TRes, error) {
19+
return e(ctx, req)
20+
}
21+
22+
// Extracter allows extracting additional data from the HTTP request,
23+
// such as headers, query params, etc.
24+
type Extracter interface {
25+
Extract(r *http.Request) error
26+
}
27+
28+
// Validator allows validating endpoint requests.
29+
type Validator interface {
30+
Validate() error
31+
}
32+
33+
// StatusSetter allows setting a custom HTTP status code for the response.
34+
type StatusSetter interface {
35+
StatusCode() int
36+
}
37+
38+
// RawWriter allows writing raw data to the HTTP response instead of
39+
// the default JSON encoder.
40+
type RawWriter interface {
41+
Write(w http.ResponseWriter) error
42+
}
43+
44+
// Endpoint represents a type-safe HTTP endpoint with middleware and error handling.
45+
type Endpoint[TReq, TRes any] struct {
46+
handler EndpointHandler[TReq, TRes]
47+
opts *options
48+
}
49+
50+
// NewEndpoint creates a new Endpoint with the given handler and options.
51+
func NewEndpoint[TReq, TRes any](handler EndpointHandler[TReq, TRes], opts ...EndpointOption) *Endpoint[TReq, TRes] {
52+
e := &Endpoint[TReq, TRes]{
53+
handler: handler,
54+
opts: &options{
55+
middleware: MiddlewareStack{},
56+
errorHandler: ErrorFunc(DefaultErrorHandler),
57+
},
58+
}
59+
60+
for _, option := range opts {
61+
option.apply(e.opts)
62+
}
63+
64+
return e
65+
}
66+
67+
// Handler returns an http.Handler that processes requests for this endpoint.
68+
func (e *Endpoint[TReq, TRes]) Handler() http.Handler {
69+
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
70+
var req TReq
71+
72+
if r.Body != nil {
73+
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
74+
e.opts.errorHandler.HandleError(w, err)
75+
return
76+
}
77+
78+
defer r.Body.Close()
79+
}
80+
81+
if extracter, ok := any(&req).(Extracter); ok {
82+
if err := extracter.Extract(r); err != nil {
83+
e.opts.errorHandler.HandleError(w, err)
84+
return
85+
}
86+
}
87+
88+
if validator, ok := any(&req).(Validator); ok {
89+
if err := validator.Validate(); err != nil {
90+
e.opts.errorHandler.HandleError(w, err)
91+
return
92+
}
93+
}
94+
95+
res, err := e.handler.Handle(r.Context(), &req)
96+
if err != nil {
97+
e.opts.errorHandler.HandleError(w, err)
98+
return
99+
}
100+
101+
if rawWriter, ok := any(res).(RawWriter); ok {
102+
if err := rawWriter.Write(w); err != nil {
103+
e.opts.errorHandler.HandleError(w, err)
104+
return
105+
}
106+
107+
return
108+
}
109+
110+
statusCode := http.StatusOK
111+
112+
if statusSetter, ok := any(res).(StatusSetter); ok {
113+
statusCode = statusSetter.StatusCode()
114+
}
115+
116+
resBody, err := json.Marshal(res)
117+
if err != nil {
118+
e.opts.errorHandler.HandleError(w, err)
119+
return
120+
}
121+
122+
w.Header().Set("Content-Type", "application/json; charset=utf-8")
123+
w.WriteHeader(statusCode)
124+
w.Write(resBody)
125+
})
126+
127+
if len(e.opts.middleware) > 0 {
128+
return e.opts.middleware.Middleware(h)
129+
}
130+
131+
return h
132+
}

0 commit comments

Comments
 (0)