API: Move swagger.json to /internal/api and embed it in build #2132

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer
2024-07-19 22:08:56 +02:00
parent d86fc2863e
commit 16f02e41fd
13 changed files with 548 additions and 37 deletions

View File

@@ -95,16 +95,27 @@ logs:
help:
@echo "For build instructions, visit <https://docs.photoprism.app/developer-guide/>."
docs: swag
swag:
@echo "Generating Swagger API documentation..."
swag init --generatedTime --parseDependency --parseDepth 1 --dir internal/api -g api.go -o ./assets/docs/api/v1
swag: swag-json
swag-json:
@echo "Generating ./internal/api/swagger.json..."
swag init --ot json --parseDependency --parseDepth 1 --dir internal/api -g api.go -o ./internal/api
swag-yaml:
@echo "Generating ./internal/api/swagger.yaml..."
swag init --ot yaml --parseDependency --parseDepth 1 --dir internal/api -g api.go -o ./internal/api
swag-clean:
@echo "Removing Swagger API documentation..."
rm -rf ./assets/docs/api
rm -f ./internal/api/swagger.json
rm -f ./internal/api/swagger.yaml
swag-fmt:
@echo "Formatting Swagger API documentation..."
@echo "Formatting Swagger API annotations..."
swag fmt --dir internal/api
swag-go:
docker run --rm --pull always -v ./assets/docs:/assets/docs swaggerapi/swagger-codegen-cli generate -i /assets/docs/api/v1/swagger.json -l go -o /assets/docs/api/v1/go
swag init --ot json --generatedTime --parseDependency --parseDepth 1 --dir internal/api -g api.go -o ./assets/docs/api/v1
docker run --rm -u $(UID) --pull always -v ./assets/docs:/assets/docs swaggerapi/swagger-codegen-cli generate -i /assets/docs/api/v1/swagger.json -l go -o /assets/docs/api/v1/go
swag-html:
docker run --rm --pull always -v ./assets/docs:/assets/docs swaggerapi/swagger-codegen-cli generate -i /assets/docs/api/v1/swagger.json -l html2 -o /assets/docs/api/v1/html
swag init --ot json --generatedTime --parseDependency --parseDepth 1 --dir internal/api -g api.go -o ./assets/docs/api/v1
docker run --rm -u $(UID) --pull always -v ./assets/docs:/assets/docs swaggerapi/swagger-codegen-cli generate -i /assets/docs/api/v1/swagger.json -l html2 -o /assets/docs/api/v1/html
notice:
@echo "Creating license report for Go dependencies..."
go-licenses report ./internal/... ./pkg/... --template=.report.tmpl > NOTICE

View File

