Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support OpenAPI servers base path & router groups #338

Merged
merged 4 commits into from
Mar 29, 2024

Conversation

danielgtaylor
Copy link
Owner

@danielgtaylor danielgtaylor commented Mar 28, 2024

This PR is an alternative approach to #305 by @burgesQ to enable serving the OpenAPI and schemas at the correct path when given a router group / sub-router at some base path like /api. It works like this:

mux := chi.NewMux()
mux.Route("/api", func(r chi.Router) {
	config := huma.DefaultConfig("My API", "1.0.0")
	config.Servers = []*huma.Server{{URL: "https://example.com/api"}}
	api = humachi.New(r, config)

	// TODO: register operations
	huma.Get(api, "/demo", ...)
})
http.ListenAndServe("localhost:8888", mux)

This creates a sub-router at /api and then sets the https://example.com/api server URL including the base path. The sub-router is used to create the Huma instance. The /demo route registered via Huma then becomes /api/huma when the service is running, and all the docs/openapi/schemas/etc will be linked correctly.

I personally like the idea of using the already existing OpenAPI server base path functionality rather than duplicating it via a prefix/basePath parameter in the config. What do you think?

@spa5k, I believe this should fix #331. Please let me know if this would work for you.

I also did a quick example of how other routers can do this, e.g. for Gin you can use humagin.NewWithGroup(router, group, config) (you have to pass both the router and group since *gin.RouterGroup has no ServeHTTP method).

For the Go 1.22+ http.ServeMux via humago there is now a prefix you can set which acts similarly to the other router's groups. Use humago.NewWithPrefix(mux, prefix, config).

This should also enable us to support rewriting API gateways, e.g. document the server's URL with base path like https://example.com/api but then do not use a sub-router as the incoming request to Go will never see the /api which was stripped off. Any other cases I'm missing?

Fixes #305, #331.

Copy link

codecov bot commented Mar 28, 2024

Codecov Report

All modified and coverable lines are covered by tests ✅

Project coverage is 95.24%. Comparing base (f160410) to head (fc86a8c).
Report is 6 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main     #338      +/-   ##
==========================================
- Coverage   95.29%   95.24%   -0.06%     
==========================================
  Files          19       19              
  Lines        2827     2838      +11     
==========================================
+ Hits         2694     2703       +9     
- Misses         97       98       +1     
- Partials       36       37       +1     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

@x-user
Copy link
Contributor

x-user commented Mar 29, 2024

Not tested but should work.

type Router interface {
	Add(method, path string, handler echo.HandlerFunc, middlewares ...echo.MiddlewareFunc) *echo.Route
}

type echoAdapter struct {
	http.Handler
	router Router
}

// ...

func New(r *echo.Echo, config huma.Config) huma.API {
	return huma.NewAPI(config, &echoAdapter{Handler: r, router: r})
}

// NewWithGroup creates a new Huma API using the provided Echo router and group,
// letting you mount the API at a sub-path. Can be used in combination with
// the `OpenAPI.Servers` field to set the correct base URL for the API / docs
// / schemas / etc.
func NewWithGroup(r *echo.Echo, g *echo.Group, config huma.Config) huma.API {
	return huma.NewAPI(config, &echoAdapter{Handler: r, router: g})
}
import (
	// ...
	"github.com/gofiber/fiber/v2/middleware/adaptor"
)

// ...

type Router interface {
	Add(method, path string, handlers ...fiber.Handler) fiber.Router
}

type fiberAdapter struct {
	http.Handler
	router Router
}

// ...

func New(r *fiber.App, config huma.Config) huma.API {
	return huma.NewAPI(config, &fiberAdapter{Handler: adaptor.FiberApp(r), router: r})
}

func NewWithGroup(r *fiber.App, g *fiber.Group, config huma.Config) huma.API {
	return huma.NewAPI(config, &fiberAdapter{Handler: adaptor.FiberApp(r), router: g})
}

PS: I like this changes because resp, err := a.router.Test(r) in ServeHTTP of fiberAdapter looks kinda bad.

@danielgtaylor
Copy link
Owner Author

