Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,8 @@ jobs:
run: make test
- name: Coverage
run: make test-cov
- name: Generate coverage report in XML format
run: make test-xml
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v2
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./coverage.xml
Expand Down
31 changes: 4 additions & 27 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,7 @@ vet:
@$(call run-go-mod-dir,go vet ./...,"go vet")

lint: golangci-lint
$(GOLANGCI_LINT) run --timeout=10m -v

$(GOLANGCI_LINT) run --timeout=10m -v --fix

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

test-cov: gocov
@$(call run-go-mod-dir-exclude,$(GOCOV) convert coverage.out > coverage.json,$(EXCLUDE_GO_MOD_DIRS),"gocov convert")
@$(call run-go-mod-dir-exclude,$(GOCOV) convert coverage.out | $(GOCOV) report,$(EXCLUDE_GO_MOD_DIRS),"gocov report")

test-xml: test-cov gocov-xml
@jq -n '{ Packages: [ inputs.Packages ] | add }' $(shell find . -type f -name 'coverage.json' | sort) | $(GOCOVXML) > coverage.xml

.PHONY: test-html

test-html: test-cov gocov-html
@jq -n '{ Packages: [ inputs.Packages ] | add }' $(shell find . -type f -name 'coverage.json' | sort) | $(GOCOVHTML) -t kit -r > coverage.html
@open coverage.html
test-cov:
@$(call run-go-mod-dir-exclude,go tool cover -func=coverage.out,$(EXCLUDE_GO_MOD_DIRS),"go tool cover")

.PHONY: check
check: fmt vet lint
check: tidy fmt vet lint
@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

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

GOCOV = $(BIN_DIR)/gocov
gocov:
$(call go-get-tool,$(GOCOV),github.com/axw/gocov/[email protected])

GOCOVXML = $(BIN_DIR)/gocov-xml
gocov-xml:
$(call go-get-tool,$(GOCOVXML),github.com/AlekSi/[email protected])

GOCOVHTML = $(BIN_DIR)/gocov-html
gocov-html:
$(call go-get-tool,$(GOCOVHTML),github.com/matm/gocov-html/cmd/[email protected])

MOCKERY = $(BIN_DIR)/mockery
mockery:
$(call go-get-tool,$(MOCKERY),github.com/vektra/mockery/[email protected])
Expand Down
53 changes: 53 additions & 0 deletions xapi/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// Package xapi provides a type-safe lightweight HTTP API framework for Go.
//
// Most HTTP handlers follow the same pattern - decode JSON, extract headers/params,
// validate, call business logic, encode response. xapi codifies that pattern using
// generics, so you write less but get more type safety. Your request and response
// types define the API contract. The optional interfaces provide flexibility when needed.
//
// The result: handlers that are mostly business logic, with HTTP operations abstracted
// away into a lightweight framework. You can use it with your existing HTTP router and
// server, keeping all existing middlewares and error handling.
//
// # Core Types
//
// [Endpoint] is the main type that wraps your [EndpointHandler] and applies middleware
// and error handling. Create endpoints using [NewEndpoint] with your handler and optional
// configuration via [EndpointOption] values.
//
// [EndpointFunc] is a function type that implements [EndpointHandler], providing a
// convenient way to create handlers from functions.
//
// # Optional Interfaces
//
// xapi defines four optional interfaces. Implement them on request and response types
// only when needed:
//
// [Validator] runs after JSON decoding to validate the request. You can use any validation
// library here.
//
// [Extracter] pulls data from the HTTP request that isn't in the JSON body, such as headers,
// route path params, or query strings.
//
// [StatusSetter] controls the HTTP status code. The default is 200, but you can override it
// to return 201 for creation, 204 for no content, etc.
//
// [RawWriter] bypasses JSON encoding entirely for HTML, or binary responses.
// Use this when you need full control over the response format.
//
// # Middleware
//
// Middleware works exactly like standard http.Handler middleware. Any middleware you're
// already using will work. Stack them in the order you need using [WithMiddleware]. They
// wrap the endpoint cleanly, keeping auth, logging, and metrics separate from your
// business logic. Use [MiddlewareFunc] to convert functions to middleware, or implement
// [MiddlewareHandler] for custom middleware types.
//
// # Error Handling
//
// Default behavior is a 500 status with the error text. Customize this using
// [WithErrorHandler] to distinguish validation errors from auth failures, map them to
// appropriate status codes, and format them consistently. Implement the [ErrorHandler]
// interface or use [ErrorFunc] for simple function-based handlers. The default error
// handling is provided by [DefaultErrorHandler].
package xapi
132 changes: 132 additions & 0 deletions xapi/endpoint.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
package xapi