@@ -16,7 +16,16 @@ import (
// SearchAlbums finds albums and returns them as JSON.
//
// GET /api/v1/albums
// @Summary finds albums and returns them as JSON
// @Id SearchAlbums
// @Tags Albums
// @Produce json
// @Success 200 {object} search.AlbumResults
// @Failure 400,404 {object} i18n.Response
// @Param count query int true "maximum number of results" minimum(1) maximum(100000)
// @Param offset query int false "search result offset" minimum(0) maximum(100000)
// @Param order query string false "sort order" Enums(favorites, name, title, added, edited)
// @Router /api/v1/albums [get]
func SearchAlbums(router *gin.RouterGroup) {
router.GET("/albums", func(c *gin.Context) {
s := AuthAny(c, acl.ResourceAlbums, acl.Permissions{acl.ActionSearch, acl.ActionView, acl.AccessShared})

View File

@@ -47,4 +47,5 @@ import (
// @externalDocs.description Learn more
// @externalDocs.url https://docs.photoprism.app/developer-guide/api/
// @version v1
// @host demo.photoprism.app
// @query.collection.format multi

View File

@@ -4,27 +4,34 @@
package api
import (
"path/filepath"
"github.com/photoprism/photoprism/pkg/fs"
"bytes"
_ "embed"
"net/http"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/photoprism/get"
files "github.com/swaggo/files"
swagger "github.com/swaggo/gin-swagger"
"github.com/photoprism/photoprism/internal/photoprism/get"
"github.com/photoprism/photoprism/pkg/header"
)
//go:embed swagger.json
var swaggerJSON []byte
// GetDocs registers the Swagger API documentation endpoints.
func GetDocs(router *gin.RouterGroup) {
// Get global configuration.
conf := get.Config()
swaggerFile := filepath.Join(conf.AssetsPath(), "docs/api/v1/swagger.json")
if !fs.FileExistsNotEmpty(swaggerFile) {
return
// Serve swagger.json, with the default host "demo.photoprism.app" being replaced by the configured hostname.
router.GET("swagger.json", func(c *gin.Context) {
c.Data(http.StatusOK, header.ContentTypeJson, bytes.ReplaceAll(swaggerJSON, []byte("demo.photoprism.app"), []byte(conf.SiteHost())))
})
// Serve Swagger UI.
if handler := swagger.WrapHandler(files.Handler, swagger.URL(conf.ApiUri()+"/swagger.json")); handler != nil {
router.GET("/docs", handler)
router.GET("/docs/*any", handler)
}
router.StaticFile("/swagger.json", swaggerFile)
handler := swagger.WrapHandler(files.Handler, swagger.URL(conf.ApiUri()+"/swagger.json"))
router.GET("/docs", handler)
router.GET("/docs/*any", handler)
}

View File

@@ -35,9 +35,9 @@ func SaveSidecarYaml(photo *entity.Photo) {
_ = photo.SaveSidecarYaml(conf.OriginalsPath(), conf.SidecarPath())
}
// GetPhoto returns photo details as JSON.
// GetPhoto returns picture details as JSON.
//
// @Summary returns photo details as JSON
// @Summary returns picture details as JSON
// @Id GetPhoto
// @Tags Photos
// @Produce json
@@ -64,7 +64,7 @@ func GetPhoto(router *gin.RouterGroup) {
})
}
// UpdatePhoto updates photo details and returns them as JSON.
// UpdatePhoto updates picture details and returns them as JSON.
//
// PUT /api/v1/photos/:uid
func UpdatePhoto(router *gin.RouterGroup) {
@@ -161,7 +161,7 @@ func GetPhotoDownload(router *gin.RouterGroup) {
})
}
// GetPhotoYaml returns photo details as YAML.
// GetPhotoYaml returns picture details as YAML.
//
// The request parameters are:
//

View File

@@ -15,10 +15,19 @@ import (
"github.com/photoprism/photoprism/pkg/i18n"
)
// SearchPhotos searches the pictures index and returns the result as JSON.
// See form.SearchPhotos for supported search params and data types.
// SearchPhotos finds pictures and returns them as JSON.
//
// GET /api/v1/photos
// @Summary finds pictures and returns them as JSON
// @Id SearchPhotos
// @Tags Photos
// @Produce json
// @Success 200 {object} search.PhotoResults
// @Failure 400,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(favorites, name, title, added, edited)
// @Param merged query bool false "groups consecutive files that belong to the same photo"
// @Router /api/v1/photos [get]
func SearchPhotos(router *gin.RouterGroup) {
// searchPhotos checking authorization and parses the search request.
searchForm := func(c *gin.Context) (f form.SearchPhotos, s *entity.Session, err error) {

View File

@@ -9,6 +9,71 @@
"host": "demo.photoprism.app",
"paths": {
"/api/v1/albums": {
"get": {
"produces": [
"application/json"
],
"tags": [
"Albums"
],
"summary": "finds albums and returns them as JSON",
"operationId": "SearchAlbums",
"parameters": [
{
"maximum": 100000,
"minimum": 1,
"type": "integer",
"description": "maximum number of results",
"name": "count",
"in": "query",
"required": true
},
{
"maximum": 100000,
"minimum": 0,
"type": "integer",
"description": "search result offset",
"name": "offset",
"in": "query"
},
{
"enum": [
"favorites",
"name",
"title",
"added",
"edited"
],
"type": "string",
"description": "sort order",
"name": "order",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/search.Album"
}
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/i18n.Response"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/i18n.Response"
}
}
}
},
"post": {
"tags": [
"Albums"
@@ -220,6 +285,79 @@
"responses": {}
}
},
"/api/v1/photos": {
"get": {
"produces": [
"application/json"
],
"tags": [
"Photos"
],
"summary": "finds pictures and returns them as JSON",
"operationId": "SearchPhotos",
"parameters": [
{
"maximum": 100000,
"minimum": 1,
"type": "integer",
"description": "maximum number of files",
"name": "count",
"in": "query",
"required": true
},
{
"maximum": 100000,
"minimum": 0,
"type": "integer",
"description": "file offset",
"name": "offset",
"in": "query"
},
{
"enum": [
"favorites",
"name",
"title",
"added",
"edited"
],
"type": "string",
"description": "sort order",
"name": "order",
"in": "query"
},
{
"type": "boolean",
"description": "groups consecutive files that belong to the same photo",
"name": "merged",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/search.Photo"
}
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/i18n.Response"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/i18n.Response"
}
}
}
}
},
"/api/v1/photos/{uid}": {
"get": {
"produces": [
@@ -228,7 +366,7 @@
"tags": [
"Photos"
],
"summary": "returns photo details as JSON",
"summary": "returns picture details as JSON",
"operationId": "GetPhoto",
"parameters": [
{
@@ -1203,9 +1341,315 @@
}
}
},
"search.Album": {
"type": "object",
"properties": {
"Caption": {
"type": "string"
},
"Category": {
"type": "string"
},
"Country": {
"type": "string"
},
"CreatedAt": {
"type": "string"
},
"Day": {
"type": "integer"
},
"DeletedAt": {
"type": "string"
},
"Description": {
"type": "string"
},
"Favorite": {
"type": "boolean"
},
"Filter": {
"type": "string"
},
"LinkCount": {
"type": "integer"
},
"Location": {
"type": "string"
},
"Month": {
"type": "integer"
},
"Notes": {
"type": "string"
},
"Order": {
"type": "string"
},
"ParentUID": {
"type": "string"
},
"Path": {
"type": "string"
},
"PhotoCount": {
"type": "integer"
},
"Private": {
"type": "boolean"
},
"Slug": {
"type": "string"
},
"State": {
"type": "string"
},
"Template": {
"type": "string"
},
"Thumb": {
"type": "string"
},
"ThumbSrc": {
"type": "string"
},
"Title": {
"type": "string"
},
"Type": {
"type": "string"
},
"UID": {
"type": "string"
},
"UpdatedAt": {
"type": "string"
},
"Year": {
"type": "integer"
}
}
},
"search.Photo": {
"type": "object",
"properties": {
"Altitude": {
"type": "integer"
},
"CameraID": {
"description": "Camera",
"type": "integer"
},
"CameraMake": {
"type": "string"
},
"CameraModel": {
"type": "string"
},
"CameraSerial": {
"type": "string"
},
"CameraSrc": {
"type": "string"
},
"CellAccuracy": {
"type": "integer"
},
"CellID": {
"description": "Cell",
"type": "string"
},
"CheckedAt": {
"type": "string"
},
"Color": {
"type": "integer"
},
"Country": {
"type": "string"
},
"CreatedAt": {
"type": "string"
},
"Day": {
"type": "integer"
},
"DeletedAt": {
"type": "string"
},
"Description": {
"type": "string"
},
"DocumentID": {
"type": "string"
},
"Duration": {
"$ref": "#/definitions/time.Duration"
},
"EditedAt": {
"type": "string"
},
"Exposure": {
"type": "string"
},
"FNumber": {
"type": "number"
},
"Faces": {
"type": "integer"
},
"Favorite": {
"type": "boolean"
},
"FileName": {
"type": "string"
},
"FileRoot": {
"type": "string"
},
"FileUID": {
"type": "string"
},
"Files": {
"type": "array",
"items": {
"$ref": "#/definitions/entity.File"
}
},
"FocalLength": {
"type": "integer"
},
"Hash": {
"type": "string"
},
"Height": {
"type": "integer"
},
"ID": {
"type": "string"
},
"InstanceID": {
"type": "string"
},
"Iso": {
"type": "integer"
},
"Lat": {
"type": "number"
},
"LensID": {
"description": "Lens",
"type": "integer"
},
"LensMake": {
"type": "string"
},
"LensModel": {
"type": "string"
},
"Lng": {
"type": "number"
},
"Merged": {
"type": "boolean"
},
"Month": {
"type": "integer"
},
"Name": {
"type": "string"
},
"OriginalName": {
"type": "string"
},
"Panorama": {
"type": "boolean"
},
"Path": {
"type": "string"
},
"PlaceCity": {
"type": "string"
},
"PlaceCountry": {
"type": "string"
},
"PlaceID": {
"type": "string"
},
"PlaceLabel": {
"type": "string"
},
"PlaceSrc": {
"type": "string"
},
"PlaceState": {
"type": "string"
},
"Portrait": {
"type": "boolean"
},
"Private": {
"type": "boolean"
},
"Quality": {
"type": "integer"
},
"Resolution": {
"type": "integer"
},
"Scan": {
"type": "boolean"
},
"Stack": {
"type": "integer"
},
"TakenAt": {
"type": "string"
},
"TakenAtLocal": {
"type": "string"
},
"TakenSrc": {
"type": "string"
},
"TimeZone": {
"type": "string"
},
"Title": {
"type": "string"
},
"Type": {
"type": "string"
},
"TypeSrc": {
"type": "string"
},
"UID": {
"type": "string"
},
"UpdatedAt": {
"type": "string"
},
"Width": {
"type": "integer"
},
"Year": {
"type": "integer"
}
}
},
"time.Duration": {
"type": "integer",
"enum": [
-9223372036854775808,
9223372036854775807,
1,
1000,
1000000,
1000000000,
60000000000,
3600000000000,
-9223372036854775808,
9223372036854775807,
1,
@@ -1218,8 +1662,6 @@
1000,
1000000,
1000000000,
60000000000,
3600000000000,
1,
1000,
1000000,
@@ -1242,6 +1684,8 @@
"Second",
"Minute",
"Hour",
"minDuration",
"maxDuration",
"Nanosecond",
"Microsecond",
"Millisecond",
@@ -1252,6 +1696,10 @@
"Microsecond",
"Millisecond",
"Second",
"Nanosecond",
"Microsecond",
"Millisecond",
"Second",
"Minute",
"Hour",
"Nanosecond",

View File

@@ -2,12 +2,12 @@ package api
import (
"fmt"
"github.com/photoprism/photoprism/internal/config"
"net/http"
"testing"
"github.com/stretchr/testify/assert"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/entity"
)

View File

@@ -3,11 +3,11 @@ package api
import (
"encoding/json"
"fmt"
"github.com/photoprism/photoprism/internal/entity"
"net/http"
"testing"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/form"
"github.com/stretchr/testify/assert"
)

View File

@@ -1,9 +1,10 @@
package commands
import (
"testing"
"github.com/photoprism/photoprism/pkg/capture"
"github.com/stretchr/testify/assert"
"testing"
)
func TestPasswdCommand(t *testing.T) {

View File

@@ -127,7 +127,7 @@ func (c *Config) DatabaseServer() string {
if c.DatabaseDriver() == SQLite3 {
return ""
} else if c.options.DatabaseServer == "" {
return "localhost"
return localhost
}
return c.options.DatabaseServer

View File

@@ -6,6 +6,8 @@ import (
"strings"
)
const localhost = "localhost"
// BaseUri returns the site base URI for a given resource.
func (c *Config) BaseUri(res string) string {
if c.SiteUrl() == "" {
@@ -68,15 +70,28 @@ func (c *Config) SiteHttps() bool {
return strings.HasPrefix(c.options.SiteUrl, "https://")
}
// SiteDomain returns the public server domain.
// SiteDomain returns the public hostname without protocol or post.
func (c *Config) SiteDomain() string {
if u, err := url.Parse(c.SiteUrl()); err != nil {
return "localhost"
return localhost
} else {
return u.Hostname()
}
}
// SiteHost returns the public hostname and port number in the format "domain:port".
func (c *Config) SiteHost() string {
if u, err := url.Parse(c.SiteUrl()); err != nil {
return localhost
} else if hostname := u.Hostname(); hostname == "" {
return localhost
} else if port := u.Port(); port != "" {
return fmt.Sprintf("%s:%s", hostname, port)
} else {
return hostname
}
}
// SiteAuthor returns the site author / copyright.
func (c *Config) SiteAuthor() string {
return c.options.SiteAuthor

View File

@@ -82,11 +82,21 @@ func TestConfig_SiteHttps(t *testing.T) {
func TestConfig_SiteDomain(t *testing.T) {
c := NewConfig(CliTestContext())
assert.Equal(t, "localhost", c.SiteDomain())
assert.Equal(t, localhost, c.SiteDomain())
c.options.SiteUrl = "https://foo.bar.com:2342/"
assert.Equal(t, "foo.bar.com", c.SiteDomain())
c.options.SiteUrl = ""
assert.Equal(t, "localhost", c.SiteDomain())
assert.Equal(t, localhost, c.SiteDomain())
}
func TestConfig_SiteHost(t *testing.T) {
c := NewConfig(CliTestContext())
assert.Equal(t, "localhost:2342", c.SiteHost())
c.options.SiteUrl = "https://foo.bar.com:2342/"
assert.Equal(t, "foo.bar.com:2342", c.SiteHost())
c.options.SiteUrl = ""
assert.Equal(t, "localhost:2342", c.SiteHost())
}
func TestConfig_SitePreview(t *testing.T) {