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: help:
@echo "For build instructions, visit <https://docs.photoprism.app/developer-guide/>." @echo "For build instructions, visit <https://docs.photoprism.app/developer-guide/>."
docs: swag docs: swag
swag: swag: swag-json
@echo "Generating Swagger API documentation..." swag-json:
swag init --generatedTime --parseDependency --parseDepth 1 --dir internal/api -g api.go -o ./assets/docs/api/v1 @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: swag-fmt:
@echo "Formatting Swagger API documentation..." @echo "Formatting Swagger API annotations..."
swag fmt --dir internal/api swag fmt --dir internal/api
swag-go: 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: 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: notice:
@echo "Creating license report for Go dependencies..." @echo "Creating license report for Go dependencies..."
go-licenses report ./internal/... ./pkg/... --template=.report.tmpl > NOTICE go-licenses report ./internal/... ./pkg/... --template=.report.tmpl > NOTICE

View File

@@ -16,7 +16,16 @@ import (
// SearchAlbums finds albums and returns them as JSON. // 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) { func SearchAlbums(router *gin.RouterGroup) {
router.GET("/albums", func(c *gin.Context) { router.GET("/albums", func(c *gin.Context) {
s := AuthAny(c, acl.ResourceAlbums, acl.Permissions{acl.ActionSearch, acl.ActionView, acl.AccessShared}) 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.description Learn more
// @externalDocs.url https://docs.photoprism.app/developer-guide/api/ // @externalDocs.url https://docs.photoprism.app/developer-guide/api/
// @version v1 // @version v1
// @host demo.photoprism.app
// @query.collection.format multi // @query.collection.format multi

View File

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

View File

@@ -35,9 +35,9 @@ func SaveSidecarYaml(photo *entity.Photo) {
_ = photo.SaveSidecarYaml(conf.OriginalsPath(), conf.SidecarPath()) _ = 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 // @Id GetPhoto
// @Tags Photos // @Tags Photos
// @Produce json // @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 // PUT /api/v1/photos/:uid
func UpdatePhoto(router *gin.RouterGroup) { 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: // The request parameters are:
// //

View File

@@ -15,10 +15,19 @@ import (
"github.com/photoprism/photoprism/pkg/i18n" "github.com/photoprism/photoprism/pkg/i18n"
) )
// SearchPhotos searches the pictures index and returns the result as JSON. // SearchPhotos finds pictures and returns them as JSON.
// See form.SearchPhotos for supported search params and data types.
// //
// 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) { func SearchPhotos(router *gin.RouterGroup) {
// searchPhotos checking authorization and parses the search request. // searchPhotos checking authorization and parses the search request.
searchForm := func(c *gin.Context) (f form.SearchPhotos, s *entity.Session, err error) { searchForm := func(c *gin.Context) (f form.SearchPhotos, s *entity.Session, err error) {

View File

@@ -9,6 +9,71 @@
"host": "demo.photoprism.app", "host": "demo.photoprism.app",
"paths": { "paths": {
"/api/v1/albums": { "/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": { "post": {
"tags": [ "tags": [
"Albums" "Albums"
@@ -220,6 +285,79 @@
"responses": {} "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}": { "/api/v1/photos/{uid}": {
"get": { "get": {
"produces": [ "produces": [
@@ -228,7 +366,7 @@
"tags": [ "tags": [
"Photos" "Photos"
], ],
"summary": "returns photo details as JSON", "summary": "returns picture details as JSON",
"operationId": "GetPhoto", "operationId": "GetPhoto",
"parameters": [ "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": { "time.Duration": {
"type": "integer", "type": "integer",
"enum": [ "enum": [
-9223372036854775808,
9223372036854775807,
1,
1000,
1000000,
1000000000,
60000000000,
3600000000000,
-9223372036854775808, -9223372036854775808,
9223372036854775807, 9223372036854775807,
1, 1,
@@ -1218,8 +1662,6 @@
1000, 1000,
1000000, 1000000,
1000000000, 1000000000,
60000000000,
3600000000000,
1, 1,
1000, 1000,
1000000, 1000000,
@@ -1242,6 +1684,8 @@
"Second", "Second",
"Minute", "Minute",
"Hour", "Hour",
"minDuration",
"maxDuration",
"Nanosecond", "Nanosecond",
"Microsecond", "Microsecond",
"Millisecond", "Millisecond",
@@ -1252,6 +1696,10 @@
"Microsecond", "Microsecond",
"Millisecond", "Millisecond",
"Second", "Second",
"Nanosecond",
"Microsecond",
"Millisecond",
"Second",
"Minute", "Minute",
"Hour", "Hour",
"Nanosecond", "Nanosecond",

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,6 +6,8 @@ import (
"strings" "strings"
) )
const localhost = "localhost"
// BaseUri returns the site base URI for a given resource. // BaseUri returns the site base URI for a given resource.
func (c *Config) BaseUri(res string) string { func (c *Config) BaseUri(res string) string {
if c.SiteUrl() == "" { if c.SiteUrl() == "" {
@@ -68,15 +70,28 @@ func (c *Config) SiteHttps() bool {
return strings.HasPrefix(c.options.SiteUrl, "https://") 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 { func (c *Config) SiteDomain() string {
if u, err := url.Parse(c.SiteUrl()); err != nil { if u, err := url.Parse(c.SiteUrl()); err != nil {
return "localhost" return localhost
} else { } else {
return u.Hostname() 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. // SiteAuthor returns the site author / copyright.
func (c *Config) SiteAuthor() string { func (c *Config) SiteAuthor() string {
return c.options.SiteAuthor return c.options.SiteAuthor

View File

@@ -82,11 +82,21 @@ func TestConfig_SiteHttps(t *testing.T) {
func TestConfig_SiteDomain(t *testing.T) { func TestConfig_SiteDomain(t *testing.T) {
c := NewConfig(CliTestContext()) c := NewConfig(CliTestContext())
assert.Equal(t, "localhost", c.SiteDomain()) assert.Equal(t, localhost, c.SiteDomain())
c.options.SiteUrl = "https://foo.bar.com:2342/" c.options.SiteUrl = "https://foo.bar.com:2342/"
assert.Equal(t, "foo.bar.com", c.SiteDomain()) assert.Equal(t, "foo.bar.com", c.SiteDomain())
c.options.SiteUrl = "" 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) { func TestConfig_SitePreview(t *testing.T) {