import (
"context"
"encoding/json"
"net/http"
)

// EndpointHandler defines the interface for handling endpoint requests.
type EndpointHandler[TReq, TRes any] interface {
Handle(ctx context.Context, req *TReq) (*TRes, error)
}

// EndpointFunc is a function type that implements EndpointHandler.
type EndpointFunc[TReq, TRes any] func(ctx context.Context, req *TReq) (*TRes, error)

// Handle implements the EndpointHandler interface.
func (e EndpointFunc[TReq, TRes]) Handle(ctx context.Context, req *TReq) (*TRes, error) {
return e(ctx, req)
}

// Extracter allows extracting additional data from the HTTP request,
// such as headers, query params, etc.
type Extracter interface {
Extract(r *http.Request) error
}

// Validator allows validating endpoint requests.
type Validator interface {
Validate() error
}

// StatusSetter allows setting a custom HTTP status code for the response.
type StatusSetter interface {
StatusCode() int
}

// RawWriter allows writing raw data to the HTTP response instead of
// the default JSON encoder.
type RawWriter interface {
Write(w http.ResponseWriter) error
}

// Endpoint represents a type-safe HTTP endpoint with middleware and error handling.
type Endpoint[TReq, TRes any] struct {
handler EndpointHandler[TReq, TRes]
opts *options
}

// NewEndpoint creates a new Endpoint with the given handler and options.
func NewEndpoint[TReq, TRes any](handler EndpointHandler[TReq, TRes], opts ...EndpointOption) *Endpoint[TReq, TRes] {
e := &Endpoint[TReq, TRes]{
handler: handler,
opts: &options{
middleware: MiddlewareStack{},
errorHandler: ErrorFunc(DefaultErrorHandler),
},
}

for _, option := range opts {
option.apply(e.opts)
}

return e
}

// Handler returns an http.Handler that processes requests for this endpoint.
func (e *Endpoint[TReq, TRes]) Handler() http.Handler {
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req TReq

if r.Body != nil {
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
e.opts.errorHandler.HandleError(w, err)
return
}

defer r.Body.Close()
}

if extracter, ok := any(&req).(Extracter); ok {
if err := extracter.Extract(r); err != nil {
e.opts.errorHandler.HandleError(w, err)
return
}
}

if validator, ok := any(&req).(Validator); ok {
if err := validator.Validate(); err != nil {
e.opts.errorHandler.HandleError(w, err)
return
}
}

res, err := e.handler.Handle(r.Context(), &req)
if err != nil {
e.opts.errorHandler.HandleError(w, err)
return
}

if rawWriter, ok := any(res).(RawWriter); ok {
if err := rawWriter.Write(w); err != nil {
e.opts.errorHandler.HandleError(w, err)
return
}

return
}

statusCode := http.StatusOK

if statusSetter, ok := any(res).(StatusSetter); ok {
statusCode = statusSetter.StatusCode()
}

resBody, err := json.Marshal(res)
if err != nil {
e.opts.errorHandler.HandleError(w, err)
return
}

w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(statusCode)
w.Write(resBody)
})

if len(e.opts.middleware) > 0 {
return e.opts.middleware.Middleware(h)
}

return h
}
Loading