diff --git a/README.md b/README.md index 8e6fc7c..a93f674 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Shorebird Self-Hosted Server -A self-hosted replacement for the Shorebird CodePush API server (`api.shorebird.dev`). +A self-hosted replacement for the hosted Shorebird CodePush API server. See `../shorebird/OPEN_SOURCE_REPLACEMENTS.md` for the current audit of public upstream Shorebird components and the local replacements for hosted Shorebird services. diff --git a/internal/api/handlers/openapi.yaml b/internal/api/handlers/openapi.yaml new file mode 100644 index 0000000..f3d0a24 --- /dev/null +++ b/internal/api/handlers/openapi.yaml @@ -0,0 +1,985 @@ +openapi: 3.1.0 +info: + title: Open Shorebird Self-Hosted API + version: 0.1.0 + description: | + OpenAPI contract for the self-hosted Shorebird-compatible server in this + workspace. The API preserves the route shape expected by the open CLI, + CodePush client, and updater while using local/self-hosted services. +servers: + - url: http://localhost:8080 +security: + - bearerAuth: [] +tags: + - name: auth + - name: apps + - name: organizations + - name: releases + - name: patches + - name: devices + - name: diagnostics + - name: admin +paths: + /health: + get: + operationId: getHealth + security: [] + tags: [diagnostics] + responses: + '200': + description: Server health and backend status. + content: + application/json: + schema: + type: object + additionalProperties: true + /openapi.yaml: + get: + operationId: getOpenAPISpec + security: [] + tags: [diagnostics] + responses: + '200': + description: This OpenAPI document. + content: + application/yaml: + schema: + type: string + /api/v1/openapi.yaml: + get: + operationId: getOpenAPISpecV1 + security: [] + tags: [diagnostics] + responses: + '200': + description: This OpenAPI document served from a versioned path. + content: + application/yaml: + schema: + type: string + /auth/token: + post: + operationId: createAuthToken + security: [] + tags: [auth] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/LoginRequest' + responses: + '200': + description: JWT and user metadata. + content: + application/json: + schema: + $ref: '#/components/schemas/AuthTokenResponse' + /auth/register: + post: + operationId: createAuthUser + security: [] + tags: [auth] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/RegisterRequest' + responses: + '200': + description: Registered user. + content: + application/json: + schema: + $ref: '#/components/schemas/PrivateUser' + /auth/refresh: + post: + operationId: refreshAuthToken + tags: [auth] + responses: + '200': + description: Refreshed JWT. + content: + application/json: + schema: + $ref: '#/components/schemas/AuthTokenResponse' + /auth/public-settings: + get: + operationId: getAuthPublicSettings + security: [] + tags: [auth] + responses: + '200': + description: Public registration and SSO settings. + content: + application/json: + schema: + type: object + additionalProperties: true + /api/v1/patches/check: + post: + operationId: patchCheck + security: [] + tags: [devices] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/PatchCheckRequest' + responses: + '200': + description: Device patch check result. + content: + application/json: + schema: + $ref: '#/components/schemas/PatchCheckResponse' + /api/v1/patches/events: + post: + operationId: patchEvents + security: [] + tags: [devices] + requestBody: + required: true + content: + application/json: + schema: + type: object + additionalProperties: true + responses: + '204': + description: Event accepted. + /api/v1/users/me: + get: + operationId: getCurrentUser + tags: [auth] + responses: + '200': + description: Current user. + content: + application/json: + schema: + $ref: '#/components/schemas/PrivateUser' + patch: + operationId: updateCurrentUserPassword + tags: [auth] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [current_password, new_password] + properties: + current_password: + type: string + new_password: + type: string + responses: + '200': + description: Password updated. + /api/v1/users: + post: + operationId: createUser + tags: [auth] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateUserRequest' + responses: + '200': + description: Created user. + content: + application/json: + schema: + $ref: '#/components/schemas/PrivateUser' + /api/v1/apps: + get: + operationId: getApps + tags: [apps] + responses: + '200': + description: Apps visible to the current user. + content: + application/json: + schema: + type: object + required: [apps] + properties: + apps: + type: array + items: + $ref: '#/components/schemas/App' + post: + operationId: createApp + tags: [apps] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateAppRequest' + responses: + '200': + description: Created app. + content: + application/json: + schema: + $ref: '#/components/schemas/App' + /api/v1/apps/{appId}: + delete: + operationId: deleteApp + tags: [apps] + parameters: + - $ref: '#/components/parameters/AppId' + responses: + '204': + description: App deleted. + /api/v1/apps/{appId}/transfer: + patch: + operationId: transferApp + tags: [apps] + parameters: + - $ref: '#/components/parameters/AppId' + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [organization_id] + properties: + organization_id: + type: integer + responses: + '200': + description: Transferred app. + /api/v1/apps/{appId}/channels: + get: + operationId: getChannels + tags: [apps] + parameters: + - $ref: '#/components/parameters/AppId' + responses: + '200': + description: App channels. + content: + application/json: + schema: + type: object + required: [channels] + properties: + channels: + type: array + items: + $ref: '#/components/schemas/Channel' + post: + operationId: createChannel + tags: [apps] + parameters: + - $ref: '#/components/parameters/AppId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateChannelRequest' + responses: + '200': + description: Created channel. + /api/v1/apps/{appId}/releases: + get: + operationId: getReleases + tags: [releases] + parameters: + - $ref: '#/components/parameters/AppId' + responses: + '200': + description: App releases. + content: + application/json: + schema: + type: object + required: [releases] + properties: + releases: + type: array + items: + $ref: '#/components/schemas/Release' + post: + operationId: createRelease + tags: [releases] + parameters: + - $ref: '#/components/parameters/AppId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateReleaseRequest' + responses: + '200': + description: Created release. + content: + application/json: + schema: + $ref: '#/components/schemas/Release' + /api/v1/apps/{appId}/releases/{releaseId}: + patch: + operationId: updateRelease + tags: [releases] + parameters: + - $ref: '#/components/parameters/AppId' + - $ref: '#/components/parameters/ReleaseId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateReleaseRequest' + responses: + '200': + description: Updated release. + delete: + operationId: deleteRelease + tags: [releases] + parameters: + - $ref: '#/components/parameters/AppId' + - $ref: '#/components/parameters/ReleaseId' + responses: + '204': + description: Release deleted. + /api/v1/apps/{appId}/releases/{releaseId}/artifacts: + get: + operationId: getReleaseArtifacts + tags: [releases] + parameters: + - $ref: '#/components/parameters/AppId' + - $ref: '#/components/parameters/ReleaseId' + responses: + '200': + description: Release artifacts. + content: + application/json: + schema: + type: object + additionalProperties: true + post: + operationId: createReleaseArtifact + tags: [releases] + parameters: + - $ref: '#/components/parameters/AppId' + - $ref: '#/components/parameters/ReleaseId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateReleaseArtifactRequest' + responses: + '200': + description: Uploaded release artifact metadata. + /api/v1/apps/{appId}/patches: + post: + operationId: createPatch + tags: [patches] + parameters: + - $ref: '#/components/parameters/AppId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreatePatchRequest' + responses: + '200': + description: Created patch. + content: + application/json: + schema: + $ref: '#/components/schemas/Patch' + /api/v1/apps/{appId}/patches/{patchId}: + patch: + operationId: updatePatch + tags: [patches] + parameters: + - $ref: '#/components/parameters/AppId' + - $ref: '#/components/parameters/PatchId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdatePatchRequest' + responses: + '200': + description: Updated patch. + /api/v1/apps/{appId}/patches/{patchId}/artifacts: + post: + operationId: createPatchArtifact + tags: [patches] + parameters: + - $ref: '#/components/parameters/AppId' + - $ref: '#/components/parameters/PatchId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreatePatchArtifactRequest' + responses: + '200': + description: Uploaded patch artifact metadata. + /api/v1/apps/{appId}/releases/{releaseId}/patches: + get: + operationId: getReleasePatches + tags: [patches] + parameters: + - $ref: '#/components/parameters/AppId' + - $ref: '#/components/parameters/ReleaseId' + responses: + '200': + description: Patches for a release. + content: + application/json: + schema: + type: object + required: [patches] + properties: + patches: + type: array + items: + $ref: '#/components/schemas/Patch' + /api/v1/apps/{appId}/patches/promote: + post: + operationId: promotePatch + tags: [patches] + parameters: + - $ref: '#/components/parameters/AppId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/PromotePatchRequest' + responses: + '200': + description: Promoted patch. + /api/v1/apps/{appId}/patches/rollback: + post: + operationId: rollbackPatch + tags: [patches] + parameters: + - $ref: '#/components/parameters/AppId' + requestBody: + required: true + content: + application/json: + schema: + type: object + additionalProperties: true + responses: + '200': + description: Rolled back patch. + /api/v1/organizations: + get: + operationId: getOrganizations + tags: [organizations] + responses: + '200': + description: Organizations. + content: + application/json: + schema: + type: object + required: [organizations] + properties: + organizations: + type: array + items: + $ref: '#/components/schemas/Organization' + post: + operationId: createOrganization + tags: [organizations] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [name] + properties: + name: + type: string + organization_type: + type: string + responses: + '200': + description: Created organization. + /api/v1/organizations/{orgId}/users: + get: + operationId: getOrganizationUsers + tags: [organizations] + parameters: + - $ref: '#/components/parameters/OrgId' + responses: + '200': + description: Organization users. + content: + application/json: + schema: + type: object + required: [users] + properties: + users: + type: array + items: + $ref: '#/components/schemas/OrganizationUser' + post: + operationId: updateOrganizationUser + tags: [organizations] + parameters: + - $ref: '#/components/parameters/OrgId' + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [email, role] + properties: + email: + type: string + role: + type: string + responses: + '200': + description: Organization user role updated. + /api/v1/apps/{appId}/metrics/active-hours: + get: + operationId: getActiveHours + tags: [apps] + parameters: + - $ref: '#/components/parameters/AppId' + responses: + '200': + description: Active hour metrics. + /api/v1/apps/{appId}/metrics/unique-users: + get: + operationId: getUniqueUsers + tags: [apps] + parameters: + - $ref: '#/components/parameters/AppId' + responses: + '200': + description: Unique user metrics. + /api/v1/apps/{appId}/metrics/version-distribution: + get: + operationId: getVersionDistribution + tags: [apps] + parameters: + - $ref: '#/components/parameters/AppId' + responses: + '200': + description: Version distribution metrics. + /api/v1/apps/{appId}/metrics/patch-adoption: + get: + operationId: getPatchAdoption + tags: [apps] + parameters: + - $ref: '#/components/parameters/AppId' + responses: + '200': + description: Patch adoption metrics. + /api/v1/diagnostics/gcp_upload: + get: + operationId: getGCPUploadSpeedTestURL + tags: [diagnostics] + responses: + '200': + description: Upload speed-test URL. + /api/v1/diagnostics/gcp_download: + get: + operationId: getGCPDownloadSpeedTestURL + tags: [diagnostics] + responses: + '200': + description: Download speed-test URL. + /api/v1/admin/users: + get: + operationId: listAdminUsers + tags: [admin] + responses: + '200': + description: Admin user list. + /api/v1/admin/users/{userId}/verify-email: + post: + operationId: verifyAdminUserEmail + tags: [admin] + parameters: + - $ref: '#/components/parameters/UserId' + responses: + '200': + description: Email marked verified. + /api/v1/admin/users/{userId}/admin: + post: + operationId: setAdminUser + tags: [admin] + parameters: + - $ref: '#/components/parameters/UserId' + responses: + '200': + description: Admin flag updated. + /api/v1/admin/settings: + get: + operationId: getAdminSettings + tags: [admin] + responses: + '200': + description: Registration and SSO settings. + put: + operationId: updateAdminSettings + tags: [admin] + requestBody: + required: true + content: + application/json: + schema: + type: object + additionalProperties: true + responses: + '200': + description: Settings updated. + /api/v1/admin/patches/{patchId}/target-devices: + get: + operationId: getPatchTargetDevices + tags: [admin] + parameters: + - $ref: '#/components/parameters/PatchId' + responses: + '200': + description: Targeted device IDs. + post: + operationId: addPatchTargetDevice + tags: [admin] + parameters: + - $ref: '#/components/parameters/PatchId' + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [client_id] + properties: + client_id: + type: string + responses: + '200': + description: Target device added. + /api/v1/admin/patches/{patchId}/target-devices/{clientId}: + delete: + operationId: removePatchTargetDevice + tags: [admin] + parameters: + - $ref: '#/components/parameters/PatchId' + - name: clientId + in: path + required: true + schema: + type: string + responses: + '204': + description: Target device removed. + /api/v1/admin/apps/{appId}/events: + get: + operationId: getPatchEvents + tags: [admin] + parameters: + - $ref: '#/components/parameters/AppId' + responses: + '200': + description: Patch event list. +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + parameters: + AppId: + name: appId + in: path + required: true + schema: + type: string + format: uuid + OrgId: + name: orgId + in: path + required: true + schema: + type: integer + ReleaseId: + name: releaseId + in: path + required: true + schema: + type: integer + PatchId: + name: patchId + in: path + required: true + schema: + type: integer + UserId: + name: userId + in: path + required: true + schema: + type: integer + schemas: + ErrorResponse: + type: object + required: [message] + properties: + message: + type: string + details: + type: [string, 'null'] + LoginRequest: + type: object + required: [email, password] + properties: + email: + type: string + format: email + password: + type: string + RegisterRequest: + type: object + required: [email, password, name] + properties: + email: + type: string + format: email + password: + type: string + name: + type: string + AuthTokenResponse: + type: object + additionalProperties: true + properties: + token: + type: string + refresh_token: + type: string + user: + $ref: '#/components/schemas/PrivateUser' + PrivateUser: + type: object + additionalProperties: true + properties: + id: + type: integer + email: + type: string + name: + type: string + PublicUser: + type: object + additionalProperties: true + CreateUserRequest: + type: object + required: [email, password, name] + properties: + email: + type: string + password: + type: string + name: + type: string + Organization: + type: object + additionalProperties: true + properties: + id: + type: integer + name: + type: string + type: + type: string + OrganizationUser: + type: object + additionalProperties: true + App: + type: object + additionalProperties: true + properties: + app_id: + type: string + format: uuid + display_name: + type: string + CreateAppRequest: + type: object + required: [display_name] + properties: + display_name: + type: string + organization_id: + type: integer + Channel: + type: object + additionalProperties: true + properties: + id: + type: integer + name: + type: string + CreateChannelRequest: + type: object + required: [name] + properties: + name: + type: string + Release: + type: object + additionalProperties: true + properties: + id: + type: integer + version: + type: string + flutter_revision: + type: string + CreateReleaseRequest: + type: object + additionalProperties: true + required: [version, flutter_revision] + properties: + version: + type: string + flutter_revision: + type: string + flutter_version: + type: string + platform_statuses: + type: object + additionalProperties: true + UpdateReleaseRequest: + type: object + additionalProperties: true + CreateReleaseArtifactRequest: + type: object + additionalProperties: true + required: [arch, platform, hash, size, storage_key] + properties: + arch: + type: string + platform: + type: string + hash: + type: string + size: + type: integer + storage_key: + type: string + can_sideload: + type: boolean + ReleaseArtifact: + type: object + additionalProperties: true + Patch: + type: object + additionalProperties: true + properties: + id: + type: integer + number: + type: integer + CreatePatchRequest: + type: object + additionalProperties: true + required: [release_id] + properties: + release_id: + type: integer + notes: + type: string + UpdatePatchRequest: + type: object + additionalProperties: true + CreatePatchArtifactRequest: + type: object + additionalProperties: true + required: [arch, platform, hash, size, storage_key] + properties: + arch: + type: string + platform: + type: string + hash: + type: string + size: + type: integer + storage_key: + type: string + hash_signature: + type: string + podfile_lock_hash: + type: string + offline_expires_at: + type: string + format: date-time + PromotePatchRequest: + type: object + required: [patch_id, channel] + properties: + patch_id: + type: integer + channel: + type: string + PatchCheckRequest: + type: object + additionalProperties: true + required: [app_id, channel, release_version, platform, arch] + properties: + app_id: + type: string + format: uuid + channel: + type: string + release_version: + type: string + platform: + type: string + arch: + type: string + current_patch_number: + type: [integer, 'null'] + client_id: + type: [string, 'null'] + accept_encrypted_patch: + type: boolean + PatchCheckResponse: + type: object + additionalProperties: true + properties: + patch_available: + type: boolean + patch: + type: [object, 'null'] + remove_patch: + type: [object, 'null'] + rolled_back_patch_numbers: + type: array + items: + type: integer + PatchEncryptionMetadata: + type: object + additionalProperties: true diff --git a/internal/api/handlers/router.go b/internal/api/handlers/router.go index 9d76f03..842d987 100644 --- a/internal/api/handlers/router.go +++ b/internal/api/handlers/router.go @@ -1,6 +1,7 @@ package handlers import ( + _ "embed" "net/http" "os" "path/filepath" @@ -15,6 +16,9 @@ import ( "github.com/shorebird-server/internal/storage" ) +//go:embed openapi.yaml +var openAPISpec []byte + // NewRouter creates the HTTP router with all API routes. func NewRouter(authService *authpkg.Service, database db.Store, store storage.Store, mailer *email.Mailer, baseURL string, patchDelivery PatchDeliveryConfig) *chi.Mux { r := chi.NewRouter() @@ -48,6 +52,9 @@ func NewRouter(authService *authpkg.Service, database db.Store, store storage.St // Auth middleware authMw := middleware.AuthMiddleware(authService, database) + r.Get("/openapi.yaml", serveOpenAPISpec) + r.Get("/api/v1/openapi.yaml", serveOpenAPISpec) + // --- Local storage upload/download (used when STORAGE_DRIVER=local) --- r.Route("/storage", func(r chi.Router) { r.Post("/upload/{scope}/*", storageHandler.Upload) @@ -188,6 +195,12 @@ func NewRouter(authService *authpkg.Service, database db.Store, store storage.St return r } +func serveOpenAPISpec(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/yaml; charset=utf-8") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(openAPISpec) +} + // findWebDir locates the web/ directory. It searches relative to the // binary and from common workspace paths so that the dashboard works // in local development as well as in the Docker image. diff --git a/internal/api/handlers/router_test.go b/internal/api/handlers/router_test.go new file mode 100644 index 0000000..c5f97f7 --- /dev/null +++ b/internal/api/handlers/router_test.go @@ -0,0 +1,38 @@ +package handlers + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestOpenAPISpecRoutes(t *testing.T) { + router := NewRouter(nil, nil, nil, nil, "", PatchDeliveryConfig{}) + + for _, path := range []string{"/openapi.yaml", "/api/v1/openapi.yaml"} { + t.Run(path, func(t *testing.T) { + request := httptest.NewRequest(http.MethodGet, path, nil) + response := httptest.NewRecorder() + + router.ServeHTTP(response, request) + + if response.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", response.Code, http.StatusOK) + } + if contentType := response.Header().Get("Content-Type"); !strings.Contains(contentType, "application/yaml") { + t.Fatalf("Content-Type = %q, want application/yaml", contentType) + } + body := response.Body.String() + for _, required := range []string{ + "openapi: 3.1.0", + "/api/v1/patches/check:", + "/openapi.yaml:", + } { + if !strings.Contains(body, required) { + t.Fatalf("spec body missing %q", required) + } + } + }) + } +}