mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-10-10 11:49:05 +02:00
feat: built in api documentation via huma gin adapter
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
)
|
||||
|
||||
@@ -23,25 +25,25 @@ type apiPermissionResponseDto struct {
|
||||
// apiCreateDto is the payload for creating an API
|
||||
// The resource identifier is only accepted here because changing it later would invalidate every token already minted for the API
|
||||
type apiCreateDto struct {
|
||||
Name string `json:"name" binding:"required,min=1,max=50" unorm:"nfc"`
|
||||
Resource string `json:"resource" binding:"required,resource_uri,max=350" unorm:"nfc"`
|
||||
Name string `json:"name" required:"true" minLength:"1" maxLength:"50" unorm:"nfc"`
|
||||
Resource string `json:"resource" required:"true" maxLength:"350" unorm:"nfc"`
|
||||
}
|
||||
|
||||
// apiUpdateDto is the payload for updating an API
|
||||
// The resource identifier is intentionally not updatable
|
||||
type apiUpdateDto struct {
|
||||
Name string `json:"name" binding:"required,min=1,max=50" unorm:"nfc"`
|
||||
Name string `json:"name" required:"true" minLength:"1" maxLength:"50" unorm:"nfc"`
|
||||
}
|
||||
|
||||
type apiPermissionInputDto struct {
|
||||
Key string `json:"key" binding:"required,min=1,max=128" unorm:"nfc"`
|
||||
Name string `json:"name" binding:"required,min=1,max=50" unorm:"nfc"`
|
||||
Description *string `json:"description" binding:"omitempty,max=200"`
|
||||
Key string `json:"key" required:"true" minLength:"1" maxLength:"128" unorm:"nfc"`
|
||||
Name string `json:"name" required:"true" minLength:"1" maxLength:"50" unorm:"nfc"`
|
||||
Description *string `json:"description" required:"false" maxLength:"200"`
|
||||
}
|
||||
|
||||
// apiPermissionsUpdateDto replaces the full permission set of an API
|
||||
type apiPermissionsUpdateDto struct {
|
||||
Permissions []apiPermissionInputDto `json:"permissions" binding:"omitempty,dive"`
|
||||
Permissions []apiPermissionInputDto `json:"permissions" required:"false"`
|
||||
}
|
||||
|
||||
// clientApiAccessDto is the set of API permissions a client is allowed to request, split by subject type
|
||||
@@ -52,6 +54,28 @@ type clientApiAccessDto struct {
|
||||
}
|
||||
|
||||
type clientApiAccessUpdateDto struct {
|
||||
UserDelegatedPermissionIDs []string `json:"userDelegatedPermissionIds" binding:"omitempty,dive,required"`
|
||||
ClientPermissionIDs []string `json:"clientPermissionIds" binding:"omitempty,dive,required"`
|
||||
UserDelegatedPermissionIDs []string `json:"userDelegatedPermissionIds" required:"false"`
|
||||
ClientPermissionIDs []string `json:"clientPermissionIds" required:"false"`
|
||||
}
|
||||
|
||||
func (d *apiCreateDto) Resolve(huma.Context) []error {
|
||||
if dto.ValidateResourceURI(d.Resource) {
|
||||
return nil
|
||||
}
|
||||
return []error{&huma.ErrorDetail{Location: "body.resource", Message: "Resource must be an absolute URI without whitespace or a fragment"}}
|
||||
}
|
||||
|
||||
func (d *clientApiAccessUpdateDto) Resolve(huma.Context) []error {
|
||||
var errs []error
|
||||
for _, id := range d.UserDelegatedPermissionIDs {
|
||||
if id == "" {
|
||||
errs = append(errs, &huma.ErrorDetail{Location: "body.userDelegatedPermissionIds", Message: "Permission IDs cannot be empty"})
|
||||
}
|
||||
}
|
||||
for _, id := range d.ClientPermissionIDs {
|
||||
if id == "" {
|
||||
errs = append(errs, &huma.ErrorDetail{Location: "body.clientPermissionIds", Message: "Permission IDs cannot be empty"})
|
||||
}
|
||||
}
|
||||
return errs
|
||||
}
|
||||
|
||||
+83
-179
@@ -1,14 +1,44 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"context"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
httpapi "github.com/pocket-id/pocket-id/backend/internal/utils/huma"
|
||||
)
|
||||
|
||||
type listInput struct {
|
||||
httpapi.ListInput
|
||||
Search string `query:"search" required:"false"`
|
||||
}
|
||||
|
||||
type idInput struct {
|
||||
ID string `path:"id"`
|
||||
}
|
||||
|
||||
type createInput struct {
|
||||
Body apiCreateDto
|
||||
}
|
||||
|
||||
type updateInput struct {
|
||||
ID string `path:"id"`
|
||||
Body apiUpdateDto
|
||||
}
|
||||
|
||||
type permissionsInput struct {
|
||||
ID string `path:"id"`
|
||||
Body apiPermissionsUpdateDto
|
||||
}
|
||||
|
||||
type clientInput struct {
|
||||
ClientID string `path:"clientId"`
|
||||
}
|
||||
|
||||
type clientUpdateInput struct {
|
||||
ClientID string `path:"clientId"`
|
||||
Body clientApiAccessUpdateDto
|
||||
}
|
||||
|
||||
type handler struct {
|
||||
service *Service
|
||||
}
|
||||
@@ -17,219 +47,93 @@ func newHandler(service *Service) *handler {
|
||||
return &handler{service: service}
|
||||
}
|
||||
|
||||
// list godoc
|
||||
// @Summary List APIs
|
||||
// @Description Get a paginated list of APIs with optional search and sorting
|
||||
// @Tags APIs
|
||||
// @Produce json
|
||||
// @Param search query string false "Search term to filter APIs by name or resource"
|
||||
// @Param pagination[page] query int false "Page number for pagination" default(1)
|
||||
// @Param pagination[limit] query int false "Number of items per page" default(20)
|
||||
// @Param sort[column] query string false "Column to sort by"
|
||||
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
|
||||
// @Success 200 {object} dto.Paginated[apiResponseDto]
|
||||
// @Router /api/apis [get]
|
||||
func (h *handler) list(c *gin.Context) {
|
||||
search := c.Query("search")
|
||||
listRequestOptions := utils.ParseListRequestOptions(c)
|
||||
|
||||
apis, pagination, err := h.service.List(c.Request.Context(), search, listRequestOptions)
|
||||
func (h *handler) list(ctx context.Context, input *listInput) (*httpapi.BodyOutput[dto.Paginated[apiResponseDto]], error) {
|
||||
apis, pagination, err := h.service.List(ctx, input.Search, input.ListRequestOptions)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
|
||||
items := make([]apiResponseDto, len(apis))
|
||||
for i, api := range apis {
|
||||
var item apiResponseDto
|
||||
if err := dto.MapStruct(api, &item); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
for i := range apis {
|
||||
if err := dto.MapStruct(apis[i], &items[i]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item.Resource = api.Audience
|
||||
items[i] = item
|
||||
items[i].Resource = apis[i].Audience
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, dto.Paginated[apiResponseDto]{
|
||||
Data: items,
|
||||
Pagination: pagination,
|
||||
})
|
||||
return &httpapi.BodyOutput[dto.Paginated[apiResponseDto]]{Body: dto.Paginated[apiResponseDto]{Data: items, Pagination: pagination}}, nil
|
||||
}
|
||||
|
||||
// get godoc
|
||||
// @Summary Get API by ID
|
||||
// @Description Retrieve a single API including its permissions
|
||||
// @Tags APIs
|
||||
// @Produce json
|
||||
// @Param id path string true "API ID"
|
||||
// @Success 200 {object} apiResponseDto
|
||||
// @Router /api/apis/{id} [get]
|
||||
func (h *handler) get(c *gin.Context) {
|
||||
api, err := h.service.Get(c.Request.Context(), nil, c.Param("id"))
|
||||
func (h *handler) get(ctx context.Context, input *idInput) (*httpapi.BodyOutput[apiResponseDto], error) {
|
||||
item, err := h.service.Get(ctx, nil, input.ID)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
|
||||
h.respond(c, http.StatusOK, api)
|
||||
return mapAPI(item)
|
||||
}
|
||||
|
||||
// create godoc
|
||||
// @Summary Create API
|
||||
// @Description Create a new API resource server
|
||||
// @Tags APIs
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param api body apiCreateDto true "API information"
|
||||
// @Success 201 {object} apiResponseDto "Created API"
|
||||
// @Router /api/apis [post]
|
||||
func (h *handler) create(c *gin.Context) {
|
||||
var input apiCreateDto
|
||||
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
api, err := h.service.Create(c.Request.Context(), input)
|
||||
func (h *handler) create(ctx context.Context, input *createInput) (*httpapi.BodyOutput[apiResponseDto], error) {
|
||||
item, err := h.service.Create(ctx, input.Body)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
|
||||
h.respond(c, http.StatusCreated, api)
|
||||
return mapAPI(item)
|
||||
}
|
||||
|
||||
// update godoc
|
||||
// @Summary Update API
|
||||
// @Description Update an existing API by ID
|
||||
// @Tags APIs
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "API ID"
|
||||
// @Param api body apiUpdateDto true "API information"
|
||||
// @Success 200 {object} apiResponseDto "Updated API"
|
||||
// @Router /api/apis/{id} [put]
|
||||
func (h *handler) update(c *gin.Context) {
|
||||
var input apiUpdateDto
|
||||
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
api, err := h.service.Update(c.Request.Context(), c.Param("id"), input)
|
||||
func (h *handler) update(ctx context.Context, input *updateInput) (*httpapi.BodyOutput[apiResponseDto], error) {
|
||||
item, err := h.service.Update(ctx, input.ID, input.Body)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
|
||||
h.respond(c, http.StatusOK, api)
|
||||
return mapAPI(item)
|
||||
}
|
||||
|
||||
// delete godoc
|
||||
// @Summary Delete API
|
||||
// @Description Delete an API by ID
|
||||
// @Tags APIs
|
||||
// @Param id path string true "API ID"
|
||||
// @Success 204 "No Content"
|
||||
// @Router /api/apis/{id} [delete]
|
||||
func (h *handler) delete(c *gin.Context) {
|
||||
if err := h.service.Delete(c.Request.Context(), c.Param("id")); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
func (h *handler) delete(ctx context.Context, input *idInput) (*httpapi.EmptyOutput, error) {
|
||||
if err := h.service.Delete(ctx, input.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
return &httpapi.EmptyOutput{}, nil
|
||||
}
|
||||
|
||||
// updatePermissions godoc
|
||||
// @Summary Update API permissions
|
||||
// @Description Replace the full set of permissions for an API
|
||||
// @Tags APIs
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "API ID"
|
||||
// @Param permissions body apiPermissionsUpdateDto true "Permissions to set"
|
||||
// @Success 200 {object} apiResponseDto "Updated API"
|
||||
// @Router /api/apis/{id}/permissions [put]
|
||||
func (h *handler) updatePermissions(c *gin.Context) {
|
||||
var input apiPermissionsUpdateDto
|
||||
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
api, err := h.service.UpdatePermissions(c.Request.Context(), c.Param("id"), input)
|
||||
func (h *handler) updatePermissions(ctx context.Context, input *permissionsInput) (*httpapi.BodyOutput[apiResponseDto], error) {
|
||||
item, err := h.service.UpdatePermissions(ctx, input.ID, input.Body)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
|
||||
h.respond(c, http.StatusOK, api)
|
||||
return mapAPI(item)
|
||||
}
|
||||
|
||||
// getClientAccess godoc
|
||||
// @Summary Get client API access
|
||||
// @Description Get the API permissions an OIDC client is allowed to request, split into user-delegated and client (machine-to-machine) access
|
||||
// @Tags APIs
|
||||
// @Produce json
|
||||
// @Param clientId path string true "OIDC Client ID"
|
||||
// @Success 200 {object} clientApiAccessDto
|
||||
// @Router /api/api-access/{clientId} [get]
|
||||
func (h *handler) getClientAccess(c *gin.Context) {
|
||||
access, err := h.service.GetClientAPIAccess(c.Request.Context(), c.Param("clientId"))
|
||||
func (h *handler) getClientAccess(ctx context.Context, input *clientInput) (*httpapi.BodyOutput[clientApiAccessDto], error) {
|
||||
access, err := h.service.GetClientAPIAccess(ctx, input.ClientID)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, newClientApiAccessDto(access))
|
||||
return &httpapi.BodyOutput[clientApiAccessDto]{Body: newClientAPIAccessDTO(access)}, nil
|
||||
}
|
||||
|
||||
// updateClientAccess godoc
|
||||
// @Summary Update client API access
|
||||
// @Description Replace the API permissions an OIDC client is allowed to request, split into user-delegated and client (machine-to-machine) access
|
||||
// @Tags APIs
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param clientId path string true "OIDC Client ID"
|
||||
// @Param access body clientApiAccessUpdateDto true "Allowed permission IDs per subject type"
|
||||
// @Success 200 {object} clientApiAccessDto
|
||||
// @Router /api/api-access/{clientId} [put]
|
||||
func (h *handler) updateClientAccess(c *gin.Context) {
|
||||
var input clientApiAccessUpdateDto
|
||||
err := c.ShouldBindJSON(&input)
|
||||
func (h *handler) updateClientAccess(ctx context.Context, input *clientUpdateInput) (*httpapi.BodyOutput[clientApiAccessDto], error) {
|
||||
applied, err := h.service.SetClientAPIAccess(ctx, input.ClientID, ClientAPIAccess(input.Body))
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
|
||||
applied, err := h.service.SetClientAPIAccess(c.Request.Context(), c.Param("clientId"), ClientAPIAccess(input))
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, newClientApiAccessDto(applied))
|
||||
return &httpapi.BodyOutput[clientApiAccessDto]{Body: newClientAPIAccessDTO(applied)}, nil
|
||||
}
|
||||
|
||||
// newClientApiAccessDto always serializes both permission lists as arrays rather than null
|
||||
func newClientApiAccessDto(access ClientAPIAccess) clientApiAccessDto {
|
||||
dto := clientApiAccessDto(access)
|
||||
if dto.UserDelegatedPermissionIDs == nil {
|
||||
dto.UserDelegatedPermissionIDs = []string{}
|
||||
func newClientAPIAccessDTO(access ClientAPIAccess) clientApiAccessDto {
|
||||
output := clientApiAccessDto(access)
|
||||
if output.UserDelegatedPermissionIDs == nil {
|
||||
output.UserDelegatedPermissionIDs = []string{}
|
||||
}
|
||||
if dto.ClientPermissionIDs == nil {
|
||||
dto.ClientPermissionIDs = []string{}
|
||||
if output.ClientPermissionIDs == nil {
|
||||
output.ClientPermissionIDs = []string{}
|
||||
}
|
||||
return dto
|
||||
return output
|
||||
}
|
||||
|
||||
func (h *handler) respond(c *gin.Context, status int, api API) {
|
||||
var responseDto apiResponseDto
|
||||
if err := dto.MapStruct(api, &responseDto); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
func mapAPI(item API) (*httpapi.BodyOutput[apiResponseDto], error) {
|
||||
var output apiResponseDto
|
||||
if err := dto.MapStruct(item, &output); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
responseDto.Resource = api.Audience
|
||||
c.JSON(status, responseDto)
|
||||
output.Resource = item.Audience
|
||||
return &httpapi.BodyOutput[apiResponseDto]{Body: output}, nil
|
||||
}
|
||||
|
||||
@@ -2,12 +2,14 @@ package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/oidc"
|
||||
httpapi "github.com/pocket-id/pocket-id/backend/internal/utils/huma"
|
||||
)
|
||||
|
||||
type Dependencies struct {
|
||||
@@ -59,20 +61,26 @@ func (m *Module) DescribePermissions(ctx context.Context, audience string, keys
|
||||
}
|
||||
|
||||
// RegisterRoutes mounts the admin CRUD endpoints
|
||||
// adminAuth is passed in as a gin handler so the module does not import internal/middleware
|
||||
func (m *Module) RegisterRoutes(apiGroup *gin.RouterGroup, adminAuth gin.HandlerFunc) {
|
||||
apis := apiGroup.Group("/apis")
|
||||
apis.Use(adminAuth)
|
||||
apis.GET("", m.handler.list)
|
||||
apis.POST("", m.handler.create)
|
||||
apis.GET("/:id", m.handler.get)
|
||||
apis.PUT("/:id", m.handler.update)
|
||||
apis.DELETE("/:id", m.handler.delete)
|
||||
apis.PUT("/:id/permissions", m.handler.updatePermissions)
|
||||
func (m *Module) RegisterRoutes(api huma.API, adminAuth func(*huma.Operation)) {
|
||||
register := func(operation huma.Operation, registerHandler func(huma.Operation)) {
|
||||
adminAuth(&operation)
|
||||
registerHandler(operation)
|
||||
}
|
||||
|
||||
// The per-client API-access allow-list lives on a separate path so it does not collide with the /apis/:id wildcard
|
||||
access := apiGroup.Group("/api-access")
|
||||
access.Use(adminAuth)
|
||||
access.GET("/:clientId", m.handler.getClientAccess)
|
||||
access.PUT("/:clientId", m.handler.updateClientAccess)
|
||||
register(apiOperation("list-apis", http.MethodGet, "/api/apis", "List APIs"), func(operation huma.Operation) { httpapi.Register(api, operation, m.handler.list) })
|
||||
createOperation := apiOperation("create-api", http.MethodPost, "/api/apis", "Create API")
|
||||
createOperation.DefaultStatus = http.StatusCreated
|
||||
register(createOperation, func(operation huma.Operation) { httpapi.Register(api, operation, m.handler.create) })
|
||||
register(apiOperation("get-api", http.MethodGet, "/api/apis/{id}", "Get API by ID"), func(operation huma.Operation) { httpapi.Register(api, operation, m.handler.get) })
|
||||
register(apiOperation("update-api", http.MethodPut, "/api/apis/{id}", "Update API"), func(operation huma.Operation) { httpapi.Register(api, operation, m.handler.update) })
|
||||
deleteOperation := apiOperation("delete-api", http.MethodDelete, "/api/apis/{id}", "Delete API")
|
||||
deleteOperation.DefaultStatus = http.StatusNoContent
|
||||
register(deleteOperation, func(operation huma.Operation) { httpapi.Register(api, operation, m.handler.delete) })
|
||||
register(apiOperation("update-api-permissions", http.MethodPut, "/api/apis/{id}/permissions", "Update API permissions"), func(operation huma.Operation) { httpapi.Register(api, operation, m.handler.updatePermissions) })
|
||||
register(apiOperation("get-client-api-access", http.MethodGet, "/api/api-access/{clientId}", "Get client API access"), func(operation huma.Operation) { httpapi.Register(api, operation, m.handler.getClientAccess) })
|
||||
register(apiOperation("update-client-api-access", http.MethodPut, "/api/api-access/{clientId}", "Update client API access"), func(operation huma.Operation) { httpapi.Register(api, operation, m.handler.updateClientAccess) })
|
||||
}
|
||||
|
||||
func apiOperation(id, method, path, summary string) huma.Operation {
|
||||
return huma.Operation{OperationID: id, Method: method, Path: path, Summary: summary, Tags: []string{"APIs"}}
|
||||
}
|
||||
|
||||
@@ -56,8 +56,8 @@ func (s *Service) List(ctx context.Context, search string, listRequestOptions ut
|
||||
Preload("Permissions").
|
||||
Model(&API{})
|
||||
|
||||
if listRequestOptions.Sort.Column == "resource" {
|
||||
listRequestOptions.Sort.Column = "audience"
|
||||
if listRequestOptions.SortColumn == "resource" {
|
||||
listRequestOptions.SortColumn = "audience"
|
||||
}
|
||||
|
||||
if search != "" {
|
||||
|
||||
Reference in New Issue
Block a user