init
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
// Package api holds the wire format shared by pages-server and the pages CLI.
|
||||
//
|
||||
// It must depend on the standard library only. cmd/pages imports this package,
|
||||
// and cmd/pages/deps_test.go fails the build if any server-side dependency
|
||||
// (SQLite driver, database/sql, internal/store, ...) reaches the CLI through it.
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Code is a machine-readable error code. Clients switch on these; the
|
||||
// accompanying message is for humans and may change.
|
||||
type Code string
|
||||
|
||||
const (
|
||||
CodeBadRequest Code = "bad_request"
|
||||
CodeUnauthorized Code = "unauthorized"
|
||||
CodeForbidden Code = "forbidden"
|
||||
CodeNotFound Code = "not_found"
|
||||
CodeMethodNotAllowed Code = "method_not_allowed"
|
||||
CodeConflict Code = "conflict"
|
||||
CodePayloadTooLarge Code = "payload_too_large"
|
||||
CodeRateLimited Code = "rate_limited"
|
||||
CodeInternal Code = "internal"
|
||||
CodeUnavailable Code = "unavailable"
|
||||
|
||||
// Domain-specific codes.
|
||||
CodeProjectExists Code = "project_exists"
|
||||
CodeInvalidProjectName Code = "invalid_project_name"
|
||||
CodeInvalidPath Code = "invalid_path"
|
||||
CodeDigestMismatch Code = "digest_mismatch"
|
||||
CodeSizeMismatch Code = "size_mismatch"
|
||||
CodeDeploymentNotReady Code = "deployment_not_ready"
|
||||
CodeDeploymentActive Code = "deployment_active"
|
||||
CodeBlobsMissing Code = "blobs_missing"
|
||||
CodeLimitExceeded Code = "limit_exceeded"
|
||||
)
|
||||
|
||||
// Error is the body of every non-2xx management API response:
|
||||
//
|
||||
// {"error":{"code":"deployment_not_ready","message":"...","details":{...}}}
|
||||
type Error struct {
|
||||
Code Code `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Details map[string]any `json:"details,omitempty"`
|
||||
}
|
||||
|
||||
// ErrorEnvelope wraps Error so the JSON has a single top-level "error" key.
|
||||
type ErrorEnvelope struct {
|
||||
Error Error `json:"error"`
|
||||
}
|
||||
|
||||
func (e *Error) Error() string {
|
||||
if e.Message == "" {
|
||||
return string(e.Code)
|
||||
}
|
||||
return fmt.Sprintf("%s: %s", e.Code, e.Message)
|
||||
}
|
||||
|
||||
// Errorf builds an *Error with a formatted message.
|
||||
func Errorf(code Code, format string, args ...any) *Error {
|
||||
return &Error{Code: code, Message: fmt.Sprintf(format, args...)}
|
||||
}
|
||||
|
||||
// WithDetail attaches a detail key and returns the receiver for chaining.
|
||||
func (e *Error) WithDetail(key string, val any) *Error {
|
||||
if e.Details == nil {
|
||||
e.Details = make(map[string]any, 1)
|
||||
}
|
||||
e.Details[key] = val
|
||||
return e
|
||||
}
|
||||
|
||||
// CodeOf reports the Code carried by err, or CodeInternal when err is not an
|
||||
// *Error. It unwraps, so a wrapped *Error is still recognised.
|
||||
func CodeOf(err error) Code {
|
||||
var apiErr *Error
|
||||
if errors.As(err, &apiErr) {
|
||||
return apiErr.Code
|
||||
}
|
||||
return CodeInternal
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package api
|
||||
|
||||
import "net/url"
|
||||
|
||||
// Version is the API path prefix. It is a constant rather than a client option
|
||||
// because the CLI and the server are released together; a mismatch is a bug,
|
||||
// not a configuration.
|
||||
const Version = "/api/v1"
|
||||
|
||||
// Path builders, shared so the client and the server's route table cannot drift
|
||||
// apart. Every segment that comes from user input is escaped: a project named
|
||||
// with a slash could otherwise rewrite the request into a different endpoint.
|
||||
// (Project names are pattern-checked on creation, so this is defence in depth
|
||||
// against a name that predates a stricter pattern.)
|
||||
|
||||
func PathProjects() string { return Version + "/projects" }
|
||||
|
||||
func PathProject(name string) string {
|
||||
return Version + "/projects/" + url.PathEscape(name)
|
||||
}
|
||||
|
||||
func PathProjectKeys(name string) string {
|
||||
return PathProject(name) + "/keys"
|
||||
}
|
||||
|
||||
func PathKeys() string { return Version + "/keys" }
|
||||
|
||||
func PathKey(id string) string {
|
||||
return Version + "/keys/" + url.PathEscape(id)
|
||||
}
|
||||
|
||||
func PathWhoAmI() string { return Version + "/whoami" }
|
||||
|
||||
func PathSystemInfo() string { return Version + "/system/info" }
|
||||
|
||||
func PathGC() string { return Version + "/gc" }
|
||||
|
||||
func PathFsck() string { return Version + "/fsck" }
|
||||
|
||||
func PathDeployments(project string) string {
|
||||
return PathProject(project) + "/deployments"
|
||||
}
|
||||
|
||||
func PathDeployment(project, id string) string {
|
||||
return PathDeployments(project) + "/" + url.PathEscape(id)
|
||||
}
|
||||
|
||||
func PathManifest(project, id string) string {
|
||||
return PathDeployment(project, id) + "/manifest"
|
||||
}
|
||||
|
||||
func PathFinalize(project, id string) string {
|
||||
return PathDeployment(project, id) + "/finalize"
|
||||
}
|
||||
|
||||
func PathActivate(project, id string) string {
|
||||
return PathDeployment(project, id) + "/activate"
|
||||
}
|
||||
|
||||
// PathBlob addresses a blob by lowercase hex digest. Blobs are global rather
|
||||
// than per-project because the content-addressed store deduplicates across
|
||||
// projects; see the security notes on the blob existence oracle.
|
||||
func PathBlob(hexDigest string) string {
|
||||
return Version + "/blobs/" + url.PathEscape(hexDigest)
|
||||
}
|
||||
|
||||
// SiteURL returns where a project is served under path routing.
|
||||
func SiteURL(base, project string) string {
|
||||
return base + "/~" + url.PathEscape(project) + "/"
|
||||
}
|
||||
+258
@@ -0,0 +1,258 @@
|
||||
package api
|
||||
|
||||
import "time"
|
||||
|
||||
// Deployment states, as they appear on the wire.
|
||||
const (
|
||||
StatePending = "pending"
|
||||
StateUploading = "uploading"
|
||||
StateReady = "ready"
|
||||
StateFailed = "failed"
|
||||
StateDeleting = "deleting"
|
||||
)
|
||||
|
||||
// Key scopes, as they appear on the wire.
|
||||
const (
|
||||
ScopeAdmin = "admin"
|
||||
ScopeProject = "project"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------- projects
|
||||
|
||||
// Project is the server's view of a project.
|
||||
//
|
||||
// Every mutable setting also appears in ProjectPatch. Adding a field here that
|
||||
// cannot be changed afterwards is a deliberate choice, not an oversight: Name
|
||||
// is immutable because renaming would invalidate every deployed URL and every
|
||||
// webroot symlink pointing at it.
|
||||
type Project struct {
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name,omitempty"`
|
||||
IndexFile string `json:"index_file"`
|
||||
NotFoundFile string `json:"not_found_file,omitempty"`
|
||||
SPAFallback bool `json:"spa_fallback"`
|
||||
CacheControl string `json:"cache_control"`
|
||||
|
||||
RetentionCount int `json:"retention_count"`
|
||||
RetentionGrace int `json:"retention_grace_s"`
|
||||
MaxFiles int `json:"max_files"`
|
||||
MaxFileBytes int64 `json:"max_file_bytes"`
|
||||
MaxTotalBytes int64 `json:"max_total_bytes"`
|
||||
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
// ActiveDeployment is nil when the project has never been deployed, which
|
||||
// is why it is a pointer rather than a zero-valued struct.
|
||||
ActiveDeployment *Deployment `json:"active_deployment,omitempty"`
|
||||
// URL is where the active deployment is served, when the server knows its
|
||||
// public base URL.
|
||||
URL string `json:"url,omitempty"`
|
||||
}
|
||||
|
||||
// CreateProjectRequest creates a project. Everything except Name is optional
|
||||
// and falls back to the server's defaults.
|
||||
type CreateProjectRequest struct {
|
||||
Name string `json:"name"`
|
||||
Patch *ProjectPatch `json:"config,omitempty"`
|
||||
}
|
||||
|
||||
// ProjectPatch is a partial update.
|
||||
//
|
||||
// Every field is a pointer so the server can tell "leave this alone" from "set
|
||||
// this to the zero value" — without that, PATCH could never clear a custom 404
|
||||
// document or turn the SPA fallback off.
|
||||
type ProjectPatch struct {
|
||||
DisplayName *string `json:"display_name,omitempty"`
|
||||
IndexFile *string `json:"index_file,omitempty"`
|
||||
NotFoundFile *string `json:"not_found_file,omitempty"`
|
||||
SPAFallback *bool `json:"spa_fallback,omitempty"`
|
||||
CacheControl *string `json:"cache_control,omitempty"`
|
||||
RetentionCount *int `json:"retention_count,omitempty"`
|
||||
RetentionGrace *int `json:"retention_grace_s,omitempty"`
|
||||
MaxFiles *int `json:"max_files,omitempty"`
|
||||
MaxFileBytes *int64 `json:"max_file_bytes,omitempty"`
|
||||
MaxTotalBytes *int64 `json:"max_total_bytes,omitempty"`
|
||||
}
|
||||
|
||||
// ProjectList is the paged response for GET /api/v1/projects.
|
||||
type ProjectList struct {
|
||||
Projects []Project `json:"projects"`
|
||||
NextCursor string `json:"next_cursor,omitempty"`
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------- keys
|
||||
|
||||
// Key describes an API key. It never carries the secret: the full token exists
|
||||
// on the wire exactly once, in CreateKeyResponse.
|
||||
type Key struct {
|
||||
ID string `json:"id"`
|
||||
Scope string `json:"scope"`
|
||||
Project string `json:"project,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
LastUsed *time.Time `json:"last_used_at,omitempty"`
|
||||
RevokedAt *time.Time `json:"revoked_at,omitempty"`
|
||||
}
|
||||
|
||||
// Revoked reports whether the key has been revoked.
|
||||
func (k Key) Revoked() bool { return k.RevokedAt != nil }
|
||||
|
||||
// CreateKeyRequest mints a key. Project is set by the URL for the
|
||||
// project-scoped endpoint and must be empty otherwise.
|
||||
type CreateKeyRequest struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
// ExpiresAt is absolute, not a duration: the CLI parses "90d" locally so a
|
||||
// clock skew between client and server cannot silently shift expiry.
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
}
|
||||
|
||||
// CreateKeyResponse is the only place a full token ever appears.
|
||||
type CreateKeyResponse struct {
|
||||
Key Key `json:"key"`
|
||||
// Token is shown once and never retrievable again. Clients must not log it.
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
// KeyList is the response for the key listing endpoints.
|
||||
type KeyList struct {
|
||||
Keys []Key `json:"keys"`
|
||||
}
|
||||
|
||||
// WhoAmI describes the caller's own credential.
|
||||
type WhoAmI struct {
|
||||
KeyID string `json:"key_id"`
|
||||
Scope string `json:"scope"`
|
||||
Project string `json:"project,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- deployments
|
||||
|
||||
// Deployment is the server's view of one upload.
|
||||
type Deployment struct {
|
||||
ID string `json:"id"`
|
||||
Project string `json:"project"`
|
||||
State string `json:"state"`
|
||||
Active bool `json:"active"`
|
||||
FileCount int `json:"file_count"`
|
||||
TotalBytes int64 `json:"total_bytes"`
|
||||
Meta map[string]string `json:"meta,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
FinalizedAt *time.Time `json:"finalized_at,omitempty"`
|
||||
ActivatedAt *time.Time `json:"activated_at,omitempty"`
|
||||
|
||||
// URL is where this deployment is served, set on the response to an
|
||||
// activation when the server knows its public base URL. It is the project's
|
||||
// URL: only the active deployment has one, since there are no per-version
|
||||
// preview addresses.
|
||||
URL string `json:"url,omitempty"`
|
||||
|
||||
// Files is populated only by GET .../deployments/{id}?files=true.
|
||||
Files []FileEntry `json:"files,omitempty"`
|
||||
}
|
||||
|
||||
// FileEntry is one line of a manifest. Digest is lowercase hex; the server
|
||||
// stores the raw 32 bytes, and hex exists only at this boundary.
|
||||
type FileEntry struct {
|
||||
Path string `json:"path"`
|
||||
Digest string `json:"digest"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
// CreateDeploymentRequest starts a deployment.
|
||||
type CreateDeploymentRequest struct {
|
||||
Meta map[string]string `json:"meta,omitempty"`
|
||||
}
|
||||
|
||||
// ManifestRequest declares the complete file list of a deployment.
|
||||
type ManifestRequest struct {
|
||||
Files []FileEntry `json:"files"`
|
||||
}
|
||||
|
||||
// ManifestResponse tells the client which blobs the server does not have yet.
|
||||
//
|
||||
// Missing is the number that makes content-addressed upload worth having, so
|
||||
// the CLI prints it: "142 files, 3.1 MiB; 11 new blobs, 402 KiB to upload".
|
||||
type ManifestResponse struct {
|
||||
Missing []string `json:"missing"`
|
||||
MissingBytes int64 `json:"missing_bytes"`
|
||||
Have int `json:"have"`
|
||||
FileCount int `json:"file_count"`
|
||||
TotalBytes int64 `json:"total_bytes"`
|
||||
}
|
||||
|
||||
// BlobResponse acknowledges an uploaded blob.
|
||||
type BlobResponse struct {
|
||||
Digest string `json:"digest"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
// DeploymentList is the paged response for the deployment listing endpoint.
|
||||
type DeploymentList struct {
|
||||
Deployments []Deployment `json:"deployments"`
|
||||
NextCursor string `json:"next_cursor,omitempty"`
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ system
|
||||
|
||||
// SystemInfo is the response for GET /api/v1/system/info.
|
||||
type SystemInfo struct {
|
||||
Version string `json:"version"`
|
||||
UptimeS int64 `json:"uptime_s"`
|
||||
Projects int64 `json:"projects"`
|
||||
Deployments int64 `json:"deployments"`
|
||||
Blobs int64 `json:"blobs"`
|
||||
CASBytes int64 `json:"cas_bytes"`
|
||||
LinkMode string `json:"link_mode"`
|
||||
SchemaVer int `json:"schema_version"`
|
||||
}
|
||||
|
||||
// GCRequest asks for a garbage collection pass.
|
||||
type GCRequest struct {
|
||||
DryRun bool `json:"dry_run,omitempty"`
|
||||
}
|
||||
|
||||
// GCStats reports what a collection pass did, or would have done.
|
||||
//
|
||||
// On a dry run the blob numbers count what is collectable right now, not what
|
||||
// deleting the listed deployments would additionally free: nothing was deleted,
|
||||
// so those blobs are still referenced. The figures are a floor.
|
||||
type GCStats struct {
|
||||
DryRun bool `json:"dry_run"`
|
||||
DeploymentsDeleted int `json:"deployments_deleted"`
|
||||
BlobsDeleted int `json:"blobs_deleted"`
|
||||
BytesFreed int64 `json:"bytes_freed"`
|
||||
}
|
||||
|
||||
// FsckRequest asks for a consistency check, optionally correcting what it
|
||||
// finds.
|
||||
type FsckRequest struct {
|
||||
Repair bool `json:"repair,omitempty"`
|
||||
}
|
||||
|
||||
// FsckReport is the result of a consistency check.
|
||||
type FsckReport struct {
|
||||
// Blobs is how many were examined, DriftCount how many disagreed with the
|
||||
// manifests that reference them. Drift lists the first hundred of them,
|
||||
// because the list is for a person to read.
|
||||
Blobs int64 `json:"blobs"`
|
||||
DriftCount int `json:"drift_count"`
|
||||
Drift []BlobDrift `json:"drift,omitempty"`
|
||||
Repaired int `json:"repaired"`
|
||||
}
|
||||
|
||||
// BlobDrift is one blob whose stored reference count is not the number of
|
||||
// manifest entries that name it.
|
||||
type BlobDrift struct {
|
||||
Digest string `json:"digest"`
|
||||
// Stored above Actual only wastes disk. Stored below Actual is the
|
||||
// dangerous direction: the collector may remove content a deployment still
|
||||
// needs.
|
||||
Stored int64 `json:"stored"`
|
||||
Actual int64 `json:"actual"`
|
||||
}
|
||||
Reference in New Issue
Block a user