mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-10-27 02:59:04 +01:00
feat: built in api documentation via huma gin adapter
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user