mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-08 02:17:25 +02:00
feat(api): add the API v1 foundation behind DevAPIv1 (#6227)
* feat(api): add OpenAPI v1 spec skeleton, lint ruleset and bundle tooling vacuum v0.30.6's `bundle --composed` mangles component names for this spec's multi-file layout (duplicates Problem as Problem__schemas etc.), so api-bundle uses the Redocly CLI (npx @redocly/cli bundle) instead. * fix(api): pin the Redocly CLI version Tried moving components out of the root document (per libopenapi's nested_files example) so vacuum's own bundler could produce clean names, but any component declared via $ref inside components.* still gets a __<parent>-suffixed twin regardless of collisions elsewhere, so vacuum's --composed bundler can't cleanly bundle this spec. Pin the already-working Redocly fallback to an exact version instead of @latest. * fix(api): bundle the OpenAPI spec with vacuum vacuum's --composed bundler suffixes any component reached via a $ref written directly inside the root document's own components.* block, regardless of collisions elsewhere. Dropping the root-level schemas/ parameters/responses declarations (keeping only securitySchemes, and leaving every component file under api/openapi/components/ untouched) lets vacuum bundle cleanly with no __ suffixes, going back to Go-only tooling. Components nothing references yet (ListMeta, offset, limit, BadRequest, Unauthorized, Forbidden, NotFound) are absent from the bundle until a later task's operation references them. * fix(api): make spec lint rules cover all schemas and error codes nd-schema-property-descriptions targeted $.components.schemas, but our schemas live in path/response files, not the root document, so it was dead code; switched to $..properties[*] to walk every resolved schema wherever it ends up. nd-error-responses-are-problems only checked a hardcoded status-code list; switched to a patternProperties schema matching the full 4xx/5xx range. Also: api-diff now diffs against the merge-base with API_DIFF_BASE (falling back to its tip with a notice if no merge-base exists), gen no longer depends on api-gen until Task 3 wires up oapi-codegen, and api-lint suppresses vacuum's banner. * feat(api): embed the bundled OpenAPI spec and expose its version * feat(api): generate the v1 server interface with oapi-codegen * feat(api): add RFC 9457 problem responses for API v1 * feat(api): add API v1 router with /server discovery and spec routes * fix(api): serve the OpenAPI document without range support * feat(api): mount API v1 behind the DevAPIv1 flag * chore(ci): lint, regenerate and diff the OpenAPI v1 spec * refactor(api): tighten spec version access, lint rules and test naming * refactor(api): simplify spec routes, tests and OpenAPI tooling Share one If-None-Match parser (utils/req) between the image and spec routes, declare the YAML spec response as an object so tests need no decoder override, and reuse ETag/304 spec components. Install the OpenAPI tools only when missing or at a different version, fail api-diff when its base ref does not exist, and in CI cache the tools, fold regeneration into the go generate check, and fetch only the PR base commit for the breaking-change gate. * refactor(api): raise the list limit maximum to 2000 and drop the flag test * feat(api): treat added enum values as non-breaking Enums in API v1 are open: clients must accept unknown values. api-diff now downgrades response-property-enum-value-added to INFO, while removing a value from a request enum stays breaking. * feat(api): gate breaking changes on x-stability-level Every operation declares x-stability-level (alpha, beta, stable). oasdiff ignores breaking changes to alpha operations and rejects lowering a level, so unreleased endpoints can evolve while beta and stable ones stay additive. All current operations start as alpha. * feat(api): declare loginMethods as an enum Prefix generated enum constants with their type name so enums sharing a value (for example password) cannot collide in package apiv1. * feat(api): send Allow on 405 and answer HEAD wherever GET is routed chi only sets Allow in its default 405 handler, so the problem-format handler now builds it by matching each method against the v1 router. HEAD requests fall back to the GET route, as RFC 9110 expects. * refactor(api): hash the spec ETag with xxh3 The bytes are compiled in, and the digest was truncated to 64 bits anyway, so this matches the artwork ETags instead of paying for cryptographic strength we discard. * docs(api): explain the about:blank problem type * feat(api): make code the problem identifier and omit a blank type RFC 9457 says clients switch on the type URI, but no adopter surveyed ships both a populated type and a separate code. Declare code as an enum, and send type only once a problem has semantics of its own. * fix(api): advertise the configured base path in the served OpenAPI spec With BaseURL=/music the API is mounted at /music/api/v1, but the spec told clients to call /api/v1 at the host root. The server now rewrites servers[0].url to BasePath + /api/v1 when it serves the document. Relative server URLs were tested first: "." and "../v1" work in openapi-generator, Swagger UI and Redoc, but Scalar resolves them against the page origin, so it breaks even without a base path. The committed bundle keeps /api/v1, and a test pins that it appears exactly once, which the rewrite relies on.
This commit is contained in:
parent
bb7d81a5ea
commit
c22ce9ebb2
46 changed files with 2177 additions and 26 deletions
23
.github/workflows/pipeline.yml
vendored
23
.github/workflows/pipeline.yml
vendored
|
|
@ -92,8 +92,23 @@ jobs:
|
|||
exit 1
|
||||
fi
|
||||
|
||||
- name: Resolve OpenAPI tool versions
|
||||
id: api-tools
|
||||
run: echo "key=$(grep -E '^(VACUUM|OAPI_CODEGEN|OASDIFF)_VERSION' Makefile | tr -d ' \n')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Cache OpenAPI tools
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: bin
|
||||
key: api-tools-${{ runner.os }}-${{ steps.api-tools.outputs.key }}
|
||||
|
||||
- name: Lint OpenAPI spec
|
||||
run: make api-lint
|
||||
|
||||
- name: Run go generate
|
||||
run: go generate ./...
|
||||
run: |
|
||||
make api-gen
|
||||
go generate ./...
|
||||
- name: Verify no changes from go generate
|
||||
run: |
|
||||
git status --porcelain
|
||||
|
|
@ -102,6 +117,12 @@ jobs:
|
|||
exit 1
|
||||
fi
|
||||
|
||||
- name: Check for breaking OpenAPI changes
|
||||
if: github.event_name == 'pull_request'
|
||||
run: |
|
||||
git fetch --no-tags --depth=1 origin ${{ github.event.pull_request.base.sha }}
|
||||
make api-diff API_DIFF_BASE=${{ github.event.pull_request.base.sha }}
|
||||
|
||||
validate-migrations:
|
||||
name: Validate DB migrations
|
||||
runs-on: ubuntu-latest
|
||||
|
|
|
|||
45
Makefile
45
Makefile
|
|
@ -21,6 +21,10 @@ PLATFORMS ?= $(SUPPORTED_PLATFORMS)
|
|||
DOCKER_TAG ?= deluan/navidrome:develop
|
||||
|
||||
GOLANGCI_LINT_VERSION ?= v2.14.0
|
||||
VACUUM_VERSION ?= v0.30.6
|
||||
OAPI_CODEGEN_VERSION ?= v2.8.0
|
||||
OASDIFF_VERSION ?= v1.32.1
|
||||
API_DIFF_BASE ?= origin/master
|
||||
|
||||
UI_SRC_FILES := $(shell find ui -type f -not -path "ui/build/*" -not -path "ui/node_modules/*")
|
||||
|
||||
|
|
@ -92,6 +96,45 @@ install-golangci-lint: ##@Development Install golangci-lint if not present
|
|||
fi
|
||||
.PHONY: install-golangci-lint
|
||||
|
||||
install-api-tools: ##@Development Install OpenAPI tools (vacuum, oapi-codegen, oasdiff) into ./bin
|
||||
@STAMP=bin/.api-tools-$(VACUUM_VERSION)-$(OAPI_CODEGEN_VERSION)-$(OASDIFF_VERSION); \
|
||||
if [ ! -f $$STAMP ] || [ ! -x bin/vacuum ] || [ ! -x bin/oapi-codegen ] || [ ! -x bin/oasdiff ]; then \
|
||||
echo "Installing OpenAPI tools..."; \
|
||||
GOBIN=$(CURDIR)/bin go install github.com/daveshanley/vacuum@$(VACUUM_VERSION) && \
|
||||
GOBIN=$(CURDIR)/bin go install github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@$(OAPI_CODEGEN_VERSION) && \
|
||||
GOBIN=$(CURDIR)/bin go install github.com/oasdiff/oasdiff@$(OASDIFF_VERSION) && \
|
||||
rm -f bin/.api-tools-* && touch $$STAMP; \
|
||||
fi
|
||||
.PHONY: install-api-tools
|
||||
|
||||
api-lint: install-api-tools ##@Development Lint the OpenAPI spec
|
||||
./bin/vacuum lint -r api/.vacuum.yaml -d -q -b --fail-severity error api/openapi/openapi.yaml
|
||||
.PHONY: api-lint
|
||||
|
||||
api-bundle: install-api-tools ##@Development Bundle the multi-file OpenAPI spec into api/bundled
|
||||
./bin/vacuum bundle -q --composed -p api/openapi api/openapi/openapi.yaml api/bundled/openapi.yaml
|
||||
./bin/vacuum bundle -q --composed --format json -p api/openapi api/openapi/openapi.yaml api/bundled/openapi.json
|
||||
.PHONY: api-bundle
|
||||
|
||||
api-gen: api-bundle ##@Development Generate the API v1 server code from the bundled spec
|
||||
./bin/oapi-codegen -config server/apiv1/oapi-codegen.yaml api/bundled/openapi.json
|
||||
.PHONY: api-gen
|
||||
|
||||
api-diff: api-bundle ##@Development Fail on breaking OpenAPI changes against the merge-base with $(API_DIFF_BASE)
|
||||
@git rev-parse --verify --quiet $(API_DIFF_BASE)^{commit} >/dev/null || { echo "Base ref $(API_DIFF_BASE) not found; set API_DIFF_BASE"; exit 1; }; \
|
||||
BASE="$$(git merge-base HEAD $(API_DIFF_BASE) 2>/dev/null)"; \
|
||||
if [ -z "$$BASE" ]; then \
|
||||
echo "No merge-base with $(API_DIFF_BASE); falling back to its tip"; \
|
||||
BASE=$(API_DIFF_BASE); \
|
||||
fi; \
|
||||
if git cat-file -e $$BASE:api/bundled/openapi.json 2>/dev/null; then \
|
||||
git show $$BASE:api/bundled/openapi.json > $(CURDIR)/bin/api-base.json && \
|
||||
./bin/oasdiff breaking $(CURDIR)/bin/api-base.json api/bundled/openapi.json --fail-on ERR --severity-levels api/.oasdiff-levels.txt; \
|
||||
else \
|
||||
echo "No bundled spec at $$BASE; skipping breaking-change check"; \
|
||||
fi
|
||||
.PHONY: api-diff
|
||||
|
||||
lint: install-golangci-lint ##@Development Lint Go code
|
||||
PATH=./bin:$$PATH golangci-lint run --timeout 5m
|
||||
.PHONY: lint
|
||||
|
|
@ -111,7 +154,7 @@ wire: check_go_env ##@Development Update Dependency Injection
|
|||
go tool wire gen -tags="$$(echo '$(GO_BUILD_TAGS)' | tr ',' ' ')" ./...
|
||||
.PHONY: wire
|
||||
|
||||
gen: check_go_env ##@Development Run go generate for code generation
|
||||
gen: check_go_env api-gen ##@Development Run go generate for code generation
|
||||
go generate ./...
|
||||
cd plugins/cmd/ndpgen && go run . -shared-types -input=../../types -output=../../pdk -go -rust
|
||||
cd plugins/cmd/ndpgen && go run . -host-wrappers -input=../../host -package=host -shared=../../types
|
||||
|
|
|
|||
1
api/.oasdiff-levels.txt
Normal file
1
api/.oasdiff-levels.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
response-property-enum-value-added INFO
|
||||
155
api/.vacuum.yaml
Normal file
155
api/.vacuum.yaml
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
extends: [[spectral:oas, recommended]]
|
||||
rules:
|
||||
# vacuum's `enumeration` function mis-resolves hyphenated `then.field` names,
|
||||
# so the value check below targets `x-module` via `given` instead.
|
||||
nd-operation-x-module-required:
|
||||
description: Every operation belongs to exactly one capability module.
|
||||
severity: error
|
||||
given: $.paths[*][get,put,post,delete,patch]
|
||||
then:
|
||||
field: x-module
|
||||
function: truthy
|
||||
nd-operation-x-module:
|
||||
description: Every operation's capability module is one of the known values.
|
||||
severity: error
|
||||
given: $.paths[*][get,put,post,delete,patch]['x-module']
|
||||
then:
|
||||
function: enumeration
|
||||
functionOptions:
|
||||
values:
|
||||
- core
|
||||
- streaming
|
||||
- download
|
||||
- artwork
|
||||
- lyrics
|
||||
- transcoding
|
||||
- annotations
|
||||
- playback
|
||||
- queue
|
||||
- custom-tags
|
||||
- grouping
|
||||
- playlists
|
||||
- smart-playlists
|
||||
- sync
|
||||
- events
|
||||
- jukebox
|
||||
- sharing
|
||||
- radio
|
||||
- admin
|
||||
nd-operation-stability-level-required:
|
||||
description: Every operation declares its stability level, which the breaking-change gate relies on.
|
||||
severity: error
|
||||
given: $.paths[*][get,put,post,delete,patch]
|
||||
then:
|
||||
field: x-stability-level
|
||||
function: truthy
|
||||
nd-operation-stability-level:
|
||||
description: Every operation's stability level is alpha, beta, or stable.
|
||||
severity: error
|
||||
given: $.paths[*][get,put,post,delete,patch]['x-stability-level']
|
||||
then:
|
||||
function: enumeration
|
||||
functionOptions:
|
||||
values:
|
||||
- alpha
|
||||
- beta
|
||||
- stable
|
||||
nd-operation-required-fields:
|
||||
description: Operations need a stable operationId, summary, description and tags.
|
||||
severity: error
|
||||
given: $.paths[*][get,put,post,delete,patch]
|
||||
then:
|
||||
- field: operationId
|
||||
function: truthy
|
||||
- field: summary
|
||||
function: truthy
|
||||
- field: description
|
||||
function: truthy
|
||||
- field: tags
|
||||
function: truthy
|
||||
# Our schemas live in path/response files, not root components, so this
|
||||
# walks every resolved `properties` map in the document via `$..` instead.
|
||||
nd-schema-property-descriptions:
|
||||
description: Every schema property is documented.
|
||||
severity: error
|
||||
given: $..properties[*]
|
||||
then:
|
||||
field: description
|
||||
function: truthy
|
||||
# patternProperties covers the full 4xx/5xx range; needs an explicit
|
||||
# `properties` entry too, or `additionalProperties: false` rejects it.
|
||||
nd-error-responses-are-problems:
|
||||
description: 4xx and 5xx responses use application/problem+json.
|
||||
severity: error
|
||||
given: $.paths[*][*].responses
|
||||
then:
|
||||
function: schema
|
||||
functionOptions:
|
||||
forceValidationOnCurrentNode: true
|
||||
schema:
|
||||
type: object
|
||||
patternProperties:
|
||||
"^[45][0-9][0-9]$":
|
||||
type: object
|
||||
required: [content]
|
||||
properties:
|
||||
content:
|
||||
type: object
|
||||
properties:
|
||||
application/problem+json: {}
|
||||
required: [application/problem+json]
|
||||
additionalProperties: false
|
||||
# Same filter limitation applies here: "is this a list endpoint" is expressed
|
||||
# as a JSON Schema if/then on the operation object instead of a `given` filter.
|
||||
nd-list-endpoints-paginate:
|
||||
description: List endpoints declare the shared offset and limit parameters.
|
||||
severity: error
|
||||
given: $.paths[*].get
|
||||
then:
|
||||
function: schema
|
||||
functionOptions:
|
||||
forceValidationOnCurrentNode: true
|
||||
schema:
|
||||
type: object
|
||||
if:
|
||||
required: [responses]
|
||||
properties:
|
||||
responses:
|
||||
type: object
|
||||
required: ['200']
|
||||
properties:
|
||||
'200':
|
||||
type: object
|
||||
required: [content]
|
||||
properties:
|
||||
content:
|
||||
type: object
|
||||
required: [application/json]
|
||||
properties:
|
||||
application/json:
|
||||
type: object
|
||||
required: [schema]
|
||||
properties:
|
||||
schema:
|
||||
type: object
|
||||
required: [properties]
|
||||
properties:
|
||||
properties:
|
||||
type: object
|
||||
required: [items]
|
||||
then:
|
||||
required: [parameters]
|
||||
properties:
|
||||
parameters:
|
||||
type: array
|
||||
allOf:
|
||||
- contains:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
const: offset
|
||||
- contains:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
const: limit
|
||||
17
api/api_suite_test.go
Normal file
17
api/api_suite_test.go
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
package api_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestAPI(t *testing.T) {
|
||||
tests.Init(t, false)
|
||||
log.SetLevel(log.LevelFatal)
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "API Spec Suite")
|
||||
}
|
||||
262
api/bundled/openapi.json
Normal file
262
api/bundled/openapi.json
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
{
|
||||
"openapi": "3.0.3",
|
||||
"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",
|
||||
"license": {
|
||||
"name": "GPL-3.0",
|
||||
"url": "https://www.gnu.org/licenses/gpl-3.0.html"
|
||||
}
|
||||
},
|
||||
"servers": [
|
||||
{
|
||||
"url": "/api/v1"
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
{
|
||||
"name": "server",
|
||||
"description": "Server discovery and the published OpenAPI document."
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"/server": {
|
||||
"get": {
|
||||
"operationId": "getServerInfo",
|
||||
"x-module": "core",
|
||||
"x-stability-level": "alpha",
|
||||
"tags": [
|
||||
"server"
|
||||
],
|
||||
"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",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Server description.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ServerInfo"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"$ref": "#/components/responses/InternalError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/openapi.json": {
|
||||
"get": {
|
||||
"operationId": "getOpenAPISpecJSON",
|
||||
"x-module": "core",
|
||||
"x-stability-level": "alpha",
|
||||
"tags": [
|
||||
"server"
|
||||
],
|
||||
"summary": "Get the OpenAPI document (JSON)",
|
||||
"description": "The bundled OpenAPI document of the running server version. Supports ETag revalidation.",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The OpenAPI document.",
|
||||
"headers": {
|
||||
"ETag": {
|
||||
"$ref": "#/components/headers/ETag"
|
||||
}
|
||||
},
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"description": "OpenAPI 3.0 document."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"304": {
|
||||
"$ref": "#/components/responses/NotModified"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/openapi.yaml": {
|
||||
"get": {
|
||||
"operationId": "getOpenAPISpecYAML",
|
||||
"x-module": "core",
|
||||
"x-stability-level": "alpha",
|
||||
"tags": [
|
||||
"server"
|
||||
],
|
||||
"summary": "Get the OpenAPI document (YAML)",
|
||||
"description": "The bundled OpenAPI document of the running server version. Supports ETag revalidation.",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The OpenAPI document.",
|
||||
"headers": {
|
||||
"ETag": {
|
||||
"$ref": "#/components/headers/ETag"
|
||||
}
|
||||
},
|
||||
"content": {
|
||||
"application/yaml": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"description": "OpenAPI 3.0 document."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"304": {
|
||||
"$ref": "#/components/responses/NotModified"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"securitySchemes": {
|
||||
"bearerAuth": {
|
||||
"type": "http",
|
||||
"scheme": "bearer",
|
||||
"bearerFormat": "JWT",
|
||||
"description": "Short-lived access token minted from a device grant. Not yet applied to any operation."
|
||||
}
|
||||
},
|
||||
"schemas": {
|
||||
"ServerInfo": {
|
||||
"type": "object",
|
||||
"description": "Public server description. Everything an add-server screen needs before login.",
|
||||
"required": [
|
||||
"name",
|
||||
"serverVersion",
|
||||
"specVersion",
|
||||
"setupRequired",
|
||||
"loginMethods"
|
||||
],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Human-readable server product name."
|
||||
},
|
||||
"serverVersion": {
|
||||
"type": "string",
|
||||
"description": "Version of the running server build."
|
||||
},
|
||||
"specVersion": {
|
||||
"type": "string",
|
||||
"description": "Version of the OpenAPI document this server implements."
|
||||
},
|
||||
"setupRequired": {
|
||||
"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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Problem": {
|
||||
"type": "object",
|
||||
"description": "RFC 9457 problem details, returned for every 4xx and 5xx response.",
|
||||
"required": [
|
||||
"title",
|
||||
"status",
|
||||
"code"
|
||||
],
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"description": "URI reference identifying the problem type. Omitted while the problem carries no semantics\nbeyond its HTTP status code, which RFC 9457 defines as `about:blank`. Problems with their\nown semantics get their own URI; switch on `code` instead.\n"
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "Short human-readable summary, the same for all occurrences of this problem type."
|
||||
},
|
||||
"status": {
|
||||
"type": "integer",
|
||||
"description": "HTTP status code of this response."
|
||||
},
|
||||
"detail": {
|
||||
"type": "string",
|
||||
"description": "Human-readable explanation specific to this occurrence. Omitted for internal errors."
|
||||
},
|
||||
"code": {
|
||||
"type": "string",
|
||||
"description": "Machine-readable error code, and the value clients switch on. New codes may be added.",
|
||||
"enum": [
|
||||
"validation",
|
||||
"unauthorized",
|
||||
"forbidden",
|
||||
"not_found",
|
||||
"method_not_allowed",
|
||||
"unavailable",
|
||||
"internal"
|
||||
]
|
||||
},
|
||||
"errors": {
|
||||
"type": "array",
|
||||
"description": "Per-field failures. Present only when `code` is `validation`.",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/ValidationError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"ValidationError": {
|
||||
"type": "object",
|
||||
"description": "One field-level validation failure.",
|
||||
"required": [
|
||||
"field",
|
||||
"message"
|
||||
],
|
||||
"properties": {
|
||||
"field": {
|
||||
"type": "string",
|
||||
"description": "Name of the offending query parameter, path parameter, or body field (dotted for nested)."
|
||||
},
|
||||
"message": {
|
||||
"type": "string",
|
||||
"description": "Why the value was rejected."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"InternalError": {
|
||||
"description": "Unexpected server failure. Details are in the server log.",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Problem"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"NotModified": {
|
||||
"description": "Not modified.",
|
||||
"headers": {
|
||||
"ETag": {
|
||||
"$ref": "#/components/headers/ETag"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"headers": {
|
||||
"ETag": {
|
||||
"description": "Entity tag for `If-None-Match` revalidation.",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
194
api/bundled/openapi.yaml
Normal file
194
api/bundled/openapi.yaml
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
openapi: 3.0.3
|
||||
info:
|
||||
title: Navidrome API
|
||||
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.
|
||||
|
||||
Enums are open: new values may be added to any enum within v1. Clients must
|
||||
accept values they do not recognise instead of failing.
|
||||
|
||||
Every operation declares `x-stability-level`: `alpha` operations may change or
|
||||
disappear without notice, `beta` and `stable` operations only change additively.
|
||||
A level is only ever raised, never lowered.
|
||||
|
||||
`HEAD` is accepted wherever `GET` is. A `405` response lists the allowed methods
|
||||
in its `Allow` header.
|
||||
license:
|
||||
name: GPL-3.0
|
||||
url: https://www.gnu.org/licenses/gpl-3.0.html
|
||||
servers:
|
||||
- url: /api/v1
|
||||
tags:
|
||||
- name: server
|
||||
description: Server discovery and the published OpenAPI document.
|
||||
paths:
|
||||
/server:
|
||||
get:
|
||||
operationId: getServerInfo
|
||||
x-module: core
|
||||
x-stability-level: alpha
|
||||
tags: [server]
|
||||
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.
|
||||
responses:
|
||||
'200':
|
||||
description: Server description.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ServerInfo'
|
||||
'500':
|
||||
$ref: '#/components/responses/InternalError'
|
||||
/openapi.json:
|
||||
get:
|
||||
operationId: getOpenAPISpecJSON
|
||||
x-module: core
|
||||
x-stability-level: alpha
|
||||
tags: [server]
|
||||
summary: Get the OpenAPI document (JSON)
|
||||
description: The bundled OpenAPI document of the running server version. Supports ETag revalidation.
|
||||
responses:
|
||||
'200':
|
||||
description: The OpenAPI document.
|
||||
headers:
|
||||
ETag:
|
||||
$ref: '#/components/headers/ETag'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
description: OpenAPI 3.0 document.
|
||||
'304':
|
||||
$ref: '#/components/responses/NotModified'
|
||||
/openapi.yaml:
|
||||
get:
|
||||
operationId: getOpenAPISpecYAML
|
||||
x-module: core
|
||||
x-stability-level: alpha
|
||||
tags: [server]
|
||||
summary: Get the OpenAPI document (YAML)
|
||||
description: The bundled OpenAPI document of the running server version. Supports ETag revalidation.
|
||||
responses:
|
||||
'200':
|
||||
description: The OpenAPI document.
|
||||
headers:
|
||||
ETag:
|
||||
$ref: '#/components/headers/ETag'
|
||||
content:
|
||||
application/yaml:
|
||||
schema:
|
||||
type: object
|
||||
description: OpenAPI 3.0 document.
|
||||
'304':
|
||||
$ref: '#/components/responses/NotModified'
|
||||
components:
|
||||
securitySchemes:
|
||||
bearerAuth:
|
||||
type: http
|
||||
scheme: bearer
|
||||
bearerFormat: JWT
|
||||
description: Short-lived access token minted from a device grant. Not yet applied to any operation.
|
||||
schemas:
|
||||
ServerInfo:
|
||||
type: object
|
||||
description: Public server description. Everything an add-server screen needs before login.
|
||||
required:
|
||||
- name
|
||||
- serverVersion
|
||||
- specVersion
|
||||
- setupRequired
|
||||
- loginMethods
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
description: Human-readable server product name.
|
||||
serverVersion:
|
||||
type: string
|
||||
description: Version of the running server build.
|
||||
specVersion:
|
||||
type: string
|
||||
description: Version of the OpenAPI document this server implements.
|
||||
setupRequired:
|
||||
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
|
||||
Problem:
|
||||
type: object
|
||||
description: RFC 9457 problem details, returned for every 4xx and 5xx response.
|
||||
required:
|
||||
- title
|
||||
- status
|
||||
- code
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
description: |
|
||||
URI reference identifying the problem type. Omitted while the problem carries no semantics
|
||||
beyond its HTTP status code, which RFC 9457 defines as `about:blank`. Problems with their
|
||||
own semantics get their own URI; switch on `code` instead.
|
||||
title:
|
||||
type: string
|
||||
description: Short human-readable summary, the same for all occurrences of this problem type.
|
||||
status:
|
||||
type: integer
|
||||
description: HTTP status code of this response.
|
||||
detail:
|
||||
type: string
|
||||
description: Human-readable explanation specific to this occurrence. Omitted for internal errors.
|
||||
code:
|
||||
type: string
|
||||
description: Machine-readable error code, and the value clients switch on. New codes may be added.
|
||||
enum:
|
||||
- validation
|
||||
- unauthorized
|
||||
- forbidden
|
||||
- not_found
|
||||
- method_not_allowed
|
||||
- unavailable
|
||||
- internal
|
||||
errors:
|
||||
type: array
|
||||
description: Per-field failures. Present only when `code` is `validation`.
|
||||
items:
|
||||
$ref: '#/components/schemas/ValidationError'
|
||||
ValidationError:
|
||||
type: object
|
||||
description: One field-level validation failure.
|
||||
required:
|
||||
- field
|
||||
- message
|
||||
properties:
|
||||
field:
|
||||
type: string
|
||||
description: Name of the offending query parameter, path parameter, or body field (dotted for nested).
|
||||
message:
|
||||
type: string
|
||||
description: Why the value was rejected.
|
||||
responses:
|
||||
InternalError:
|
||||
description: Unexpected server failure. Details are in the server log.
|
||||
content:
|
||||
application/problem+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Problem'
|
||||
NotModified:
|
||||
description: Not modified.
|
||||
headers:
|
||||
ETag:
|
||||
$ref: '#/components/headers/ETag'
|
||||
headers:
|
||||
ETag:
|
||||
description: Entity tag for `If-None-Match` revalidation.
|
||||
schema:
|
||||
type: string
|
||||
35
api/embed.go
Normal file
35
api/embed.go
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"sync"
|
||||
)
|
||||
|
||||
//go:embed bundled/openapi.json
|
||||
var specJSON []byte
|
||||
|
||||
//go:embed bundled/openapi.yaml
|
||||
var specYAML []byte
|
||||
|
||||
func SpecJSON() []byte {
|
||||
return specJSON
|
||||
}
|
||||
|
||||
func SpecYAML() []byte {
|
||||
return specYAML
|
||||
}
|
||||
|
||||
var specVersion = sync.OnceValue(func() string {
|
||||
var doc struct {
|
||||
Info struct {
|
||||
Version string `json:"version"`
|
||||
} `json:"info"`
|
||||
}
|
||||
_ = json.Unmarshal(SpecJSON(), &doc)
|
||||
return doc.Info.Version
|
||||
})
|
||||
|
||||
func SpecVersion() string {
|
||||
return specVersion()
|
||||
}
|
||||
37
api/embed_test.go
Normal file
37
api/embed_test.go
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
package api_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/getkin/kin-openapi/openapi3"
|
||||
"github.com/navidrome/navidrome/api"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
var _ = Describe("Bundled spec", func() {
|
||||
It("embeds a valid OpenAPI 3 document", func() {
|
||||
doc, err := openapi3.NewLoader().LoadFromData(api.SpecJSON())
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(doc.Validate(GinkgoT().Context())).To(Succeed())
|
||||
Expect(doc.Paths.Find("/server")).ToNot(BeNil())
|
||||
})
|
||||
|
||||
It("embeds the YAML variant", func() {
|
||||
var doc map[string]any
|
||||
Expect(yaml.Unmarshal(api.SpecYAML(), &doc)).To(Succeed())
|
||||
Expect(doc).To(HaveKey("paths"))
|
||||
})
|
||||
|
||||
It("reports the version from the bundle, matching the source root document", func() {
|
||||
src, err := os.ReadFile("api/openapi/openapi.yaml")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
var root struct {
|
||||
Info struct{ Version string } `yaml:"info"`
|
||||
}
|
||||
Expect(yaml.Unmarshal(src, &root)).To(Succeed())
|
||||
Expect(api.SpecVersion()).To(Equal(root.Info.Version))
|
||||
Expect(api.SpecVersion()).ToNot(BeEmpty())
|
||||
})
|
||||
})
|
||||
3
api/openapi/components/headers/ETag.yaml
Normal file
3
api/openapi/components/headers/ETag.yaml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
description: Entity tag for `If-None-Match` revalidation.
|
||||
schema:
|
||||
type: string
|
||||
9
api/openapi/components/parameters/limit.yaml
Normal file
9
api/openapi/components/parameters/limit.yaml
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
name: limit
|
||||
in: query
|
||||
description: Maximum number of items to return.
|
||||
required: false
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 2000
|
||||
default: 100
|
||||
8
api/openapi/components/parameters/offset.yaml
Normal file
8
api/openapi/components/parameters/offset.yaml
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
name: offset
|
||||
in: query
|
||||
description: Zero-based index of the first item to return.
|
||||
required: false
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 0
|
||||
default: 0
|
||||
5
api/openapi/components/responses/BadRequest.yaml
Normal file
5
api/openapi/components/responses/BadRequest.yaml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
description: The request is malformed or fails validation.
|
||||
content:
|
||||
application/problem+json:
|
||||
schema:
|
||||
$ref: ../schemas/Problem.yaml
|
||||
5
api/openapi/components/responses/Forbidden.yaml
Normal file
5
api/openapi/components/responses/Forbidden.yaml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
description: The caller is authenticated but not allowed to do this.
|
||||
content:
|
||||
application/problem+json:
|
||||
schema:
|
||||
$ref: ../schemas/Problem.yaml
|
||||
5
api/openapi/components/responses/InternalError.yaml
Normal file
5
api/openapi/components/responses/InternalError.yaml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
description: Unexpected server failure. Details are in the server log.
|
||||
content:
|
||||
application/problem+json:
|
||||
schema:
|
||||
$ref: ../schemas/Problem.yaml
|
||||
5
api/openapi/components/responses/NotFound.yaml
Normal file
5
api/openapi/components/responses/NotFound.yaml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
description: No such resource or endpoint.
|
||||
content:
|
||||
application/problem+json:
|
||||
schema:
|
||||
$ref: ../schemas/Problem.yaml
|
||||
4
api/openapi/components/responses/NotModified.yaml
Normal file
4
api/openapi/components/responses/NotModified.yaml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
description: Not modified.
|
||||
headers:
|
||||
ETag:
|
||||
$ref: ../headers/ETag.yaml
|
||||
5
api/openapi/components/responses/Unauthorized.yaml
Normal file
5
api/openapi/components/responses/Unauthorized.yaml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
description: Missing, invalid, or expired credentials.
|
||||
content:
|
||||
application/problem+json:
|
||||
schema:
|
||||
$ref: ../schemas/Problem.yaml
|
||||
13
api/openapi/components/schemas/ListMeta.yaml
Normal file
13
api/openapi/components/schemas/ListMeta.yaml
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
type: object
|
||||
description: Pagination metadata carried by every list response.
|
||||
required: [total, offset, limit]
|
||||
properties:
|
||||
total:
|
||||
type: integer
|
||||
description: Total number of items matching the request, ignoring pagination.
|
||||
offset:
|
||||
type: integer
|
||||
description: Zero-based index of the first returned item.
|
||||
limit:
|
||||
type: integer
|
||||
description: Maximum number of items in this page.
|
||||
35
api/openapi/components/schemas/Problem.yaml
Normal file
35
api/openapi/components/schemas/Problem.yaml
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
type: object
|
||||
description: RFC 9457 problem details, returned for every 4xx and 5xx response.
|
||||
required: [title, status, code]
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
description: |
|
||||
URI reference identifying the problem type. Omitted while the problem carries no semantics
|
||||
beyond its HTTP status code, which RFC 9457 defines as `about:blank`. Problems with their
|
||||
own semantics get their own URI; switch on `code` instead.
|
||||
title:
|
||||
type: string
|
||||
description: Short human-readable summary, the same for all occurrences of this problem type.
|
||||
status:
|
||||
type: integer
|
||||
description: HTTP status code of this response.
|
||||
detail:
|
||||
type: string
|
||||
description: Human-readable explanation specific to this occurrence. Omitted for internal errors.
|
||||
code:
|
||||
type: string
|
||||
description: Machine-readable error code, and the value clients switch on. New codes may be added.
|
||||
enum:
|
||||
- validation
|
||||
- unauthorized
|
||||
- forbidden
|
||||
- not_found
|
||||
- method_not_allowed
|
||||
- unavailable
|
||||
- internal
|
||||
errors:
|
||||
type: array
|
||||
description: Per-field failures. Present only when `code` is `validation`.
|
||||
items:
|
||||
$ref: ./ValidationError.yaml
|
||||
22
api/openapi/components/schemas/ServerInfo.yaml
Normal file
22
api/openapi/components/schemas/ServerInfo.yaml
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
type: object
|
||||
description: Public server description. Everything an add-server screen needs before login.
|
||||
required: [name, serverVersion, specVersion, setupRequired, loginMethods]
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
description: Human-readable server product name.
|
||||
serverVersion:
|
||||
type: string
|
||||
description: Version of the running server build.
|
||||
specVersion:
|
||||
type: string
|
||||
description: Version of the OpenAPI document this server implements.
|
||||
setupRequired:
|
||||
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]
|
||||
10
api/openapi/components/schemas/ValidationError.yaml
Normal file
10
api/openapi/components/schemas/ValidationError.yaml
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
type: object
|
||||
description: One field-level validation failure.
|
||||
required: [field, message]
|
||||
properties:
|
||||
field:
|
||||
type: string
|
||||
description: Name of the offending query parameter, path parameter, or body field (dotted for nested).
|
||||
message:
|
||||
type: string
|
||||
description: Why the value was rejected.
|
||||
39
api/openapi/openapi.yaml
Normal file
39
api/openapi/openapi.yaml
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
openapi: 3.0.3
|
||||
info:
|
||||
title: Navidrome API
|
||||
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.
|
||||
|
||||
Enums are open: new values may be added to any enum within v1. Clients must
|
||||
accept values they do not recognise instead of failing.
|
||||
|
||||
Every operation declares `x-stability-level`: `alpha` operations may change or
|
||||
disappear without notice, `beta` and `stable` operations only change additively.
|
||||
A level is only ever raised, never lowered.
|
||||
|
||||
`HEAD` is accepted wherever `GET` is. A `405` response lists the allowed methods
|
||||
in its `Allow` header.
|
||||
license:
|
||||
name: GPL-3.0
|
||||
url: https://www.gnu.org/licenses/gpl-3.0.html
|
||||
servers:
|
||||
- url: /api/v1
|
||||
tags:
|
||||
- name: server
|
||||
description: Server discovery and the published OpenAPI document.
|
||||
paths:
|
||||
/server:
|
||||
$ref: ./paths/server.yaml
|
||||
/openapi.json:
|
||||
$ref: ./paths/openapi.yaml#/json
|
||||
/openapi.yaml:
|
||||
$ref: ./paths/openapi.yaml#/yaml
|
||||
components:
|
||||
securitySchemes:
|
||||
bearerAuth:
|
||||
type: http
|
||||
scheme: bearer
|
||||
bearerFormat: JWT
|
||||
description: Short-lived access token minted from a device grant. Not yet applied to any operation.
|
||||
42
api/openapi/paths/openapi.yaml
Normal file
42
api/openapi/paths/openapi.yaml
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
json:
|
||||
get:
|
||||
operationId: getOpenAPISpecJSON
|
||||
x-module: core
|
||||
x-stability-level: alpha
|
||||
tags: [server]
|
||||
summary: Get the OpenAPI document (JSON)
|
||||
description: The bundled OpenAPI document of the running server version. Supports ETag revalidation.
|
||||
responses:
|
||||
'200':
|
||||
description: The OpenAPI document.
|
||||
headers:
|
||||
ETag:
|
||||
$ref: ../components/headers/ETag.yaml
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
description: OpenAPI 3.0 document.
|
||||
'304':
|
||||
$ref: ../components/responses/NotModified.yaml
|
||||
yaml:
|
||||
get:
|
||||
operationId: getOpenAPISpecYAML
|
||||
x-module: core
|
||||
x-stability-level: alpha
|
||||
tags: [server]
|
||||
summary: Get the OpenAPI document (YAML)
|
||||
description: The bundled OpenAPI document of the running server version. Supports ETag revalidation.
|
||||
responses:
|
||||
'200':
|
||||
description: The OpenAPI document.
|
||||
headers:
|
||||
ETag:
|
||||
$ref: ../components/headers/ETag.yaml
|
||||
content:
|
||||
application/yaml:
|
||||
schema:
|
||||
type: object
|
||||
description: OpenAPI 3.0 document.
|
||||
'304':
|
||||
$ref: ../components/responses/NotModified.yaml
|
||||
19
api/openapi/paths/server.yaml
Normal file
19
api/openapi/paths/server.yaml
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
get:
|
||||
operationId: getServerInfo
|
||||
x-module: core
|
||||
x-stability-level: alpha
|
||||
tags: [server]
|
||||
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.
|
||||
responses:
|
||||
'200':
|
||||
description: Server description.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: ../components/schemas/ServerInfo.yaml
|
||||
'500':
|
||||
$ref: ../components/responses/InternalError.yaml
|
||||
|
|
@ -133,6 +133,9 @@ func startServer(ctx context.Context) func() error {
|
|||
if conf.Server.Jellyfin.Enabled {
|
||||
a.MountRouter("Jellyfin API", consts.URLPathJellyfinAPI, CreateJellyfinAPIRouter(ctx))
|
||||
}
|
||||
if conf.Server.DevAPIv1 {
|
||||
a.MountRouter("API v1", consts.URLPathAPIv1, CreateAPIv1Router(ctx))
|
||||
}
|
||||
if conf.Server.Prometheus.Enabled {
|
||||
p := CreatePrometheus()
|
||||
// blocking call because takes <100ms but useful if fails
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import (
|
|||
"github.com/navidrome/navidrome/plugins"
|
||||
"github.com/navidrome/navidrome/scanner"
|
||||
"github.com/navidrome/navidrome/server"
|
||||
"github.com/navidrome/navidrome/server/apiv1"
|
||||
"github.com/navidrome/navidrome/server/events"
|
||||
"github.com/navidrome/navidrome/server/jellyfin"
|
||||
"github.com/navidrome/navidrome/server/nativeapi"
|
||||
|
|
@ -142,6 +143,13 @@ func CreateJellyfinAPIRouter(ctx context.Context) *jellyfin.Router {
|
|||
return router
|
||||
}
|
||||
|
||||
func CreateAPIv1Router(ctx context.Context) *apiv1.Router {
|
||||
sqlDB := db.Db()
|
||||
dataStore := persistence.New(sqlDB)
|
||||
router := apiv1.New(dataStore)
|
||||
return router
|
||||
}
|
||||
|
||||
func CreatePublicRouter() *public.Router {
|
||||
sqlDB := db.Db()
|
||||
dataStore := persistence.New(sqlDB)
|
||||
|
|
@ -259,7 +267,7 @@ func getPluginManager() *plugins.Manager {
|
|||
|
||||
// wire_injectors.go:
|
||||
|
||||
var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, jellyfin.New, jellyfin.NewDiscovery, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.GetInstance, scanner.GetWatcher, metrics.GetPrometheusInstance, db.Db, plugins.GetManager, sonic.New, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), wire.Bind(new(lyrics.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.Engine), new(*sonic.Sonic)), wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)), wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)), wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)), wire.Bind(new(core.Watcher), new(scanner.Watcher)), wire.Bind(new(playlists.ImageUploadService), new(artwork.Uploader)))
|
||||
var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, jellyfin.New, jellyfin.NewDiscovery, apiv1.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.GetInstance, scanner.GetWatcher, metrics.GetPrometheusInstance, db.Db, plugins.GetManager, sonic.New, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), wire.Bind(new(lyrics.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.Engine), new(*sonic.Sonic)), wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)), wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)), wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)), wire.Bind(new(core.Watcher), new(scanner.Watcher)), wire.Bind(new(playlists.ImageUploadService), new(artwork.Uploader)))
|
||||
|
||||
func GetPluginManager(ctx context.Context) *plugins.Manager {
|
||||
manager := getPluginManager()
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import (
|
|||
"github.com/navidrome/navidrome/plugins"
|
||||
"github.com/navidrome/navidrome/scanner"
|
||||
"github.com/navidrome/navidrome/server"
|
||||
"github.com/navidrome/navidrome/server/apiv1"
|
||||
"github.com/navidrome/navidrome/server/events"
|
||||
"github.com/navidrome/navidrome/server/jellyfin"
|
||||
"github.com/navidrome/navidrome/server/nativeapi"
|
||||
|
|
@ -37,6 +38,7 @@ var allProviders = wire.NewSet(
|
|||
subsonic.New,
|
||||
jellyfin.New,
|
||||
jellyfin.NewDiscovery,
|
||||
apiv1.New,
|
||||
nativeapi.New,
|
||||
public.New,
|
||||
persistence.New,
|
||||
|
|
@ -91,6 +93,12 @@ func CreateJellyfinAPIRouter(ctx context.Context) *jellyfin.Router {
|
|||
))
|
||||
}
|
||||
|
||||
func CreateAPIv1Router(ctx context.Context) *apiv1.Router {
|
||||
panic(wire.Build(
|
||||
allProviders,
|
||||
))
|
||||
}
|
||||
|
||||
func CreatePublicRouter() *public.Router {
|
||||
panic(wire.Build(
|
||||
allProviders,
|
||||
|
|
|
|||
|
|
@ -161,6 +161,7 @@ type configOptions struct {
|
|||
DevExternalArtistFetchMultiplier float64
|
||||
DevPreserveUnicodeInExternalCalls bool
|
||||
DevEnableMediaFileProbe bool
|
||||
DevAPIv1 bool
|
||||
}
|
||||
|
||||
type scannerOptions struct {
|
||||
|
|
@ -1124,6 +1125,7 @@ func setViperDefaults() {
|
|||
viper.SetDefault("devshowartistpage", true)
|
||||
viper.SetDefault("devuishowconfig", true)
|
||||
viper.SetDefault("devneweventstream", true)
|
||||
viper.SetDefault("devapiv1", false)
|
||||
viper.SetDefault("devoffsetoptimize", 50000)
|
||||
// Half the pool: streams may take up to this many connections, leaving the rest for the scanner,
|
||||
// scrobbles and the UI. See MaxOpenConns.
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ const (
|
|||
URLPathPublic = "/share"
|
||||
URLPathPublicImages = URLPathPublic + "/img"
|
||||
URLPathJellyfinAPI = "/jellyfin"
|
||||
URLPathAPIv1 = "/api/v1"
|
||||
|
||||
// JellyfinServerIDKey is the Property key for the stable, persisted server Id reported by the
|
||||
// Jellyfin API. Jellyfin clients cache this value, so it must survive process restarts.
|
||||
|
|
|
|||
6
go.mod
6
go.mod
|
|
@ -20,6 +20,7 @@ require (
|
|||
github.com/extism/go-sdk v1.7.1
|
||||
github.com/fatih/structs v1.1.0
|
||||
github.com/gen2brain/webp v0.6.4
|
||||
github.com/getkin/kin-openapi v0.149.0
|
||||
github.com/go-chi/chi/v5 v5.3.2
|
||||
github.com/go-chi/cors v1.2.2
|
||||
github.com/go-chi/httprate v0.16.0
|
||||
|
|
@ -84,6 +85,8 @@ require (
|
|||
github.com/ebitengine/purego v0.11.1 // indirect
|
||||
github.com/fsnotify/fsnotify v1.10.1 // indirect
|
||||
github.com/go-logr/logr v1.4.4 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.22.5 // indirect
|
||||
github.com/go-openapi/swag/jsonname v0.25.5 // indirect
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
|
||||
github.com/gobwas/glob v1.0.0 // indirect
|
||||
github.com/goccy/go-json v0.10.6 // indirect
|
||||
|
|
@ -92,6 +95,7 @@ require (
|
|||
github.com/google/pprof v0.0.0-20260906184651-6331bc6350fe // indirect
|
||||
github.com/google/subcommands v1.2.0 // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/gorilla/mux v1.8.0 // indirect
|
||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||
github.com/ianlancetaylor/demangle v0.0.0-20260724033716-83e58baca724 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
|
|
@ -110,6 +114,8 @@ require (
|
|||
github.com/mfridman/interpolate v0.0.2 // indirect
|
||||
github.com/mitchellh/go-wordwrap v1.0.1 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/oasdiff/yaml v0.1.1 // indirect
|
||||
github.com/oasdiff/yaml3 v0.0.14 // indirect
|
||||
github.com/ogier/pflag v0.0.1 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
|
|
|
|||
18
go.sum
18
go.sum
|
|
@ -63,6 +63,8 @@ github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx5
|
|||
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
|
||||
github.com/gen2brain/webp v0.6.4 h1:SUDdmxADOAiPQ+5ylNmuHhuYf2dOi0KgKZHL5vpVCNU=
|
||||
github.com/gen2brain/webp v0.6.4/go.mod h1:iGWMaCSw7t3I/Cv9llzEKmpnR36S8lS8VL/ZVjxU0JE=
|
||||
github.com/getkin/kin-openapi v0.149.0 h1:ZbhmVJ4yq5RZDUsyP8lcBcGMsjsaTqXEFt6isdtMDfA=
|
||||
github.com/getkin/kin-openapi v0.149.0/go.mod h1:1+BHDzstro+P5CKtPy1X4PfofnFgmRe6uvMy9+r9fKY=
|
||||
github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs=
|
||||
github.com/gkampitakis/ciinfo v0.3.2/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo=
|
||||
github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZdC4M=
|
||||
|
|
@ -79,6 +81,12 @@ github.com/go-chi/jwtauth/v5 v5.4.0 h1:Ieh0xMJsFvqylqJ02/mQHKzbbKO9DYNBh4DPKCwTw
|
|||
github.com/go-chi/jwtauth/v5 v5.4.0/go.mod h1:w6yjqUUXz1b8+oiJel64Sz1KJwduQM6qUA5QNzO5+bQ=
|
||||
github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
|
||||
github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-openapi/jsonpointer v0.22.5 h1:8on/0Yp4uTb9f4XvTrM2+1CPrV05QPZXu+rvu2o9jcA=
|
||||
github.com/go-openapi/jsonpointer v0.22.5/go.mod h1:gyUR3sCvGSWchA2sUBJGluYMbe1zazrYWIkWPjjMUY0=
|
||||
github.com/go-openapi/swag/jsonname v0.25.5 h1:8p150i44rv/Drip4vWI3kGi9+4W9TdI3US3uUYSFhSo=
|
||||
github.com/go-openapi/swag/jsonname v0.25.5/go.mod h1:jNqqikyiAK56uS7n8sLkdaNY/uq6+D2m2LANat09pKU=
|
||||
github.com/go-openapi/testify/v2 v2.4.0 h1:8nsPrHVCWkQ4p8h1EsRVymA2XABB4OT40gcvAu+voFM=
|
||||
github.com/go-openapi/testify/v2 v2.4.0/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
|
||||
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
|
||||
github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw=
|
||||
github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk=
|
||||
|
|
@ -111,6 +119,8 @@ github.com/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4=
|
|||
github.com/google/wire v0.7.0/go.mod h1:n6YbUQD9cPKTnHXEBN2DXlOp/mVADhVErcMFb0v3J18=
|
||||
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
|
||||
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
|
|
@ -178,6 +188,10 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq
|
|||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/oasdiff/yaml v0.1.1 h1:6nHx+pn9gBRM6YpBlFZFQGCCd1nuvqOBtTD3KKTgGxY=
|
||||
github.com/oasdiff/yaml v0.1.1/go.mod h1:EYJNoyktvWMJ0Hmhx+6qTaqMOsalUaRGT8Sj1hNcegU=
|
||||
github.com/oasdiff/yaml3 v0.0.14 h1:aLJee3hxBK2H5wdXd9iPcIXb93Nty1Ge0pT171eHtkw=
|
||||
github.com/oasdiff/yaml3 v0.0.14/go.mod h1:csto2xfDjYccdUn/yw/bPjj/cYTdp6HtFA0J4TWG+gg=
|
||||
github.com/ogier/pflag v0.0.1 h1:RW6JSWSu/RkSatfcLtogGfFgpim5p7ARQ10ECk5O750=
|
||||
github.com/ogier/pflag v0.0.1/go.mod h1:zkFki7tvTa0tafRvTBIZTvzYyAu6kQhPZFnshFFPE+g=
|
||||
github.com/onsi/ginkgo/v2 v2.33.0 h1:C8gBA6Uc2ZEubiV+SXiu5tZnMTwEmXHgkJwGozKtZf8=
|
||||
|
|
@ -326,8 +340,8 @@ google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCID
|
|||
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
|
||||
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/ini.v1 v1.67.3 h1:iM9Lhz5MRSGhHVGGwCuzG9KO8PoirCXj/m/qTmOJJQw=
|
||||
gopkg.in/ini.v1 v1.67.3/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss=
|
||||
gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce h1:+JknDZhAj8YMt7GC73Ei8pv4MzjDUNPHgQWJdtMAaDU=
|
||||
|
|
|
|||
98
server/apiv1/api.go
Normal file
98
server/apiv1/api.go
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
package apiv1
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"runtime/debug"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/navidrome/navidrome/api"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
type Router struct {
|
||||
http.Handler
|
||||
ds model.DataStore
|
||||
}
|
||||
|
||||
func New(ds model.DataStore) *Router {
|
||||
rt := &Router{ds: ds}
|
||||
rt.Handler = rt.routes()
|
||||
return rt
|
||||
}
|
||||
|
||||
func (rt *Router) routes() http.Handler {
|
||||
r := chi.NewRouter()
|
||||
r.Use(problemRecoverer, headAsGet(r))
|
||||
r.NotFound(func(w http.ResponseWriter, req *http.Request) {
|
||||
writeProblemStatus(w, req, http.StatusNotFound, ProblemCodeNotFound, "no such endpoint")
|
||||
})
|
||||
r.MethodNotAllowed(func(w http.ResponseWriter, req *http.Request) {
|
||||
w.Header().Set("Allow", strings.Join(allowedMethods(r, req), ", "))
|
||||
writeProblemStatus(w, req, http.StatusMethodNotAllowed, ProblemCodeMethodNotAllowed, "")
|
||||
})
|
||||
|
||||
r.Get("/openapi.json", specHandler(withBasePath(api.SpecJSON(), `"url": `, true), "application/json"))
|
||||
r.Get("/openapi.yaml", specHandler(withBasePath(api.SpecYAML(), "url: ", false), "application/yaml"))
|
||||
|
||||
strict := NewStrictHandlerWithOptions(rt, nil, StrictHTTPServerOptions{
|
||||
RequestErrorHandlerFunc: func(w http.ResponseWriter, req *http.Request, err error) {
|
||||
writeProblemStatus(w, req, http.StatusBadRequest, "validation", err.Error())
|
||||
},
|
||||
ResponseErrorHandlerFunc: writeProblem,
|
||||
})
|
||||
HandlerWithOptions(strict, ChiServerOptions{BaseRouter: r, ErrorHandlerFunc: bindingErrorHandler})
|
||||
return r
|
||||
}
|
||||
|
||||
func problemRecoverer(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defer func() {
|
||||
rec := recover()
|
||||
if rec == nil {
|
||||
return
|
||||
}
|
||||
if err, ok := rec.(error); ok && errors.Is(err, http.ErrAbortHandler) {
|
||||
panic(rec)
|
||||
}
|
||||
log.Error(r.Context(), "API v1: panic in handler", "panic", rec, "stack", string(debug.Stack()))
|
||||
writeProblemStatus(w, r, http.StatusInternalServerError, ProblemCodeInternal, "")
|
||||
}()
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
var routableMethods = []string{http.MethodGet, http.MethodHead, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete}
|
||||
|
||||
// Looks routes up on mux itself: chi's RouteContext().Routes points at the parent router when mounted.
|
||||
func allowedMethods(mux chi.Routes, req *http.Request) []string {
|
||||
path := routePath(req)
|
||||
var allowed []string
|
||||
for _, m := range routableMethods {
|
||||
if mux.Match(chi.NewRouteContext(), m, path) || (m == http.MethodHead && slices.Contains(allowed, http.MethodGet)) {
|
||||
allowed = append(allowed, m)
|
||||
}
|
||||
}
|
||||
return allowed
|
||||
}
|
||||
|
||||
func headAsGet(mux chi.Routes) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
if req.Method == http.MethodHead && !mux.Match(chi.NewRouteContext(), http.MethodHead, routePath(req)) {
|
||||
chi.RouteContext(req.Context()).RouteMethod = http.MethodGet
|
||||
}
|
||||
next.ServeHTTP(w, req)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func routePath(req *http.Request) string {
|
||||
if rctx := chi.RouteContext(req.Context()); rctx != nil && rctx.RoutePath != "" {
|
||||
return rctx.RoutePath
|
||||
}
|
||||
return req.URL.Path
|
||||
}
|
||||
390
server/apiv1/api_gen.go
Normal file
390
server/apiv1/api_gen.go
Normal file
|
|
@ -0,0 +1,390 @@
|
|||
// Package apiv1 provides primitives to interact with the openapi HTTP API.
|
||||
//
|
||||
// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.8.0 DO NOT EDIT.
|
||||
package apiv1
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// Defines values for ProblemCode.
|
||||
const (
|
||||
ProblemCodeForbidden ProblemCode = "forbidden"
|
||||
ProblemCodeInternal ProblemCode = "internal"
|
||||
ProblemCodeMethodNotAllowed ProblemCode = "method_not_allowed"
|
||||
ProblemCodeNotFound ProblemCode = "not_found"
|
||||
ProblemCodeUnauthorized ProblemCode = "unauthorized"
|
||||
ProblemCodeUnavailable ProblemCode = "unavailable"
|
||||
ProblemCodeValidation ProblemCode = "validation"
|
||||
)
|
||||
|
||||
// Valid indicates whether the value is a known member of the ProblemCode enum.
|
||||
func (e ProblemCode) Valid() bool {
|
||||
switch e {
|
||||
case ProblemCodeForbidden:
|
||||
return true
|
||||
case ProblemCodeInternal:
|
||||
return true
|
||||
case ProblemCodeMethodNotAllowed:
|
||||
return true
|
||||
case ProblemCodeNotFound:
|
||||
return true
|
||||
case ProblemCodeUnauthorized:
|
||||
return true
|
||||
case ProblemCodeUnavailable:
|
||||
return true
|
||||
case ProblemCodeValidation:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
Code ProblemCode `json:"code"`
|
||||
|
||||
// Detail Human-readable explanation specific to this occurrence. Omitted for internal errors.
|
||||
Detail *string `json:"detail,omitempty"`
|
||||
|
||||
// Errors Per-field failures. Present only when `code` is `validation`.
|
||||
Errors *[]ValidationError `json:"errors,omitempty"`
|
||||
|
||||
// Status HTTP status code of this response.
|
||||
Status int `json:"status"`
|
||||
|
||||
// Title Short human-readable summary, the same for all occurrences of this problem type.
|
||||
Title string `json:"title"`
|
||||
|
||||
// Type URI reference identifying the problem type. Omitted while the problem carries no semantics
|
||||
// beyond its HTTP status code, which RFC 9457 defines as `about:blank`. Problems with their
|
||||
// own semantics get their own URI; switch on `code` instead.
|
||||
Type *string `json:"type,omitempty"`
|
||||
}
|
||||
|
||||
// ProblemCode Machine-readable error code, and the value clients switch on. New codes may be added.
|
||||
type ProblemCode 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"`
|
||||
|
||||
// Name Human-readable server product name.
|
||||
Name string `json:"name"`
|
||||
|
||||
// ServerVersion Version of the running server build.
|
||||
ServerVersion string `json:"serverVersion"`
|
||||
|
||||
// SetupRequired True until the first admin user has been created.
|
||||
SetupRequired bool `json:"setupRequired"`
|
||||
|
||||
// SpecVersion Version of the OpenAPI document this server implements.
|
||||
SpecVersion string `json:"specVersion"`
|
||||
}
|
||||
|
||||
// ServerInfoLoginMethods defines model for ServerInfo.LoginMethods.
|
||||
type ServerInfoLoginMethods string
|
||||
|
||||
// ValidationError One field-level validation failure.
|
||||
type ValidationError struct {
|
||||
// Field Name of the offending query parameter, path parameter, or body field (dotted for nested).
|
||||
Field string `json:"field"`
|
||||
|
||||
// Message Why the value was rejected.
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// InternalError RFC 9457 problem details, returned for every 4xx and 5xx response.
|
||||
type InternalError = Problem
|
||||
|
||||
// ServerInterface represents all server handlers.
|
||||
type ServerInterface interface {
|
||||
// GetServerInfo Describe the server
|
||||
// (GET /server)
|
||||
GetServerInfo(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
// Unimplemented server implementation that returns http.StatusNotImplemented for each endpoint.
|
||||
|
||||
type Unimplemented struct{}
|
||||
|
||||
// GetServerInfo Describe the server
|
||||
// (GET /server)
|
||||
func (_ Unimplemented) GetServerInfo(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotImplemented)
|
||||
}
|
||||
|
||||
// ServerInterfaceWrapper converts contexts to parameters.
|
||||
type ServerInterfaceWrapper struct {
|
||||
Handler ServerInterface
|
||||
HandlerMiddlewares []MiddlewareFunc
|
||||
ErrorHandlerFunc func(w http.ResponseWriter, r *http.Request, err error)
|
||||
}
|
||||
|
||||
type MiddlewareFunc func(http.Handler) http.Handler
|
||||
|
||||
// GetServerInfo operation middleware
|
||||
func (siw *ServerInterfaceWrapper) GetServerInfo(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
siw.Handler.GetServerInfo(w, r)
|
||||
}))
|
||||
|
||||
for _, middleware := range siw.HandlerMiddlewares {
|
||||
handler = middleware(handler)
|
||||
}
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
type UnescapedCookieParamError struct {
|
||||
ParamName string
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *UnescapedCookieParamError) Error() string {
|
||||
return fmt.Sprintf("error unescaping cookie parameter '%s'", e.ParamName)
|
||||
}
|
||||
|
||||
func (e *UnescapedCookieParamError) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
|
||||
type UnmarshalingParamError struct {
|
||||
ParamName string
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *UnmarshalingParamError) Error() string {
|
||||
return fmt.Sprintf("Error unmarshaling parameter %s as JSON: %s", e.ParamName, e.Err.Error())
|
||||
}
|
||||
|
||||
func (e *UnmarshalingParamError) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
|
||||
type RequiredParamError struct {
|
||||
ParamName string
|
||||
}
|
||||
|
||||
func (e *RequiredParamError) Error() string {
|
||||
return fmt.Sprintf("Query argument %s is required, but not found", e.ParamName)
|
||||
}
|
||||
|
||||
type RequiredHeaderError struct {
|
||||
ParamName string
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *RequiredHeaderError) Error() string {
|
||||
return fmt.Sprintf("Header parameter %s is required, but not found", e.ParamName)
|
||||
}
|
||||
|
||||
func (e *RequiredHeaderError) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
|
||||
type InvalidParamFormatError struct {
|
||||
ParamName string
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *InvalidParamFormatError) Error() string {
|
||||
return fmt.Sprintf("Invalid format for parameter %s: %s", e.ParamName, e.Err.Error())
|
||||
}
|
||||
|
||||
func (e *InvalidParamFormatError) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
|
||||
type TooManyValuesForParamError struct {
|
||||
ParamName string
|
||||
Count int
|
||||
}
|
||||
|
||||
func (e *TooManyValuesForParamError) Error() string {
|
||||
return fmt.Sprintf("Expected one value for %s, got %d", e.ParamName, e.Count)
|
||||
}
|
||||
|
||||
// Handler creates http.Handler with routing matching OpenAPI spec.
|
||||
func Handler(si ServerInterface) http.Handler {
|
||||
return HandlerWithOptions(si, ChiServerOptions{})
|
||||
}
|
||||
|
||||
type ChiServerOptions struct {
|
||||
BaseURL string
|
||||
BaseRouter chi.Router
|
||||
Middlewares []MiddlewareFunc
|
||||
ErrorHandlerFunc func(w http.ResponseWriter, r *http.Request, err error)
|
||||
}
|
||||
|
||||
// HandlerFromMux creates http.Handler with routing matching OpenAPI spec based on the provided mux.
|
||||
func HandlerFromMux(si ServerInterface, r chi.Router) http.Handler {
|
||||
return HandlerWithOptions(si, ChiServerOptions{
|
||||
BaseRouter: r,
|
||||
})
|
||||
}
|
||||
|
||||
func HandlerFromMuxWithBaseURL(si ServerInterface, r chi.Router, baseURL string) http.Handler {
|
||||
return HandlerWithOptions(si, ChiServerOptions{
|
||||
BaseURL: baseURL,
|
||||
BaseRouter: r,
|
||||
})
|
||||
}
|
||||
|
||||
// HandlerWithOptions creates http.Handler with additional options
|
||||
func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handler {
|
||||
r := options.BaseRouter
|
||||
|
||||
if r == nil {
|
||||
r = chi.NewRouter()
|
||||
}
|
||||
if options.ErrorHandlerFunc == nil {
|
||||
options.ErrorHandlerFunc = func(w http.ResponseWriter, r *http.Request, err error) {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
wrapper := ServerInterfaceWrapper{
|
||||
Handler: si,
|
||||
HandlerMiddlewares: options.Middlewares,
|
||||
ErrorHandlerFunc: options.ErrorHandlerFunc,
|
||||
}
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Get(options.BaseURL+"/server", wrapper.GetServerInfo)
|
||||
})
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
type InternalErrorApplicationProblemPlusJSONResponse Problem
|
||||
|
||||
type GetServerInfoRequestObject struct {
|
||||
}
|
||||
|
||||
type GetServerInfoResponseObject interface {
|
||||
VisitGetServerInfoResponse(w http.ResponseWriter) error
|
||||
}
|
||||
|
||||
type GetServerInfo200JSONResponse ServerInfo
|
||||
|
||||
func (response GetServerInfo200JSONResponse) VisitGetServerInfoResponse(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 GetServerInfo500ApplicationProblemPlusJSONResponse struct {
|
||||
InternalErrorApplicationProblemPlusJSONResponse
|
||||
}
|
||||
|
||||
func (response GetServerInfo500ApplicationProblemPlusJSONResponse) VisitGetServerInfoResponse(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
|
||||
}
|
||||
|
||||
// StrictServerInterface represents all server handlers.
|
||||
type StrictServerInterface interface {
|
||||
// GetServerInfo Describe the server
|
||||
// (GET /server)
|
||||
GetServerInfo(ctx context.Context, request GetServerInfoRequestObject) (GetServerInfoResponseObject, error)
|
||||
}
|
||||
|
||||
type StrictHandlerFunc func(ctx context.Context, w http.ResponseWriter, r *http.Request, request any) (any, error)
|
||||
type StrictMiddlewareFunc func(f StrictHandlerFunc, operationID string) StrictHandlerFunc
|
||||
|
||||
type StrictHTTPServerOptions struct {
|
||||
RequestErrorHandlerFunc func(w http.ResponseWriter, r *http.Request, err error)
|
||||
ResponseErrorHandlerFunc func(w http.ResponseWriter, r *http.Request, err error)
|
||||
}
|
||||
|
||||
func NewStrictHandler(ssi StrictServerInterface, middlewares []StrictMiddlewareFunc) ServerInterface {
|
||||
return &strictHandler{ssi: ssi, middlewares: middlewares, options: StrictHTTPServerOptions{
|
||||
RequestErrorHandlerFunc: func(w http.ResponseWriter, r *http.Request, err error) {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
},
|
||||
ResponseErrorHandlerFunc: func(w http.ResponseWriter, r *http.Request, err error) {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
func NewStrictHandlerWithOptions(ssi StrictServerInterface, middlewares []StrictMiddlewareFunc, options StrictHTTPServerOptions) ServerInterface {
|
||||
if options.RequestErrorHandlerFunc == nil {
|
||||
options.RequestErrorHandlerFunc = func(w http.ResponseWriter, r *http.Request, err error) {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
if options.ResponseErrorHandlerFunc == nil {
|
||||
options.ResponseErrorHandlerFunc = func(w http.ResponseWriter, r *http.Request, err error) {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
return &strictHandler{ssi: ssi, middlewares: middlewares, options: options}
|
||||
}
|
||||
|
||||
type strictHandler struct {
|
||||
ssi StrictServerInterface
|
||||
middlewares []StrictMiddlewareFunc
|
||||
options StrictHTTPServerOptions
|
||||
}
|
||||
|
||||
// GetServerInfo operation middleware
|
||||
func (sh *strictHandler) GetServerInfo(w http.ResponseWriter, r *http.Request) {
|
||||
var request GetServerInfoRequestObject
|
||||
|
||||
handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) {
|
||||
return sh.ssi.GetServerInfo(ctx, request.(GetServerInfoRequestObject))
|
||||
}
|
||||
for _, middleware := range sh.middlewares {
|
||||
handler = middleware(handler, "GetServerInfo")
|
||||
}
|
||||
|
||||
response, err := handler(r.Context(), w, r, request)
|
||||
|
||||
if err != nil {
|
||||
sh.options.ResponseErrorHandlerFunc(w, r, err)
|
||||
} else if validResponse, ok := response.(GetServerInfoResponseObject); ok {
|
||||
if err := validResponse.VisitGetServerInfoResponse(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))
|
||||
}
|
||||
}
|
||||
75
server/apiv1/api_test.go
Normal file
75
server/apiv1/api_test.go
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
package apiv1
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Router", func() {
|
||||
var router *Router
|
||||
|
||||
BeforeEach(func() {
|
||||
router = New(&tests.MockDataStore{})
|
||||
})
|
||||
|
||||
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))
|
||||
Expect(w.Header().Get("Content-Type")).To(Equal(problemContentType))
|
||||
Expect(decodeProblem(w).Code).To(Equal(ProblemCodeNotFound))
|
||||
})
|
||||
|
||||
It("returns a 405 problem listing the allowed methods for a wrong method on a known path", func() {
|
||||
w := serve(router, httptest.NewRequest(http.MethodPost, "/api/v1/server", nil))
|
||||
Expect(w.Code).To(Equal(http.StatusMethodNotAllowed))
|
||||
Expect(w.Header().Get("Allow")).To(Equal("GET, HEAD"))
|
||||
Expect(decodeProblem(w).Code).To(Equal(ProblemCodeMethodNotAllowed))
|
||||
})
|
||||
|
||||
DescribeTable("answers HEAD wherever GET is routed",
|
||||
func(path, contentType string) {
|
||||
w := serve(router, httptest.NewRequest(http.MethodHead, path, nil))
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(w.Header().Get("Content-Type")).To(Equal(contentType))
|
||||
},
|
||||
Entry("server info", "/api/v1/server", "application/json"),
|
||||
Entry("JSON spec", "/api/v1/openapi.json", "application/json"),
|
||||
Entry("YAML spec", "/api/v1/openapi.yaml", "application/yaml"),
|
||||
)
|
||||
|
||||
It("revalidates HEAD requests with If-None-Match", func() {
|
||||
etag := serve(router, httptest.NewRequest(http.MethodHead, "/api/v1/openapi.json", nil)).Header().Get("ETag")
|
||||
req := httptest.NewRequest(http.MethodHead, "/api/v1/openapi.json", nil)
|
||||
req.Header.Set("If-None-Match", etag)
|
||||
Expect(serve(router, req).Code).To(Equal(http.StatusNotModified))
|
||||
})
|
||||
|
||||
It("returns a 404 problem for HEAD on unknown paths", func() {
|
||||
w := serve(router, httptest.NewRequest(http.MethodHead, "/api/v1/nope", nil))
|
||||
Expect(w.Code).To(Equal(http.StatusNotFound))
|
||||
Expect(w.Header().Get("Allow")).To(BeEmpty())
|
||||
})
|
||||
|
||||
panicking := func(v any) http.Handler {
|
||||
return problemRecoverer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { panic(v) }))
|
||||
}
|
||||
|
||||
It("turns a handler panic into a 500 problem", func() {
|
||||
w := httptest.NewRecorder()
|
||||
panicking("kaboom").ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/boom", nil))
|
||||
Expect(w.Code).To(Equal(http.StatusInternalServerError))
|
||||
p := decodeProblem(w)
|
||||
Expect(p.Code).To(Equal(ProblemCodeInternal))
|
||||
Expect(p.Detail).To(BeNil())
|
||||
})
|
||||
|
||||
It("re-panics http.ErrAbortHandler so the server can drop the connection", func() {
|
||||
Expect(func() {
|
||||
panicking(http.ErrAbortHandler).ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/abort", nil))
|
||||
}).To(PanicWith(http.ErrAbortHandler))
|
||||
})
|
||||
})
|
||||
66
server/apiv1/apiv1_suite_test.go
Normal file
66
server/apiv1/apiv1_suite_test.go
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
package apiv1
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/getkin/kin-openapi/openapi3"
|
||||
"github.com/getkin/kin-openapi/openapi3filter"
|
||||
"github.com/getkin/kin-openapi/routers"
|
||||
"github.com/getkin/kin-openapi/routers/gorillamux"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/navidrome/navidrome/api"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestAPIv1(t *testing.T) {
|
||||
tests.Init(t, false)
|
||||
log.SetLevel(log.LevelFatal)
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "API v1 Suite")
|
||||
}
|
||||
|
||||
var specRouter routers.Router
|
||||
|
||||
var _ = BeforeSuite(func() {
|
||||
doc, err := openapi3.NewLoader().LoadFromData(api.SpecJSON())
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
specRouter, err = gorillamux.NewRouter(doc)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
// serve routes req through h mounted at /api/v1 and asserts the response conforms to the spec.
|
||||
func serve(h http.Handler, req *http.Request) *httptest.ResponseRecorder {
|
||||
root := chi.NewRouter()
|
||||
root.Mount("/api/v1", h)
|
||||
w := httptest.NewRecorder()
|
||||
root.ServeHTTP(w, req)
|
||||
validateAgainstSpec(req, w)
|
||||
return w
|
||||
}
|
||||
|
||||
func validateAgainstSpec(req *http.Request, w *httptest.ResponseRecorder) {
|
||||
route, pathParams, err := specRouter.FindRoute(req)
|
||||
if errors.Is(err, routers.ErrPathNotFound) || errors.Is(err, routers.ErrMethodNotAllowed) {
|
||||
return
|
||||
}
|
||||
ExpectWithOffset(2, err).ToNot(HaveOccurred())
|
||||
input := &openapi3filter.ResponseValidationInput{
|
||||
RequestValidationInput: &openapi3filter.RequestValidationInput{
|
||||
Request: req, PathParams: pathParams, Route: route,
|
||||
},
|
||||
Status: w.Code,
|
||||
Header: w.Header(),
|
||||
Body: io.NopCloser(bytes.NewReader(w.Body.Bytes())),
|
||||
Options: &openapi3filter.Options{IncludeResponseStatus: true},
|
||||
}
|
||||
ExpectWithOffset(2, openapi3filter.ValidateResponse(req.Context(), input)).To(Succeed(),
|
||||
"response for %s %s does not conform to the spec", req.Method, req.URL.Path)
|
||||
}
|
||||
12
server/apiv1/oapi-codegen.yaml
Normal file
12
server/apiv1/oapi-codegen.yaml
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
package: apiv1
|
||||
output: server/apiv1/api_gen.go
|
||||
generate:
|
||||
chi-server: true
|
||||
strict-server: true
|
||||
models: true
|
||||
output-options:
|
||||
exclude-operation-ids:
|
||||
- getOpenAPISpecJSON
|
||||
- getOpenAPISpecYAML
|
||||
compatibility:
|
||||
always-prefix-enum-values: true
|
||||
72
server/apiv1/problem.go
Normal file
72
server/apiv1/problem.go
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
package apiv1
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
const problemContentType = "application/problem+json"
|
||||
|
||||
func writeProblem(w http.ResponseWriter, r *http.Request, err error) {
|
||||
status, code := classifyError(err)
|
||||
detail := err.Error()
|
||||
if status == http.StatusInternalServerError {
|
||||
log.Error(r.Context(), "API v1: unexpected error", "path", r.URL.Path, err)
|
||||
detail = ""
|
||||
}
|
||||
writeProblemStatus(w, r, status, code, detail)
|
||||
}
|
||||
|
||||
func classifyError(err error) (int, ProblemCode) {
|
||||
switch {
|
||||
case errors.Is(err, model.ErrNotFound):
|
||||
return http.StatusNotFound, ProblemCodeNotFound
|
||||
case errors.Is(err, model.ErrNotAuthorized):
|
||||
return http.StatusForbidden, ProblemCodeForbidden
|
||||
case errors.Is(err, model.ErrInvalidAuth), errors.Is(err, model.ErrExpired):
|
||||
return http.StatusUnauthorized, ProblemCodeUnauthorized
|
||||
case errors.Is(err, model.ErrValidation):
|
||||
return http.StatusBadRequest, ProblemCodeValidation
|
||||
case errors.Is(err, model.ErrNotAvailable):
|
||||
return http.StatusServiceUnavailable, ProblemCodeUnavailable
|
||||
}
|
||||
return http.StatusInternalServerError, ProblemCodeInternal
|
||||
}
|
||||
|
||||
func writeProblemStatus(w http.ResponseWriter, r *http.Request, status int, code ProblemCode, detail string, fieldErrors ...ValidationError) {
|
||||
p := Problem{Title: http.StatusText(status), Status: status, Code: code}
|
||||
if detail != "" {
|
||||
p.Detail = &detail
|
||||
}
|
||||
if len(fieldErrors) > 0 {
|
||||
p.Errors = &fieldErrors
|
||||
}
|
||||
w.Header().Set("Content-Type", problemContentType)
|
||||
w.WriteHeader(status)
|
||||
if err := json.NewEncoder(w).Encode(p); err != nil {
|
||||
log.Warn(r.Context(), "API v1: could not write problem response", err)
|
||||
}
|
||||
}
|
||||
|
||||
func bindingErrorHandler(w http.ResponseWriter, r *http.Request, err error) {
|
||||
var fieldErrors []ValidationError
|
||||
var required *RequiredParamError
|
||||
var invalid *InvalidParamFormatError
|
||||
var tooMany *TooManyValuesForParamError
|
||||
var unmarshal *UnmarshalingParamError
|
||||
switch {
|
||||
case errors.As(err, &required):
|
||||
fieldErrors = append(fieldErrors, ValidationError{Field: required.ParamName, Message: "is required"})
|
||||
case errors.As(err, &invalid):
|
||||
fieldErrors = append(fieldErrors, ValidationError{Field: invalid.ParamName, Message: invalid.Err.Error()})
|
||||
case errors.As(err, &tooMany):
|
||||
fieldErrors = append(fieldErrors, ValidationError{Field: tooMany.ParamName, Message: "expected a single value"})
|
||||
case errors.As(err, &unmarshal):
|
||||
fieldErrors = append(fieldErrors, ValidationError{Field: unmarshal.ParamName, Message: unmarshal.Err.Error()})
|
||||
}
|
||||
writeProblemStatus(w, r, http.StatusBadRequest, ProblemCodeValidation, err.Error(), fieldErrors...)
|
||||
}
|
||||
114
server/apiv1/problem_test.go
Normal file
114
server/apiv1/problem_test.go
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
package apiv1
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func decodeProblem(w *httptest.ResponseRecorder) Problem {
|
||||
var p Problem
|
||||
ExpectWithOffset(1, json.Unmarshal(w.Body.Bytes(), &p)).To(Succeed())
|
||||
return p
|
||||
}
|
||||
|
||||
var _ = Describe("problem", func() {
|
||||
var w *httptest.ResponseRecorder
|
||||
var r *http.Request
|
||||
|
||||
BeforeEach(func() {
|
||||
w = httptest.NewRecorder()
|
||||
r = httptest.NewRequest(http.MethodGet, "/api/v1/server", nil)
|
||||
})
|
||||
|
||||
Describe("writeProblem", func() {
|
||||
DescribeTable("maps domain errors to status and code",
|
||||
func(err error, status int, code ProblemCode) {
|
||||
writeProblem(w, r, err)
|
||||
Expect(w.Code).To(Equal(status))
|
||||
Expect(w.Header().Get("Content-Type")).To(Equal(problemContentType))
|
||||
p := decodeProblem(w)
|
||||
Expect(p.Status).To(Equal(status))
|
||||
Expect(p.Code).To(Equal(code))
|
||||
Expect(p.Title).To(Equal(http.StatusText(status)))
|
||||
Expect(p.Type).To(BeNil())
|
||||
Expect(w.Body.String()).ToNot(ContainSubstring(`"type"`))
|
||||
},
|
||||
Entry("not found", model.ErrNotFound, http.StatusNotFound, ProblemCodeNotFound),
|
||||
Entry("not authorized", model.ErrNotAuthorized, http.StatusForbidden, ProblemCodeForbidden),
|
||||
Entry("invalid auth", model.ErrInvalidAuth, http.StatusUnauthorized, ProblemCodeUnauthorized),
|
||||
Entry("expired", model.ErrExpired, http.StatusUnauthorized, ProblemCodeUnauthorized),
|
||||
Entry("validation", model.ErrValidation, http.StatusBadRequest, ProblemCodeValidation),
|
||||
Entry("not available", model.ErrNotAvailable, http.StatusServiceUnavailable, ProblemCodeUnavailable),
|
||||
Entry("unknown", errors.New("boom"), http.StatusInternalServerError, ProblemCodeInternal),
|
||||
)
|
||||
|
||||
DescribeTable("keeps the wrapping context as detail for client errors",
|
||||
func(err error) {
|
||||
writeProblem(w, r, err)
|
||||
p := decodeProblem(w)
|
||||
Expect(p.Status).To(Equal(http.StatusNotFound))
|
||||
Expect(p.Detail).ToNot(BeNil())
|
||||
Expect(*p.Detail).To(ContainSubstring("album 123"))
|
||||
},
|
||||
Entry("fmt.Errorf %w", fmt.Errorf("album 123: %w", model.ErrNotFound)),
|
||||
Entry("errors.Join", errors.Join(errors.New("album 123"), model.ErrNotFound)),
|
||||
)
|
||||
|
||||
It("hides details for internal errors", func() {
|
||||
writeProblem(w, r, errors.New("db password is hunter2"))
|
||||
p := decodeProblem(w)
|
||||
Expect(p.Detail).To(BeNil())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("writeProblemStatus", func() {
|
||||
It("writes field errors only when provided", func() {
|
||||
writeProblemStatus(w, r, http.StatusBadRequest, ProblemCodeValidation, "bad input",
|
||||
ValidationError{Field: "limit", Message: "must be <= 2000"})
|
||||
p := decodeProblem(w)
|
||||
Expect(p.Errors).ToNot(BeNil())
|
||||
Expect(*p.Errors).To(HaveLen(1))
|
||||
Expect((*p.Errors)[0].Field).To(Equal("limit"))
|
||||
})
|
||||
|
||||
It("omits detail when empty", func() {
|
||||
writeProblemStatus(w, r, http.StatusMethodNotAllowed, ProblemCodeMethodNotAllowed, "")
|
||||
Expect(w.Body.String()).ToNot(ContainSubstring(`"detail"`))
|
||||
Expect(w.Body.String()).ToNot(ContainSubstring(`"errors"`))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("bindingErrorHandler", func() {
|
||||
DescribeTable("maps parameter binding errors to a validation problem with the field",
|
||||
func(err error, field, message string) {
|
||||
bindingErrorHandler(w, r, err)
|
||||
Expect(w.Code).To(Equal(http.StatusBadRequest))
|
||||
p := decodeProblem(w)
|
||||
Expect(p.Code).To(Equal(ProblemCodeValidation))
|
||||
Expect(p.Errors).ToNot(BeNil())
|
||||
Expect(*p.Errors).To(HaveLen(1))
|
||||
Expect((*p.Errors)[0].Field).To(Equal(field))
|
||||
Expect((*p.Errors)[0].Message).To(ContainSubstring(message))
|
||||
},
|
||||
Entry("required", &RequiredParamError{ParamName: "limit"}, "limit", "is required"),
|
||||
Entry("invalid format", &InvalidParamFormatError{ParamName: "offset", Err: errors.New("not a number")}, "offset", "not a number"),
|
||||
Entry("too many values", &TooManyValuesForParamError{ParamName: "sort", Count: 2}, "sort", "single value"),
|
||||
Entry("unmarshaling", &UnmarshalingParamError{ParamName: "ids", Err: errors.New("bad json")}, "ids", "bad json"),
|
||||
)
|
||||
|
||||
It("still returns a validation problem for unknown binding errors", func() {
|
||||
bindingErrorHandler(w, r, errors.New("weird"))
|
||||
p := decodeProblem(w)
|
||||
Expect(p.Status).To(Equal(http.StatusBadRequest))
|
||||
Expect(p.Code).To(Equal(ProblemCodeValidation))
|
||||
Expect(p.Errors).To(BeNil())
|
||||
})
|
||||
})
|
||||
})
|
||||
23
server/apiv1/server_info.go
Normal file
23
server/apiv1/server_info.go
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
package apiv1
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/navidrome/navidrome/api"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
)
|
||||
|
||||
func (rt *Router) GetServerInfo(ctx context.Context, _ GetServerInfoRequestObject) (GetServerInfoResponseObject, error) {
|
||||
count, err := rt.ds.User().CountAll(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("counting users: %w", err)
|
||||
}
|
||||
return GetServerInfo200JSONResponse{
|
||||
Name: "Navidrome",
|
||||
ServerVersion: consts.Version,
|
||||
SpecVersion: api.SpecVersion(),
|
||||
SetupRequired: count == 0,
|
||||
LoginMethods: []ServerInfoLoginMethods{ServerInfoLoginMethodsPassword},
|
||||
}, nil
|
||||
}
|
||||
61
server/apiv1/server_info_test.go
Normal file
61
server/apiv1/server_info_test.go
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
package apiv1
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
|
||||
"github.com/navidrome/navidrome/api"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("GET /server", func() {
|
||||
var ctx context.Context
|
||||
var ds *tests.MockDataStore
|
||||
var users *tests.MockedUserRepo
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = GinkgoT().Context()
|
||||
users = tests.CreateMockUserRepo()
|
||||
ds = &tests.MockDataStore{MockedUser: users}
|
||||
})
|
||||
|
||||
get := func() (*httptest.ResponseRecorder, ServerInfo) {
|
||||
w := serve(New(ds), httptest.NewRequest(http.MethodGet, "/api/v1/server", nil))
|
||||
var info ServerInfo
|
||||
if w.Code == http.StatusOK {
|
||||
ExpectWithOffset(1, json.Unmarshal(w.Body.Bytes(), &info)).To(Succeed())
|
||||
}
|
||||
return w, info
|
||||
}
|
||||
|
||||
It("describes the server with setupRequired when there are no users", func() {
|
||||
w, info := get()
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(w.Header().Get("Content-Type")).To(HavePrefix("application/json"))
|
||||
Expect(info.Name).To(Equal("Navidrome"))
|
||||
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))
|
||||
})
|
||||
|
||||
It("reports setupRequired=false once a user exists", func() {
|
||||
Expect(users.Put(ctx, &model.User{ID: "u1", UserName: "admin", IsAdmin: true})).To(Succeed())
|
||||
_, info := get()
|
||||
Expect(info.SetupRequired).To(BeFalse())
|
||||
})
|
||||
|
||||
It("returns a 500 problem when the user count fails", func() {
|
||||
users.Error = errors.New("db down")
|
||||
w, _ := get()
|
||||
Expect(w.Code).To(Equal(http.StatusInternalServerError))
|
||||
Expect(decodeProblem(w).Code).To(Equal(ProblemCodeInternal))
|
||||
})
|
||||
})
|
||||
51
server/apiv1/spec.go
Normal file
51
server/apiv1/spec.go
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
package apiv1
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path"
|
||||
"strconv"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/utils/req"
|
||||
"github.com/zeebo/xxh3"
|
||||
)
|
||||
|
||||
func specHandler(body []byte, contentType string) http.HandlerFunc {
|
||||
etag := fmt.Sprintf("%016x", xxh3.Hash(body))
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("ETag", `"`+etag+`"`)
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
if req.IfNoneMatch(r, etag) {
|
||||
w.WriteHeader(http.StatusNotModified)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
}
|
||||
|
||||
// withBasePath adds BasePath to the advertised server URL, since only the running server knows it.
|
||||
func withBasePath(body []byte, key string, quotedInBundle bool) []byte {
|
||||
serverURL := path.Join(conf.Server.BasePath, consts.URLPathAPIv1)
|
||||
if serverURL == consts.URLPathAPIv1 {
|
||||
return body
|
||||
}
|
||||
oldURL := consts.URLPathAPIv1
|
||||
if quotedInBundle {
|
||||
oldURL = strconv.Quote(oldURL)
|
||||
}
|
||||
old := []byte(key + oldURL)
|
||||
if bytes.Count(body, old) != 1 {
|
||||
log.Error("API v1: server URL not found in the bundled spec, serving it without the base path", "key", key)
|
||||
return body
|
||||
}
|
||||
// A JSON string is also a valid YAML double-quoted scalar, so one encoding escapes both formats.
|
||||
newURL, _ := json.Marshal(serverURL)
|
||||
return bytes.Replace(body, old, append([]byte(key), newURL...), 1)
|
||||
}
|
||||
136
server/apiv1/spec_test.go
Normal file
136
server/apiv1/spec_test.go
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
package apiv1
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
|
||||
"github.com/navidrome/navidrome/api"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
var _ = Describe("OpenAPI document routes", func() {
|
||||
var router *Router
|
||||
|
||||
BeforeEach(func() {
|
||||
router = New(&tests.MockDataStore{})
|
||||
})
|
||||
|
||||
get := func(path string, headers map[string]string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
return serve(router, req)
|
||||
}
|
||||
|
||||
DescribeTable("serves the embedded bundle",
|
||||
func(path, contentType string, body []byte) {
|
||||
w := get(path, nil)
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(w.Header().Get("Content-Type")).To(Equal(contentType))
|
||||
Expect(w.Header().Get("ETag")).ToNot(BeEmpty())
|
||||
Expect(w.Header().Get("Cache-Control")).To(Equal("no-cache"))
|
||||
Expect(w.Body.Bytes()).To(Equal(body))
|
||||
},
|
||||
Entry("JSON", "/api/v1/openapi.json", "application/json", api.SpecJSON()),
|
||||
Entry("YAML", "/api/v1/openapi.yaml", "application/yaml", api.SpecYAML()),
|
||||
)
|
||||
|
||||
DescribeTable("revalidates with If-None-Match",
|
||||
func(ifNoneMatch func(etag string) string, expected int) {
|
||||
etag := get("/api/v1/openapi.json", nil).Header().Get("ETag")
|
||||
w := get("/api/v1/openapi.json", map[string]string{"If-None-Match": ifNoneMatch(etag)})
|
||||
Expect(w.Code).To(Equal(expected))
|
||||
if expected == http.StatusNotModified {
|
||||
Expect(w.Body.Len()).To(BeZero())
|
||||
Expect(w.Header().Get("ETag")).To(Equal(etag))
|
||||
} else {
|
||||
Expect(w.Body.Bytes()).To(Equal(api.SpecJSON()))
|
||||
}
|
||||
},
|
||||
Entry("exact ETag", func(etag string) string { return etag }, http.StatusNotModified),
|
||||
Entry("ETag in a list", func(etag string) string { return `"other", ` + etag }, http.StatusNotModified),
|
||||
Entry("weak ETag", func(etag string) string { return "W/" + etag }, http.StatusNotModified),
|
||||
Entry("wildcard", func(string) string { return "*" }, http.StatusNotModified),
|
||||
Entry("stale ETag", func(string) string { return `"stale"` }, http.StatusOK),
|
||||
)
|
||||
|
||||
It("ignores Range and returns the full document", func() {
|
||||
w := get("/api/v1/openapi.json", map[string]string{"Range": "bytes=0-9"})
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(w.Header().Get("Accept-Ranges")).To(BeEmpty())
|
||||
Expect(w.Body.Bytes()).To(Equal(api.SpecJSON()))
|
||||
})
|
||||
|
||||
It("uses different ETags for JSON and YAML", func() {
|
||||
j := get("/api/v1/openapi.json", nil)
|
||||
y := get("/api/v1/openapi.yaml", nil)
|
||||
Expect(j.Header().Get("ETag")).ToNot(Equal(y.Header().Get("ETag")))
|
||||
})
|
||||
|
||||
Describe("with a base path", func() {
|
||||
decode := func(format string, body []byte) map[string]any {
|
||||
var doc map[string]any
|
||||
if format == "json" {
|
||||
ExpectWithOffset(1, json.Unmarshal(body, &doc)).To(Succeed())
|
||||
} else {
|
||||
ExpectWithOffset(1, yaml.Unmarshal(body, &doc)).To(Succeed())
|
||||
}
|
||||
return doc
|
||||
}
|
||||
serverURL := func(doc map[string]any) any {
|
||||
return doc["servers"].([]any)[0].(map[string]any)["url"]
|
||||
}
|
||||
var plainETag string
|
||||
|
||||
BeforeEach(func() {
|
||||
plainETag = get("/api/v1/openapi.json", nil).Header().Get("ETag")
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.BasePath = "/music"
|
||||
router = New(&tests.MockDataStore{})
|
||||
})
|
||||
|
||||
DescribeTable("advertises the server under the base path and changes nothing else",
|
||||
func(path, format string, bundle []byte) {
|
||||
w := get(path, nil)
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
served, original := decode(format, w.Body.Bytes()), decode(format, bundle)
|
||||
Expect(serverURL(served)).To(Equal("/music/api/v1"))
|
||||
delete(served, "servers")
|
||||
delete(original, "servers")
|
||||
Expect(served).To(Equal(original))
|
||||
},
|
||||
Entry("JSON", "/api/v1/openapi.json", "json", api.SpecJSON()),
|
||||
Entry("YAML", "/api/v1/openapi.yaml", "yaml", api.SpecYAML()),
|
||||
)
|
||||
|
||||
It("uses its own ETag, and still revalidates", func() {
|
||||
etag := get("/api/v1/openapi.json", nil).Header().Get("ETag")
|
||||
Expect(etag).ToNot(Equal(plainETag))
|
||||
Expect(get("/api/v1/openapi.json", map[string]string{"If-None-Match": etag}).Code).To(Equal(http.StatusNotModified))
|
||||
})
|
||||
|
||||
It("escapes base paths that need quoting", func() {
|
||||
conf.Server.BasePath = "/my music: \"live\""
|
||||
router = New(&tests.MockDataStore{})
|
||||
Expect(serverURL(decode("json", get("/api/v1/openapi.json", nil).Body.Bytes()))).To(Equal("/my music: \"live\"/api/v1"))
|
||||
Expect(serverURL(decode("yaml", get("/api/v1/openapi.yaml", nil).Body.Bytes()))).To(Equal("/my music: \"live\"/api/v1"))
|
||||
})
|
||||
})
|
||||
|
||||
DescribeTable("the bundle advertises the API path exactly once, which the base-path rewrite relies on",
|
||||
func(bundle []byte) {
|
||||
Expect(bytes.Count(bundle, []byte(consts.URLPathAPIv1))).To(Equal(1))
|
||||
},
|
||||
Entry("JSON", api.SpecJSON()),
|
||||
Entry("YAML", api.SpecYAML()),
|
||||
)
|
||||
})
|
||||
|
|
@ -4,9 +4,9 @@ package imghttp
|
|||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
"github.com/navidrome/navidrome/utils/req"
|
||||
)
|
||||
|
||||
// WriteImageHeaders applies the artwork caching contract and reports whether a 304 was written
|
||||
|
|
@ -40,28 +40,9 @@ func WriteImageHeaders(w http.ResponseWriter, r *http.Request, img *artwork.Imag
|
|||
h.Set("Cache-Control", "public, no-cache")
|
||||
}
|
||||
|
||||
if etag != "" && ifNoneMatch(r.Header.Get("If-None-Match"), etag) {
|
||||
if etag != "" && req.IfNoneMatch(r, etag) {
|
||||
w.WriteHeader(http.StatusNotModified)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ifNoneMatch reports whether If-None-Match asserts hash, using RFC 9110 weak comparison.
|
||||
func ifNoneMatch(header, hash string) bool {
|
||||
header = strings.TrimSpace(header)
|
||||
if header == "" {
|
||||
return false
|
||||
}
|
||||
if header == "*" {
|
||||
return true
|
||||
}
|
||||
for tag := range strings.SplitSeq(header, ",") {
|
||||
tag = strings.TrimSpace(tag)
|
||||
tag = strings.TrimPrefix(tag, "W/")
|
||||
if strings.Trim(tag, `"`) == hash {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -178,3 +178,21 @@ func (r *Values) Float64Or(param string, def float64) float64 {
|
|||
}
|
||||
return f
|
||||
}
|
||||
|
||||
// IfNoneMatch reports whether the request's If-None-Match asserts etag (unquoted), using RFC 9110 weak comparison.
|
||||
func IfNoneMatch(r *http.Request, etag string) bool {
|
||||
header := strings.TrimSpace(r.Header.Get("If-None-Match"))
|
||||
if header == "" {
|
||||
return false
|
||||
}
|
||||
if header == "*" {
|
||||
return true
|
||||
}
|
||||
for tag := range strings.SplitSeq(header, ",") {
|
||||
tag = strings.TrimPrefix(strings.TrimSpace(tag), "W/")
|
||||
if strings.Trim(tag, `"`) == etag {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -290,3 +290,21 @@ var _ = Describe("Request Helpers", func() {
|
|||
})
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("IfNoneMatch", func() {
|
||||
DescribeTable("matches the ETag",
|
||||
func(header string, expected bool) {
|
||||
r := httptest.NewRequest("GET", "/", nil)
|
||||
if header != "" {
|
||||
r.Header.Set("If-None-Match", header)
|
||||
}
|
||||
Expect(req.IfNoneMatch(r, "abc123")).To(Equal(expected))
|
||||
},
|
||||
Entry("absent header", "", false),
|
||||
Entry("exact quoted tag", `"abc123"`, true),
|
||||
Entry("weak tag", `W/"abc123"`, true),
|
||||
Entry("tag in a list", `"other", W/"abc123"`, true),
|
||||
Entry("wildcard", "*", true),
|
||||
Entry("different tag", `"stale"`, false),
|
||||
)
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue