Skip to content

Commit 862d2d7

Browse files
Merge pull request #177 from kaleido-io/support-multiple-API-versions
adding support for multiple API versions
2 parents df0e449 + b660df4 commit 862d2d7

8 files changed

+422
-76
lines changed

pkg/ffapi/apiserver.go

Lines changed: 73 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,8 @@ type apiServer[T any] struct {
7575
type APIServerOptions[T any] struct {
7676
MetricsRegistry metric.MetricsRegistry
7777
MetricsSubsystemName string
78-
Routes []*Route
78+
Routes []*Route // move to use VersionedAPIs for support of Tags and ExternalDocs
79+
VersionedAPIs *VersionedAPIs
7980
MonitoringRoutes []*Route
8081
EnrichRequest func(r *APIRequest) (T, error)
8182
Description string
@@ -89,6 +90,11 @@ type APIServerOptions[T any] struct {
8990
HandleYAML bool
9091
}
9192

93+
type VersionedAPIs struct {
94+
DefaultVersion string // must be set to a version string if there are more than 1 API versions provided
95+
APIVersions map[string]*APIVersion // a list of APIVersions, with the key being the version string
96+
}
97+
9298
type APIServerRouteExt[T any] struct {
9399
JSONHandler func(*APIRequest, T) (output interface{}, err error)
94100
UploadHandler func(*APIRequest, T) (output interface{}, err error)
@@ -278,12 +284,70 @@ func (as *apiServer[T]) createMuxRouter(ctx context.Context) (*mux.Router, error
278284
r := mux.NewRouter().UseEncodedPath()
279285
hf := as.handlerFactory()
280286

287+
as.oah = &OpenAPIHandlerFactory{
288+
BaseSwaggerGenOptions: BaseSwaggerGenOptions{
289+
Title: as.Description,
290+
Version: "1.0",
291+
PanicOnMissingDescription: as.PanicOnMissingDescription,
292+
DefaultRequestTimeout: as.requestTimeout,
293+
SupportFieldRedaction: as.SupportFieldRedaction,
294+
},
295+
StaticPublicURL: as.apiPublicURL, // this is most likely not yet set, we'll ensure its set later on
296+
}
297+
281298
if as.monitoringEnabled {
282299
h, _ := as.MetricsRegistry.GetHTTPMetricsInstrumentationsMiddlewareForSubsystem(ctx, as.metricsSubsystemName())
283300
r.Use(h)
284301
}
285302

286-
for _, route := range as.Routes {
303+
defaultAPIVersionObject := &APIVersion{
304+
Routes: as.Routes,
305+
}
306+
defaultAPIVersion := "v1"
307+
if as.VersionedAPIs != nil {
308+
if len(as.Routes) > 0 {
309+
return nil, i18n.NewError(ctx, i18n.MsgCannotUseRouteAndVersionedAPI)
310+
}
311+
if len(as.VersionedAPIs.APIVersions) == 0 {
312+
return nil, i18n.NewError(ctx, i18n.MsgMissingVersionedAPI)
313+
}
314+
if len(as.VersionedAPIs.APIVersions) == 1 {
315+
for apiVersion := range as.VersionedAPIs.APIVersions {
316+
as.VersionedAPIs.DefaultVersion = apiVersion
317+
}
318+
}
319+
if as.VersionedAPIs.DefaultVersion == "" {
320+
return nil, i18n.NewError(ctx, i18n.MsgMissingDefaultAPIVersion)
321+
}
322+
if as.VersionedAPIs.APIVersions[as.VersionedAPIs.DefaultVersion] == nil || len(as.VersionedAPIs.APIVersions[as.VersionedAPIs.DefaultVersion].Routes) == 0 {
323+
return nil, i18n.NewError(ctx, i18n.MsgNonExistDefaultAPIVersion, as.VersionedAPIs.DefaultVersion)
324+
}
325+
defaultAPIVersionObject = as.VersionedAPIs.APIVersions[as.VersionedAPIs.DefaultVersion]
326+
defaultAPIVersion = as.VersionedAPIs.DefaultVersion
327+
for apiVersion, routes := range as.VersionedAPIs.APIVersions {
328+
if err := as.addRoutesForVersion(ctx, r, hf, apiVersion, routes); err != nil {
329+
return nil, err
330+
}
331+
}
332+
} else {
333+
if err := as.addRoutesForVersion(ctx, r, hf, defaultAPIVersion, defaultAPIVersionObject); err != nil {
334+
return nil, err
335+
}
336+
}
337+
338+
r.HandleFunc(`/api/swagger.yaml`, hf.APIWrapper(as.oah.OpenAPIHandler(fmt.Sprintf(`/api/%s`, defaultAPIVersion), OpenAPIFormatYAML, defaultAPIVersionObject)))
339+
r.HandleFunc(`/api/swagger.json`, hf.APIWrapper(as.oah.OpenAPIHandler(fmt.Sprintf(`/api/%s`, defaultAPIVersion), OpenAPIFormatJSON, defaultAPIVersionObject)))
340+
r.HandleFunc(`/api/openapi.yaml`, hf.APIWrapper(as.oah.OpenAPIHandler(fmt.Sprintf(`/api/%s`, defaultAPIVersion), OpenAPIFormatYAML, defaultAPIVersionObject)))
341+
r.HandleFunc(`/api/openapi.json`, hf.APIWrapper(as.oah.OpenAPIHandler(fmt.Sprintf(`/api/%s`, defaultAPIVersion), OpenAPIFormatJSON, defaultAPIVersionObject)))
342+
r.HandleFunc(`/api`, hf.APIWrapper(as.oah.SwaggerUIHandler(`/api/openapi.yaml`)))
343+
r.HandleFunc(`/favicon{any:.*}.png`, favIconsHandler(as.FavIcon16, as.FavIcon32))
344+
345+
r.NotFoundHandler = hf.APIWrapper(as.notFoundHandler)
346+
return r, nil
347+
}
348+
349+
func (as *apiServer[T]) addRoutesForVersion(ctx context.Context, r *mux.Router, hf *HandlerFactory, apiVersion string, apiVersionObject *APIVersion) error {
350+
for _, route := range apiVersionObject.Routes {
287351
ce, ok := route.Extensions.(*APIServerRouteExt[T])
288352
if !ok {
289353
panic(fmt.Sprintf("invalid route extensions: %t", route.Extensions))
@@ -299,32 +363,18 @@ func (as *apiServer[T]) createMuxRouter(ctx context.Context) (*mux.Router, error
299363
}
300364
if ce.JSONHandler != nil || ce.UploadHandler != nil || ce.StreamHandler != nil {
301365
if strings.HasPrefix(route.Path, "/") {
302-
return nil, fmt.Errorf("route path '%s' must not start with '/'", route.Path)
366+
return i18n.NewError(ctx, i18n.MsgRoutePathNotStartWithSlash, route.Path)
303367
}
304-
r.HandleFunc(fmt.Sprintf("/api/v1/%s", route.Path), as.routeHandler(hf, route)).
368+
r.HandleFunc(fmt.Sprintf("/api/%s/%s", apiVersion, route.Path), as.routeHandler(hf, route)).
305369
Methods(route.Method)
306370
}
307371
}
308372

309-
as.oah = &OpenAPIHandlerFactory{
310-
BaseSwaggerGenOptions: SwaggerGenOptions{
311-
Title: as.Description,
312-
Version: "1.0",
313-
PanicOnMissingDescription: as.PanicOnMissingDescription,
314-
DefaultRequestTimeout: as.requestTimeout,
315-
SupportFieldRedaction: as.SupportFieldRedaction,
316-
},
317-
StaticPublicURL: as.apiPublicURL, // this is most likely not yet set, we'll ensure its set later on
318-
}
319-
r.HandleFunc(`/api/swagger.yaml`, hf.APIWrapper(as.oah.OpenAPIHandler(`/api/v1`, OpenAPIFormatYAML, as.Routes)))
320-
r.HandleFunc(`/api/swagger.json`, hf.APIWrapper(as.oah.OpenAPIHandler(`/api/v1`, OpenAPIFormatJSON, as.Routes)))
321-
r.HandleFunc(`/api/openapi.yaml`, hf.APIWrapper(as.oah.OpenAPIHandler(`/api/v1`, OpenAPIFormatYAML, as.Routes)))
322-
r.HandleFunc(`/api/openapi.json`, hf.APIWrapper(as.oah.OpenAPIHandler(`/api/v1`, OpenAPIFormatJSON, as.Routes)))
323-
r.HandleFunc(`/api`, hf.APIWrapper(as.oah.SwaggerUIHandler(`/api/openapi.yaml`)))
324-
r.HandleFunc(`/favicon{any:.*}.png`, favIconsHandler(as.FavIcon16, as.FavIcon32))
373+
// adding open api documentation for this api version
374+
r.HandleFunc(fmt.Sprintf("/api/%s/openapi.yaml", apiVersion), hf.APIWrapper(as.oah.OpenAPIHandler(fmt.Sprintf("/api/%s", apiVersion), OpenAPIFormatYAML, apiVersionObject)))
375+
r.HandleFunc(fmt.Sprintf("/api/%s/openapi.json", apiVersion), hf.APIWrapper(as.oah.OpenAPIHandler(fmt.Sprintf("/api/%s", apiVersion), OpenAPIFormatJSON, apiVersionObject)))
325376

326-
r.NotFoundHandler = hf.APIWrapper(as.notFoundHandler)
327-
return r, nil
377+
return nil
328378
}
329379

330380
func (as *apiServer[T]) notFoundHandler(res http.ResponseWriter, req *http.Request) (status int, err error) {
@@ -351,7 +401,7 @@ func (as *apiServer[T]) createMonitoringMuxRouter(ctx context.Context) (*mux.Rou
351401
for _, route := range as.MonitoringRoutes {
352402
path := route.Path
353403
if strings.HasPrefix(route.Path, "/") {
354-
return nil, fmt.Errorf("route path '%s' must not start with '/'", route.Path)
404+
return nil, i18n.NewError(ctx, i18n.MsgRoutePathNotStartWithSlash, route.Path)
355405
}
356406
r.HandleFunc("/"+path, as.routeHandler(hf, route)).Methods(route.Method)
357407
}

pkg/ffapi/apiserver_test.go

Lines changed: 207 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,13 @@ package ffapi
1919
import (
2020
"context"
2121
"fmt"
22-
"github.com/getkin/kin-openapi/openapi3"
2322
"io"
2423
"net/http"
2524
"strings"
2625
"testing"
2726

27+
"github.com/getkin/kin-openapi/openapi3"
28+
2829
"github.com/go-resty/resty/v2"
2930
"github.com/hyperledger/firefly-common/pkg/config"
3031
"github.com/hyperledger/firefly-common/pkg/httpserver"
@@ -428,3 +429,208 @@ func TestBadMetrics(t *testing.T) {
428429
as.MetricsRegistry = metric.NewPrometheusMetricsRegistry("wrong")
429430
assert.Panics(t, func() { as.createMonitoringMuxRouter(context.Background()) })
430431
}
432+
433+
func newTestVersionedAPIServer(t *testing.T, versionedAPIs *VersionedAPIs) (*utManager, *apiServer[*utManager], func()) {
434+
ctx, cancelCtx := context.WithCancel(context.Background())
435+
apiConfig, monitoringConfig, corsConfig := initUTConfig()
436+
um := &utManager{t: t}
437+
as := NewAPIServer(ctx, APIServerOptions[*utManager]{
438+
MetricsRegistry: metric.NewPrometheusMetricsRegistry("ut"),
439+
VersionedAPIs: versionedAPIs,
440+
EnrichRequest: func(r *APIRequest) (*utManager, error) {
441+
// This could be some dynamic object based on extra processing in the request,
442+
// but the most common case is you just have a "manager" that you inject into each
443+
// request and that's the "T" on the APIServer
444+
return um, um.mockEnrichErr
445+
},
446+
Description: "unit testing",
447+
APIConfig: apiConfig,
448+
MonitoringConfig: monitoringConfig,
449+
CORSConfig: corsConfig,
450+
MetricsSubsystemName: "apiserver_ut",
451+
})
452+
done := make(chan struct{})
453+
454+
go func() {
455+
err := as.Serve(ctx)
456+
assert.NoError(t, err)
457+
close(done)
458+
}()
459+
return um, as.(*apiServer[*utManager]), func() {
460+
cancelCtx()
461+
<-done
462+
}
463+
}
464+
465+
func TestVersionedAPIsWithASingleVersion(t *testing.T) {
466+
um, as, done := newTestVersionedAPIServer(t, &VersionedAPIs{
467+
APIVersions: map[string]*APIVersion{
468+
"v2": {
469+
Routes: []*Route{utAPIRoute2},
470+
},
471+
},
472+
})
473+
defer done()
474+
475+
<-as.Started()
476+
477+
var o sampleOutput
478+
479+
// check v2 API only supports the getit route
480+
res, err := resty.New().R().
481+
SetBody(nil).
482+
SetResult(&o).
483+
Get(fmt.Sprintf("%s/api/v2/ut/utresource/id12345/getit", as.APIPublicURL()))
484+
assert.NoError(t, err)
485+
assert.Equal(t, 200, res.StatusCode())
486+
assert.Equal(t, "application/octet-stream", res.Header().Get("Content-Type"))
487+
assert.Equal(t, "id12345", um.calledStreamHandler)
488+
assert.Equal(t, "a stream!", string(res.Body()))
489+
}
490+
491+
func TestAPIsWithMultipleVersions(t *testing.T) {
492+
um, as, done := newTestVersionedAPIServer(t, &VersionedAPIs{
493+
DefaultVersion: "v2",
494+
APIVersions: map[string]*APIVersion{
495+
"v1": {
496+
Routes: []*Route{utAPIRoute1, utAPIRoute2},
497+
},
498+
"v2": {
499+
Routes: []*Route{utAPIRoute2}, // v2 only supports the getit route
500+
},
501+
},
502+
})
503+
defer done()
504+
505+
<-as.Started()
506+
507+
var o sampleOutput
508+
509+
// check v1 API supports both routes
510+
res, err := resty.New().R().
511+
SetBody(nil).
512+
SetResult(&o).
513+
Get(fmt.Sprintf("%s/api/v1/ut/utresource/id12345/getit", as.APIPublicURL()))
514+
assert.NoError(t, err)
515+
assert.Equal(t, 200, res.StatusCode())
516+
assert.Equal(t, "application/octet-stream", res.Header().Get("Content-Type"))
517+
assert.Equal(t, "id12345", um.calledStreamHandler)
518+
assert.Equal(t, "a stream!", string(res.Body()))
519+
520+
res, err = resty.New().R().
521+
SetBody(&sampleInput{
522+
Input1: "test_json_input",
523+
}).
524+
SetResult(&o).
525+
Post(fmt.Sprintf("%s/api/v1/ut/utresource/id12345/postit", as.APIPublicURL()))
526+
assert.NoError(t, err)
527+
assert.Equal(t, 200, res.StatusCode())
528+
assert.Equal(t, "id12345", um.calledJSONHandler)
529+
assert.Equal(t, "test_json_output", o.Output1)
530+
531+
// check v2 API only supports the getit route
532+
res, err = resty.New().R().
533+
SetBody(nil).
534+
SetResult(&o).
535+
Get(fmt.Sprintf("%s/api/v2/ut/utresource/id12345/getit", as.APIPublicURL()))
536+
assert.NoError(t, err)
537+
assert.Equal(t, 200, res.StatusCode())
538+
assert.Equal(t, "application/octet-stream", res.Header().Get("Content-Type"))
539+
assert.Equal(t, "id12345", um.calledStreamHandler)
540+
assert.Equal(t, "a stream!", string(res.Body()))
541+
542+
res, err = resty.New().R().
543+
SetBody(&sampleInput{
544+
Input1: "test_json_input",
545+
}).
546+
SetResult(&o).
547+
Post(fmt.Sprintf("%s/api/v2/ut/utresource/id12345/postit", as.APIPublicURL()))
548+
assert.NoError(t, err)
549+
assert.Equal(t, 404, res.StatusCode())
550+
}
551+
552+
func TestVersionedAPIInitErrors(t *testing.T) {
553+
ctx := context.Background()
554+
apiConfig, monitoringConfig, _ := initUTConfig()
555+
as := NewAPIServer(ctx, APIServerOptions[*utManager]{
556+
MetricsRegistry: metric.NewPrometheusMetricsRegistry("ut"),
557+
Routes: []*Route{utAPIRoute1, utAPIRoute2},
558+
VersionedAPIs: &VersionedAPIs{
559+
DefaultVersion: "v2",
560+
APIVersions: map[string]*APIVersion{
561+
"v1": {
562+
Routes: []*Route{utAPIRoute1, utAPIRoute2},
563+
},
564+
"v2": {
565+
Routes: []*Route{utAPIRoute2}, // v2 only supports the getit route
566+
},
567+
},
568+
},
569+
Description: "unit testing",
570+
APIConfig: apiConfig,
571+
MonitoringConfig: monitoringConfig,
572+
})
573+
574+
err := as.Serve(ctx)
575+
assert.Error(t, err)
576+
assert.Regexp(t, "FF00251", err)
577+
578+
as = NewAPIServer(ctx, APIServerOptions[*utManager]{
579+
MetricsRegistry: metric.NewPrometheusMetricsRegistry("ut"),
580+
VersionedAPIs: &VersionedAPIs{
581+
DefaultVersion: "",
582+
APIVersions: map[string]*APIVersion{},
583+
},
584+
Description: "unit testing",
585+
APIConfig: apiConfig,
586+
MonitoringConfig: monitoringConfig,
587+
})
588+
589+
err = as.Serve(ctx)
590+
assert.Error(t, err)
591+
assert.Regexp(t, "FF00252", err)
592+
593+
as = NewAPIServer(ctx, APIServerOptions[*utManager]{
594+
MetricsRegistry: metric.NewPrometheusMetricsRegistry("ut"),
595+
VersionedAPIs: &VersionedAPIs{
596+
DefaultVersion: "",
597+
APIVersions: map[string]*APIVersion{
598+
"v1": {
599+
Routes: []*Route{utAPIRoute1, utAPIRoute2},
600+
},
601+
"v2": {
602+
Routes: []*Route{utAPIRoute2}, // v2 only supports the getit route
603+
},
604+
},
605+
},
606+
Description: "unit testing",
607+
APIConfig: apiConfig,
608+
MonitoringConfig: monitoringConfig,
609+
})
610+
611+
err = as.Serve(ctx)
612+
assert.Error(t, err)
613+
assert.Regexp(t, "FF00253", err)
614+
as = NewAPIServer(ctx, APIServerOptions[*utManager]{
615+
MetricsRegistry: metric.NewPrometheusMetricsRegistry("ut"),
616+
VersionedAPIs: &VersionedAPIs{
617+
DefaultVersion: "unknown",
618+
APIVersions: map[string]*APIVersion{
619+
"v1": {
620+
Routes: []*Route{utAPIRoute1, utAPIRoute2},
621+
},
622+
"v2": {
623+
Routes: []*Route{utAPIRoute2}, // v2 only supports the getit route
624+
},
625+
},
626+
},
627+
Description: "unit testing",
628+
APIConfig: apiConfig,
629+
MonitoringConfig: monitoringConfig,
630+
})
631+
632+
err = as.Serve(ctx)
633+
assert.Error(t, err)
634+
assert.Regexp(t, "FF00254", err)
635+
636+
}

0 commit comments

Comments
 (0)