Skip to content

Commit

Permalink
Merge pull request #97 from hyperledger/openapi-enhancements
Browse files Browse the repository at this point in the history
Update Swagger UI and provide dynamic hostname support
  • Loading branch information
nguyer authored Sep 14, 2023
2 parents ba4df60 + 151b544 commit 27e89cb
Show file tree
Hide file tree
Showing 12 changed files with 305 additions and 228 deletions.
104 changes: 39 additions & 65 deletions pkg/ffapi/apiserver.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,14 @@ package ffapi

import (
"context"
"encoding/json"
"fmt"
"net"
"net/http"
"time"

"github.com/ghodss/yaml"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"

"github.com/getkin/kin-openapi/openapi3"
"github.com/gorilla/mux"
"github.com/hyperledger/firefly-common/pkg/config"
"github.com/hyperledger/firefly-common/pkg/fftls"
Expand All @@ -53,16 +50,17 @@ type APIServer interface {
type apiServer[T any] struct {
started chan struct{}

defaultFilterLimit uint64
maxFilterLimit uint64
maxFilterSkip uint64
requestTimeout time.Duration
requestMaxTimeout time.Duration
apiPublicURL string
alwaysPaginate bool
metricsEnabled bool
metricsPath string
metricsPublicURL string
defaultFilterLimit uint64
maxFilterLimit uint64
maxFilterSkip uint64
requestTimeout time.Duration
requestMaxTimeout time.Duration
apiPublicURL string
apiDynamicPublicURLHeader string
alwaysPaginate bool
metricsEnabled bool
metricsPath string
metricsPublicURL string

APIServerOptions[T]
}
Expand Down Expand Up @@ -90,16 +88,17 @@ type APIServerRouteExt[T any] struct {
// the supplied wrapper function - which will inject
func NewAPIServer[T any](ctx context.Context, options APIServerOptions[T]) APIServer {
as := &apiServer[T]{
defaultFilterLimit: options.APIConfig.GetUint64(ConfAPIDefaultFilterLimit),
maxFilterLimit: options.APIConfig.GetUint64(ConfAPIMaxFilterLimit),
maxFilterSkip: options.APIConfig.GetUint64(ConfAPIMaxFilterSkip),
requestTimeout: options.APIConfig.GetDuration(ConfAPIRequestTimeout),
requestMaxTimeout: options.APIConfig.GetDuration(ConfAPIRequestMaxTimeout),
metricsEnabled: options.MetricsConfig.GetBool(ConfMetricsServerEnabled),
metricsPath: options.MetricsConfig.GetString(ConfMetricsServerPath),
alwaysPaginate: options.APIConfig.GetBool(ConfAPIAlwaysPaginate),
APIServerOptions: options,
started: make(chan struct{}),
defaultFilterLimit: options.APIConfig.GetUint64(ConfAPIDefaultFilterLimit),
maxFilterLimit: options.APIConfig.GetUint64(ConfAPIMaxFilterLimit),
maxFilterSkip: options.APIConfig.GetUint64(ConfAPIMaxFilterSkip),
requestTimeout: options.APIConfig.GetDuration(ConfAPIRequestTimeout),
requestMaxTimeout: options.APIConfig.GetDuration(ConfAPIRequestMaxTimeout),
metricsEnabled: options.MetricsConfig.GetBool(ConfMetricsServerEnabled),
metricsPath: options.MetricsConfig.GetString(ConfMetricsServerPath),
alwaysPaginate: options.APIConfig.GetBool(ConfAPIAlwaysPaginate),
apiDynamicPublicURLHeader: options.APIConfig.GetString(ConfAPIDynamicPublicURLHeader),
APIServerOptions: options,
started: make(chan struct{}),
}
if as.FavIcon16 == nil {
as.FavIcon16 = ffLogo16
Expand Down Expand Up @@ -130,7 +129,7 @@ func (as *apiServer[T]) Serve(ctx context.Context) (err error) {
httpErrChan := make(chan error)
metricsErrChan := make(chan error)

apiHTTPServer, err := httpserver.NewHTTPServer(ctx, "api", as.createMuxRouter(ctx, as.apiPublicURL), httpErrChan, as.APIConfig, as.CORSConfig, &httpserver.ServerOptions{
apiHTTPServer, err := httpserver.NewHTTPServer(ctx, "api", as.createMuxRouter(ctx), httpErrChan, as.APIConfig, as.CORSConfig, &httpserver.ServerOptions{
MaximumRequestTimeout: as.requestMaxTimeout,
})
if err != nil {
Expand Down Expand Up @@ -185,43 +184,6 @@ func buildPublicURL(conf config.Section, a net.Addr) string {
return publicURL
}

func (as *apiServer[T]) swaggerGenConf(apiBaseURL string) *Options {
return &Options{
BaseURL: apiBaseURL,
Title: as.Description,
Version: "1.0",
PanicOnMissingDescription: as.PanicOnMissingDescription,
DefaultRequestTimeout: as.requestTimeout,
SupportFieldRedaction: as.SupportFieldRedaction,
}
}

func (as *apiServer[T]) swaggerHandler(generator func(req *http.Request) (*openapi3.T, error)) func(res http.ResponseWriter, req *http.Request) (status int, err error) {
return func(res http.ResponseWriter, req *http.Request) (status int, err error) {
vars := mux.Vars(req)
doc, err := generator(req)
if err != nil {
return 500, err
}
if vars["ext"] == ".json" {
res.Header().Add("Content-Type", "application/json")
b, _ := json.Marshal(&doc)
_, _ = res.Write(b)
} else {
res.Header().Add("Content-Type", "application/x-yaml")
b, _ := yaml.Marshal(&doc)
_, _ = res.Write(b)
}
return 200, nil
}
}

func (as *apiServer[T]) swaggerGenerator(apiBaseURL string) func(req *http.Request) (*openapi3.T, error) {
swg := NewSwaggerGen(as.swaggerGenConf(apiBaseURL))
return func(req *http.Request) (*openapi3.T, error) {
return swg.Generate(req.Context(), as.Routes), nil
}
}
func (as *apiServer[T]) routeHandler(hf *HandlerFactory, route *Route) http.HandlerFunc {
// We extend the base ffapi functionality, with standardized DB filter support for all core resources.
// We also pass the Orchestrator context through
Expand All @@ -248,7 +210,7 @@ func (as *apiServer[T]) handlerFactory() *HandlerFactory {
}
}

func (as *apiServer[T]) createMuxRouter(ctx context.Context, publicURL string) *mux.Router {
func (as *apiServer[T]) createMuxRouter(ctx context.Context) *mux.Router {
r := mux.NewRouter().UseEncodedPath()
hf := as.handlerFactory()

Expand All @@ -257,7 +219,6 @@ func (as *apiServer[T]) createMuxRouter(ctx context.Context, publicURL string) *
r.Use(h)
}

apiBaseURL := fmt.Sprintf("%s/api/v1", publicURL)
for _, route := range as.Routes {
ce, ok := route.Extensions.(*APIServerRouteExt[T])
if !ok {
Expand All @@ -278,8 +239,21 @@ func (as *apiServer[T]) createMuxRouter(ctx context.Context, publicURL string) *
}
}

r.HandleFunc(`/api/swagger{ext:\.yaml|\.json|}`, hf.APIWrapper(as.swaggerHandler(as.swaggerGenerator(apiBaseURL))))
r.HandleFunc(`/api`, hf.APIWrapper(hf.SwaggerUIHandler(publicURL+"/api/swagger.yaml")))
oah := &OpenAPIHandlerFactory{
BaseSwaggerGenOptions: SwaggerGenOptions{
Title: as.Description,
Version: "1.0",
PanicOnMissingDescription: as.PanicOnMissingDescription,
DefaultRequestTimeout: as.requestTimeout,
SupportFieldRedaction: as.SupportFieldRedaction,
},
StaticPublicURL: as.apiPublicURL,
}
r.HandleFunc(`/api/swagger.yaml`, hf.APIWrapper(oah.OpenAPIHandler(`/api/v1`, OpenAPIFormatYAML, as.Routes)))
r.HandleFunc(`/api/swagger.json`, hf.APIWrapper(oah.OpenAPIHandler(`/api/v1`, OpenAPIFormatJSON, as.Routes)))
r.HandleFunc(`/api/openapi.yaml`, hf.APIWrapper(oah.OpenAPIHandler(`/api/v1`, OpenAPIFormatYAML, as.Routes)))
r.HandleFunc(`/api/openapi.json`, hf.APIWrapper(oah.OpenAPIHandler(`/api/v1`, OpenAPIFormatJSON, as.Routes)))
r.HandleFunc(`/api`, hf.APIWrapper(oah.SwaggerUIHandler(`/api/openapi.yaml`)))
r.HandleFunc(`/favicon{any:.*}.png`, favIconsHandler(as.FavIcon16, as.FavIcon32))

r.NotFoundHandler = hf.APIWrapper(as.notFoundHandler)
Expand Down
14 changes: 8 additions & 6 deletions pkg/ffapi/apiserver_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,13 @@ var (
ConfMetricsServerEnabled = "enabled"
ConfMetricsServerPath = "/metrics"

ConfAPIDefaultFilterLimit = "defaultFilterLimit"
ConfAPIMaxFilterLimit = "maxFilterLimit"
ConfAPIMaxFilterSkip = "maxFilterSkip"
ConfAPIRequestTimeout = "requestTimeout"
ConfAPIRequestMaxTimeout = "requestMaxTimeout"
ConfAPIAlwaysPaginate = "alwaysPaginate"
ConfAPIDefaultFilterLimit = "defaultFilterLimit"
ConfAPIMaxFilterLimit = "maxFilterLimit"
ConfAPIMaxFilterSkip = "maxFilterSkip"
ConfAPIRequestTimeout = "requestTimeout"
ConfAPIRequestMaxTimeout = "requestMaxTimeout"
ConfAPIAlwaysPaginate = "alwaysPaginate"
ConfAPIDynamicPublicURLHeader = "dynamicPublicURLHeader"
)

func InitAPIServerConfig(apiConfig, metricsConfig, corsConfig config.Section) {
Expand All @@ -41,6 +42,7 @@ func InitAPIServerConfig(apiConfig, metricsConfig, corsConfig config.Section) {
apiConfig.AddKnownKey(ConfAPIRequestTimeout, "30s")
apiConfig.AddKnownKey(ConfAPIRequestMaxTimeout, "10m")
apiConfig.AddKnownKey(ConfAPIAlwaysPaginate, false)
apiConfig.AddKnownKey(ConfAPIDynamicPublicURLHeader)

httpserver.InitCORSConfig(corsConfig)

Expand Down
17 changes: 0 additions & 17 deletions pkg/ffapi/apiserver_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,9 @@ import (
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"

"github.com/getkin/kin-openapi/openapi3"
"github.com/go-resty/resty/v2"
"github.com/hyperledger/firefly-common/pkg/config"
"github.com/hyperledger/firefly-common/pkg/httpserver"
Expand Down Expand Up @@ -339,21 +337,6 @@ func TestBadRoute(t *testing.T) {
assert.Panics(t, func() { as.Serve(context.Background()) })
}

func TestBadSwagger(t *testing.T) {
_, as, done := newTestAPIServer(t, false)
defer done()

h := as.swaggerHandler(func(req *http.Request) (*openapi3.T, error) {
return nil, fmt.Errorf("pop")
})
res := httptest.NewRecorder()
req, err := http.NewRequest(http.MethodGet, "swagger", nil)
assert.NoError(t, err)
status, err := h(res, req)
assert.Equal(t, 500, status)
assert.Regexp(t, "pop", err)
}

func TestBadMetrics(t *testing.T) {
_, as, done := newTestAPIServer(t, false)
defer done()
Expand Down
12 changes: 3 additions & 9 deletions pkg/ffapi/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ type (
CtxFFRequestIDKey struct{}
)

type HandlerFunction func(res http.ResponseWriter, req *http.Request) (status int, err error)

type HandlerFactory struct {
DefaultRequestTimeout time.Duration
MaxTimeout time.Duration
Expand Down Expand Up @@ -216,14 +218,6 @@ func (hs *HandlerFactory) RouteHandler(route *Route) http.HandlerFunc {
})
}

func (hs *HandlerFactory) SwaggerUIHandler(url string) func(res http.ResponseWriter, req *http.Request) (status int, err error) {
return func(res http.ResponseWriter, req *http.Request) (status int, err error) {
res.Header().Add("Content-Type", "text/html")
_, _ = res.Write(SwaggerUIHTML(req.Context(), url))
return 200, nil
}
}

func (hs *HandlerFactory) handleOutput(ctx context.Context, res http.ResponseWriter, status int, output interface{}) (int, error) {
vOutput := reflect.ValueOf(output)
outputKind := vOutput.Kind()
Expand Down Expand Up @@ -286,7 +280,7 @@ func (hs *HandlerFactory) getTimeout(req *http.Request) time.Duration {
return CalcRequestTimeout(req, hs.DefaultRequestTimeout, hs.MaxTimeout)
}

func (hs *HandlerFactory) APIWrapper(handler func(res http.ResponseWriter, req *http.Request) (status int, err error)) http.HandlerFunc {
func (hs *HandlerFactory) APIWrapper(handler HandlerFunction) http.HandlerFunc {
return func(res http.ResponseWriter, req *http.Request) {

reqTimeout := hs.getTimeout(req)
Expand Down
11 changes: 0 additions & 11 deletions pkg/ffapi/handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -448,17 +448,6 @@ func TestMultipartBadContentType(t *testing.T) {
assert.Regexp(t, "FF00161", err)
}

func TestSwaggerUI(t *testing.T) {
hf := newTestHandlerFactory("", nil)
h := hf.SwaggerUIHandler("http://localhost:5000/api/v1")

res := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/api/v1", nil)
status, err := h(res, req)
assert.Equal(t, 200, status)
assert.NoError(t, err)
}

func TestGetTimeoutMax(t *testing.T) {
hf := newTestHandlerFactory("", nil)
hf.MaxTimeout = 1 * time.Second
Expand Down
9 changes: 4 additions & 5 deletions pkg/ffapi/openapi3.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ import (
"github.com/hyperledger/firefly-common/pkg/i18n"
)

type Options struct {
type SwaggerGenOptions struct {
BaseURL string
BaseURLVariables map[string]BaseURLVariable
Title string
Expand All @@ -47,8 +47,7 @@ type Options struct {
APIDefaultFilterLimit string
APIMaxFilterLimit uint
SupportFieldRedaction bool

RouteCustomizations func(ctx context.Context, sg *SwaggerGen, route *Route, op *openapi3.Operation)
RouteCustomizations func(ctx context.Context, sg *SwaggerGen, route *Route, op *openapi3.Operation)
}

type BaseURLVariable struct {
Expand All @@ -59,10 +58,10 @@ type BaseURLVariable struct {
var customRegexRemoval = regexp.MustCompile(`{(\w+)\:[^}]+}`)

type SwaggerGen struct {
options *Options
options *SwaggerGenOptions
}

func NewSwaggerGen(options *Options) *SwaggerGen {
func NewSwaggerGen(options *SwaggerGenOptions) *SwaggerGen {
return &SwaggerGen{
options: options,
}
Expand Down
Loading

0 comments on commit 27e89cb

Please sign in to comment.