mirror of
https://github.com/photoprism/photoprism.git
synced 2026-07-17 16:49:04 +00:00
API: Document "GET /api/v1/photos/view" in swagger.json
Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
parent
fcbfbfd8be
commit
7db82af193
5 changed files with 232 additions and 45 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue