f4ca0059dc
The web UI signs in with a username and password and holds a session cookie; the TUI was the only client still needing an API key pasted into a config file. It now asks for the same credentials on a form at start. What is kept between runs is the session token, not the password, in session.json under the config directory, mode 0600 and keyed by server URL so one server's token is never offered to another. It resumes on the next start; the server's sessions last 30 days and slide with use. L signs out, which ends the session on the server and deletes the saved one even if the server cannot be reached. The client attaches the cookie by hand instead of using a cookie jar: the server marks it Secure behind https, and a jar drops a Secure cookie it is given over plain http, which would break a local server for no reason. It sends no Authorization header at all, since the server judges a request carrying one on that alone and never falls back to the cookie. Writes go through the server's cross-origin guard, which lets a client that sends neither Origin nor Sec-Fetch-Site through; checked against a real v0.20.1 server for both reads and writes. A 401 from anything means the session is gone (expired, ended from the web UI, or the account disabled), so the TUI returns to the form with the reason, forgets the saved token, and drops what the last session loaded rather than showing it to whoever signs in next. A 403 is a permission and leaves the session alone. The refresh timer is started once, so signing out and in does not leave two running. An account with no password cannot sign in, and the server answers it exactly like a wrong password, so the form's message says a password must be set first. Users created only for API access hit this. Breaking: api_key in config.yaml is no longer used. It is not an error to leave it there; the form says it is ignored. API keys still exist on the server and k in Users still manages them.
608 lines
18 KiB
Go
608 lines
18 KiB
Go
package api
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// SessionCookie is the cookie terdut-server's web UI signs in with.
|
|
const SessionCookie = "terdut_session"
|
|
|
|
// Client talks to terdut-server as the user who signed in. Login trades a
|
|
// username and password for a session, the same one the web UI holds, and every
|
|
// request after it carries that session's cookie.
|
|
//
|
|
// The cookie is attached by hand rather than through a cookie jar: the server
|
|
// marks it Secure behind https, and a jar drops a Secure cookie it is handed
|
|
// over plain http, which would make a local server unusable for no reason. There
|
|
// is nothing else a jar would do here — the token is opaque and does not change
|
|
// while the session lives.
|
|
type Client struct {
|
|
baseURL string
|
|
httpClient *http.Client
|
|
session string
|
|
}
|
|
|
|
func NewClient(baseURL string) *Client {
|
|
return &Client{
|
|
baseURL: strings.TrimRight(baseURL, "/"),
|
|
httpClient: &http.Client{
|
|
Timeout: 10 * time.Second,
|
|
},
|
|
}
|
|
}
|
|
|
|
// SetSession resumes a session from a token saved earlier.
|
|
func (c *Client) SetSession(token string) { c.session = token }
|
|
|
|
// HasSession reports whether there is a session to try. It says nothing about
|
|
// whether the server still honours it.
|
|
func (c *Client) HasSession() bool { return c.session != "" }
|
|
|
|
// Login signs in and returns the session token, which the client also keeps and
|
|
// sends from then on. The server answers a wrong password, an unknown user and
|
|
// an account with no password all with the same 401, so the caller cannot tell
|
|
// them apart. Too many failures come back as 429.
|
|
func (c *Client) Login(username, password string) (string, error) {
|
|
body := struct {
|
|
Username string `json:"username"`
|
|
Password string `json:"password"`
|
|
}{Username: username, Password: password}
|
|
req, err := c.newRequestWithBody(http.MethodPost, "/api/login", body)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
// A stale session must not ride along on the request that replaces it.
|
|
req.Header.Del("Cookie")
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode >= 400 {
|
|
return "", statusError(resp)
|
|
}
|
|
for _, ck := range resp.Cookies() {
|
|
if ck.Name == SessionCookie && ck.Value != "" {
|
|
c.session = ck.Value
|
|
return ck.Value, nil
|
|
}
|
|
}
|
|
return "", fmt.Errorf("server signed us in but sent no %s cookie", SessionCookie)
|
|
}
|
|
|
|
// Logout ends the session on the server and forgets it here.
|
|
func (c *Client) Logout() error {
|
|
req, err := c.newRequest(http.MethodPost, "/api/logout")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
err = c.do(req, nil)
|
|
c.session = ""
|
|
return err
|
|
}
|
|
|
|
// authorize puts the session on a request.
|
|
func (c *Client) authorize(req *http.Request) {
|
|
req.Header.Set("Accept", "application/json")
|
|
if c.session != "" {
|
|
req.AddCookie(&http.Cookie{Name: SessionCookie, Value: c.session})
|
|
}
|
|
}
|
|
|
|
func (c *Client) newRequest(method, path string) (*http.Request, error) {
|
|
req, err := http.NewRequest(method, c.baseURL+path, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
c.authorize(req)
|
|
return req, nil
|
|
}
|
|
|
|
// StatusError is a response the server answered with a 4xx or 5xx. Message is
|
|
// the server's own {"error": ...} text, empty when the body carried none.
|
|
type StatusError struct {
|
|
Code int
|
|
Message string
|
|
}
|
|
|
|
func (e *StatusError) Error() string {
|
|
if e.Message != "" {
|
|
return fmt.Sprintf("server returned %d: %s", e.Code, e.Message)
|
|
}
|
|
return fmt.Sprintf("server returned %d", e.Code)
|
|
}
|
|
|
|
// IsUnauthorized reports whether err is the server refusing the session: it
|
|
// expired, was ended elsewhere, or belongs to an account since disabled.
|
|
func IsUnauthorized(err error) bool {
|
|
var se *StatusError
|
|
return errors.As(err, &se) && se.Code == http.StatusUnauthorized
|
|
}
|
|
|
|
func statusError(resp *http.Response) error {
|
|
var e struct {
|
|
Error string `json:"error"`
|
|
}
|
|
_ = json.NewDecoder(resp.Body).Decode(&e)
|
|
return &StatusError{Code: resp.StatusCode, Message: e.Error}
|
|
}
|
|
|
|
func (c *Client) do(req *http.Request, out any) error {
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode >= 400 {
|
|
return statusError(resp)
|
|
}
|
|
|
|
if out != nil {
|
|
return json.NewDecoder(resp.Body).Decode(out)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ListAlerts fetches alerts. teamID limits them to one team; 0 means every team
|
|
// the caller belongs to. status may be "firing", "resolved", or "" for all.
|
|
// Set archived=true to fetch only archived alerts; false returns only non-archived.
|
|
//
|
|
// Alerts are read-only on the server — there is nothing to acknowledge or
|
|
// archive here. This is the raw feed, useful for checking what Alertmanager is
|
|
// actually sending; the work queue is ListIncidents.
|
|
func (c *Client) ListAlerts(teamID int64, status string, archived bool, limit int) ([]Alert, error) {
|
|
q := url.Values{}
|
|
if teamID > 0 {
|
|
q.Set("team_id", strconv.FormatInt(teamID, 10))
|
|
}
|
|
if status != "" {
|
|
q.Set("status", status)
|
|
}
|
|
if archived {
|
|
q.Set("archived", "true")
|
|
}
|
|
if limit > 0 {
|
|
q.Set("limit", strconv.Itoa(limit))
|
|
}
|
|
path := "/api/alerts"
|
|
if len(q) > 0 {
|
|
path += "?" + q.Encode()
|
|
}
|
|
|
|
req, err := c.newRequest(http.MethodGet, path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var alerts []Alert
|
|
return alerts, c.do(req, &alerts)
|
|
}
|
|
|
|
// GetAlertStats fetches aggregate alert counts.
|
|
func (c *Client) GetAlertStats() (*AlertStats, error) {
|
|
req, err := c.newRequest(http.MethodGet, "/api/stats/alerts")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var stats AlertStats
|
|
return &stats, c.do(req, &stats)
|
|
}
|
|
|
|
func (c *Client) newRequestWithBody(method, path string, body any) (*http.Request, error) {
|
|
data, err := json.Marshal(body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req, err := http.NewRequest(method, c.baseURL+path, bytes.NewReader(data))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
c.authorize(req)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
return req, nil
|
|
}
|
|
|
|
func (c *Client) GetAlert(id int64) (*Alert, error) {
|
|
req, err := c.newRequest(http.MethodGet, fmt.Sprintf("/api/alerts/%d", id))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var alert Alert
|
|
return &alert, c.do(req, &alert)
|
|
}
|
|
|
|
// ── Incidents ──────────────────────────────────────────────────────────────
|
|
|
|
// ListIncidents fetches the work queue. teamID limits it to one team; 0 means
|
|
// every team the caller belongs to. status may be "triggered",
|
|
// "acknowledged", "resolved", or "" for the server default of open incidents
|
|
// only. archived and snoozed each switch the list to that set rather than
|
|
// adding to it, matching the server's filters.
|
|
func (c *Client) ListIncidents(teamID int64, status string, archived, snoozed bool, limit int) ([]Incident, error) {
|
|
q := url.Values{}
|
|
if teamID > 0 {
|
|
q.Set("team_id", strconv.FormatInt(teamID, 10))
|
|
}
|
|
if status != "" {
|
|
q.Set("status", status)
|
|
}
|
|
if archived {
|
|
q.Set("archived", "true")
|
|
}
|
|
if snoozed {
|
|
q.Set("snoozed", "true")
|
|
}
|
|
if limit > 0 {
|
|
q.Set("limit", strconv.Itoa(limit))
|
|
}
|
|
path := "/api/incidents"
|
|
if len(q) > 0 {
|
|
path += "?" + q.Encode()
|
|
}
|
|
|
|
req, err := c.newRequest(http.MethodGet, path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var incidents []Incident
|
|
return incidents, c.do(req, &incidents)
|
|
}
|
|
|
|
// GetIncident returns one incident with its member alerts inline.
|
|
func (c *Client) GetIncident(id int64) (*Incident, error) {
|
|
req, err := c.newRequest(http.MethodGet, fmt.Sprintf("/api/incidents/%d", id))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var incident Incident
|
|
return &incident, c.do(req, &incident)
|
|
}
|
|
|
|
func (c *Client) GetIncidentTimeline(id int64) ([]IncidentEvent, error) {
|
|
req, err := c.newRequest(http.MethodGet, fmt.Sprintf("/api/incidents/%d/timeline", id))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var events []IncidentEvent
|
|
return events, c.do(req, &events)
|
|
}
|
|
|
|
func (c *Client) AcknowledgeIncident(id int64) (*Incident, error) {
|
|
req, err := c.newRequest(http.MethodPost, fmt.Sprintf("/api/incidents/%d/acknowledge", id))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var incident Incident
|
|
return &incident, c.do(req, &incident)
|
|
}
|
|
|
|
func (c *Client) UnacknowledgeIncident(id int64) error {
|
|
req, err := c.newRequest(http.MethodDelete, fmt.Sprintf("/api/incidents/%d/acknowledge", id))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.do(req, nil)
|
|
}
|
|
|
|
// ResolveIncident closes an incident by hand. This is terminal on the server: a
|
|
// later occurrence in the same group opens a new incident rather than reopening
|
|
// this one, and if the alert underneath never stops firing the incident stays
|
|
// closed. Use SnoozeIncident for "not now".
|
|
func (c *Client) ResolveIncident(id int64) (*Incident, error) {
|
|
req, err := c.newRequest(http.MethodPost, fmt.Sprintf("/api/incidents/%d/resolve", id))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var incident Incident
|
|
return &incident, c.do(req, &incident)
|
|
}
|
|
|
|
func (c *Client) AssignIncident(id, userID int64) (*Incident, error) {
|
|
body := struct {
|
|
UserID int64 `json:"user_id"`
|
|
}{UserID: userID}
|
|
req, err := c.newRequestWithBody(http.MethodPost, fmt.Sprintf("/api/incidents/%d/assign", id), body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var incident Incident
|
|
return &incident, c.do(req, &incident)
|
|
}
|
|
|
|
// SnoozeIncident hides an incident from the default queue for a duration,
|
|
// without closing it.
|
|
func (c *Client) SnoozeIncident(id int64, duration string) (*Incident, error) {
|
|
body := struct {
|
|
Duration string `json:"duration"`
|
|
}{Duration: duration}
|
|
req, err := c.newRequestWithBody(http.MethodPost, fmt.Sprintf("/api/incidents/%d/snooze", id), body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var incident Incident
|
|
return &incident, c.do(req, &incident)
|
|
}
|
|
|
|
func (c *Client) UnsnoozeIncident(id int64) error {
|
|
req, err := c.newRequest(http.MethodDelete, fmt.Sprintf("/api/incidents/%d/snooze", id))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.do(req, nil)
|
|
}
|
|
|
|
func (c *Client) ArchiveIncident(id int64) (*Incident, error) {
|
|
req, err := c.newRequest(http.MethodPost, fmt.Sprintf("/api/incidents/%d/archive", id))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var incident Incident
|
|
return &incident, c.do(req, &incident)
|
|
}
|
|
|
|
func (c *Client) UnarchiveIncident(id int64) error {
|
|
req, err := c.newRequest(http.MethodDelete, fmt.Sprintf("/api/incidents/%d/archive", id))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.do(req, nil)
|
|
}
|
|
|
|
// AddNote appends a note to the incident's timeline.
|
|
func (c *Client) AddNote(incidentID int64, content string) (*IncidentEvent, error) {
|
|
req, err := c.newRequestWithBody(http.MethodPost,
|
|
fmt.Sprintf("/api/incidents/%d/notes", incidentID), map[string]string{"content": content})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var event IncidentEvent
|
|
return &event, c.do(req, &event)
|
|
}
|
|
|
|
// DeleteNote removes one of your own notes. Only notes are deletable — the rest
|
|
// of the timeline is a record of what happened.
|
|
func (c *Client) DeleteNote(incidentID, eventID int64) error {
|
|
req, err := c.newRequest(http.MethodDelete,
|
|
fmt.Sprintf("/api/incidents/%d/notes/%d", incidentID, eventID))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.do(req, nil)
|
|
}
|
|
|
|
func (c *Client) GetIncidentStats() (*IncidentStats, error) {
|
|
req, err := c.newRequest(http.MethodGet, "/api/stats/incidents")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var stats IncidentStats
|
|
return &stats, c.do(req, &stats)
|
|
}
|
|
|
|
// ── Statistics ─────────────────────────────────────────────────────────────
|
|
|
|
func (c *Client) GetTopAlerts(limit int) ([]TopAlert, error) {
|
|
req, err := c.newRequest(http.MethodGet, fmt.Sprintf("/api/stats/alerts/top?limit=%d", limit))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var result []TopAlert
|
|
return result, c.do(req, &result)
|
|
}
|
|
|
|
func (c *Client) GetStatsByHour() ([]HourStat, error) {
|
|
req, err := c.newRequest(http.MethodGet, "/api/stats/alerts/by-hour")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var result []HourStat
|
|
return result, c.do(req, &result)
|
|
}
|
|
|
|
func (c *Client) GetStatsByDay() ([]DayStat, error) {
|
|
req, err := c.newRequest(http.MethodGet, "/api/stats/alerts/by-day")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var result []DayStat
|
|
return result, c.do(req, &result)
|
|
}
|
|
|
|
// ── Teams ──────────────────────────────────────────────────────────────────
|
|
|
|
// ListTeams returns the teams the caller belongs to, with the caller's role in
|
|
// each. Everything else the server returns is scoped to these. A server that
|
|
// predates teams (v0.12) answers 404, which is how the TUI spots one.
|
|
func (c *Client) ListTeams() ([]Team, error) {
|
|
req, err := c.newRequest(http.MethodGet, "/api/teams")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var teams []Team
|
|
return teams, c.do(req, &teams)
|
|
}
|
|
|
|
// ListTeamMembers returns who belongs to a team. A schedule can only be given to
|
|
// its own members, so this is the assignee list for one.
|
|
func (c *Client) ListTeamMembers(teamID int64) ([]TeamMember, error) {
|
|
req, err := c.newRequest(http.MethodGet, fmt.Sprintf("/api/teams/%d/members", teamID))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var members []TeamMember
|
|
return members, c.do(req, &members)
|
|
}
|
|
|
|
// ── Schedule ───────────────────────────────────────────────────────────────
|
|
|
|
// GetSchedule returns a team's on-call entries between two YYYY-MM-DD dates.
|
|
func (c *Client) GetSchedule(teamID int64, from, to string) ([]ScheduleEntry, error) {
|
|
q := url.Values{"from": {from}, "to": {to}}
|
|
req, err := c.newRequest(http.MethodGet, fmt.Sprintf("/api/teams/%d/schedule?%s", teamID, q.Encode()))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var entries []ScheduleEntry
|
|
return entries, c.do(req, &entries)
|
|
}
|
|
|
|
// GetCurrentOnCall returns today's on-call entries, one per team that has
|
|
// somebody scheduled. It is empty, not an error, when nobody is.
|
|
func (c *Client) GetCurrentOnCall() ([]ScheduleEntry, error) {
|
|
req, err := c.newRequest(http.MethodGet, "/api/schedule/current")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var entries []ScheduleEntry
|
|
return entries, c.do(req, &entries)
|
|
}
|
|
|
|
// AssignSchedule puts one team member on call for the given dates. Only a team
|
|
// owner or an administrator may.
|
|
//
|
|
// The server holds one person per day and refuses a date somebody already has,
|
|
// so replace is what takes a shift off its current holder. It is all-or-nothing
|
|
// either way: a week of free and taken days moves as a unit, or not at all.
|
|
func (c *Client) AssignSchedule(teamID, userID int64, dates []string, replace bool) ([]ScheduleEntry, error) {
|
|
body := struct {
|
|
UserID int64 `json:"user_id"`
|
|
Dates []string `json:"dates"`
|
|
Replace bool `json:"replace,omitempty"`
|
|
}{UserID: userID, Dates: dates, Replace: replace}
|
|
req, err := c.newRequestWithBody(http.MethodPost, fmt.Sprintf("/api/teams/%d/schedule", teamID), body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var entries []ScheduleEntry
|
|
return entries, c.do(req, &entries)
|
|
}
|
|
|
|
func (c *Client) DeleteScheduleEntry(teamID, id int64) error {
|
|
req, err := c.newRequest(http.MethodDelete, fmt.Sprintf("/api/teams/%d/schedule/%d", teamID, id))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.do(req, nil)
|
|
}
|
|
|
|
// ── Users ──────────────────────────────────────────────────────────────────
|
|
|
|
func (c *Client) ListUsers() ([]User, error) {
|
|
req, err := c.newRequest(http.MethodGet, "/api/users")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var users []User
|
|
return users, c.do(req, &users)
|
|
}
|
|
|
|
func (c *Client) CreateUser(username, email string) (*User, error) {
|
|
body := struct {
|
|
Username string `json:"username"`
|
|
Email string `json:"email"`
|
|
}{Username: username, Email: email}
|
|
req, err := c.newRequestWithBody(http.MethodPost, "/api/users", body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var user User
|
|
return &user, c.do(req, &user)
|
|
}
|
|
|
|
// SetUserNotifyTarget points a user's push notifications at an ntfy topic.
|
|
//
|
|
// An empty topic clears it: the server stores NULL, and that user's incidents
|
|
// page the shared fallback topic instead — which carries no Acknowledge button,
|
|
// because anyone subscribed to it could otherwise acknowledge as somebody else.
|
|
func (c *Client) SetUserNotifyTarget(userID int64, topic string) (*User, error) {
|
|
body := struct {
|
|
NtfyTopic string `json:"ntfy_topic"`
|
|
}{NtfyTopic: topic}
|
|
req, err := c.newRequestWithBody(http.MethodPut, fmt.Sprintf("/api/users/%d/notify", userID), body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var user User
|
|
return &user, c.do(req, &user)
|
|
}
|
|
|
|
func (c *Client) DeleteUser(id int64) error {
|
|
req, err := c.newRequest(http.MethodDelete, fmt.Sprintf("/api/users/%d", id))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.do(req, nil)
|
|
}
|
|
|
|
func (c *Client) CreateAPIKey(userID int64, name string) (*APIKey, error) {
|
|
body := struct {
|
|
Name string `json:"name"`
|
|
}{Name: name}
|
|
req, err := c.newRequestWithBody(http.MethodPost, fmt.Sprintf("/api/users/%d/api-keys", userID), body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var key APIKey
|
|
return &key, c.do(req, &key)
|
|
}
|
|
|
|
func (c *Client) DeleteAPIKey(userID, keyID int64) error {
|
|
req, err := c.newRequest(http.MethodDelete, fmt.Sprintf("/api/users/%d/api-keys/%d", userID, keyID))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.do(req, nil)
|
|
}
|
|
|
|
// Me returns the user the API key belongs to, and whether they have a web UI
|
|
// password.
|
|
func (c *Client) Me() (*Me, error) {
|
|
req, err := c.newRequest(http.MethodGet, "/api/me")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var me Me
|
|
return &me, c.do(req, &me)
|
|
}
|
|
|
|
// SetPassword sets a user's web UI password. current is only checked by the
|
|
// server when a user changes their own existing password; pass "" otherwise.
|
|
func (c *Client) SetPassword(userID int64, password, current string) error {
|
|
body := struct {
|
|
Password string `json:"password"`
|
|
CurrentPassword string `json:"current_password,omitempty"`
|
|
}{Password: password, CurrentPassword: current}
|
|
req, err := c.newRequestWithBody(http.MethodPut, fmt.Sprintf("/api/users/%d/password", userID), body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.do(req, nil)
|
|
}
|
|
|
|
// HealthCheck calls GET /healthz, which is unauthenticated and does no database
|
|
// check, so it says the process is up, not that the API key works.
|
|
func (c *Client) HealthCheck() error {
|
|
req, err := http.NewRequest(http.MethodGet, c.baseURL+"/healthz", nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return fmt.Errorf("health check failed: %s", resp.Status)
|
|
}
|
|
return nil
|
|
}
|