API: Document "GET /api/v1/photos/view" in swagger.json

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer 2026-04-15 14:01:19 +02:00
parent fcbfbfd8be
commit 7db82af193
5 changed files with 232 additions and 45 deletions

View file

@ -1,6 +1,9 @@
## Go Test Coverage
- Every new Go function, including unexported helpers, must have focused test coverage in the corresponding `*_test.go` file; update existing tests or add new ones as needed.
- Refactors count too: when you split an existing function into new helpers (e.g., splitting one route registrar into two), each new function needs its own `Test<Name>` with at least a Success and an InvalidRequest/error case. Do not rely on the old test name still covering the old path by side effect.
- Before reporting a Go change as done, grep your diff for `^func ` additions and confirm each has a matching `Test*` entry. If you cannot justify skipping coverage for a helper, add the test.
- Regenerating `swagger.json` or updating route registration is not a substitute for tests — Swagger documents shape, tests prove behavior. Both are required.
## Go Testing Patterns

View file

@ -16,6 +16,39 @@ import (
"github.com/photoprism/photoprism/pkg/log/status"
)
// searchPhotosForm checks authorization and parses the photo search request.
func searchPhotosForm(c *gin.Context) (frm form.SearchPhotos, s *entity.Session, err error) {
s = AuthAny(c, acl.ResourcePhotos, acl.Permissions{acl.ActionSearch, acl.ActionView, acl.AccessShared})
// Abort if permission is not granted.
if s.Abort(c) {
return frm, s, i18n.Error(i18n.ErrForbidden)
}
// Abort if request params are invalid.
if err = c.MustBindWith(&frm, binding.Form); err != nil {
event.AuditWarn([]string{ClientIP(c), "session %s", string(acl.ResourcePhotos), "form invalid", status.Error(err)}, s.RefID)
AbortBadRequest(c, err)
return frm, s, err
}
settings := get.Config().Settings()
// Ignore private flag if feature is disabled.
if !settings.Features.Private {
frm.Public = false
}
// Ignore private flag if feature is disabled.
if frm.Scope == "" &&
settings.Features.Review &&
acl.Rules.Deny(acl.ResourcePhotos, s.GetUserRole(), acl.ActionManage) {
frm.Quality = 3
}
return frm, s, nil
}
// SearchPhotos finds pictures and returns them as JSON.
//
// @Summary finds pictures and returns them as JSON
@ -38,42 +71,8 @@ import (
// @Param video query bool false "is type video"
// @Router /api/v1/photos [get]
func SearchPhotos(router *gin.RouterGroup) {
// searchPhotos checking authorization and parses the search request.
searchForm := func(c *gin.Context) (frm form.SearchPhotos, s *entity.Session, err error) {
s = AuthAny(c, acl.ResourcePhotos, acl.Permissions{acl.ActionSearch, acl.ActionView, acl.AccessShared})
// Abort if permission is not granted.
if s.Abort(c) {
return frm, s, i18n.Error(i18n.ErrForbidden)
}
// Abort if request params are invalid.
if err = c.MustBindWith(&frm, binding.Form); err != nil {
event.AuditWarn([]string{ClientIP(c), "session %s", string(acl.ResourcePhotos), "form invalid", status.Error(err)}, s.RefID)
AbortBadRequest(c, err)
return frm, s, err
}
settings := get.Config().Settings()
// Ignore private flag if feature is disabled.
if !settings.Features.Private {
frm.Public = false
}
// Ignore private flag if feature is disabled.
if frm.Scope == "" &&
settings.Features.Review &&
acl.Rules.Deny(acl.ResourcePhotos, s.GetUserRole(), acl.ActionManage) {
frm.Quality = 3
}
return frm, s, nil
}
// defaultHandler a standard JSON result with all fields.
defaultHandler := func(c *gin.Context) {
f, s, err := searchForm(c)
router.GET("/photos", func(c *gin.Context) {
f, s, err := searchPhotosForm(c)
// Abort if authorization or form are invalid.
if err != nil {
@ -98,11 +97,35 @@ func SearchPhotos(router *gin.RouterGroup) {
// Return as JSON.
c.JSON(http.StatusOK, result)
}
})
}
// viewHandler returns a photo viewer formatted result.
viewHandler := func(c *gin.Context) {
f, s, err := searchForm(c)
// SearchPhotosView finds pictures and returns a viewer-formatted JSON result for the lightbox.
//
// @Summary finds pictures and returns a viewer-formatted JSON result for the lightbox
// @Description Returns search results formatted for the photo viewer (lightbox) in the web UI,
// @Description including resolved content URLs and preview/download tokens.
// @Description For more information see:
// @Description - https://docs.photoprism.app/developer-guide/api/search/#get-apiv1photos
// @Id SearchPhotosView
// @Tags Photos
// @Produce json
// @Success 200 {object} search.PhotoResults
// @Failure 400,401,403,404 {object} i18n.Response
// @Param count query int true "maximum number of files" minimum(1) maximum(100000)
// @Param offset query int false "file offset" minimum(0) maximum(100000)
// @Param order query string false "sort order" Enums(name, title, added, edited, newest, oldest, size, random, duration, relevance)
// @Param merged query bool false "groups consecutive files that belong to the same photo"
// @Param public query bool false "excludes private pictures"
// @Param quality query int false "minimum quality score (1-7)" Enums(0, 1, 2, 3, 4, 5, 6, 7)
// @Param q query string false "search query"
// @Param s query string false "album uid"
// @Param path query string false "photo path"
// @Param video query bool false "is type video"
// @Router /api/v1/photos/view [get]
func SearchPhotosView(router *gin.RouterGroup) {
router.GET("/photos/view", func(c *gin.Context) {
f, s, err := searchPhotosForm(c)
// Abort if authorization or form are invalid.
if err != nil {
@ -127,9 +150,5 @@ func SearchPhotos(router *gin.RouterGroup) {
// Return as JSON.
c.JSON(http.StatusOK, result)
}
// Register route handlers.
router.GET("/photos", defaultHandler)
router.GET("/photos/view", viewHandler)
})
}

View file

@ -37,3 +37,30 @@ func TestSearchPhotos(t *testing.T) {
assert.Equal(t, http.StatusBadRequest, result.Code)
})
}
func TestSearchPhotosView(t *testing.T) {
t.Run("Success", func(t *testing.T) {
app, router, _ := NewApiTest()
SearchPhotosView(router)
r := PerformRequest(app, "GET", "/api/v1/photos/view?count=10")
body := r.Body.String()
t.Logf("response body: %s", body)
assert.Equal(t, http.StatusOK, r.Code)
count := gjson.Get(body, "#")
assert.LessOrEqual(t, int64(2), count.Int())
// Viewer-formatted results expose resolved download/preview URLs
// that the default search endpoint does not populate.
first := gjson.Get(body, "0")
assert.True(t, first.Exists(), "expected at least one result")
assert.NotEmpty(t, first.Get("DownloadUrl").String(), "DownloadUrl should be set for viewer results")
})
t.Run("InvalidRequest", func(t *testing.T) {
app, router, _ := NewApiTest()
SearchPhotosView(router)
result := PerformRequest(app, "GET", "/api/v1/photos/view?xxx=10")
assert.Equal(t, http.StatusBadRequest, result.Code)
})
}

View file

@ -9040,6 +9040,143 @@
]
}
},
"/api/v1/photos/view": {
"get": {
"description": "Returns search results formatted for the photo viewer (lightbox) in the web UI,\nincluding resolved content URLs and preview/download tokens.\nFor more information see:\n- https://docs.photoprism.app/developer-guide/api/search/#get-apiv1photos",
"operationId": "SearchPhotosView",
"parameters": [
{
"description": "maximum number of files",
"in": "query",
"maximum": 100000,
"minimum": 1,
"name": "count",
"required": true,
"type": "integer"
},
{
"description": "file offset",
"in": "query",
"maximum": 100000,
"minimum": 0,
"name": "offset",
"type": "integer"
},
{
"description": "sort order",
"enum": [
"name",
"title",
"added",
"edited",
"newest",
"oldest",
"size",
"random",
"duration",
"relevance"
],
"in": "query",
"name": "order",
"type": "string"
},
{
"description": "groups consecutive files that belong to the same photo",
"in": "query",
"name": "merged",
"type": "boolean"
},
{
"description": "excludes private pictures",
"in": "query",
"name": "public",
"type": "boolean"
},
{
"description": "minimum quality score (1-7)",
"enum": [
0,
1,
2,
3,
4,
5,
6,
7
],
"in": "query",
"name": "quality",
"type": "integer"
},
{
"description": "search query",
"in": "query",
"name": "q",
"type": "string"
},
{
"description": "album uid",
"in": "query",
"name": "s",
"type": "string"
},
{
"description": "photo path",
"in": "query",
"name": "path",
"type": "string"
},
{
"description": "is type video",
"in": "query",
"name": "video",
"type": "boolean"
}
],
"produces": [
"application/json"
],
"responses": {
"200": {
"description": "OK",
"schema": {
"items": {
"$ref": "#/definitions/search.Photo"
},
"type": "array"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/i18n.Response"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/i18n.Response"
}
},
"403": {
"description": "Forbidden",
"schema": {
"$ref": "#/definitions/i18n.Response"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/i18n.Response"
}
}
},
"summary": "finds pictures and returns a viewer-formatted JSON result for the lightbox",
"tags": [
"Photos"
]
}
},
"/api/v1/photos/{uid}": {
"get": {
"operationId": "GetPhoto",

View file

@ -105,6 +105,7 @@ func registerRoutes(router *gin.Engine, conf *config.Config) {
// Photo Search and Organization.
api.SearchPhotos(APIv1)
api.SearchPhotosView(APIv1)
api.SearchGeo(APIv1)
api.GetPlacesReverse(APIv1)
api.GetPlacesSearch(APIv1)