mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-10-28 19:49:05 +01:00
feat: built in api documentation via huma gin adapter
This commit is contained in:
@@ -1,23 +1,35 @@
|
||||
package cookie
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func AddAccessTokenCookie(c *gin.Context, maxAgeInSeconds int, token string) {
|
||||
c.SetCookie(AccessTokenCookieName, token, maxAgeInSeconds, "/", "", true, true)
|
||||
func NewAccessTokenCookie(maxAgeInSeconds int, token string) *http.Cookie {
|
||||
return newCookie(AccessTokenCookieName, token, maxAgeInSeconds, "/")
|
||||
}
|
||||
|
||||
func AddSessionIdCookie(c *gin.Context, maxAgeInSeconds int, sessionID string) {
|
||||
c.SetCookie(SessionIdCookieName, sessionID, maxAgeInSeconds, "/", "", true, true)
|
||||
func NewSessionIDCookie(maxAgeInSeconds int, sessionID string) *http.Cookie {
|
||||
return newCookie(SessionIdCookieName, sessionID, maxAgeInSeconds, "/")
|
||||
}
|
||||
|
||||
func AddDeviceTokenCookie(c *gin.Context, deviceToken string) {
|
||||
c.SetCookie(DeviceTokenCookieName, deviceToken, int(15*time.Minute.Seconds()), "/api/one-time-access-token", "", true, true)
|
||||
func NewDeviceTokenCookie(deviceToken string) *http.Cookie {
|
||||
return newCookie(DeviceTokenCookieName, deviceToken, int(15*time.Minute.Seconds()), "/api/one-time-access-token")
|
||||
}
|
||||
|
||||
func AddReauthenticationTokenCookie(c *gin.Context, reauthenticationToken string) {
|
||||
c.SetCookie(ReauthenticationTokenCookieName, reauthenticationToken, int(3*time.Minute.Seconds()), "/", "", true, true)
|
||||
func NewReauthenticationTokenCookie(reauthenticationToken string) *http.Cookie {
|
||||
return newCookie(ReauthenticationTokenCookieName, reauthenticationToken, int(3*time.Minute.Seconds()), "/")
|
||||
}
|
||||
|
||||
func newCookie(name, value string, maxAge int, path string) *http.Cookie {
|
||||
// SameSite remains unset to preserve the cookies emitted by the existing Gin helpers
|
||||
//nolint:gosec
|
||||
return &http.Cookie{
|
||||
Name: name,
|
||||
Value: value,
|
||||
Path: path,
|
||||
MaxAge: maxAge,
|
||||
Secure: true,
|
||||
HttpOnly: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,26 @@ func BuildFormPostCSP(nonce, redirectURI, scriptHash string) string {
|
||||
return buildCSP(nonce, []string{redirectURI}, []string{scriptHash})
|
||||
}
|
||||
|
||||
// BuildAPIDocsCSP allows the pinned Scalar bundle and the assets it creates
|
||||
func BuildAPIDocsCSP(nonce string) string {
|
||||
scriptSrc := "script-src 'self' https://cdn.jsdelivr.net"
|
||||
if nonce != "" {
|
||||
scriptSrc += " 'nonce-" + nonce + "'"
|
||||
}
|
||||
|
||||
return "default-src 'self'; " +
|
||||
"base-uri 'self'; " +
|
||||
"object-src 'none'; " +
|
||||
"frame-ancestors 'none'; " +
|
||||
"form-action 'self'; " +
|
||||
"img-src * blob: data:; " +
|
||||
"font-src 'self' https://cdn.jsdelivr.net data:; " +
|
||||
"style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; " +
|
||||
"worker-src blob:; " +
|
||||
"connect-src 'self'; " +
|
||||
scriptSrc
|
||||
}
|
||||
|
||||
func buildCSP(nonce string, formActionExtra, scriptSrcExtra []string) string {
|
||||
formAction := "'self'"
|
||||
scriptSrc := "script-src 'self'"
|
||||
|
||||
@@ -3,17 +3,10 @@ package utils
|
||||
import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// SetCacheControlHeader sets the Cache-Control header for the response.
|
||||
func SetCacheControlHeader(ctx *gin.Context, maxAge, staleWhileRevalidate time.Duration) {
|
||||
_, ok := ctx.GetQuery("skipCache")
|
||||
if !ok {
|
||||
maxAgeSeconds := strconv.Itoa(int(maxAge.Seconds()))
|
||||
staleWhileRevalidateSeconds := strconv.Itoa(int(staleWhileRevalidate.Seconds()))
|
||||
ctx.Header("Cache-Control", "public, max-age="+maxAgeSeconds+", stale-while-revalidate="+staleWhileRevalidateSeconds)
|
||||
}
|
||||
|
||||
func CacheControlValue(maxAge, staleWhileRevalidate time.Duration) string {
|
||||
maxAgeSeconds := strconv.Itoa(int(maxAge.Seconds()))
|
||||
staleWhileRevalidateSeconds := strconv.Itoa(int(staleWhileRevalidate.Seconds()))
|
||||
return "public, max-age=" + maxAgeSeconds + ", stale-while-revalidate=" + staleWhileRevalidateSeconds
|
||||
}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
package humautils
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
"github.com/danielgtaylor/huma/v2/adapters/humagin"
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
|
||||
)
|
||||
|
||||
const scalarDocsHTML = `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Pocket ID API Reference</title>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
</head>
|
||||
<body>
|
||||
<script
|
||||
id="api-reference"
|
||||
data-url="/api/openapi.json"
|
||||
data-configuration='{"theme":"purple","darkMode":true,"layout":"modern","hiddenClients":["unirest"],"defaultHttpClient":{"targetKey":"shell","clientKey":"curl"}}'></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference@1.62.5" integrity="sha384-jVBCKhcCfx34USN27x4iQK1SBNdL/HxKq3KuBAxTS4WPaP5w80K4fjpwB+DezJL5" crossorigin="anonymous"></script>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
var ginCompatibleJSONFormat = huma.Format{
|
||||
Marshal: func(w io.Writer, value any) error {
|
||||
data, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = w.Write(data)
|
||||
return err
|
||||
},
|
||||
Unmarshal: json.Unmarshal,
|
||||
}
|
||||
|
||||
// New creates the Huma API on the existing rate-limited Gin group
|
||||
func New(r *gin.Engine, group *gin.RouterGroup) huma.API {
|
||||
config := huma.DefaultConfig("Pocket ID API", common.Version)
|
||||
config.CreateHooks = nil
|
||||
config.DocsPath = ""
|
||||
config.OpenAPIPath = "/api/openapi"
|
||||
config.SchemasPath = "/api/schemas"
|
||||
config.AllowAdditionalPropertiesByDefault = true
|
||||
config.Security = nil
|
||||
config.Formats = map[string]huma.Format{
|
||||
"application/json": ginCompatibleJSONFormat,
|
||||
"json": ginCompatibleJSONFormat,
|
||||
}
|
||||
config.DefaultFormat = "application/json"
|
||||
config.OnAddOperation = append(config.OnAddOperation, rewriteValidationResponse)
|
||||
if common.EnvConfig.AppURL != "" {
|
||||
config.Servers = []*huma.Server{{URL: common.EnvConfig.AppURL}}
|
||||
}
|
||||
config.Components.SecuritySchemes = map[string]*huma.SecurityScheme{
|
||||
"BearerAuth": {
|
||||
Type: "http",
|
||||
Scheme: "bearer",
|
||||
BearerFormat: "JWT",
|
||||
Description: "Pocket ID session JWT sent in the Authorization header",
|
||||
},
|
||||
"SessionCookie": {
|
||||
Type: "apiKey",
|
||||
In: "cookie",
|
||||
Name: cookie.AccessTokenCookieName,
|
||||
Description: "Pocket ID browser session cookie",
|
||||
},
|
||||
"ApiKeyAuth": {
|
||||
Type: "apiKey",
|
||||
In: "header",
|
||||
Name: "X-API-Key",
|
||||
Description: "Pocket ID API key",
|
||||
},
|
||||
"OIDCAccessToken": {
|
||||
Type: "http",
|
||||
Scheme: "bearer",
|
||||
Description: "OIDC access token",
|
||||
},
|
||||
"OIDCClientBasic": {
|
||||
Type: "http",
|
||||
Scheme: "basic",
|
||||
Description: "OIDC client credentials",
|
||||
},
|
||||
}
|
||||
|
||||
humagin.MultipartMaxMemory = r.MaxMultipartMemory
|
||||
api := humagin.NewWithGroup(r, group, config)
|
||||
api.UseMiddleware(CaptureRequestContext)
|
||||
registerScalarDocs(group)
|
||||
return api
|
||||
}
|
||||
|
||||
func rewriteValidationResponse(_ *huma.OpenAPI, operation *huma.Operation) {
|
||||
response, ok := operation.Responses["422"]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if _, exists := operation.Responses["400"]; !exists {
|
||||
operation.Responses["400"] = response
|
||||
}
|
||||
delete(operation.Responses, "422")
|
||||
}
|
||||
|
||||
func registerScalarDocs(group *gin.RouterGroup) {
|
||||
group.GET("/api/docs", func(ctx *gin.Context) {
|
||||
nonce := utils.GetCSPNonce(ctx)
|
||||
ctx.Header("Content-Security-Policy", utils.BuildAPIDocsCSP(nonce))
|
||||
ctx.Data(http.StatusOK, "text/html; charset=utf-8", []byte(scalarDocsHTML))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package humautils
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type testInput struct {
|
||||
Body struct {
|
||||
Name string `json:"name" required:"true" minLength:"3"`
|
||||
}
|
||||
}
|
||||
|
||||
type testOutput struct {
|
||||
Body map[string]string
|
||||
}
|
||||
|
||||
type testCookieOutput struct {
|
||||
SetCookie []http.Cookie `header:"Set-Cookie"`
|
||||
}
|
||||
|
||||
type testStreamOutput struct {
|
||||
ContentType string `header:"Content-Type"`
|
||||
Body func(huma.Context)
|
||||
}
|
||||
|
||||
type optionalBodyInput struct {
|
||||
Body *json.RawMessage `required:"false"`
|
||||
}
|
||||
|
||||
type testAppError struct{}
|
||||
|
||||
func (testAppError) Error() string { return "test error" }
|
||||
func (testAppError) Description() string { return "test description" }
|
||||
func (testAppError) HttpStatusCode() int { return http.StatusConflict }
|
||||
|
||||
type trackingReader struct {
|
||||
io.Reader
|
||||
closed bool
|
||||
}
|
||||
|
||||
func (r *trackingReader) Close() error {
|
||||
r.closed = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func newTestAPI(t *testing.T) (*gin.Engine, huma.API) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
api := New(router, router.Group("/"))
|
||||
return router, api
|
||||
}
|
||||
|
||||
func TestRequestAndErrorCompatibility(t *testing.T) {
|
||||
router, api := newTestAPI(t)
|
||||
Register(api, huma.Operation{OperationID: "test-request", Method: http.MethodPost, Path: "/api/test"}, func(_ context.Context, input *testInput) (*testOutput, error) {
|
||||
return &testOutput{Body: map[string]string{"name": input.Body.Name}}, nil
|
||||
})
|
||||
Register(api, huma.Operation{OperationID: "test-app-error", Method: http.MethodGet, Path: "/api/test-error"}, func(context.Context, *struct{}) (*struct{}, error) {
|
||||
return nil, testAppError{}
|
||||
})
|
||||
Register(api, huma.Operation{OperationID: "test-unknown-error", Method: http.MethodGet, Path: "/api/test-unknown-error"}, func(context.Context, *struct{}) (*struct{}, error) {
|
||||
return nil, errors.New("private failure")
|
||||
})
|
||||
Register(api, huma.Operation{OperationID: "test-optional-body", Method: http.MethodPost, Path: "/api/test-optional-body", DefaultStatus: http.StatusNoContent}, func(_ context.Context, input *optionalBodyInput) (*struct{}, error) {
|
||||
require.Nil(t, input.Body)
|
||||
return &struct{}{}, nil
|
||||
})
|
||||
|
||||
request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/test", strings.NewReader(`{"name":"Pocket ID","unknown":true}`))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
require.Equal(t, http.StatusOK, response.Code)
|
||||
require.Equal(t, "application/json", response.Header().Get("Content-Type"))
|
||||
require.JSONEq(t, `{"name":"Pocket ID"}`, response.Body.String())
|
||||
require.Empty(t, response.Header().Get("Link"))
|
||||
require.NotContains(t, response.Body.String(), "$schema")
|
||||
|
||||
request = httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/test", nil)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response = httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
require.Equal(t, http.StatusBadRequest, response.Code)
|
||||
require.Equal(t, "application/json; charset=utf-8", response.Header().Get("Content-Type"))
|
||||
require.JSONEq(t, `{"error":"Request body is required"}`, response.Body.String())
|
||||
|
||||
request = httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/test", strings.NewReader(`{"name":"x"}`))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response = httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
require.Equal(t, http.StatusBadRequest, response.Code)
|
||||
require.JSONEq(t, `{"error":"Expected length >= 3"}`, response.Body.String())
|
||||
|
||||
response = httptest.NewRecorder()
|
||||
router.ServeHTTP(response, httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/api/test-error", nil))
|
||||
require.Equal(t, http.StatusConflict, response.Code)
|
||||
require.JSONEq(t, `{"error":"Test error","error_description":"test description"}`, response.Body.String())
|
||||
require.Less(t, strings.Index(response.Body.String(), `"error"`), strings.Index(response.Body.String(), `"error_description"`))
|
||||
|
||||
response = httptest.NewRecorder()
|
||||
router.ServeHTTP(response, httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/api/test-unknown-error", nil))
|
||||
require.Equal(t, http.StatusInternalServerError, response.Code)
|
||||
require.JSONEq(t, `{"error":"Something went wrong"}`, response.Body.String())
|
||||
|
||||
response = httptest.NewRecorder()
|
||||
router.ServeHTTP(response, httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/test-optional-body", nil))
|
||||
require.Equal(t, http.StatusNoContent, response.Code)
|
||||
}
|
||||
|
||||
func TestCookiesStreamingAndOpenAPI(t *testing.T) {
|
||||
router, api := newTestAPI(t)
|
||||
Register(api, huma.Operation{OperationID: "test-cookies", Method: http.MethodPost, Path: "/api/test-cookies", DefaultStatus: http.StatusNoContent}, func(context.Context, *struct{}) (*testCookieOutput, error) {
|
||||
return &testCookieOutput{SetCookie: []http.Cookie{{Name: "one", Value: "1"}, {Name: "two", Value: "2"}}}, nil
|
||||
})
|
||||
|
||||
reader := &trackingReader{Reader: strings.NewReader("streamed")}
|
||||
Register(api, huma.Operation{OperationID: "test-stream", Method: http.MethodGet, Path: "/api/test-stream"}, func(context.Context, *struct{}) (*testStreamOutput, error) {
|
||||
return &testStreamOutput{ContentType: "text/plain", Body: func(ctx huma.Context) {
|
||||
defer reader.Close()
|
||||
_, _ = io.Copy(ctx.BodyWriter(), reader)
|
||||
}}, nil
|
||||
})
|
||||
AddRawOperation(api, "test-raw", http.MethodPost, "/api/test-raw", "Raw test", []string{"Test"}, nil)
|
||||
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/test-cookies", nil))
|
||||
require.Equal(t, http.StatusNoContent, response.Code)
|
||||
require.Equal(t, []string{"one=1", "two=2"}, response.Header().Values("Set-Cookie"))
|
||||
|
||||
response = httptest.NewRecorder()
|
||||
router.ServeHTTP(response, httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/api/test-stream", nil))
|
||||
require.Equal(t, http.StatusOK, response.Code)
|
||||
require.Equal(t, "text/plain", response.Header().Get("Content-Type"))
|
||||
require.Equal(t, "streamed", response.Body.String())
|
||||
require.True(t, reader.closed)
|
||||
|
||||
response = httptest.NewRecorder()
|
||||
router.ServeHTTP(response, httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/api/openapi.json", nil))
|
||||
require.Equal(t, http.StatusOK, response.Code)
|
||||
require.Contains(t, response.Body.String(), `"/api/test-raw"`)
|
||||
require.NotContains(t, response.Body.String(), `"422"`)
|
||||
require.NotContains(t, response.Body.String(), `"$schema"`)
|
||||
|
||||
response = httptest.NewRecorder()
|
||||
router.ServeHTTP(response, httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/api/docs", nil))
|
||||
require.Equal(t, http.StatusOK, response.Code)
|
||||
require.Contains(t, response.Body.String(), "@scalar/api-reference@1.62.5")
|
||||
require.Contains(t, response.Header().Get("Content-Security-Policy"), "worker-src blob:")
|
||||
require.NotContains(t, response.Header().Get("Content-Security-Policy"), "script-src 'unsafe-inline'")
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package humautils
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
"github.com/danielgtaylor/huma/v2/adapters/humagin"
|
||||
)
|
||||
|
||||
type contextKey uint8
|
||||
|
||||
const (
|
||||
requestContextKey contextKey = iota
|
||||
clientIPContextKey
|
||||
userIDContextKey
|
||||
userIsAdminContextKey
|
||||
authenticationMethodContextKey
|
||||
authenticationTimeContextKey
|
||||
)
|
||||
|
||||
// CaptureRequestContext exposes trusted Gin request metadata to typed handlers
|
||||
func CaptureRequestContext(ctx huma.Context, next func(huma.Context)) {
|
||||
ginCtx := humagin.Unwrap(ctx)
|
||||
ctx = huma.WithValue(ctx, requestContextKey, ginCtx.Request)
|
||||
ctx = huma.WithValue(ctx, clientIPContextKey, ginCtx.ClientIP())
|
||||
next(ctx)
|
||||
}
|
||||
|
||||
// WithAuthentication adds the authenticated identity to a Huma request context
|
||||
func WithAuthentication(ctx huma.Context, userID string, isAdmin bool, method string, authenticationTime time.Time) huma.Context {
|
||||
ctx = huma.WithValue(ctx, userIDContextKey, userID)
|
||||
ctx = huma.WithValue(ctx, userIsAdminContextKey, isAdmin)
|
||||
ctx = huma.WithValue(ctx, authenticationMethodContextKey, method)
|
||||
return huma.WithValue(ctx, authenticationTimeContextKey, authenticationTime)
|
||||
}
|
||||
|
||||
// Request returns the underlying HTTP request for protocol handlers that require it
|
||||
func Request(ctx context.Context) *http.Request {
|
||||
request, _ := ctx.Value(requestContextKey).(*http.Request)
|
||||
return request
|
||||
}
|
||||
|
||||
// ClientIP returns the trusted client IP calculated by Gin
|
||||
func ClientIP(ctx context.Context) string {
|
||||
value, _ := ctx.Value(clientIPContextKey).(string)
|
||||
return value
|
||||
}
|
||||
|
||||
// UserAgent returns the request user agent
|
||||
func UserAgent(ctx context.Context) string {
|
||||
request := Request(ctx)
|
||||
if request == nil {
|
||||
return ""
|
||||
}
|
||||
return request.UserAgent()
|
||||
}
|
||||
|
||||
// Cookie returns a dynamically named request cookie
|
||||
func Cookie(ctx context.Context, name string) (*http.Cookie, error) {
|
||||
request := Request(ctx)
|
||||
if request == nil {
|
||||
return nil, http.ErrNoCookie
|
||||
}
|
||||
return request.Cookie(name)
|
||||
}
|
||||
|
||||
// QueryPresent reports whether a query key was present regardless of its value
|
||||
func QueryPresent(ctx context.Context, name string) bool {
|
||||
request := Request(ctx)
|
||||
if request == nil {
|
||||
return false
|
||||
}
|
||||
_, ok := request.URL.Query()[name]
|
||||
return ok
|
||||
}
|
||||
|
||||
// UserID returns the authenticated user ID
|
||||
func UserID(ctx context.Context) string {
|
||||
value, _ := ctx.Value(userIDContextKey).(string)
|
||||
return value
|
||||
}
|
||||
|
||||
// IsAdmin reports whether the authenticated user is an administrator
|
||||
func IsAdmin(ctx context.Context) bool {
|
||||
value, _ := ctx.Value(userIsAdminContextKey).(bool)
|
||||
return value
|
||||
}
|
||||
|
||||
// AuthenticationMethod returns the session authentication method
|
||||
func AuthenticationMethod(ctx context.Context) string {
|
||||
value, _ := ctx.Value(authenticationMethodContextKey).(string)
|
||||
return value
|
||||
}
|
||||
|
||||
// AuthenticationTime returns the session authentication time
|
||||
func AuthenticationTime(ctx context.Context) time.Time {
|
||||
value, _ := ctx.Value(authenticationTimeContextKey).(time.Time)
|
||||
return value
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package humautils
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type apiError struct {
|
||||
status int
|
||||
Message string `json:"error"`
|
||||
Description string `json:"error_description,omitempty"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
huma.NewError = newHumaError
|
||||
}
|
||||
|
||||
func (e *apiError) Error() string { return e.Message }
|
||||
func (e *apiError) GetStatus() int { return e.status }
|
||||
|
||||
func (e *apiError) ContentType(contentType string) string {
|
||||
if contentType == "application/json" {
|
||||
return "application/json; charset=utf-8"
|
||||
}
|
||||
return contentType
|
||||
}
|
||||
|
||||
func newHumaError(status int, message string, errs ...error) huma.StatusError {
|
||||
if status == http.StatusUnprocessableEntity {
|
||||
status = http.StatusBadRequest
|
||||
}
|
||||
|
||||
messages := make([]string, 0, len(errs))
|
||||
for _, err := range errs {
|
||||
if err == nil {
|
||||
continue
|
||||
}
|
||||
var detailer huma.ErrorDetailer
|
||||
if errors.As(err, &detailer) {
|
||||
messages = append(messages, detailer.ErrorDetail().Message)
|
||||
continue
|
||||
}
|
||||
messages = append(messages, err.Error())
|
||||
}
|
||||
if len(messages) > 0 {
|
||||
message = strings.Join(messages, ", ")
|
||||
}
|
||||
|
||||
return &apiError{status: status, Message: capitalize(message)}
|
||||
}
|
||||
|
||||
func capitalize(message string) string {
|
||||
if message == "" {
|
||||
return message
|
||||
}
|
||||
r, size := utf8.DecodeRuneInString(message)
|
||||
return string(unicode.ToUpper(r)) + message[size:]
|
||||
}
|
||||
|
||||
func mapError(ctx context.Context, err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return &apiError{status: http.StatusNotFound, Message: "Record not found"}
|
||||
}
|
||||
|
||||
var appDescriptionError common.AppErrorDescription
|
||||
if errors.As(err, &appDescriptionError) {
|
||||
return &apiError{
|
||||
status: appDescriptionError.HttpStatusCode(),
|
||||
Message: capitalize(appDescriptionError.Error()),
|
||||
Description: appDescriptionError.Description(),
|
||||
}
|
||||
}
|
||||
|
||||
var appError common.AppError
|
||||
if errors.As(err, &appError) {
|
||||
return &apiError{status: appError.HttpStatusCode(), Message: capitalize(appError.Error())}
|
||||
}
|
||||
|
||||
var maxBytesError *http.MaxBytesError
|
||||
if errors.As(err, &maxBytesError) {
|
||||
return &apiError{status: http.StatusRequestEntityTooLarge, Message: "The request body is too large"}
|
||||
}
|
||||
|
||||
slog.ErrorContext(ctx, "Unhandled API error", slog.Any("error", err))
|
||||
return &apiError{status: http.StatusInternalServerError, Message: "Something went wrong"}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package humautils
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
)
|
||||
|
||||
// AddRawOperation documents a Gin endpoint that must retain direct response control
|
||||
func AddRawOperation(api huma.API, operationID, method, path, summary string, tags []string, security []map[string][]string, statuses ...int) {
|
||||
if len(statuses) == 0 {
|
||||
statuses = []int{http.StatusOK}
|
||||
}
|
||||
responses := make(map[string]*huma.Response, len(statuses))
|
||||
for _, status := range statuses {
|
||||
responses[strconv.Itoa(status)] = &huma.Response{Description: http.StatusText(status)}
|
||||
}
|
||||
api.OpenAPI().AddOperation(&huma.Operation{
|
||||
OperationID: operationID,
|
||||
Method: method,
|
||||
Path: path,
|
||||
Summary: summary,
|
||||
Tags: tags,
|
||||
Security: security,
|
||||
Responses: responses,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package humautils
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
)
|
||||
|
||||
// Register adds a typed operation while preserving Pocket ID error and body-reading behavior
|
||||
func Register[I, O any](api huma.API, operation huma.Operation, handler func(context.Context, *I) (*O, error)) {
|
||||
if operation.MaxBodyBytes == 0 {
|
||||
operation.MaxBodyBytes = -1
|
||||
}
|
||||
if operation.BodyReadTimeout == 0 {
|
||||
operation.BodyReadTimeout = -1
|
||||
}
|
||||
huma.Register(api, operation, func(ctx context.Context, input *I) (*O, error) {
|
||||
output, err := handler(ctx, input)
|
||||
if err != nil {
|
||||
return nil, mapError(ctx, err)
|
||||
}
|
||||
return output, nil
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package humautils
|
||||
|
||||
import "github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
|
||||
// EmptyInput represents an operation without path, query, header, or body input
|
||||
type EmptyInput struct{}
|
||||
|
||||
// EmptyOutput represents an operation without a response body or headers
|
||||
type EmptyOutput struct{}
|
||||
|
||||
// BodyOutput wraps a typed response body for Huma
|
||||
type BodyOutput[T any] struct {
|
||||
Body T
|
||||
}
|
||||
|
||||
// ListInput exposes the shared list query parameters to Huma
|
||||
type ListInput struct {
|
||||
utils.ListRequestOptions
|
||||
}
|
||||
@@ -5,13 +5,20 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
)
|
||||
|
||||
// JSONDuration is a type that allows marshalling/unmarshalling a Duration
|
||||
type JSONDuration struct {
|
||||
type JSONDuration struct { //nolint:recvcheck
|
||||
time.Duration
|
||||
}
|
||||
|
||||
// Schema documents the string and numeric representations accepted by UnmarshalJSON
|
||||
func (d JSONDuration) Schema(huma.Registry) *huma.Schema {
|
||||
return &huma.Schema{OneOf: []*huma.Schema{{Type: huma.TypeString}, {Type: huma.TypeNumber}}}
|
||||
}
|
||||
|
||||
func (d JSONDuration) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(d.String())
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
@@ -17,15 +18,11 @@ type PaginationResponse struct {
|
||||
}
|
||||
|
||||
type ListRequestOptions struct {
|
||||
Pagination struct {
|
||||
Page int `form:"pagination[page]"`
|
||||
Limit int `form:"pagination[limit]"`
|
||||
} `form:"pagination"`
|
||||
Sort struct {
|
||||
Column string `form:"sort[column]"`
|
||||
Direction string `form:"sort[direction]"`
|
||||
} `form:"sort"`
|
||||
Filters map[string][]any
|
||||
Page int `query:"pagination[page]" required:"false"`
|
||||
Limit int `query:"pagination[limit]" required:"false"`
|
||||
SortColumn string `query:"sort[column]" required:"false"`
|
||||
SortDirection string `query:"sort[direction]" required:"false"`
|
||||
Filters map[string][]any
|
||||
}
|
||||
|
||||
type FieldMeta struct {
|
||||
@@ -34,22 +31,19 @@ type FieldMeta struct {
|
||||
IsFilterable bool
|
||||
}
|
||||
|
||||
func ParseListRequestOptions(ctx *gin.Context) (listRequestOptions ListRequestOptions) {
|
||||
if err := ctx.ShouldBindQuery(&listRequestOptions); err != nil {
|
||||
return listRequestOptions
|
||||
}
|
||||
|
||||
listRequestOptions.Filters = parseNestedFilters(ctx)
|
||||
return listRequestOptions
|
||||
func (options *ListRequestOptions) Resolve(ctx huma.Context) []error {
|
||||
requestURL := ctx.URL()
|
||||
options.Filters = parseNestedFilters(requestURL.Query())
|
||||
return nil
|
||||
}
|
||||
|
||||
func PaginateFilterAndSort(params ListRequestOptions, query *gorm.DB, result any) (PaginationResponse, error) {
|
||||
meta := extractModelMetadata(result)
|
||||
|
||||
query = applyFilters(params.Filters, query, meta)
|
||||
query = applySorting(params.Sort.Column, params.Sort.Direction, query, meta)
|
||||
query = applySorting(params.SortColumn, params.SortDirection, query, meta)
|
||||
|
||||
return Paginate(params.Pagination.Page, params.Pagination.Limit, query, result)
|
||||
return Paginate(params.Page, params.Limit, query, result)
|
||||
}
|
||||
|
||||
func Paginate(page int, pageSize int, query *gorm.DB, result any) (PaginationResponse, error) {
|
||||
@@ -105,9 +99,8 @@ func IsValidSortDirection(direction string) bool {
|
||||
}
|
||||
|
||||
// parseNestedFilters handles ?filters[field][0]=val1&filters[field][1]=val2
|
||||
func parseNestedFilters(ctx *gin.Context) map[string][]any {
|
||||
func parseNestedFilters(query url.Values) map[string][]any {
|
||||
result := make(map[string][]any)
|
||||
query := ctx.Request.URL.Query()
|
||||
|
||||
for key, values := range query {
|
||||
if !strings.HasPrefix(key, "filters[") {
|
||||
|
||||
Reference in New Issue
Block a user