@x-user I adapted your suggestions to the latest commit. Unfortunately the Fiber adaptor doesn't actually implement the underlying request connection's SetReadDeadline and results in a nil pointer panic, so I've kept the router.Test(r) call for now. May be able to fix this upstream but it'll take a bit to be released.

@danielgtaylor danielgtaylor merged commit a741488 into main Mar 29, 2024
4 of 5 checks passed
@danielgtaylor danielgtaylor deleted the api-base-path branch March 29, 2024 21:02
@x-user
Copy link
Contributor

x-user commented Mar 29, 2024

Unfortunately the Fiber adaptor doesn't actually implement the underlying request connection's SetReadDeadline and results in a nil pointer panic, so I've kept the router.Test(r) call for now. May be able to fix this upstream but it'll take a bit to be released.

It's happens because net.Conn embedded in fasthttp.fakeAddrer is nil.

func (ctx *RequestCtx) Init(req *Request, remoteAddr net.Addr, logger Logger) {
	if remoteAddr == nil {
		remoteAddr = zeroTCPAddr
	}
	c := &fakeAddrer{
		laddr: zeroTCPAddr,
		raddr: remoteAddr,
	}
	if logger == nil {
		logger = defaultLogger
	}
	ctx.Init2(c, logger, true)
	req.CopyTo(&ctx.Request)
}

PS: Actually SetReadDeadline does nothing in current fiber adapter.

Details

Screenshot_20240330_011853

@burgesQ
Copy link

burgesQ commented Apr 2, 2024

Sadly, this approach won't cover my use-case where I need to have 2 routers each with their own middlewares:

rootMx, apiMx := chi.NewMux(), chi.NewMux()

rootMx.Use(httplog.RequestLogger(logger))
apiMx.Use(middleware.JWT)
apiMx.NotFound(jsonNotFound)
rootMx.Mount("/api", apiMx)

cfg := huma.DefaultConfig("My API", "1.0.0")

cfg.Prefix = "/api" // missing

api := humachi.New(apiMx, cfg)

huma.AutoRegister(api, struct{}{})

huma.AutoRegister()

@x-user
Copy link
Contributor

x-user commented Apr 2, 2024

Sadly, this approach won't cover my use-case where I need to have 2 routers each with their own middlewares:

rootMx, apiMx := chi.NewMux(), chi.NewMux()

rootMx.Use(httplog.RequestLogger(logger))
apiMx.Use(middleware.JWT)
apiMx.NotFound(jsonNotFound)
rootMx.Mount("/api", apiMx)

cfg := huma.DefaultConfig("My API", "1.0.0")

cfg.Prefix = "/api" // missing

api := humachi.New(apiMx, cfg)

huma.AutoRegister(api, struct{}{})

huma.AutoRegister()

Can you describe what the problem? I can't understand.

package main

import (
	"context"
	"errors"
	"flag"
	"fmt"
	"log/slog"
	"net/http"
	"slices"
	"time"

	"github.com/danielgtaylor/huma/v2"
	"github.com/danielgtaylor/huma/v2/adapters/humachi"
	"github.com/go-chi/chi/v5"
	"github.com/go-chi/chi/v5/middleware"
	"github.com/go-chi/httplog/v2"
	"github.com/go-chi/jwtauth/v5"
	"github.com/lestrrat-go/jwx/v2/jwt"
)

type (
	TestOutput struct {
		Body TestOutputBody
	}

	TestOutputBody struct {
		Message string `json:"message"`
	}

	humaMiddleware func(ctx huma.Context, next func(huma.Context))
)

var (
	tokenAuth *jwtauth.JWTAuth

	port = flag.Uint("port", 8080, "")

	logger = httplog.NewLogger("testapp", httplog.Options{
		JSON:             true,
		LogLevel:         slog.LevelDebug,
		Concise:          true,
		RequestHeaders:   true,
		MessageFieldName: "message",
	})
)

