diff --git a/api/bundled/openapi.json b/api/bundled/openapi.json index 0a5d889c8..a2cfce901 100644 --- a/api/bundled/openapi.json +++ b/api/bundled/openapi.json @@ -3,7 +3,7 @@ "info": { "title": "Navidrome API", "version": "1.0.0", - "description": "Navidrome API v1. Spec-first, additive within v1. Clients discover implemented\ncapability modules through `GET /server` and never sniff versions.\n\nEnums are open: new values may be added to any enum within v1. Clients must\naccept values they do not recognise instead of failing.\n\nEvery operation declares `x-stability-level`: `alpha` operations may change or\ndisappear without notice, `beta` and `stable` operations only change additively.\nA level is only ever raised, never lowered.\n\n`HEAD` is accepted wherever `GET` is. A `405` response lists the allowed methods\nin its `Allow` header.\n\nOperations that need an access token declare `security: [{bearerAuth: []}]` and the scope they need in\n`x-scope` (OpenAPI 3.0 does not allow scopes on bearer schemes). A revoked grant, and every token minted\nfrom it, stops working within one access-token lifetime at most.\n", + "description": "Navidrome API v1. Spec-first, additive within v1. Clients discover implemented\ncapability modules through `GET /capabilities` and never sniff versions.\n\nEnums are open: new values may be added to any enum within v1. Clients must\naccept values they do not recognise instead of failing.\n\nEvery operation declares `x-stability-level`: `alpha` operations may change or\ndisappear without notice, `beta` and `stable` operations only change additively.\nA level is only ever raised, never lowered.\n\n`HEAD` is accepted wherever `GET` is. A `405` response lists the allowed methods\nin its `Allow` header.\n\nOperations that need an access token declare `security: [{bearerAuth: []}]` and the scope they need in\n`x-scope` (OpenAPI 3.0 does not allow scopes on bearer schemes). A revoked grant, and every token minted\nfrom it, stops working within one access-token lifetime at most.\n", "license": { "name": "GPL-3.0", "url": "https://www.gnu.org/licenses/gpl-3.0.html" @@ -35,7 +35,7 @@ ], "security": [], "summary": "Describe the server", - "description": "Returns the public server description. No authentication required.\nAuthenticated requests will additionally receive the implemented capability modules\nonce authentication is available.\n", + "description": "Returns the public server description. No authentication required.\nCapability modules are listed by `GET /capabilities`.\n", "responses": { "200": { "description": "Server description.", @@ -53,6 +53,41 @@ } } }, + "/capabilities": { + "get": { + "operationId": "getCapabilities", + "x-module": "core", + "x-stability-level": "alpha", + "tags": [ + "server" + ], + "summary": "List implemented capability modules", + "description": "The capability modules this server implements. Any valid access token may read it, whatever its scopes.", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Implemented modules.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Capabilities" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, "/openapi.json": { "get": { "operationId": "getOpenAPISpecJSON", @@ -512,17 +547,23 @@ "description": "True until the first admin user has been created." }, "loginMethods": { - "type": "array", - "description": "Login methods this server accepts. New methods may be added; clients ignore values they do not recognise.", - "items": { - "type": "string", - "enum": [ - "password" - ] - } + "$ref": "#/components/schemas/LoginMethods" } } }, + "LoginMethods": { + "type": "object", + "description": "Login methods this server accepts, keyed by method. A missing key means the method is not offered.\nKeys are optional on purpose: discovery is read by clients of any version against servers of any\nversion, so new methods are added as new optional keys. Clients ignore keys they do not know.\n", + "properties": { + "password": { + "$ref": "#/components/schemas/PasswordLoginMethod" + } + } + }, + "PasswordLoginMethod": { + "type": "object", + "description": "Username and password login (`POST /auth/login`). No settings yet." + }, "Problem": { "type": "object", "description": "RFC 9457 problem details, returned for every 4xx and 5xx response.", @@ -598,6 +639,44 @@ } } }, + "Capabilities": { + "type": "object", + "description": "Capability modules this server implements, keyed by module. Keys are optional; a missing key means the\nmodule is not implemented. New modules are added as new optional keys. These are server facts, not what\nthe calling token may use.\n", + "properties": { + "core": { + "$ref": "#/components/schemas/CoreCapability" + }, + "password": { + "$ref": "#/components/schemas/PasswordCapability" + } + } + }, + "CoreCapability": { + "type": "object", + "description": "The mandatory core module.", + "required": [ + "version" + ], + "properties": { + "version": { + "type": "integer", + "description": "Module version. Bumped only on semantic change." + } + } + }, + "PasswordCapability": { + "type": "object", + "description": "The password login module (login, first-admin setup, password change).", + "required": [ + "version" + ], + "properties": { + "version": { + "type": "integer", + "description": "Module version. Bumped only on semantic change." + } + } + }, "TokenRequest": { "type": "object", "description": "Optional narrowing of a new access token.", @@ -924,6 +1003,21 @@ } } }, + "Unauthorized": { + "description": "Missing, invalid, or expired credentials.", + "headers": { + "WWW-Authenticate": { + "$ref": "#/components/headers/WWWAuthenticate" + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, "NotModified": { "description": "Not modified.", "headers": { @@ -942,21 +1036,6 @@ } } }, - "Unauthorized": { - "description": "Missing, invalid, or expired credentials.", - "headers": { - "WWW-Authenticate": { - "$ref": "#/components/headers/WWWAuthenticate" - } - }, - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } - }, "PayloadTooLarge": { "description": "The request body is too large (`payload_too_large`).", "content": { @@ -1047,14 +1126,14 @@ } }, "headers": { - "ETag": { - "description": "Entity tag for `If-None-Match` revalidation.", + "WWWAuthenticate": { + "description": "RFC 6750 Bearer challenge, for example `Bearer error=\"insufficient_scope\", scope=\"read\"`.", "schema": { "type": "string" } }, - "WWWAuthenticate": { - "description": "RFC 6750 Bearer challenge, for example `Bearer error=\"insufficient_scope\", scope=\"read\"`.", + "ETag": { + "description": "Entity tag for `If-None-Match` revalidation.", "schema": { "type": "string" } diff --git a/api/bundled/openapi.yaml b/api/bundled/openapi.yaml index 778791f97..f4a76c006 100644 --- a/api/bundled/openapi.yaml +++ b/api/bundled/openapi.yaml @@ -4,7 +4,7 @@ info: version: 1.0.0 description: | Navidrome API v1. Spec-first, additive within v1. Clients discover implemented - capability modules through `GET /server` and never sniff versions. + capability modules through `GET /capabilities` and never sniff versions. Enums are open: new values may be added to any enum within v1. Clients must accept values they do not recognise instead of failing. @@ -40,8 +40,7 @@ paths: summary: Describe the server description: | Returns the public server description. No authentication required. - Authenticated requests will additionally receive the implemented capability modules - once authentication is available. + Capability modules are listed by `GET /capabilities`. responses: '200': description: Server description. @@ -51,6 +50,23 @@ paths: $ref: '#/components/schemas/ServerInfo' '500': $ref: '#/components/responses/InternalError' + /capabilities: + get: + operationId: getCapabilities + x-module: core + x-stability-level: alpha + tags: [server] + summary: List implemented capability modules + description: The capability modules this server implements. Any valid access token may read it, whatever its scopes. + security: [{bearerAuth: []}] + responses: + '200': + description: Implemented modules. + content: + application/json: + schema: {$ref: '#/components/schemas/Capabilities'} + '401': {$ref: '#/components/responses/Unauthorized'} + '500': {$ref: '#/components/responses/InternalError'} /openapi.json: get: operationId: getOpenAPISpecJSON @@ -342,12 +358,19 @@ components: type: boolean description: True until the first admin user has been created. loginMethods: - type: array - description: Login methods this server accepts. New methods may be added; clients ignore values they do not recognise. - items: - type: string - enum: - - password + $ref: '#/components/schemas/LoginMethods' + LoginMethods: + type: object + description: | + Login methods this server accepts, keyed by method. A missing key means the method is not offered. + Keys are optional on purpose: discovery is read by clients of any version against servers of any + version, so new methods are added as new optional keys. Clients ignore keys they do not know. + properties: + password: + $ref: '#/components/schemas/PasswordLoginMethod' + PasswordLoginMethod: + type: object + description: Username and password login (`POST /auth/login`). No settings yet. Problem: type: object description: RFC 9457 problem details, returned for every 4xx and 5xx response. @@ -409,6 +432,35 @@ components: message: type: string description: Why the value was rejected. + Capabilities: + type: object + description: | + Capability modules this server implements, keyed by module. Keys are optional; a missing key means the + module is not implemented. New modules are added as new optional keys. These are server facts, not what + the calling token may use. + properties: + core: + $ref: '#/components/schemas/CoreCapability' + password: + $ref: '#/components/schemas/PasswordCapability' + CoreCapability: + type: object + description: The mandatory core module. + required: + - version + properties: + version: + type: integer + description: Module version. Bumped only on semantic change. + PasswordCapability: + type: object + description: The password login module (login, first-admin setup, password change). + required: + - version + properties: + version: + type: integer + description: Module version. Bumped only on semantic change. TokenRequest: type: object description: Optional narrowing of a new access token. @@ -654,6 +706,15 @@ components: application/problem+json: schema: $ref: '#/components/schemas/Problem' + Unauthorized: + description: Missing, invalid, or expired credentials. + headers: + WWW-Authenticate: + $ref: '#/components/headers/WWWAuthenticate' + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' NotModified: description: Not modified. headers: @@ -665,15 +726,6 @@ components: application/problem+json: schema: $ref: '#/components/schemas/Problem' - Unauthorized: - description: Missing, invalid, or expired credentials. - headers: - WWW-Authenticate: - $ref: '#/components/headers/WWWAuthenticate' - content: - application/problem+json: - schema: - $ref: '#/components/schemas/Problem' PayloadTooLarge: description: "The request body is too large (`payload_too_large`)." content: @@ -733,11 +785,11 @@ components: maximum: 2000 default: 100 headers: - ETag: - description: Entity tag for `If-None-Match` revalidation. - schema: - type: string WWWAuthenticate: description: 'RFC 6750 Bearer challenge, for example `Bearer error="insufficient_scope", scope="read"`.' schema: type: string + ETag: + description: Entity tag for `If-None-Match` revalidation. + schema: + type: string diff --git a/api/openapi/components/schemas/Capabilities.yaml b/api/openapi/components/schemas/Capabilities.yaml new file mode 100644 index 000000000..26445a312 --- /dev/null +++ b/api/openapi/components/schemas/Capabilities.yaml @@ -0,0 +1,10 @@ +type: object +description: | + Capability modules this server implements, keyed by module. Keys are optional; a missing key means the + module is not implemented. New modules are added as new optional keys. These are server facts, not what + the calling token may use. +properties: + core: + $ref: ./CoreCapability.yaml + password: + $ref: ./PasswordCapability.yaml diff --git a/api/openapi/components/schemas/CoreCapability.yaml b/api/openapi/components/schemas/CoreCapability.yaml new file mode 100644 index 000000000..c52c21318 --- /dev/null +++ b/api/openapi/components/schemas/CoreCapability.yaml @@ -0,0 +1,5 @@ +type: object +description: The mandatory core module. +required: [version] +properties: + version: {type: integer, description: Module version. Bumped only on semantic change.} diff --git a/api/openapi/components/schemas/LoginMethods.yaml b/api/openapi/components/schemas/LoginMethods.yaml new file mode 100644 index 000000000..106d3a6ed --- /dev/null +++ b/api/openapi/components/schemas/LoginMethods.yaml @@ -0,0 +1,8 @@ +type: object +description: | + Login methods this server accepts, keyed by method. A missing key means the method is not offered. + Keys are optional on purpose: discovery is read by clients of any version against servers of any + version, so new methods are added as new optional keys. Clients ignore keys they do not know. +properties: + password: + $ref: ./PasswordLoginMethod.yaml diff --git a/api/openapi/components/schemas/PasswordCapability.yaml b/api/openapi/components/schemas/PasswordCapability.yaml new file mode 100644 index 000000000..319cdbf3d --- /dev/null +++ b/api/openapi/components/schemas/PasswordCapability.yaml @@ -0,0 +1,5 @@ +type: object +description: The password login module (login, first-admin setup, password change). +required: [version] +properties: + version: {type: integer, description: Module version. Bumped only on semantic change.} diff --git a/api/openapi/components/schemas/PasswordLoginMethod.yaml b/api/openapi/components/schemas/PasswordLoginMethod.yaml new file mode 100644 index 000000000..e8a1f0ee2 --- /dev/null +++ b/api/openapi/components/schemas/PasswordLoginMethod.yaml @@ -0,0 +1,2 @@ +type: object +description: Username and password login (`POST /auth/login`). No settings yet. diff --git a/api/openapi/components/schemas/ServerInfo.yaml b/api/openapi/components/schemas/ServerInfo.yaml index 8906924eb..f80fbce5c 100644 --- a/api/openapi/components/schemas/ServerInfo.yaml +++ b/api/openapi/components/schemas/ServerInfo.yaml @@ -15,8 +15,4 @@ properties: type: boolean description: True until the first admin user has been created. loginMethods: - type: array - description: Login methods this server accepts. New methods may be added; clients ignore values they do not recognise. - items: - type: string - enum: [password] + $ref: ./LoginMethods.yaml diff --git a/api/openapi/openapi.yaml b/api/openapi/openapi.yaml index 02d3a56a4..5e55ef2ea 100644 --- a/api/openapi/openapi.yaml +++ b/api/openapi/openapi.yaml @@ -4,7 +4,7 @@ info: version: 1.0.0 description: | Navidrome API v1. Spec-first, additive within v1. Clients discover implemented - capability modules through `GET /server` and never sniff versions. + capability modules through `GET /capabilities` and never sniff versions. Enums are open: new values may be added to any enum within v1. Clients must accept values they do not recognise instead of failing. @@ -32,6 +32,8 @@ tags: paths: /server: $ref: ./paths/server.yaml + /capabilities: + $ref: ./paths/capabilities.yaml /openapi.json: $ref: ./paths/openapi.yaml#/json /openapi.yaml: diff --git a/api/openapi/paths/capabilities.yaml b/api/openapi/paths/capabilities.yaml new file mode 100644 index 000000000..111ba723d --- /dev/null +++ b/api/openapi/paths/capabilities.yaml @@ -0,0 +1,16 @@ +get: + operationId: getCapabilities + x-module: core + x-stability-level: alpha + tags: [server] + summary: List implemented capability modules + description: The capability modules this server implements. Any valid access token may read it, whatever its scopes. + security: [{bearerAuth: []}] + responses: + '200': + description: Implemented modules. + content: + application/json: + schema: {$ref: ../components/schemas/Capabilities.yaml} + '401': {$ref: ../components/responses/Unauthorized.yaml} + '500': {$ref: ../components/responses/InternalError.yaml} diff --git a/api/openapi/paths/server.yaml b/api/openapi/paths/server.yaml index ef30bcb10..1e17a3b87 100644 --- a/api/openapi/paths/server.yaml +++ b/api/openapi/paths/server.yaml @@ -7,8 +7,7 @@ get: summary: Describe the server description: | Returns the public server description. No authentication required. - Authenticated requests will additionally receive the implemented capability modules - once authentication is available. + Capability modules are listed by `GET /capabilities`. responses: '200': description: Server description. diff --git a/server/apiv1/api_gen.go b/server/apiv1/api_gen.go index 9851e485c..ccca0efc0 100644 --- a/server/apiv1/api_gen.go +++ b/server/apiv1/api_gen.go @@ -104,21 +104,6 @@ func (e Scope) Valid() bool { } } -// Defines values for ServerInfoLoginMethods. -const ( - ServerInfoLoginMethodsPassword ServerInfoLoginMethods = "password" -) - -// Valid indicates whether the value is a known member of the ServerInfoLoginMethods enum. -func (e ServerInfoLoginMethods) Valid() bool { - switch e { - case ServerInfoLoginMethodsPassword: - return true - default: - return false - } -} - // AccessToken A short-lived access token. Opaque; clients must not decode it. type AccessToken struct { // AccessToken The token. Send it as `Authorization: Bearer `. @@ -155,6 +140,23 @@ type AuthUser struct { UserName string `json:"userName"` } +// Capabilities Capability modules this server implements, keyed by module. Keys are optional; a missing key means the +// module is not implemented. New modules are added as new optional keys. These are server facts, not what +// the calling token may use. +type Capabilities struct { + // Core The mandatory core module. + Core *CoreCapability `json:"core,omitempty"` + + // Password The password login module (login, first-admin setup, password change). + Password *PasswordCapability `json:"password,omitempty"` +} + +// CoreCapability The mandatory core module. +type CoreCapability struct { + // Version Module version. Bumped only on semantic change. + Version int `json:"version"` +} + // CredentialsRequest Username, password and client description for a login or first-admin setup. type CredentialsRequest struct { // Client Name of the client app. @@ -236,12 +238,26 @@ type GrantList struct { Total int `json:"total"` } +// LoginMethods Login methods this server accepts, keyed by method. A missing key means the method is not offered. +// Keys are optional on purpose: discovery is read by clients of any version against servers of any +// version, so new methods are added as new optional keys. Clients ignore keys they do not know. +type LoginMethods struct { + // Password Username and password login (`POST /auth/login`). No settings yet. + Password *PasswordLoginMethod `json:"password,omitempty"` +} + // LogoutResponse Result of a logout. type LogoutResponse struct { // LogoutUrl Where to send the browser to finish logging out of an external provider. Null when there is nothing more to do. LogoutUrl *string `json:"logoutUrl"` } +// PasswordCapability The password login module (login, first-admin setup, password change). +type PasswordCapability struct { + // Version Module version. Bumped only on semantic change. + Version int `json:"version"` +} + // PasswordChangeRequest Change the caller's own password. type PasswordChangeRequest struct { // CurrentPassword The current password. @@ -254,6 +270,9 @@ type PasswordChangeRequest struct { RevokeOtherGrants *bool `json:"revokeOtherGrants,omitempty"` } +// PasswordLoginMethod Username and password login (`POST /auth/login`). No settings yet. +type PasswordLoginMethod = map[string]interface{} + // Problem RFC 9457 problem details, returned for every 4xx and 5xx response. type Problem struct { // Code Machine-readable error code, and the value clients switch on. New codes may be added. @@ -292,8 +311,10 @@ type ScopeRequest = string // ServerInfo Public server description. Everything an add-server screen needs before login. type ServerInfo struct { - // LoginMethods Login methods this server accepts. New methods may be added; clients ignore values they do not recognise. - LoginMethods []ServerInfoLoginMethods `json:"loginMethods"` + // LoginMethods Login methods this server accepts, keyed by method. A missing key means the method is not offered. + // Keys are optional on purpose: discovery is read by clients of any version against servers of any + // version, so new methods are added as new optional keys. Clients ignore keys they do not know. + LoginMethods LoginMethods `json:"loginMethods"` // Name Human-readable server product name. Name string `json:"name"` @@ -308,9 +329,6 @@ type ServerInfo struct { SpecVersion string `json:"specVersion"` } -// ServerInfoLoginMethods defines model for ServerInfo.LoginMethods. -type ServerInfoLoginMethods string - // TokenRequest Optional narrowing of a new access token. type TokenRequest struct { // Scopes Subset of the grant's scopes. Omit for all of them; an empty list asks for none. @@ -400,6 +418,9 @@ type ServerInterface interface { // CreateAccessToken Mint an access token // (POST /auth/token) CreateAccessToken(w http.ResponseWriter, r *http.Request) + // GetCapabilities List implemented capability modules + // (GET /capabilities) + GetCapabilities(w http.ResponseWriter, r *http.Request) // GetServerInfo Describe the server // (GET /server) GetServerInfo(w http.ResponseWriter, r *http.Request) @@ -451,6 +472,12 @@ func (_ Unimplemented) CreateAccessToken(w http.ResponseWriter, r *http.Request) w.WriteHeader(http.StatusNotImplemented) } +// GetCapabilities List implemented capability modules +// (GET /capabilities) +func (_ Unimplemented) GetCapabilities(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) +} + // GetServerInfo Describe the server // (GET /server) func (_ Unimplemented) GetServerInfo(w http.ResponseWriter, r *http.Request) { @@ -608,6 +635,20 @@ func (siw *ServerInterfaceWrapper) CreateAccessToken(w http.ResponseWriter, r *h handler.ServeHTTP(w, r) } +// GetCapabilities operation middleware +func (siw *ServerInterfaceWrapper) GetCapabilities(w http.ResponseWriter, r *http.Request) { + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.GetCapabilities(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + // GetServerInfo operation middleware func (siw *ServerInterfaceWrapper) GetServerInfo(w http.ResponseWriter, r *http.Request) { @@ -756,6 +797,9 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl r.Group(func(r chi.Router) { r.Post(options.BaseURL+"/auth/token", wrapper.CreateAccessToken) }) + r.Group(func(r chi.Router) { + r.Get(options.BaseURL+"/capabilities", wrapper.GetCapabilities) + }) r.Group(func(r chi.Router) { r.Get(options.BaseURL+"/server", wrapper.GetServerInfo) }) @@ -1508,6 +1552,62 @@ func (response CreateAccessToken500ApplicationProblemPlusJSONResponse) VisitCrea return err } +type GetCapabilitiesRequestObject struct { +} + +type GetCapabilitiesResponseObject interface { + VisitGetCapabilitiesResponse(w http.ResponseWriter) error +} + +type GetCapabilities200JSONResponse Capabilities + +func (response GetCapabilities200JSONResponse) VisitGetCapabilitiesResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + _, err := buf.WriteTo(w) + return err +} + +type GetCapabilities401ApplicationProblemPlusJSONResponse struct { + UnauthorizedApplicationProblemPlusJSONResponse +} + +func (response GetCapabilities401ApplicationProblemPlusJSONResponse) VisitGetCapabilitiesResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response.Body); err != nil { + return err + } + w.Header().Set("Content-Type", "application/problem+json") + if response.Headers.WWWAuthenticate != nil { + w.Header().Set("WWW-Authenticate", fmt.Sprint(*response.Headers.WWWAuthenticate)) + } + w.WriteHeader(401) + _, err := buf.WriteTo(w) + return err +} + +type GetCapabilities500ApplicationProblemPlusJSONResponse struct { + InternalErrorApplicationProblemPlusJSONResponse +} + +func (response GetCapabilities500ApplicationProblemPlusJSONResponse) VisitGetCapabilitiesResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(500) + _, err := buf.WriteTo(w) + return err +} + type GetServerInfoRequestObject struct { } @@ -1568,6 +1668,9 @@ type StrictServerInterface interface { // CreateAccessToken Mint an access token // (POST /auth/token) CreateAccessToken(ctx context.Context, request CreateAccessTokenRequestObject) (CreateAccessTokenResponseObject, error) + // GetCapabilities List implemented capability modules + // (GET /capabilities) + GetCapabilities(ctx context.Context, request GetCapabilitiesRequestObject) (GetCapabilitiesResponseObject, error) // GetServerInfo Describe the server // (GET /server) GetServerInfo(ctx context.Context, request GetServerInfoRequestObject) (GetServerInfoResponseObject, error) @@ -1815,6 +1918,30 @@ func (sh *strictHandler) CreateAccessToken(w http.ResponseWriter, r *http.Reques } } +// GetCapabilities operation middleware +func (sh *strictHandler) GetCapabilities(w http.ResponseWriter, r *http.Request) { + var request GetCapabilitiesRequestObject + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.GetCapabilities(ctx, request.(GetCapabilitiesRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetCapabilities") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(GetCapabilitiesResponseObject); ok { + if err := validResponse.VisitGetCapabilitiesResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + // GetServerInfo operation middleware func (sh *strictHandler) GetServerInfo(w http.ResponseWriter, r *http.Request) { var request GetServerInfoRequestObject diff --git a/server/apiv1/api_test.go b/server/apiv1/api_test.go index b8743f622..9e38672d1 100644 --- a/server/apiv1/api_test.go +++ b/server/apiv1/api_test.go @@ -4,6 +4,9 @@ import ( "net/http" "net/http/httptest" + "github.com/getkin/kin-openapi/openapi3" + "github.com/go-chi/chi/v5" + "github.com/navidrome/navidrome/api" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -16,6 +19,17 @@ var _ = Describe("Router", func() { router = New(&tests.MockDataStore{}) }) + It("routes every operation in the embedded spec", func() { + doc, err := openapi3.NewLoader().LoadFromData(api.SpecJSON()) + Expect(err).ToNot(HaveOccurred()) + mux := New(&tests.MockDataStore{}).Handler.(chi.Routes) + for path, item := range doc.Paths.Map() { + for method := range item.Operations() { + Expect(mux.Find(chi.NewRouteContext(), method, path)).To(Equal(path), method+" "+path) + } + } + }) + It("returns a 404 problem for unknown paths", func() { w := serve(router, httptest.NewRequest(http.MethodGet, "/api/v1/nope", nil)) Expect(w.Code).To(Equal(http.StatusNotFound)) diff --git a/server/apiv1/capabilities.go b/server/apiv1/capabilities.go new file mode 100644 index 000000000..f3b8c78c7 --- /dev/null +++ b/server/apiv1/capabilities.go @@ -0,0 +1,10 @@ +package apiv1 + +import "context" + +func (rt *Router) GetCapabilities(context.Context, GetCapabilitiesRequestObject) (GetCapabilitiesResponseObject, error) { + return GetCapabilities200JSONResponse{ + Core: &CoreCapability{Version: 1}, + Password: &PasswordCapability{Version: 1}, + }, nil +} diff --git a/server/apiv1/capabilities_test.go b/server/apiv1/capabilities_test.go new file mode 100644 index 000000000..ac1ec1b69 --- /dev/null +++ b/server/apiv1/capabilities_test.go @@ -0,0 +1,53 @@ +package apiv1 + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + + "github.com/navidrome/navidrome/conf/configtest" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("GET /capabilities", func() { + var ctx context.Context + var router *Router + + BeforeEach(func() { + ctx = GinkgoT().Context() + DeferCleanup(configtest.SetupConfig()) + resetDB() + router = New(realDS) + }) + + It("needs a token", func() { + w := serve(router, httptest.NewRequestWithContext(ctx, http.MethodGet, "/api/v1/capabilities", nil)) + Expect(w.Code).To(Equal(http.StatusUnauthorized)) + }) + + It("lists core and password for any valid token, even one with no scopes", func() { + body, _ := json.Marshal(map[string]any{"username": "admin", "password": "pw", "client": "c"}) + setupReq := httptest.NewRequestWithContext(ctx, http.MethodPost, "/api/v1/auth/setup", bytes.NewReader(body)) + setupReq.Header.Set("Content-Type", "application/json") + var gc GrantCreated + Expect(json.Unmarshal(serve(router, setupReq).Body.Bytes(), &gc)).To(Succeed()) + + tokReq := httptest.NewRequestWithContext(ctx, http.MethodPost, "/api/v1/auth/token", bytes.NewReader([]byte(`{"scopes":[]}`))) + tokReq.Header.Set("Content-Type", "application/json") + tokReq.Header.Set("Authorization", "Bearer "+gc.Secret) + var at AccessToken + Expect(json.Unmarshal(serve(router, tokReq).Body.Bytes(), &at)).To(Succeed()) + + req := httptest.NewRequestWithContext(ctx, http.MethodGet, "/api/v1/capabilities", nil) + req.Header.Set("Authorization", "Bearer "+at.AccessToken) + w := serve(router, req) + Expect(w.Code).To(Equal(http.StatusOK)) + var caps Capabilities + Expect(json.Unmarshal(w.Body.Bytes(), &caps)).To(Succeed()) + Expect(caps.Core.Version).To(Equal(1)) + Expect(caps.Password.Version).To(Equal(1)) + }) +}) diff --git a/server/apiv1/server_info.go b/server/apiv1/server_info.go index 458363efe..d7d46175a 100644 --- a/server/apiv1/server_info.go +++ b/server/apiv1/server_info.go @@ -18,6 +18,6 @@ func (rt *Router) GetServerInfo(ctx context.Context, _ GetServerInfoRequestObjec ServerVersion: consts.Version, SpecVersion: api.SpecVersion(), SetupRequired: count == 0, - LoginMethods: []ServerInfoLoginMethods{ServerInfoLoginMethodsPassword}, + LoginMethods: LoginMethods{Password: &PasswordLoginMethod{}}, }, nil } diff --git a/server/apiv1/server_info_test.go b/server/apiv1/server_info_test.go index b9dae6f8d..3d6c938d9 100644 --- a/server/apiv1/server_info_test.go +++ b/server/apiv1/server_info_test.go @@ -43,7 +43,8 @@ var _ = Describe("GET /server", func() { Expect(info.ServerVersion).To(Equal(consts.Version)) Expect(info.SpecVersion).To(Equal(api.SpecVersion())) Expect(info.SetupRequired).To(BeTrue()) - Expect(info.LoginMethods).To(ConsistOf(ServerInfoLoginMethodsPassword)) + Expect(info.LoginMethods.Password).ToNot(BeNil()) + Expect(w.Body.String()).To(ContainSubstring(`"loginMethods":{"password":{}}`)) }) It("reports setupRequired=false once a user exists", func() {