swaggest / rest

Web services with OpenAPI and JSON Schema done quick in Go
https://pkg.go.dev/github.com/swaggest/rest
MIT License
335 stars 17 forks source link

Mount example doesn't work. what's the right way to have both v1 and v2 on a server? #190

Open chz8494 opened 4 months ago

chz8494 commented 4 months ago

Hey guys,

Very good project for swagger 3.x, really appreciated your work. I'd like to create a server with v1 and v2 server url, so was trying to follow https://github.com/swaggest/rest/blob/master/_examples/mount/main.go example to mount endpoints under api/v1, but has following error with service.Mount("/api/v1", apiV1):

panic: reflect API schema for GET /api/v1/sum: operation already exists: get /api/v1/sum

goroutine 1 [running]:
github.com/swaggest/rest/nethttp.OpenAPIMiddleware.func1({0x1012d9600?, 0x14000373920?})
    /Users/267121010/go/pkg/mod/github.com/swaggest/rest@v0.2.61/nethttp/openapi.go:35 +0x1a8

I also tried to use

r := openapi31.NewReflector()
r.Spec.WithServers(
    openapi31.Server{
        URL: "/api/v1",
    })
s := web.NewService(r)
s.Route("/data", func(r chi.Router) {
    r.Group(func(r chi.Router) {
        r.Use(serviceTokenAuth, serviceTokenDoc, checkSize)
        r.Method(http.MethodPost, "/", nethttp.NewHandler(handler.GenericPost()))
        r.Method(http.MethodPost, "/file-upload", nethttp.NewHandler(handler.FileUploader()))
    })
})
s.Docs(“/docs”, swgui.New)

with this code I can see server url options in the swagger gui, but the actual endpoint logic is not correctly mapped to server selection. I'd expect to be able to call endpoint <url>/api/v1/data, but the server is actually only listening on <url>/data, the swagger GUI call test does show correct curl example <url>/api/v1/data though.

vearutop commented 4 months ago

Thank you for raising this, this example is now fixed in latest master.

chz8494 commented 4 months ago

Thank you for the quick update. I've tried it and no more errors. But it still cannot achieve what I want. Your example

apiV1.Post("/sum", sum())
s.Mount("/api/v1", apiV1)

seems providing same function as using

s.Route("/api/v1", func(r chi.Router) {
    r.Group(func(r chi.Router) {
        r.Use(sessMW, sessDoc)

        r.Method(http.MethodGet, "/sum", nethttp.NewHandler(sum()))
    })
})

it just adds pattern /api/v1 in front of whatever defined in apiV1.

what I want to do is to have server version options selectable and make routes mapping correctly in swagger GUI. with your new code, if I add this line in the beginning:

r := openapi3.NewReflector()
r.Spec.WithServers(
  openapi31.Server{
    URL: "/api/v1",
  },
  openapi31.Server{
    URL: "/api/v2",
  }
)
s := web.NewService(r)

and if choose /api/v1, the swagger GUI curl example will call to endpoint localhost/api/v1/api/v1/ instead of localhost/api/v1

vearutop commented 4 months ago

Hi, I think you need both individual spec configuration for each versioned API and Swagger UI setup that allows selection from multiple API specs.

Please check an example https://github.com/swaggest/rest/blob/master/_examples/multi-api/main.go

// Package main implements an example where two versioned API revisions are mounted into root web service
// and are available through a service selector in Swagger UI.
package main

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

    "github.com/go-chi/chi/v5/middleware"
    "github.com/swaggest/openapi-go"
    "github.com/swaggest/openapi-go/openapi3"
    "github.com/swaggest/rest/nethttp"
    "github.com/swaggest/rest/web"
    swg "github.com/swaggest/swgui"
    swgui "github.com/swaggest/swgui/v5emb"
    "github.com/swaggest/usecase"
)

func main() {
    fmt.Println("Swagger UI at http://localhost:8010/api/docs.")
    if err := http.ListenAndServe("localhost:8010", service()); err != nil {
        log.Fatal(err)
    }
}