func main() {
	flag.Parse()
	slog.SetDefault(logger.Logger)

	rootMu := chi.NewMux()

	rootMu.Use(middleware.RealIP)
	rootMu.Use(middleware.RequestID)
	rootMu.Use(httplog.RequestLogger(logger))
	rootMu.Use(middleware.Recoverer)

	rootMu.Route("/api", func(apiMu chi.Router) {
		apiMu.Use(jwtauth.Verifier(tokenAuth))
		apiMu.Use(middleware.Timeout(10 * time.Second))

		apiMu.NotFound(func(w http.ResponseWriter, r *http.Request) {
			w.Header().Set("Content-Type", "application/problem+json")
			w.WriteHeader(http.StatusNotFound)
			fmt.Fprintf(w, `{"title": "Not Found","status": 404, "detail": "Endpoint Not Found"}`)
		})

		config := huma.DefaultConfig("API", "0.1.0")
		config.Servers = []*huma.Server{{URL: fmt.Sprintf("http://localhost:%d/api", *port)}}
		config.Components.SecuritySchemes = map[string]*huma.SecurityScheme{
			"JWTAuth": {
				Type:         "http",
				Scheme:       "bearer",
				BearerFormat: "JWT",
				In:           "header",
				Name:         "Authorization",
			},
		}

		api := humachi.New(apiMu, config)
		api.UseMiddleware(jwtAuthMiddleware(api, tokenAuth))

		huma.Register(
			api,
			huma.Operation{
				OperationID: "test",
				Path:        "/test",
				Method:      http.MethodGet,
				Summary:     "Test endpoint",
				Security: []map[string][]string{
					{"JWTAuth": nil},
				},
			},
			func(ctx context.Context, _ *struct{}) (*TestOutput, error) {
				_, claims, _ := jwtauth.FromContext(ctx)
				body := TestOutputBody{
					Message: fmt.Sprintf("Hello, %v!", claims["user_id"]),
				}
				return &TestOutput{Body: body}, nil
			},
		)
	})

	srv := http.Server{
		Addr:     fmt.Sprintf(":%d", *port),
		ErrorLog: slog.NewLogLogger(logger.Handler(), slog.LevelError),
		Handler:  rootMu,
	}

	if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
		slog.LogAttrs(
			context.Background(),
			slog.LevelError,
			"server error",
			slog.Any("error", err),
		)
	}
}

func init() {
	tokenAuth = jwtauth.New("HS256", []byte("secret"), nil)

	// For debugging/example purposes, we generate and print
	// a sample jwt token with claims `user_id:123` here:
	_, tokenString, _ := tokenAuth.Encode(map[string]interface{}{"user_id": 123})
	slog.LogAttrs(
		context.Background(),
		slog.LevelDebug,
		"sample jwt",
		slog.String("jwt", tokenString),
	)
}

func jwtAuthMiddleware(api huma.API, ja *jwtauth.JWTAuth) humaMiddleware {
	return func(ctx huma.Context, next func(huma.Context)) {
		var anyOfNeededScopes []string
		isAuthorizationRequired := false
		for _, opScheme := range ctx.Operation().Security {
			var ok bool
			if anyOfNeededScopes, ok = opScheme["JWTAuth"]; ok {
				isAuthorizationRequired = true
				break
			}
		}

		if !isAuthorizationRequired {
			next(ctx)
			return
		}

		token, claims, err := jwtauth.FromContext(ctx.Context())
		if err != nil {
			huma.WriteErr(api, ctx, http.StatusUnauthorized, err.Error())
			return
		}
		if token == nil || jwt.Validate(token, ja.ValidateOptions()...) != nil {
			huma.WriteErr(api, ctx, http.StatusUnauthorized, http.StatusText(http.StatusUnauthorized))
			return
		}

		if len(anyOfNeededScopes) == 0 {
			next(ctx)
			return
		}

		scopes, _ := claims["scopes"]
		if scopes, ok := scopes.([]string); ok {
			for _, scope := range scopes {
				if slices.Contains(anyOfNeededScopes, scope) {
					next(ctx)
					return
				}
			}
		}

		huma.WriteErr(api, ctx, http.StatusForbidden, http.StatusText(http.StatusForbidden))
	}
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Streamlining Base Path Setup in OpenAPI Schemas
3 participants