From 16f02e41fd25f90fa04592c4933502a1dec6e1a8 Mon Sep 17 00:00:00 2001 From: Michael Mayer Date: Fri, 19 Jul 2024 22:08:56 +0200 Subject: [PATCH] API: Move swagger.json to /internal/api and embed it in build #2132 Signed-off-by: Michael Mayer --- Makefile | 23 +- internal/api/albums_search.go | 11 +- internal/api/api.go | 1 + internal/api/docs.go | 31 +- internal/api/photos.go | 8 +- internal/api/photos_search.go | 15 +- internal/api/{docs => }/swagger.json | 454 ++++++++++++++++++++++++++- internal/api/users_avatar_test.go | 2 +- internal/api/users_update_test.go | 2 +- internal/commands/passwd_test.go | 3 +- internal/config/config_db.go | 2 +- internal/config/config_site.go | 19 +- internal/config/config_site_test.go | 14 +- 13 files changed, 548 insertions(+), 37 deletions(-) rename internal/api/{docs => }/swagger.json (71%) diff --git a/Makefile b/Makefile index f426ac9ba..f4c8c6da3 100644 --- a/Makefile +++ b/Makefile @@ -95,16 +95,27 @@ logs: help: @echo "For build instructions, visit ." 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 diff --git a/internal/api/albums_search.go b/internal/api/albums_search.go index 3960560ac..e92f9f4c8 100644 --- a/internal/api/albums_search.go +++ b/internal/api/albums_search.go @@ -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}) diff --git a/internal/api/api.go b/internal/api/api.go index 58c9200b5..4a1545e29 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -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 diff --git a/internal/api/docs.go b/internal/api/docs.go index b226e8b96..3c76a3b2d 100644 --- a/internal/api/docs.go +++ b/internal/api/docs.go @@ -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) } diff --git a/internal/api/photos.go b/internal/api/photos.go index 0a277f9f0..1c0ee0910 100644 --- a/internal/api/photos.go +++ b/internal/api/photos.go @@ -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: // diff --git a/internal/api/photos_search.go b/internal/api/photos_search.go index 4d9292254..1e41092e1 100644 --- a/internal/api/photos_search.go +++ b/internal/api/photos_search.go @@ -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) { diff --git a/internal/api/docs/swagger.json b/internal/api/swagger.json similarity index 71% rename from internal/api/docs/swagger.json rename to internal/api/swagger.json index f12dd0038..aaa636900 100644 --- a/internal/api/docs/swagger.json +++ b/internal/api/swagger.json @@ -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", diff --git a/internal/api/users_avatar_test.go b/internal/api/users_avatar_test.go index 55091f956..e143dc386 100644 --- a/internal/api/users_avatar_test.go +++ b/internal/api/users_avatar_test.go @@ -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" ) diff --git a/internal/api/users_update_test.go b/internal/api/users_update_test.go index 5ecc05ce3..45434f5eb 100644 --- a/internal/api/users_update_test.go +++ b/internal/api/users_update_test.go @@ -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" ) diff --git a/internal/commands/passwd_test.go b/internal/commands/passwd_test.go index f8e986486..7ba50a372 100644 --- a/internal/commands/passwd_test.go +++ b/internal/commands/passwd_test.go @@ -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) { diff --git a/internal/config/config_db.go b/internal/config/config_db.go index 7fdd8697f..2eb6e1f28 100644 --- a/internal/config/config_db.go +++ b/internal/config/config_db.go @@ -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 diff --git a/internal/config/config_site.go b/internal/config/config_site.go index 8e74145d5..698baebfa 100644 --- a/internal/config/config_site.go +++ b/internal/config/config_site.go @@ -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 diff --git a/internal/config/config_site_test.go b/internal/config/config_site_test.go index 623867afc..8c9b82430 100644 --- a/internal/config/config_site_test.go +++ b/internal/config/config_site_test.go @@ -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) {