feat(docker-watcher): phases 3+4 - Docker client & NPM client

Phase 3: Docker Engine API wrapper — pull/inspect images, container
lifecycle (create/start/stop/remove/restart), network management,
label-based container tracking, deterministic naming.

Phase 4: Nginx Proxy Manager API client — JWT auth with auto-refresh,
CRUD for proxy hosts, domain-based host lookup.
This commit is contained in:
2026-03-27 21:08:57 +03:00
parent cdf21682d6
commit 389ed5aff8
10 changed files with 963 additions and 34 deletions
+50
View File
@@ -0,0 +1,50 @@
package docker
import (
"context"
"fmt"
"github.com/docker/docker/client"
)
// Labels applied to all containers managed by docker-watcher.
const (
LabelProject = "docker-watcher.project"
LabelStage = "docker-watcher.stage"
LabelInstanceID = "docker-watcher.instance-id"
)
// Client wraps the Docker Engine API client.
type Client struct {
api client.APIClient
}
// New creates a new Docker client connected to the default Docker socket.
func New() (*Client, error) {
api, err := client.NewClientWithOpts(
client.FromEnv,
client.WithAPIVersionNegotiation(),
)
if err != nil {
return nil, fmt.Errorf("create docker client: %w", err)
}
return &Client{api: api}, nil
}
// Close releases resources held by the Docker client.
func (c *Client) Close() error {
if err := c.api.Close(); err != nil {
return fmt.Errorf("close docker client: %w", err)
}
return nil
}
// Ping checks connectivity to the Docker daemon.
func (c *Client) Ping(ctx context.Context) error {
_, err := c.api.Ping(ctx)
if err != nil {
return fmt.Errorf("ping docker daemon: %w", err)
}
return nil
}
+276
View File
@@ -0,0 +1,276 @@
package docker
import (
"context"
"fmt"
"regexp"
"strconv"
"strings"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/filters"
"github.com/docker/docker/api/types/network"
"github.com/docker/go-connections/nat"
)
// ContainerConfig holds all parameters needed to create a managed container.
type ContainerConfig struct {
// Name is the container name (deterministic: dw-{project}-{stage}-{tag}).
Name string
// Image is the full image reference including tag (e.g. "myapp:v1.2.3").
Image string
// Env is a list of environment variables in "KEY=VALUE" format.
Env []string
// ExposedPorts lists the container ports to publish (e.g. ["8080/tcp"]).
// Each port is mapped to a random host port via Docker auto-assignment.
ExposedPorts []string
// NetworkName is the Docker network to attach the container to at creation.
NetworkName string
// NetworkID is the ID of the Docker network (used for endpoint config).
NetworkID string
// Labels are additional labels to apply to the container.
// docker-watcher management labels are added automatically via Project, Stage, and InstanceID.
Labels map[string]string
// Project is the docker-watcher project name (used for labelling).
Project string
// Stage is the docker-watcher stage name (used for labelling).
Stage string
// InstanceID is the docker-watcher instance ID (used for labelling).
InstanceID string
}
// sanitizeTag replaces characters that are invalid in Docker container names
// with hyphens and lowercases the result.
var invalidNameChars = regexp.MustCompile(`[^a-zA-Z0-9_.-]`)
// ContainerName builds a deterministic container name from project, stage, and tag.
func ContainerName(project, stage, tag string) string {
sanitizeComponent := func(s string) string {
s = invalidNameChars.ReplaceAllString(s, "-")
return strings.Trim(s, "-")
}
return fmt.Sprintf("dw-%s-%s-%s", sanitizeComponent(project), sanitizeComponent(stage), sanitizeComponent(tag))
}
// CreateContainer creates a new container with the given configuration.
// It returns the container ID on success.
func (c *Client) CreateContainer(ctx context.Context, cfg ContainerConfig) (string, error) {
// Build port bindings: each exposed port maps to a random host port.
exposedPorts := nat.PortSet{}
portBindings := nat.PortMap{}
for _, p := range cfg.ExposedPorts {
port := nat.Port(p)
exposedPorts[port] = struct{}{}
portBindings[port] = []nat.PortBinding{
{HostIP: "0.0.0.0", HostPort: ""}, // empty HostPort = auto-assign
}
}
// Merge docker-watcher labels with any additional labels.
labels := make(map[string]string)
for k, v := range cfg.Labels {
labels[k] = v
}
labels[LabelProject] = cfg.Project
labels[LabelStage] = cfg.Stage
labels[LabelInstanceID] = cfg.InstanceID
containerCfg := &container.Config{
Image: cfg.Image,
Env: cfg.Env,
ExposedPorts: exposedPorts,
Labels: labels,
}
hostCfg := &container.HostConfig{
PortBindings: portBindings,
RestartPolicy: container.RestartPolicy{Name: container.RestartPolicyDisabled},
}
// Attach to network at creation time if specified.
var networkCfg *network.NetworkingConfig
if cfg.NetworkName != "" {
networkCfg = &network.NetworkingConfig{
EndpointsConfig: map[string]*network.EndpointSettings{
cfg.NetworkName: {
NetworkID: cfg.NetworkID,
},
},
}
}
resp, err := c.api.ContainerCreate(ctx, containerCfg, hostCfg, networkCfg, nil, cfg.Name)
if err != nil {
return "", fmt.Errorf("create container %s: %w", cfg.Name, err)
}
return resp.ID, nil
}
// StartContainer starts a stopped container.
func (c *Client) StartContainer(ctx context.Context, containerID string) error {
if err := c.api.ContainerStart(ctx, containerID, container.StartOptions{}); err != nil {
return fmt.Errorf("start container %s: %w", containerID, err)
}
return nil
}
// StopContainer gracefully stops a running container with the given timeout in seconds.
// A nil timeout uses the Docker default (10 seconds).
func (c *Client) StopContainer(ctx context.Context, containerID string, timeoutSeconds int) error {
opts := container.StopOptions{}
if timeoutSeconds > 0 {
opts.Timeout = &timeoutSeconds
}
if err := c.api.ContainerStop(ctx, containerID, opts); err != nil {
return fmt.Errorf("stop container %s: %w", containerID, err)
}
return nil
}
// RemoveContainer removes a container. If force is true, a running container
// will be killed before removal.
func (c *Client) RemoveContainer(ctx context.Context, containerID string, force bool) error {
opts := container.RemoveOptions{
Force: force,
RemoveVolumes: true,
}
if err := c.api.ContainerRemove(ctx, containerID, opts); err != nil {
return fmt.Errorf("remove container %s: %w", containerID, err)
}
return nil
}
// RestartContainer restarts a container with the given timeout in seconds.
func (c *Client) RestartContainer(ctx context.Context, containerID string, timeoutSeconds int) error {
opts := container.StopOptions{}
if timeoutSeconds > 0 {
opts.Timeout = &timeoutSeconds
}
if err := c.api.ContainerRestart(ctx, containerID, opts); err != nil {
return fmt.Errorf("restart container %s: %w", containerID, err)
}
return nil
}
// ManagedContainer holds summary information about a container managed by docker-watcher.
type ManagedContainer struct {
ID string
Name string
Image string
Status string
State string
Project string
Stage string
InstanceID string
Ports []uint16
}
// ListContainers returns all containers matching the given label filters.
// Pass nil or an empty map to list all docker-watcher managed containers.
// Label filters are key=value pairs applied as Docker label filters.
func (c *Client) ListContainers(ctx context.Context, labelFilters map[string]string) ([]ManagedContainer, error) {
filterArgs := filters.NewArgs()
// Always filter by the docker-watcher project label to only return managed containers.
filterArgs.Add("label", LabelProject)
for k, v := range labelFilters {
if v != "" {
filterArgs.Add("label", k+"="+v)
} else {
filterArgs.Add("label", k)
}
}
containers, err := c.api.ContainerList(ctx, container.ListOptions{
All: true,
Filters: filterArgs,
})
if err != nil {
return nil, fmt.Errorf("list containers: %w", err)
}
result := make([]ManagedContainer, 0, len(containers))
for _, ctr := range containers {
name := ""
if len(ctr.Names) > 0 {
// Docker prefixes names with "/".
name = strings.TrimPrefix(ctr.Names[0], "/")
}
var ports []uint16
for _, p := range ctr.Ports {
if p.PublicPort > 0 {
ports = append(ports, p.PublicPort)
}
}
result = append(result, ManagedContainer{
ID: ctr.ID,
Name: name,
Image: ctr.Image,
Status: ctr.Status,
State: ctr.State,
Project: ctr.Labels[LabelProject],
Stage: ctr.Labels[LabelStage],
InstanceID: ctr.Labels[LabelInstanceID],
Ports: ports,
})
}
return result, nil
}
// InspectContainerPort returns the host port mapped to a given container port.
// This is useful after starting a container with auto-assigned ports.
func (c *Client) InspectContainerPort(ctx context.Context, containerID string, containerPort string) (uint16, error) {
inspect, err := c.api.ContainerInspect(ctx, containerID)
if err != nil {
return 0, fmt.Errorf("inspect container %s: %w", containerID, err)
}
port := nat.Port(containerPort)
bindings, ok := inspect.NetworkSettings.Ports[port]
if !ok || len(bindings) == 0 {
return 0, fmt.Errorf("container %s: no binding for port %s", containerID, containerPort)
}
var hostPort uint16
for _, b := range bindings {
if b.HostPort != "" {
parsed := parsePort(b.HostPort)
if parsed > 0 {
hostPort = parsed
break
}
}
}
if hostPort == 0 {
return 0, fmt.Errorf("container %s: no host port for %s", containerID, containerPort)
}
return hostPort, nil
}
// parsePort converts a port string to uint16. Returns 0 on failure.
func parsePort(s string) uint16 {
n, err := strconv.ParseUint(s, 10, 16)
if err != nil {
return 0
}
return uint16(n)
}
+103
View File
@@ -0,0 +1,103 @@
package docker
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"strings"
"github.com/docker/docker/api/types/image"
"github.com/docker/docker/api/types/registry"
)
// ImageInfo holds metadata extracted from a Docker image inspection.
type ImageInfo struct {
// ExposedPorts lists the ports declared via EXPOSE in the Dockerfile (e.g. ["8080/tcp"]).
ExposedPorts []string
// Healthcheck is the CMD string from the image's HEALTHCHECK instruction, if any.
Healthcheck string
// Labels are the key-value pairs defined in the image metadata.
Labels map[string]string
}
// PullImage pulls an image from a registry. If authConfig is non-empty, it is
// used as the base64-encoded JSON auth payload for private registries.
// The image reference should be in the form "repository:tag".
func (c *Client) PullImage(ctx context.Context, imageRef string, tag string, authConfig string) error {
ref := imageRef
if tag != "" {
ref = imageRef + ":" + tag
}
opts := image.PullOptions{}
if authConfig != "" {
opts.RegistryAuth = authConfig
}
reader, err := c.api.ImagePull(ctx, ref, opts)
if err != nil {
return fmt.Errorf("pull image %s: %w", ref, err)
}
defer reader.Close()
// Drain the pull progress stream to completion.
if _, err := io.Copy(io.Discard, reader); err != nil {
return fmt.Errorf("read pull response for %s: %w", ref, err)
}
return nil
}
// InspectImage retrieves metadata from a local image.
func (c *Client) InspectImage(ctx context.Context, imageRef string) (ImageInfo, error) {
inspect, _, err := c.api.ImageInspectWithRaw(ctx, imageRef)
if err != nil {
return ImageInfo{}, fmt.Errorf("inspect image %s: %w", imageRef, err)
}
info := ImageInfo{
Labels: inspect.Config.Labels,
}
// Extract exposed ports.
for port := range inspect.Config.ExposedPorts {
info.ExposedPorts = append(info.ExposedPorts, string(port))
}
// Extract healthcheck command.
if inspect.Config.Healthcheck != nil && len(inspect.Config.Healthcheck.Test) > 0 {
// The Test slice is ["CMD", "arg1", "arg2", ...] or ["CMD-SHELL", "cmd string"].
// Join all parts after the first element for a readable representation.
if len(inspect.Config.Healthcheck.Test) > 1 {
info.Healthcheck = joinArgs(inspect.Config.Healthcheck.Test[1:])
}
}
return info, nil
}
// EncodeRegistryAuth builds a base64-encoded JSON auth string suitable for
// Docker API calls. Pass empty strings for anonymous access.
func EncodeRegistryAuth(username, password, serverAddress string) (string, error) {
cfg := registry.AuthConfig{
Username: username,
Password: password,
ServerAddress: serverAddress,
}
data, err := json.Marshal(cfg)
if err != nil {
return "", fmt.Errorf("encode registry auth: %w", err)
}
return base64.URLEncoding.EncodeToString(data), nil
}
// joinArgs joins string arguments with spaces.
func joinArgs(args []string) string {
return strings.Join(args, " ")
}
+53
View File
@@ -0,0 +1,53 @@
package docker
import (
"context"
"fmt"
"github.com/docker/docker/api/types/filters"
"github.com/docker/docker/api/types/network"
)
// EnsureNetwork creates a Docker network with the given name if it does not
// already exist. It returns the network ID in all cases.
func (c *Client) EnsureNetwork(ctx context.Context, networkName string) (string, error) {
// Check if the network already exists.
filterArgs := filters.NewArgs()
filterArgs.Add("name", networkName)
networks, err := c.api.NetworkList(ctx, network.ListOptions{
Filters: filterArgs,
})
if err != nil {
return "", fmt.Errorf("list networks for %s: %w", networkName, err)
}
// NetworkList with a name filter may return partial matches, so check exact name.
for _, n := range networks {
if n.Name == networkName {
return n.ID, nil
}
}
// Create the network.
resp, err := c.api.NetworkCreate(ctx, networkName, network.CreateOptions{
Driver: "bridge",
Labels: map[string]string{
LabelProject: "docker-watcher",
},
})
if err != nil {
return "", fmt.Errorf("create network %s: %w", networkName, err)
}
return resp.ID, nil
}
// ConnectNetwork attaches a container to an existing network.
func (c *Client) ConnectNetwork(ctx context.Context, networkID string, containerID string) error {
err := c.api.NetworkConnect(ctx, networkID, containerID, &network.EndpointSettings{})
if err != nil {
return fmt.Errorf("connect container %s to network %s: %w", containerID, networkID, err)
}
return nil
}
+293
View File
@@ -0,0 +1,293 @@
package npm
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"sync"
"time"
)
// Client is an HTTP client for the Nginx Proxy Manager API.
// It handles JWT authentication, automatic token refresh, and CRUD for proxy hosts.
type Client struct {
baseURL string
httpClient *http.Client
mu sync.Mutex
token string
expiry time.Time
email string
password string
}
// New creates an NPM client targeting the given base URL (e.g. "http://npm:81/api").
// The returned client is not yet authenticated — call Authenticate before other methods.
func New(baseURL string) *Client {
return &Client{
baseURL: strings.TrimRight(baseURL, "/"),
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
}
}
// Authenticate obtains a JWT from the NPM API and caches it for future requests.
// The credentials are also stored so the client can re-authenticate automatically on 401.
func (c *Client) Authenticate(ctx context.Context, email, password string) error {
c.mu.Lock()
c.email = email
c.password = password
c.mu.Unlock()
return c.authenticate(ctx, email, password)
}
func (c *Client) authenticate(ctx context.Context, email, password string) error {
body, err := json.Marshal(authRequest{
Identity: email,
Secret: password,
})
if err != nil {
return fmt.Errorf("marshal auth request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/tokens", bytes.NewReader(body))
if err != nil {
return fmt.Errorf("create auth request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("send auth request: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("read auth response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("authenticate: status %d: %s", resp.StatusCode, string(respBody))
}
var authResp authResponse
if err := json.Unmarshal(respBody, &authResp); err != nil {
return fmt.Errorf("decode auth response: %w", err)
}
expiry, err := time.Parse(time.RFC3339, authResp.Expires)
if err != nil {
// If we cannot parse the expiry, set a conservative 12-hour window.
expiry = time.Now().Add(12 * time.Hour)
}
c.mu.Lock()
c.token = authResp.Token
c.expiry = expiry
c.mu.Unlock()
return nil
}
// CreateProxyHost creates a new proxy host and returns the created resource.
func (c *Client) CreateProxyHost(ctx context.Context, config ProxyHostConfig) (ProxyHost, error) {
var host ProxyHost
if err := c.doJSON(ctx, http.MethodPost, "/nginx/proxy-hosts", config, &host); err != nil {
return ProxyHost{}, fmt.Errorf("create proxy host: %w", err)
}
return host, nil
}
// UpdateProxyHost updates an existing proxy host by ID and returns the updated resource.
func (c *Client) UpdateProxyHost(ctx context.Context, id int, config ProxyHostConfig) (ProxyHost, error) {
var host ProxyHost
path := fmt.Sprintf("/nginx/proxy-hosts/%d", id)
if err := c.doJSON(ctx, http.MethodPut, path, config, &host); err != nil {
return ProxyHost{}, fmt.Errorf("update proxy host %d: %w", id, err)
}
return host, nil
}
// DeleteProxyHost deletes a proxy host by ID.
func (c *Client) DeleteProxyHost(ctx context.Context, id int) error {
path := fmt.Sprintf("/nginx/proxy-hosts/%d", id)
if err := c.doJSON(ctx, http.MethodDelete, path, nil, nil); err != nil {
return fmt.Errorf("delete proxy host %d: %w", id, err)
}
return nil
}
// ListProxyHosts returns all proxy hosts.
func (c *Client) ListProxyHosts(ctx context.Context) ([]ProxyHost, error) {
var hosts []ProxyHost
if err := c.doJSON(ctx, http.MethodGet, "/nginx/proxy-hosts", nil, &hosts); err != nil {
return nil, fmt.Errorf("list proxy hosts: %w", err)
}
return hosts, nil
}
// FindProxyHostByDomain searches existing proxy hosts for one that serves the given domain.
// Returns the matching host and true if found, or a zero-value ProxyHost and false otherwise.
func (c *Client) FindProxyHostByDomain(ctx context.Context, domain string) (ProxyHost, bool, error) {
hosts, err := c.ListProxyHosts(ctx)
if err != nil {
return ProxyHost{}, false, fmt.Errorf("find proxy host by domain: %w", err)
}
needle := strings.ToLower(domain)
for _, h := range hosts {
for _, d := range h.DomainNames {
if strings.ToLower(d) == needle {
return h, true, nil
}
}
}
return ProxyHost{}, false, nil
}
// doJSON performs an authenticated JSON API request. If the token is expired or a 401
// is received, it automatically re-authenticates and retries the request once.
func (c *Client) doJSON(ctx context.Context, method, path string, reqBody any, result any) error {
if err := c.ensureToken(ctx); err != nil {
return err
}
err := c.doJSONOnce(ctx, method, path, reqBody, result)
if err == nil {
return nil
}
// If we got a 401, attempt re-auth and retry once.
if isUnauthorized(err) {
c.mu.Lock()
email := c.email
password := c.password
c.mu.Unlock()
if authErr := c.authenticate(ctx, email, password); authErr != nil {
return fmt.Errorf("re-authenticate after 401: %w", authErr)
}
return c.doJSONOnce(ctx, method, path, reqBody, result)
}
return err
}
// errUnauthorized is a sentinel used to detect 401 responses for automatic re-auth.
type errUnauthorized struct {
wrapped error
}
func (e *errUnauthorized) Error() string { return e.wrapped.Error() }
func (e *errUnauthorized) Unwrap() error { return e.wrapped }
func isUnauthorized(err error) bool {
var target *errUnauthorized
return errors.As(err, &target)
}
func (c *Client) doJSONOnce(ctx context.Context, method, path string, reqBody any, result any) error {
var bodyReader io.Reader
if reqBody != nil {
data, err := json.Marshal(reqBody)
if err != nil {
return fmt.Errorf("marshal request body: %w", err)
}
bodyReader = bytes.NewReader(data)
}
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, bodyReader)
if err != nil {
return fmt.Errorf("create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
c.mu.Lock()
token := c.token
c.mu.Unlock()
req.Header.Set("Authorization", "Bearer "+token)
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("send request %s %s: %w", method, path, err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("read response body: %w", err)
}
if resp.StatusCode == http.StatusUnauthorized {
return &errUnauthorized{
wrapped: fmt.Errorf("status 401: %s", string(respBody)),
}
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("npm api %s %s: status %d: %s", method, path, resp.StatusCode, string(respBody))
}
// DELETE returns 200 with no body.
if result != nil && len(respBody) > 0 {
if err := json.Unmarshal(respBody, result); err != nil {
return fmt.Errorf("decode response: %w", err)
}
}
return nil
}
// ensureToken checks if the cached token is still valid and re-authenticates if needed.
func (c *Client) ensureToken(ctx context.Context) error {
c.mu.Lock()
token := c.token
expiry := c.expiry
email := c.email
password := c.password
c.mu.Unlock()
if token == "" {
return fmt.Errorf("npm client not authenticated: call Authenticate first")
}
// Refresh the token 5 minutes before expiry to avoid race conditions.
if time.Now().Add(5 * time.Minute).After(expiry) {
if err := c.authenticate(ctx, email, password); err != nil {
return fmt.Errorf("refresh expired token: %w", err)
}
}
return nil
}
// UnmarshalJSON allows boolInt to decode both JSON booleans and 0/1 integers.
func (b *boolInt) UnmarshalJSON(data []byte) error {
s := strings.TrimSpace(string(data))
switch s {
case "true", "1":
*b = true
case "false", "0", "null":
*b = false
default:
return fmt.Errorf("cannot unmarshal %q as boolInt", s)
}
return nil
}
// MarshalJSON encodes boolInt as a JSON boolean.
func (b boolInt) MarshalJSON() ([]byte, error) {
if b {
return []byte("true"), nil
}
return []byte("false"), nil
}
+76
View File
@@ -0,0 +1,76 @@
package npm
// ProxyHostConfig holds the input fields for creating or updating a proxy host.
type ProxyHostConfig struct {
DomainNames []string `json:"domain_names"`
ForwardScheme string `json:"forward_scheme"`
ForwardHost string `json:"forward_host"`
ForwardPort int `json:"forward_port"`
CertificateID int `json:"certificate_id"`
SSLForced bool `json:"ssl_forced"`
BlockExploits bool `json:"block_exploits"`
CachingEnabled bool `json:"caching_enabled"`
AllowWebsocket bool `json:"allow_websocket_upgrade"`
HTTP2Support bool `json:"http2_support"`
AdvancedConfig string `json:"advanced_config"`
HSTSEnabled bool `json:"hsts_enabled"`
HSTSSubdomains bool `json:"hsts_subdomains"`
AccessListID int `json:"access_list_id"`
Meta Meta `json:"meta"`
Locations []any `json:"locations"`
}
// Meta holds metadata tags for a proxy host.
type Meta struct {
LetsEncryptAgree bool `json:"letsencrypt_agree"`
DNSChallenge bool `json:"dns_challenge"`
LetsEncryptEmail string `json:"letsencrypt_email,omitempty"`
}
// ProxyHost represents a proxy host as returned by the NPM API.
type ProxyHost struct {
ID int `json:"id"`
DomainNames []string `json:"domain_names"`
ForwardScheme string `json:"forward_scheme"`
ForwardHost string `json:"forward_host"`
ForwardPort int `json:"forward_port"`
CertificateID any `json:"certificate_id"`
SSLForced boolInt `json:"ssl_forced"`
BlockExploits boolInt `json:"block_exploits"`
CachingEnabled boolInt `json:"caching_enabled"`
AllowWebsocket boolInt `json:"allow_websocket_upgrade"`
HTTP2Support boolInt `json:"http2_support"`
AdvancedConfig string `json:"advanced_config"`
HSTSEnabled boolInt `json:"hsts_enabled"`
HSTSSubdomains boolInt `json:"hsts_subdomains"`
AccessListID int `json:"access_list_id"`
Meta Meta `json:"meta"`
Enabled boolInt `json:"enabled"`
CreatedOn string `json:"created_on"`
ModifiedOn string `json:"modified_on"`
}
// boolInt handles the NPM API's inconsistent use of 0/1 integers for boolean fields.
type boolInt bool
// authRequest is the request body for POST /api/tokens.
type authRequest struct {
Identity string `json:"identity"`
Secret string `json:"secret"`
}
// authResponse is the response body from POST /api/tokens.
type authResponse struct {
Token string `json:"token"`
Expires string `json:"expires"`
}
// apiError represents an error response from the NPM API.
type apiError struct {
Error apiErrorDetail `json:"error"`
}
type apiErrorDetail struct {
Message string `json:"message"`
Code int `json:"code"`
}