storj/private/apigen/endpoint.go
Ivan Fraixedes adcd810e37 private/apigen: Allow to customize handlers logic
The API generator doesn't have a way to customize each Go handler
endpoint unless that the Go generator is modified.

This commit adds a way to customize each endpoint injecting instances of
types that implement an interface (Middleware) that return the code to inject.

To show how it works, the commit get rid of the 2 fields that we used to
customize the authentication request with the logic that the
satellite/console/consoleweb/consoleapi needs and replace the hardcoded
customization using this new way to customize handlers.

This new way should allow to hook the satellite/admin/back-office
authorization into the handlers using a Middleware implementation.

Change-Id: I894aa0026b30fa2f4a5604a6c34c22e0ed582e2b
2023-11-23 06:57:40 +00:00

441 lines
14 KiB
Go

// Copyright (C) 2022 Storj Labs, Inc.
// See LICENSE for copying information.
package apigen
import (
"fmt"
"net/http"
"path/filepath"
"reflect"
"regexp"
"strings"
"time"
"unicode"
"github.com/zeebo/errs"
"storj.io/common/uuid"
)
var (
errsEndpoint = errs.Class("Endpoint")
goNameRegExp = regexp.MustCompile(`^[A-Z]\w*$`)
typeScriptNameRegExp = regexp.MustCompile(`^[a-z][a-zA-Z0-9_$]*$`)
)
// Endpoint represents endpoint's configuration.
//
// Passing an anonymous type to the fields that define the request or response will make the API
// generator to panic. Anonymous types aren't allowed such as named structs that have fields with
// direct or indirect of anonymous types, slices or arrays whose direct or indirect elements are of
// anonymous types.
type Endpoint struct {
// Name is a free text used to name the endpoint for documentation purpose.
// It cannot be empty.
Name string
// Description is a free text to describe the endpoint for documentation purpose.
Description string
// GoName is an identifier used by the Go generator to generate specific server side code for this
// endpoint.
//
// It must start with an uppercase letter and fulfill the Go language specification for method
// names (https://go.dev/ref/spec#MethodName).
// It cannot be empty.
GoName string
// TypeScriptName is an identifier used by the TypeScript generator to generate specific client
// code for this endpoint
//
// It must start with a lowercase letter and can only contains letters, digits, _, and $.
// It cannot be empty.
TypeScriptName string
// Request is the type that defines the format of the request body.
Request interface{}
// Response is the type that defines the format of the response body.
Response interface{}
// QueryParams is the list of query parameters that the endpoint accepts.
QueryParams []Param
// PathParams is the list of path parameters that appear in the path associated with this
// endpoint.
PathParams []Param
// ResponseMock is the data to use as a response for the generated mocks.
// It must be of the same type than Response.
// If a mock generator is called it must not be nil unless Response is nil.
ResponseMock interface{}
// Settings is the data to pass to the middleware handlers to adapt the generated
// code to this endpoints.
//
// Not all the middlware handlers need extra data. Some of them use this data to disable it in
// some endpoints.
Settings map[any]any
}
// Validate validates the endpoint fields values are correct according to the documented constraints.
func (e *Endpoint) Validate() error {
newErr := func(m string, a ...any) error {
e := fmt.Sprintf(". Endpoint: %s", e.Name)
m += e
return errsEndpoint.New(m, a...)
}
if e.Name == "" {
return newErr("Name cannot be empty")
}
if e.Description == "" {
return newErr("Description cannot be empty")
}
if !goNameRegExp.MatchString(e.GoName) {
return newErr("GoName doesn't match the regular expression %q", goNameRegExp)
}
if !typeScriptNameRegExp.MatchString(e.TypeScriptName) {
return newErr("TypeScriptName doesn't match the regular expression %q", typeScriptNameRegExp)
}
if e.Request != nil {
switch t := reflect.TypeOf(e.Request); t.Kind() {
case reflect.Invalid,
reflect.Complex64,
reflect.Complex128,
reflect.Chan,
reflect.Func,
reflect.Interface,
reflect.Map,
reflect.Pointer,
reflect.UnsafePointer:
return newErr("Request cannot be of a type %q", t.Kind())
case reflect.Array, reflect.Slice:
if t.Elem().Name() == "" {
return newErr("Request cannot be of %q of anonymous struct elements", t.Kind())
}
case reflect.Struct:
if t.Name() == "" {
return newErr("Request cannot be of an anonymous struct")
}
}
}
if e.Response != nil {
switch t := reflect.TypeOf(e.Response); t.Kind() {
case reflect.Invalid,
reflect.Complex64,
reflect.Complex128,
reflect.Chan,
reflect.Func,
reflect.Interface,
reflect.Map,
reflect.Pointer,
reflect.UnsafePointer:
return newErr("Response cannot be of a type %q", t.Kind())
case reflect.Array, reflect.Slice:
if t.Elem().Name() == "" {
return newErr("Response cannot be of %q of anonymous struct elements", t.Kind())
}
case reflect.Struct:
if t.Name() == "" {
return newErr("Response cannot be of an anonymous struct")
}
}
if e.ResponseMock != nil {
if m, r := reflect.TypeOf(e.ResponseMock), reflect.TypeOf(e.Response); m != r {
return newErr(
"ResponseMock isn't of the same type than Response. Have=%q Want=%q", m, r,
)
}
}
}
return nil
}
// FullEndpoint represents endpoint with path and method.
type FullEndpoint struct {
Endpoint
Path string
Method string
}
// EndpointGroup represents endpoints group.
// You should always create a group using API.Group because it validates the field values to
// guarantee correct code generation.
type EndpointGroup struct {
// Name is the group name.
//
// Go generator uses it as part of type, functions, interfaces names, and in code comments.
// The casing is adjusted according where it's used.
//
// TypeScript generator uses it as part of types names for the API functionality of this group.
// The casing is adjusted according where it's used.
//
// Document generator uses as it is.
Name string
// Prefix is a prefix used for
//
// Go generator uses it as part of variables names, error messages, and the URL base path for the group.
// The casing is adjusted according where it's used, but for the URL base path, lowercase is used.
//
// TypeScript generator uses it for composing the URL base path (lowercase).
//
// Document generator uses as it is.
Prefix string
// Middleware is a list of additional processing of requests that apply to all the endpoints of this group.
Middleware []Middleware
// endpoints is the list of endpoints added to this group through the "HTTP method" methods (e.g.
// Get, Patch, etc.).
endpoints []*FullEndpoint
}
// Get adds new GET endpoint to endpoints group.
// It panics if path doesn't begin with '/'.
func (eg *EndpointGroup) Get(path string, endpoint *Endpoint) {
eg.addEndpoint(path, http.MethodGet, endpoint)
}
// Patch adds new PATCH endpoint to endpoints group.
// It panics if path doesn't begin with '/'.
func (eg *EndpointGroup) Patch(path string, endpoint *Endpoint) {
eg.addEndpoint(path, http.MethodPatch, endpoint)
}
// Post adds new POST endpoint to endpoints group.
// It panics if path doesn't begin with '/'.
func (eg *EndpointGroup) Post(path string, endpoint *Endpoint) {
eg.addEndpoint(path, http.MethodPost, endpoint)
}
// Delete adds new DELETE endpoint to endpoints group.
// It panics if path doesn't begin with '/'.
func (eg *EndpointGroup) Delete(path string, endpoint *Endpoint) {
eg.addEndpoint(path, http.MethodDelete, endpoint)
}
// addEndpoint adds new endpoint to endpoints list.
// It panics if:
// - path doesn't begin with '/'.
// - endpoint.Validate() returns an error.
// - An Endpoint with the same path and method already exists.
func (eg *EndpointGroup) addEndpoint(path, method string, endpoint *Endpoint) {
if !strings.HasPrefix(path, "/") {
panic(
fmt.Sprintf(
"invalid path for method %q of EndpointGroup %q. path must start with slash, got %q",
method,
eg.Name,
path,
),
)
}
if err := endpoint.Validate(); err != nil {
panic(err)
}
ep := &FullEndpoint{*endpoint, path, method}
for _, e := range eg.endpoints {
if e.Path == path && e.Method == method {
panic(fmt.Sprintf("there is already an endpoint defined with path %q and method %q", path, method))
}
if e.GoName == ep.GoName {
panic(
fmt.Sprintf("GoName %q is already used by the endpoint with path %q and method %q", e.GoName, e.Path, e.Method),
)
}
if e.TypeScriptName == ep.TypeScriptName {
panic(
fmt.Sprintf(
"TypeScriptName %q is already used by the endpoint with path %q and method %q",
e.TypeScriptName,
e.Path,
e.Method,
),
)
}
}
eg.endpoints = append(eg.endpoints, ep)
}
// Param represents string interpretation of param's name and type.
type Param struct {
Name string
Type reflect.Type
}
// NewParam constructor which creates new Param entity by given name and type through instance.
//
// instance can only be a unsigned integer (of any size), string, uuid.UUID or time.Time, otherwise
// it panics.
func NewParam(name string, instance interface{}) Param {
switch t := reflect.TypeOf(instance); t {
case reflect.TypeOf(uuid.UUID{}), reflect.TypeOf(time.Time{}):
default:
switch k := t.Kind(); k {
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.String:
default:
panic(
fmt.Sprintf(
`Unsupported parameter, only types: %q, %q, string, and "unsigned numbers" are supported . Found type=%q, Kind=%q`,
reflect.TypeOf(uuid.UUID{}),
reflect.TypeOf(time.Time{}),
t,
k,
),
)
}
}
return Param{
Name: name,
Type: reflect.TypeOf(instance),
}
}
// Middleware allows to generate custom code that's executed at the beginning of the handler.
//
// The implementation must declare their dependencies through unexported struct fields which doesn't
// begin with underscore (_), except fields whose name is just underscore (the blank identifier).
// The API generator will add the import those dependencies and allow to pass them through the
// constructor parameters of the group handler implementation, except the fields named with the
// blank identifier that should be only used to import packages that the generated code needs.
//
// The limitation of using fields with the blank identifier as its names is that those packages
// must at least to export a type, hence, it isn't possible to import packages that only export
// constants or variables.
//
// Middleware implementation with the same struct field name and type will be handled as one
// parameter, so the dependency will be shared between them. If they have the same struct field
// name, but a different type, the API generator will panic.
// NOTE types are compared as [package].[type name], hence, package name collision are not handled
// and it will produce code that doesn't compile.
type Middleware interface {
// Generate generates the code that the API generator adds to a handler endpoint before calling
// the service.
//
// All the dependencies defined as struct fields of the implementation of this interface are
// available as fields of the struct handler. The generated code is executed inside of the methods
// of the struct handler, hence it has access to all its fields. The handler instance is available
// through the variable name h. For example:
//
// type middlewareImpl struct {
// log *zap.Logger // Import path: "go.uber.org/zap"
// auth api.Auth // Import path: "storj.io/storj/private/api"
// }
//
// The generated code can access to log and auth through h.log and h.auth.
//
// Each handler method where the code is executed has access to the following variables names:
// ctx of type context.Context, w of type http.ResponseWriter, and r of type *http.Request.
// Make sure to not declare variable with those names in the generated code unless that's wrapped
// in a scope.
Generate(api *API, group *EndpointGroup, ep *FullEndpoint) string
}
func middlewareImports(m any) []string {
imports := []string{}
middlewareWalkFields(m, func(f reflect.StructField) {
if p := f.Type.PkgPath(); p != "" {
imports = append(imports, p)
}
})
return imports
}
// middlewareFields returns the list of fields of a middleware implementation. It panics if m isn't
// a struct type, it has embedded fields, or it has unexported fields.
func middlewareFields(m any) []middlewareField {
fields := []middlewareField{}
middlewareWalkFields(m, func(f reflect.StructField) {
if f.Name == "_" {
return
}
psymbol := ""
t := f.Type
if t.Kind() == reflect.Pointer {
psymbol = "*"
t = f.Type.Elem()
}
typeref := t.Name()
if p := t.PkgPath(); p != "" {
typeref = fmt.Sprintf("%s%s.%s", psymbol, filepath.Base(p), typeref)
}
fields = append(fields, middlewareField{Name: f.Name, Type: typeref})
})
return fields
}
func middlewareWalkFields(m any, walk func(f reflect.StructField)) {
t := reflect.TypeOf(m)
if t.Kind() != reflect.Struct {
panic(fmt.Sprintf("middleware %q isn't a struct type", t.Name()))
}
for i := 0; i < t.NumField(); i++ {
f := t.FieldByIndex([]int{i})
if f.Anonymous {
panic(fmt.Sprintf("middleware %q has a embedded field %q", t.Name(), f.Name))
}
if f.Name != "_" {
// Disallow fields that begin with underscore.
if !unicode.IsLetter([]rune(f.Name)[0]) {
panic(
fmt.Sprintf(
"middleware %q has a field name beginning with no letter %q. Change it to begin with lower case letter",
t.Name(),
f.Name,
),
)
}
if unicode.IsUpper([]rune(f.Name)[0]) {
panic(
fmt.Sprintf(
"middleware %q has a field name beginning with upper case %q. Change it to begin with lower case",
t.Name(),
f.Name,
),
)
}
}
walk(f)
}
}
// middlewareField has the name of the field and type for adding to handler structs that the
// API generator generates during the generation phase.
type middlewareField struct {
// Name is the name of the field. It must fulfill Go identifiers specification
// https://go.dev/ref/spec#Identifiers
Name string
// Type is the type's name of the field.
Type string
}
// LoadSetting returns from endpoint.Settings the value assigned to key or
// returns defaultValue if the key doesn't exist.
//
// It panics if key doesn't have a value of the type T.
func LoadSetting[T any](key any, endpoint *FullEndpoint, defaultValue T) T {
v, ok := endpoint.Settings[key]
if !ok {
return defaultValue
}
vt, vtok := v.(T)
if !vtok {
panic(fmt.Sprintf("expected %T got %T", vt, v))
}
return vt
}