func service() *web.Service {
    // Creating root service, to host versioned APIs.
    s := web.NewService(openapi3.NewReflector())
    s.OpenAPISchema().SetTitle("Security and Mount Example")

    // Each versioned API is exposed with its own OpenAPI schema.
    v1r := openapi3.NewReflector()
    v1r.SpecEns().WithServers(openapi3.Server{URL: "/api/v1/"}).WithInfo(openapi3.Info{Title: "My API of version 1"})
    apiV1 := web.NewService(v1r)

    v2r := openapi3.NewReflector()
    v2r.SpecEns().WithServers(openapi3.Server{URL: "/api/v2/"})
    apiV2 := web.NewService(v2r)

    // Versioned APIs may or may not have their own middlewares and wraps.
    apiV1.Wrap(
        middleware.BasicAuth("Admin Access", map[string]string{"admin": "admin"}),
        nethttp.HTTPBasicSecurityMiddleware(s.OpenAPICollector, "Admin", "Admin access"),
        nethttp.OpenAPIAnnotationsMiddleware(s.OpenAPICollector, func(oc openapi.OperationContext) error {
            oc.SetTags(append(oc.Tags(), "V1")...)
            return nil
        }),
    )
    apiV1.Post("/sum", sum())
    apiV1.Post("/mul", mul())
    // Once all API use cases are added, schema can be served too.
    apiV1.Method(http.MethodGet, "/openapi.json", specHandler(apiV1.OpenAPICollector.SpecSchema()))

    apiV2.Post("/summarization", sum())
    apiV2.Post("/multiplication", mul())
    apiV2.Method(http.MethodGet, "/openapi.json", specHandler(apiV2.OpenAPICollector.SpecSchema()))

    // Prepared versioned API services are mounted with their base URLs into root service.
    s.Mount("/api/v1", apiV1)
    s.Mount("/api/v2", apiV2)

    // Root docs needs a bit of hackery to expose versioned APIs as separate services.
    s.Docs("/api/docs", swgui.NewWithConfig(swg.Config{
        ShowTopBar: true,
        SettingsUI: map[string]string{
            // When "urls" are configured, Swagger UI ignores "url" and switches to multi API mode.
            "urls": `[
    {"url": "/api/v1/openapi.json", "name": "APIv1"}, 
    {"url": "/api/v2/openapi.json", "name": "APIv2"}
]`,
            `"urls.primaryName"`: `"APIv2"`, // Using APIv2 as default.
        },
    }))

    // Blanket handler, for example to serve static content.
    s.Mount("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        _, _ = w.Write([]byte("blanket handler got a request: " + r.URL.String()))
    }))

    return s
}

func specHandler(s openapi.SpecSchema) http.Handler {
    j, err := json.Marshal(s)
    if err != nil {
        panic(err)
    }

    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Content-Type", "application/json")
        _, _ = w.Write(j)
    })
}

func mul() usecase.Interactor {
    return usecase.NewInteractor(func(ctx context.Context, input []int, output *int) error {
        *output = 1

        for _, v := range input {
            *output *= v
        }

        return nil
    })
}

func sum() usecase.Interactor {
    return usecase.NewInteractor(func(ctx context.Context, input []int, output *int) error {
        for _, v := range input {
            *output += v
        }

        return nil
    })
}
chz8494 commented 4 months ago

@vearutop Thank you for the example code update, it does solved most of the problem, I can now use banner to switch json profile and gui reflects endpoint pattern correctly. but this solution seems not work with grouped route auth middleware, e.g:

s.Route("/data", func(r chi.Router) {
    r.Group(func(r chi.Router) {
        r.Use(sessMW, sessDoc)

        r.Method(http.MethodGet, "/sum", nethttp.NewHandler(sum()))
    })
})

the api is functioning correctly and accepting auth, but GUI doesn't have auth options/icon displayed, openapi.json doesn't have security related content either. I also tried with s.With(sessMW, sessDoc), not work either.

and the example code

apiV1.Wrap(
        middleware.BasicAuth("Admin Access", map[string]string{"admin": "admin"}),
        nethttp.HTTPBasicSecurityMiddleware(s.OpenAPICollector, "Admin", "Admin access"),
        nethttp.OpenAPIAnnotationsMiddleware(s.OpenAPICollector, func(oc openapi.OperationContext) error {
            oc.SetTags(append(oc.Tags(), "V1")...)
            return nil
        }),
    )

causes the whole apiV1 page becomes BasicAuth

chz8494 commented 4 months ago

Ok, I kind of got it work by

apiV1.Route("/data", func(r chi.Router) {
    r.Group(func(r chi.Router) {
        r.Use(serviceTokenAuth, serviceTokenDoc, checkSize)
        r.Method(http.MethodGet, "/sum", nethttp.NewHandler(sum()))
    })
})
// Swagger GUI to have authorization schema and input
apiV1.OpenAPISchema().SetAPIKeySecurity("apiKey", "Authorization", oapi.InHeader, "API Key.")
// to add authorization schema under route group so that Swagger GUI example curl can call
for _, pi := range v1r.Spec.Paths.MapOfPathItemValues {
  pi.Post.Security = []map[string][]string{
    {
      "apiKey": []string{},
    },
  }
}
apiV1.Method(http.MethodGet, "/docs/openapi.json", specHandler(apiV1.OpenAPICollector.SpecSchema()))

but it's not ideal, I'd prefer apiV1.OpenAPICollector.SpecSchema() to pick up correct security values by itself.