@@ -0,0 +1,48 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// UpdateAccountProfile changes the current user's display name and color.
|
||||
func (c *Client) UpdateAccountProfile(ctx context.Context, name, color string) (User, *Response, error) {
|
||||
var envelope struct {
|
||||
User User `json:"user"`
|
||||
}
|
||||
response, err := c.do(ctx, http.MethodPatch, "v1/account", map[string]string{"name": name, "color": color}, &envelope)
|
||||
return envelope.User, response, err
|
||||
}
|
||||
|
||||
// UpdateAccountPassword changes the current user's password after verifying the old one.
|
||||
func (c *Client) UpdateAccountPassword(ctx context.Context, currentPassword, newPassword string) (*Response, error) {
|
||||
return c.do(ctx, http.MethodPut, "v1/account/password", map[string]string{"current_password": currentPassword, "new_password": newPassword}, nil)
|
||||
}
|
||||
|
||||
// RequestAccountEmailChange starts email verification after password confirmation.
|
||||
func (c *Client) RequestAccountEmailChange(ctx context.Context, email, currentPassword string) (*Response, error) {
|
||||
return c.do(ctx, http.MethodPost, "v1/account/email", map[string]string{"email": email, "current_password": currentPassword}, nil)
|
||||
}
|
||||
|
||||
// ConfirmAccountEmail applies a pending email change using its verification token.
|
||||
func (c *Client) ConfirmAccountEmail(ctx context.Context, token string) (User, *Response, error) {
|
||||
var envelope struct {
|
||||
User User `json:"user"`
|
||||
}
|
||||
response, err := c.do(ctx, http.MethodPost, "v1/account/email/confirm", map[string]string{"token": token}, &envelope)
|
||||
return envelope.User, response, err
|
||||
}
|
||||
|
||||
// AccountSessions lists the current user's active login sessions.
|
||||
func (c *Client) AccountSessions(ctx context.Context) ([]AccountSession, *Response, error) {
|
||||
var envelope struct {
|
||||
Sessions []AccountSession `json:"sessions"`
|
||||
}
|
||||
response, err := c.do(ctx, http.MethodGet, "v1/account/sessions", nil, &envelope)
|
||||
return envelope.Sessions, response, err
|
||||
}
|
||||
|
||||
// DeleteAccountSession revokes one login session owned by the current user.
|
||||
func (c *Client) DeleteAccountSession(ctx context.Context, id string) (*Response, error) {
|
||||
return c.do(ctx, http.MethodDelete, "v1/account/sessions/"+id, nil, nil)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// AdminApplicationSettings returns the application-wide automation settings.
|
||||
func (c *Client) AdminApplicationSettings(ctx context.Context) (ApplicationSettings, *Response, error) {
|
||||
var envelope struct {
|
||||
Settings ApplicationSettings `json:"settings"`
|
||||
}
|
||||
response, err := c.do(ctx, http.MethodGet, "v1/admin/application-settings", nil, &envelope)
|
||||
return envelope.Settings, response, err
|
||||
}
|
||||
|
||||
// UpdateAdminApplicationSettings changes application-wide automation settings.
|
||||
func (c *Client) UpdateAdminApplicationSettings(ctx context.Context, input ApplicationSettingsInput) (ApplicationSettings, *Response, error) {
|
||||
var envelope struct {
|
||||
Settings ApplicationSettings `json:"settings"`
|
||||
}
|
||||
response, err := c.do(ctx, http.MethodPatch, "v1/admin/application-settings", input, &envelope)
|
||||
return envelope.Settings, response, err
|
||||
}
|
||||
|
||||
// AdminEnvironment returns the API's masked effective environment configuration.
|
||||
func (c *Client) AdminEnvironment(ctx context.Context) ([]EnvironmentVariable, *Response, error) {
|
||||
var envelope struct {
|
||||
Variables []EnvironmentVariable `json:"variables"`
|
||||
}
|
||||
response, err := c.do(ctx, http.MethodGet, "v1/admin/environment", nil, &envelope)
|
||||
return envelope.Variables, response, err
|
||||
}
|
||||
|
||||
// SendAdminTestMail asks the API to send a configuration test email.
|
||||
func (c *Client) SendAdminTestMail(ctx context.Context, recipient string) (*Response, error) {
|
||||
return c.do(ctx, http.MethodPost, "v1/admin/test-mail", map[string]string{"email": recipient}, nil)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
func carePath(gardenID, speciesID int) string {
|
||||
return "v1/gardens/" + strconv.Itoa(gardenID) + "/species/" + strconv.Itoa(speciesID) + "/care-instructions"
|
||||
}
|
||||
|
||||
// CareInstructions lists care guidance for a species visible in a garden.
|
||||
func (c *Client) CareInstructions(ctx context.Context, gardenID, speciesID int) ([]CareInstruction, *Response, error) {
|
||||
var out struct {
|
||||
Items []CareInstruction `json:"care_instructions"`
|
||||
}
|
||||
r, e := c.do(ctx, http.MethodGet, carePath(gardenID, speciesID), nil, &out)
|
||||
return out.Items, r, e
|
||||
}
|
||||
|
||||
// CreateCareInstruction adds garden-specific care guidance to a species.
|
||||
func (c *Client) CreateCareInstruction(ctx context.Context, gardenID, speciesID int, input CareInstructionInput) (CareInstruction, *Response, error) {
|
||||
return c.writeCareInstruction(ctx, http.MethodPost, carePath(gardenID, speciesID), input)
|
||||
}
|
||||
|
||||
// UpdateCareInstruction changes care guidance within its garden and species.
|
||||
func (c *Client) UpdateCareInstruction(ctx context.Context, gardenID, speciesID, id int, input CareInstructionInput) (CareInstruction, *Response, error) {
|
||||
return c.writeCareInstruction(ctx, http.MethodPatch, carePath(gardenID, speciesID)+"/"+strconv.Itoa(id), input)
|
||||
}
|
||||
|
||||
// DeleteCareInstruction removes care guidance within its garden and species.
|
||||
func (c *Client) DeleteCareInstruction(ctx context.Context, gardenID, speciesID, id int) (*Response, error) {
|
||||
return c.do(ctx, http.MethodDelete, carePath(gardenID, speciesID)+"/"+strconv.Itoa(id), nil, nil)
|
||||
}
|
||||
func (c *Client) writeCareInstruction(ctx context.Context, method, path string, input CareInstructionInput) (CareInstruction, *Response, error) {
|
||||
var out struct {
|
||||
Item CareInstruction `json:"care_instruction"`
|
||||
}
|
||||
r, e := c.do(ctx, method, path, input, &out)
|
||||
return out.Item, r, e
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
maxResponseSize = 2 << 20 // 2 MiB
|
||||
defaultTimeout = 15 * time.Second
|
||||
defaultSessionCookieName = "gardomatic_session"
|
||||
)
|
||||
|
||||
// Client is safe for concurrent use. A client configured with WithSessions
|
||||
// owns one cookie jar and must therefore only be shared by callers that are
|
||||
// meant to share the same login session.
|
||||
type Client struct {
|
||||
baseURL *url.URL
|
||||
httpClient *http.Client
|
||||
bearerToken string
|
||||
headers http.Header
|
||||
cookieName string
|
||||
}
|
||||
|
||||
type config struct {
|
||||
httpClient *http.Client
|
||||
bearerToken string
|
||||
headers http.Header
|
||||
sessions bool
|
||||
initialCookies []*http.Cookie
|
||||
cookieName string
|
||||
}
|
||||
|
||||
// Option configures a Client.
|
||||
type Option func(*config) error
|
||||
|
||||
// WithHTTPClient supplies the HTTP client used for requests. The client is
|
||||
// shallow-copied, so Client never changes the caller's value.
|
||||
func WithHTTPClient(httpClient *http.Client) Option {
|
||||
return func(cfg *config) error {
|
||||
if httpClient == nil {
|
||||
return errors.New("client: HTTP client must not be nil")
|
||||
}
|
||||
cfg.httpClient = httpClient
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// WithBearerToken authenticates every request using a bearer token.
|
||||
func WithBearerToken(token string) Option {
|
||||
return func(cfg *config) error {
|
||||
if strings.TrimSpace(token) == "" {
|
||||
return errors.New("client: bearer token must not be empty")
|
||||
}
|
||||
cfg.bearerToken = token
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// WithHeader adds a header to every request. Authorization and Cookie should
|
||||
// be configured using WithBearerToken and WithSessions instead.
|
||||
func WithHeader(name, value string) Option {
|
||||
return func(cfg *config) error {
|
||||
if strings.TrimSpace(name) == "" {
|
||||
return errors.New("client: header name must not be empty")
|
||||
}
|
||||
if cfg.headers == nil {
|
||||
cfg.headers = make(http.Header)
|
||||
}
|
||||
cfg.headers.Add(name, value)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// WithSessions gives the client a private cookie jar. Use one such Client per
|
||||
// independent user session; do not share it globally in a web server.
|
||||
func WithSessions() Option {
|
||||
return func(cfg *config) error {
|
||||
cfg.sessions = true
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// WithSessionCookieName selects the cookie copied by ForRequest. It should
|
||||
// match api.Config.Session.CookieName.
|
||||
func WithSessionCookieName(name string) Option {
|
||||
return func(cfg *config) error {
|
||||
if strings.TrimSpace(name) == "" {
|
||||
return errors.New("client: session cookie name must not be empty")
|
||||
}
|
||||
cfg.cookieName = name
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// WithInitialCookies enables sessions and seeds the private cookie jar with
|
||||
// the configured session cookie. Other frontend cookies are not forwarded.
|
||||
func WithInitialCookies(cookies ...*http.Cookie) Option {
|
||||
return func(cfg *config) error {
|
||||
cfg.sessions = true
|
||||
cfg.initialCookies = append(cfg.initialCookies, cookies...)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// New constructs a Gardomatic API client. baseURL may contain a path prefix;
|
||||
// API paths are resolved below that prefix.
|
||||
func New(baseURL string, options ...Option) (*Client, error) {
|
||||
parsedURL, err := url.Parse(baseURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("client: parse base URL: %w", err)
|
||||
}
|
||||
if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
|
||||
return nil, errors.New("client: base URL must use http or https")
|
||||
}
|
||||
if parsedURL.Host == "" {
|
||||
return nil, errors.New("client: base URL must include a host")
|
||||
}
|
||||
if parsedURL.RawQuery != "" || parsedURL.Fragment != "" {
|
||||
return nil, errors.New("client: base URL must not contain a query or fragment")
|
||||
}
|
||||
parsedURL.Path = strings.TrimSuffix(parsedURL.Path, "/") + "/"
|
||||
|
||||
cfg := config{
|
||||
httpClient: &http.Client{Timeout: defaultTimeout},
|
||||
headers: make(http.Header),
|
||||
cookieName: defaultSessionCookieName,
|
||||
}
|
||||
for _, option := range options {
|
||||
if option == nil {
|
||||
return nil, errors.New("client: option must not be nil")
|
||||
}
|
||||
if err := option(&cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
httpClient := *cfg.httpClient
|
||||
if cfg.sessions {
|
||||
jar, err := cookiejar.New(nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("client: create cookie jar: %w", err)
|
||||
}
|
||||
jar.SetCookies(parsedURL, sessionCookies(cfg.initialCookies, cfg.cookieName))
|
||||
httpClient.Jar = jar
|
||||
}
|
||||
|
||||
return &Client{
|
||||
baseURL: parsedURL,
|
||||
httpClient: &httpClient,
|
||||
bearerToken: cfg.bearerToken,
|
||||
headers: cfg.headers.Clone(),
|
||||
cookieName: cfg.cookieName,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func sessionCookies(cookies []*http.Cookie, name string) []*http.Cookie {
|
||||
for _, cookie := range cookies {
|
||||
if cookie != nil && cookie.Name == name {
|
||||
return []*http.Cookie{cookie}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ForRequest returns a client with a private cookie jar seeded from r. This is
|
||||
// the safe way to use a shared base client in an HTTP frontend.
|
||||
func (c *Client) ForRequest(r *http.Request) (*Client, error) {
|
||||
if r == nil {
|
||||
return nil, errors.New("client: request must not be nil")
|
||||
}
|
||||
|
||||
httpClient := *c.httpClient
|
||||
jar, err := cookiejar.New(nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("client: create cookie jar: %w", err)
|
||||
}
|
||||
jar.SetCookies(c.baseURL, sessionCookies(r.Cookies(), c.cookieName))
|
||||
httpClient.Jar = jar
|
||||
|
||||
return &Client{
|
||||
baseURL: c.baseURL,
|
||||
httpClient: &httpClient,
|
||||
bearerToken: c.bearerToken,
|
||||
headers: c.headers.Clone(),
|
||||
cookieName: c.cookieName,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Response contains the HTTP response metadata. The body has already been
|
||||
// read and closed. Cookies remains useful for forwarding Set-Cookie headers.
|
||||
type Response struct {
|
||||
*http.Response
|
||||
}
|
||||
|
||||
func (c *Client) do(ctx context.Context, method, path string, input, output any) (*Response, error) {
|
||||
if ctx == nil {
|
||||
return nil, errors.New("client: context must not be nil")
|
||||
}
|
||||
|
||||
var body io.Reader
|
||||
if input != nil {
|
||||
encoded, err := json.Marshal(input)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("client: encode request: %w", err)
|
||||
}
|
||||
body = bytes.NewReader(encoded)
|
||||
}
|
||||
|
||||
relativeURL, err := url.Parse(strings.TrimPrefix(path, "/"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("client: parse request path: %w", err)
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, method, c.baseURL.ResolveReference(relativeURL).String(), body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("client: create request: %w", err)
|
||||
}
|
||||
request.Header = c.headers.Clone()
|
||||
request.Header.Set("Accept", "application/json")
|
||||
if input != nil {
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
if c.bearerToken != "" {
|
||||
request.Header.Set("Authorization", "Bearer "+c.bearerToken)
|
||||
}
|
||||
|
||||
httpResponse, err := c.httpClient.Do(request)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("client: execute request: %w", err)
|
||||
}
|
||||
response := &Response{httpResponse}
|
||||
defer httpResponse.Body.Close()
|
||||
|
||||
responseBody, err := io.ReadAll(io.LimitReader(httpResponse.Body, maxResponseSize+1))
|
||||
if err != nil {
|
||||
return response, fmt.Errorf("client: read response: %w", err)
|
||||
}
|
||||
if len(responseBody) > maxResponseSize {
|
||||
return response, errors.New("client: response exceeds 2 MiB")
|
||||
}
|
||||
|
||||
if httpResponse.StatusCode < http.StatusOK || httpResponse.StatusCode >= http.StatusMultipleChoices {
|
||||
return response, newAPIError(httpResponse, responseBody)
|
||||
}
|
||||
if output == nil || httpResponse.StatusCode == http.StatusNoContent || len(bytes.TrimSpace(responseBody)) == 0 {
|
||||
return response, nil
|
||||
}
|
||||
if err := json.Unmarshal(responseBody, output); err != nil {
|
||||
return response, fmt.Errorf("client: decode response: %w", err)
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type handlerTransport struct {
|
||||
handler http.Handler
|
||||
}
|
||||
|
||||
func (transport handlerTransport) RoundTrip(request *http.Request) (*http.Response, error) {
|
||||
recorder := httptest.NewRecorder()
|
||||
transport.handler.ServeHTTP(recorder, request)
|
||||
response := recorder.Result()
|
||||
response.Request = request
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func newTestClient(t *testing.T, handler http.Handler, options ...Option) *Client {
|
||||
t.Helper()
|
||||
httpClient := &http.Client{Transport: handlerTransport{handler: handler}}
|
||||
options = append([]Option{WithHTTPClient(httpClient)}, options...)
|
||||
apiClient, err := New("https://api.example", options...)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return apiClient
|
||||
}
|
||||
|
||||
func TestClientSupportsBearerAuthenticationAndBasePath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/v1/healthcheck" {
|
||||
t.Errorf("path: got %q, want %q", r.URL.Path, "/api/v1/healthcheck")
|
||||
}
|
||||
if got := r.Header.Get("Authorization"); got != "Bearer secret" {
|
||||
t.Errorf("Authorization: got %q, want %q", got, "Bearer secret")
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"status":"available","server_time":"2026-09-11T10:00:00Z","system_info":{"environment":"test","version":"v1"}}`))
|
||||
})
|
||||
|
||||
httpClient := &http.Client{Transport: handlerTransport{handler: handler}}
|
||||
apiClient, err := New("https://api.example/api", WithHTTPClient(httpClient), WithBearerToken("secret"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
health, response, err := apiClient.Healthcheck(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if response.StatusCode != http.StatusOK {
|
||||
t.Errorf("status: got %d, want %d", response.StatusCode, http.StatusOK)
|
||||
}
|
||||
if health.Status != "available" || health.SystemInfo.Environment != "test" {
|
||||
t.Errorf("unexpected health response: %+v", health)
|
||||
}
|
||||
if health.ServerTime.IsZero() {
|
||||
t.Errorf("health response does not include server time: %+v", health)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionClientPersistsCookies(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/v1/session":
|
||||
http.SetCookie(w, &http.Cookie{Name: "gardomatic_session", Value: "session-token", Path: "/", HttpOnly: true})
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = w.Write([]byte(`{"user":{"id":42,"name":"Alice","email":"alice@example.com","activated":true}}`))
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/v1/session":
|
||||
cookie, err := r.Cookie("gardomatic_session")
|
||||
if err != nil || cookie.Value != "session-token" {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = w.Write([]byte(`{"error":"you must be authenticated"}`))
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"user":{"id":42,"name":"Alice","email":"alice@example.com","activated":true}}`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
})
|
||||
|
||||
apiClient := newTestClient(t, handler, WithSessions())
|
||||
user, response, err := apiClient.CreateSession(context.Background(), Credentials{
|
||||
Email: "alice@example.com", Password: "correct horse battery staple",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if user.ID != 42 {
|
||||
t.Errorf("user ID: got %d, want 42", user.ID)
|
||||
}
|
||||
if len(response.Cookies()) != 1 {
|
||||
t.Fatalf("response cookies: got %d, want 1", len(response.Cookies()))
|
||||
}
|
||||
|
||||
user, _, err = apiClient.Session(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if user.Email != "alice@example.com" {
|
||||
t.Errorf("email: got %q, want %q", user.Email, "alice@example.com")
|
||||
}
|
||||
}
|
||||
|
||||
func TestForRequestUsesIsolatedIncomingSession(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if _, err := r.Cookie("frontend_csrf"); !errors.Is(err, http.ErrNoCookie) {
|
||||
t.Errorf("frontend-only cookie was forwarded to API")
|
||||
}
|
||||
cookie, err := r.Cookie("gardomatic_session")
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = w.Write([]byte(`{"error":"missing session"}`))
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"user":{"id":1,"name":"` + cookie.Value + `"}}`))
|
||||
})
|
||||
|
||||
baseClient := newTestClient(t, handler)
|
||||
incoming := httptest.NewRequest(http.MethodGet, "https://frontend.example/", nil)
|
||||
incoming.AddCookie(&http.Cookie{Name: "gardomatic_session", Value: "browser-a"})
|
||||
incoming.AddCookie(&http.Cookie{Name: "frontend_csrf", Value: "do-not-forward"})
|
||||
requestClient, err := baseClient.ForRequest(incoming)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
user, _, err := requestClient.Session(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if user.Name != "browser-a" {
|
||||
t.Errorf("user name: got %q, want %q", user.Name, "browser-a")
|
||||
}
|
||||
|
||||
_, _, err = baseClient.Session(context.Background())
|
||||
var apiError *APIError
|
||||
if !errors.As(err, &apiError) || apiError.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("base client should have no session; got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidationError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||
_, _ = w.Write([]byte(`{"error":{"email":"must be a valid email address"}}`))
|
||||
})
|
||||
|
||||
apiClient := newTestClient(t, handler)
|
||||
_, response, err := apiClient.RegisterUser(context.Background(), RegisterUserInput{})
|
||||
var apiError *APIError
|
||||
if !errors.As(err, &apiError) {
|
||||
t.Fatalf("error type: got %T, want *APIError", err)
|
||||
}
|
||||
if response.StatusCode != http.StatusUnprocessableEntity {
|
||||
t.Errorf("response status: got %d, want %d", response.StatusCode, http.StatusUnprocessableEntity)
|
||||
}
|
||||
if apiError.Validation["email"] != "must be a valid email address" {
|
||||
t.Errorf("validation errors: got %#v", apiError.Validation)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponseBodyIsClosed(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
closed := false
|
||||
apiClient, err := New("https://api.example", WithHTTPClient(&http.Client{
|
||||
Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: make(http.Header),
|
||||
Body: &trackingReadCloser{
|
||||
Reader: io.NopCloser(http.NoBody),
|
||||
closed: &closed,
|
||||
},
|
||||
Request: request,
|
||||
}, nil
|
||||
}),
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _, _ = apiClient.Healthcheck(context.Background())
|
||||
if !closed {
|
||||
t.Error("response body was not closed")
|
||||
}
|
||||
}
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (fn roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) {
|
||||
return fn(request)
|
||||
}
|
||||
|
||||
type trackingReadCloser struct {
|
||||
Reader io.ReadCloser
|
||||
closed *bool
|
||||
}
|
||||
|
||||
func (reader *trackingReadCloser) Read(buffer []byte) (int, error) {
|
||||
return reader.Reader.Read(buffer)
|
||||
}
|
||||
|
||||
func (reader *trackingReadCloser) Close() error {
|
||||
*reader.closed = true
|
||||
return reader.Reader.Close()
|
||||
}
|
||||
|
||||
func TestForwardCookies(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
apiResponse := &http.Response{Header: make(http.Header)}
|
||||
apiResponse.Header.Add("Set-Cookie", "gardomatic_session=token; Path=/; HttpOnly")
|
||||
frontendResponse := httptest.NewRecorder()
|
||||
|
||||
ForwardCookies(frontendResponse, &Response{apiResponse})
|
||||
|
||||
if got := frontendResponse.Header().Values("Set-Cookie"); len(got) != 1 {
|
||||
t.Fatalf("Set-Cookie headers: got %d, want 1", len(got))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package client
|
||||
|
||||
import "context"
|
||||
|
||||
type contextKey struct{}
|
||||
|
||||
// NewContext attaches an API client to a context.
|
||||
func NewContext(ctx context.Context, apiClient *Client) context.Context {
|
||||
return context.WithValue(ctx, contextKey{}, apiClient)
|
||||
}
|
||||
|
||||
// FromContext returns the API client attached to ctx, or nil if none exists.
|
||||
func FromContext(ctx context.Context) *Client {
|
||||
apiClient, _ := ctx.Value(contextKey{}).(*Client)
|
||||
return apiClient
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// Package client provides a typed HTTP client for the Gardomatic JSON API.
|
||||
package client
|
||||
@@ -0,0 +1,49 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// APIError is returned for every non-2xx API response. Validation contains
|
||||
// field-specific messages for 422 responses; Message contains ordinary API
|
||||
// error strings.
|
||||
type APIError struct {
|
||||
StatusCode int
|
||||
Message string
|
||||
Validation map[string]string
|
||||
Body string
|
||||
}
|
||||
|
||||
// Error implements error using the API message or HTTP status.
|
||||
func (e *APIError) Error() string {
|
||||
switch {
|
||||
case e.Message != "":
|
||||
return fmt.Sprintf("gardomatic API: %s (%d)", e.Message, e.StatusCode)
|
||||
case len(e.Validation) != 0:
|
||||
return fmt.Sprintf("gardomatic API: validation failed (%d)", e.StatusCode)
|
||||
default:
|
||||
return fmt.Sprintf("gardomatic API: request failed (%d)", e.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func newAPIError(response *http.Response, body []byte) *APIError {
|
||||
apiError := &APIError{
|
||||
StatusCode: response.StatusCode,
|
||||
Body: strings.TrimSpace(string(body)),
|
||||
}
|
||||
|
||||
var envelope struct {
|
||||
Error json.RawMessage `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &envelope); err != nil || len(envelope.Error) == 0 {
|
||||
return apiError
|
||||
}
|
||||
if err := json.Unmarshal(envelope.Error, &apiError.Message); err == nil {
|
||||
return apiError
|
||||
}
|
||||
_ = json.Unmarshal(envelope.Error, &apiError.Validation)
|
||||
return apiError
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// GardenMembers lists all members of a garden.
|
||||
func (c *Client) GardenMembers(ctx context.Context, gardenID int) ([]GardenMember, *Response, error) {
|
||||
var envelope struct {
|
||||
Members []GardenMember `json:"members"`
|
||||
}
|
||||
response, err := c.do(ctx, http.MethodGet, gardenPath(gardenID)+"/members", nil, &envelope)
|
||||
return envelope.Members, response, err
|
||||
}
|
||||
|
||||
// UpdateGardenMember assigns a garden role to an existing member.
|
||||
func (c *Client) UpdateGardenMember(ctx context.Context, gardenID, userID int, role string) (GardenMember, *Response, error) {
|
||||
var envelope struct {
|
||||
Member GardenMember `json:"member"`
|
||||
}
|
||||
response, err := c.do(ctx, http.MethodPatch, gardenPath(gardenID)+"/members/"+strconv.Itoa(userID), map[string]string{"role": role}, &envelope)
|
||||
return envelope.Member, response, err
|
||||
}
|
||||
|
||||
// DeleteGardenMember removes a user from a garden.
|
||||
func (c *Client) DeleteGardenMember(ctx context.Context, gardenID, userID int) (*Response, error) {
|
||||
return c.do(ctx, http.MethodDelete, gardenPath(gardenID)+"/members/"+strconv.Itoa(userID), nil, nil)
|
||||
}
|
||||
|
||||
// TransferGardenOwnership makes userID the garden owner.
|
||||
func (c *Client) TransferGardenOwnership(ctx context.Context, gardenID, userID int) (*Response, error) {
|
||||
return c.do(ctx, http.MethodPost, gardenPath(gardenID)+"/members/"+strconv.Itoa(userID)+"/transfer-ownership", nil, nil)
|
||||
}
|
||||
|
||||
// GardenInvites lists pending invitations for a garden.
|
||||
func (c *Client) GardenInvites(ctx context.Context, gardenID int) ([]GardenInvite, *Response, error) {
|
||||
var envelope struct {
|
||||
Invites []GardenInvite `json:"invites"`
|
||||
}
|
||||
response, err := c.do(ctx, http.MethodGet, gardenPath(gardenID)+"/invites", nil, &envelope)
|
||||
return envelope.Invites, response, err
|
||||
}
|
||||
|
||||
// CreateGardenInvite creates or replaces an invitation for an email address.
|
||||
func (c *Client) CreateGardenInvite(ctx context.Context, gardenID int, input GardenInviteInput) (GardenInvite, *Response, error) {
|
||||
var envelope struct {
|
||||
Invite GardenInvite `json:"invite"`
|
||||
}
|
||||
response, err := c.do(ctx, http.MethodPost, gardenPath(gardenID)+"/invites", input, &envelope)
|
||||
return envelope.Invite, response, err
|
||||
}
|
||||
|
||||
// DeleteGardenInvite revokes a pending invitation.
|
||||
func (c *Client) DeleteGardenInvite(ctx context.Context, gardenID, inviteID int) (*Response, error) {
|
||||
return c.do(ctx, http.MethodDelete, gardenPath(gardenID)+"/invites/"+strconv.Itoa(inviteID), nil, nil)
|
||||
}
|
||||
|
||||
// AcceptGardenInvite joins the current user to the invited garden.
|
||||
func (c *Client) AcceptGardenInvite(ctx context.Context, token string) (GardenMember, *Response, error) {
|
||||
var envelope struct {
|
||||
Member GardenMember `json:"member"`
|
||||
}
|
||||
response, err := c.do(ctx, http.MethodPost, "v1/invites/"+token+"/accept", nil, &envelope)
|
||||
return envelope.Member, response, err
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// CreateGarden creates a garden owned by the authenticated user.
|
||||
func (c *Client) CreateGarden(ctx context.Context, input CreateGardenInput) (Garden, *Response, error) {
|
||||
var envelope struct {
|
||||
Garden Garden `json:"garden"`
|
||||
}
|
||||
response, err := c.do(ctx, http.MethodPost, "v1/gardens", input, &envelope)
|
||||
return envelope.Garden, response, err
|
||||
}
|
||||
|
||||
// Gardens lists gardens visible to the authenticated user.
|
||||
func (c *Client) Gardens(ctx context.Context) ([]Garden, *Response, error) {
|
||||
var envelope struct {
|
||||
Gardens []Garden `json:"gardens"`
|
||||
}
|
||||
response, err := c.do(ctx, http.MethodGet, "v1/gardens", nil, &envelope)
|
||||
return envelope.Gardens, response, err
|
||||
}
|
||||
|
||||
// Garden returns one garden visible to the authenticated user.
|
||||
func (c *Client) Garden(ctx context.Context, gardenID int) (Garden, *Response, error) {
|
||||
var envelope struct {
|
||||
Garden Garden `json:"garden"`
|
||||
}
|
||||
response, err := c.do(ctx, http.MethodGet, gardenPath(gardenID), nil, &envelope)
|
||||
return envelope.Garden, response, err
|
||||
}
|
||||
|
||||
// UpdateGarden partially updates a garden.
|
||||
func (c *Client) UpdateGarden(ctx context.Context, gardenID int, input UpdateGardenInput) (Garden, *Response, error) {
|
||||
var envelope struct {
|
||||
Garden Garden `json:"garden"`
|
||||
}
|
||||
response, err := c.do(ctx, http.MethodPatch, gardenPath(gardenID), input, &envelope)
|
||||
return envelope.Garden, response, err
|
||||
}
|
||||
|
||||
// DeleteGarden permanently deletes a garden and its dependent records.
|
||||
func (c *Client) DeleteGarden(ctx context.Context, gardenID int) (*Response, error) {
|
||||
return c.do(ctx, http.MethodDelete, gardenPath(gardenID), nil, nil)
|
||||
}
|
||||
|
||||
func gardenPath(gardenID int) string {
|
||||
return "v1/gardens/" + strconv.Itoa(gardenID)
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGardenCanUsesEffectivePermissions(t *testing.T) {
|
||||
custom := Garden{Role: "garden:custom", Permissions: []string{"plants:create"}}
|
||||
if !custom.Can("plants:create") || custom.Can("garden:update") {
|
||||
t.Fatalf("custom role did not use effective permissions: %+v", custom)
|
||||
}
|
||||
|
||||
deniedOverride := Garden{Role: "owner", Permissions: []string{"garden:read"}}
|
||||
if deniedOverride.Can("garden:update") {
|
||||
t.Fatal("explicit permissions must override the built-in role fallback")
|
||||
}
|
||||
|
||||
legacyOwner := Garden{Role: "owner"}
|
||||
if !legacyOwner.Can("garden:update") {
|
||||
t.Fatal("legacy built-in role response should retain compatibility")
|
||||
}
|
||||
if legacyOwner.Can("unknown:permission") {
|
||||
t.Fatal("legacy role fallback must reject unknown permissions")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGardenClientCRUD(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
name := "Neu"
|
||||
description := "Beschreibung"
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/v1/gardens":
|
||||
var input CreateGardenInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
if input.Name != "Hinterhof" {
|
||||
t.Errorf("create name: got %q, want %q", input.Name, "Hinterhof")
|
||||
}
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = w.Write([]byte(`{"garden":{"id":12,"name":"Hinterhof","version":1}}`))
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/v1/gardens":
|
||||
_, _ = w.Write([]byte(`{"gardens":[{"id":12,"name":"Hinterhof","version":1}]}`))
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/v1/gardens/12":
|
||||
_, _ = w.Write([]byte(`{"garden":{"id":12,"name":"Hinterhof","version":1}}`))
|
||||
case r.Method == http.MethodPatch && r.URL.Path == "/v1/gardens/12":
|
||||
var input UpdateGardenInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
if input.Name == nil || *input.Name != name || input.Description == nil || *input.Description != description {
|
||||
t.Errorf("update input: got %+v", input)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"garden":{"id":12,"name":"Neu","description":"Beschreibung","version":2}}`))
|
||||
case r.Method == http.MethodDelete && r.URL.Path == "/v1/gardens/12":
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
})
|
||||
|
||||
apiClient := newTestClient(t, handler)
|
||||
created, response, err := apiClient.CreateGarden(context.Background(), CreateGardenInput{Name: "Hinterhof"})
|
||||
if err != nil || response.StatusCode != http.StatusCreated || created.ID != 12 {
|
||||
t.Fatalf("CreateGarden(): garden=%+v response=%v err=%v", created, response, err)
|
||||
}
|
||||
gardens, _, err := apiClient.Gardens(context.Background())
|
||||
if err != nil || len(gardens) != 1 || gardens[0].ID != 12 {
|
||||
t.Fatalf("Gardens(): gardens=%+v err=%v", gardens, err)
|
||||
}
|
||||
garden, _, err := apiClient.Garden(context.Background(), 12)
|
||||
if err != nil || garden.Name != "Hinterhof" {
|
||||
t.Fatalf("Garden(): garden=%+v err=%v", garden, err)
|
||||
}
|
||||
updated, _, err := apiClient.UpdateGarden(context.Background(), 12, UpdateGardenInput{Name: &name, Description: &description})
|
||||
if err != nil || updated.Version != 2 || updated.Name != name {
|
||||
t.Fatalf("UpdateGarden(): garden=%+v err=%v", updated, err)
|
||||
}
|
||||
response, err = apiClient.DeleteGarden(context.Background(), 12)
|
||||
if err != nil || response.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("DeleteGarden(): response=%v err=%v", response, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// Healthcheck returns API availability and build information.
|
||||
func (c *Client) Healthcheck(ctx context.Context) (Health, *Response, error) {
|
||||
var health Health
|
||||
response, err := c.do(ctx, http.MethodGet, "v1/healthcheck", nil, &health)
|
||||
return health, response, err
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Image describes an item in a garden's reusable image library.
|
||||
type Image struct {
|
||||
ID int `json:"id"`
|
||||
GardenID int `json:"garden_id"`
|
||||
FileName string `json:"file_name"`
|
||||
MediaType string `json:"media_type"`
|
||||
Size int64 `json:"size"`
|
||||
Source string `json:"source"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// Images lists a garden's images, optionally filtered by text and source.
|
||||
func (c *Client) Images(ctx context.Context, gardenID int, query, source string) ([]Image, *Response, error) {
|
||||
values := url.Values{}
|
||||
if query != "" {
|
||||
values.Set("q", query)
|
||||
}
|
||||
if source != "" {
|
||||
values.Set("source", source)
|
||||
}
|
||||
path := "v1/gardens/" + strconv.Itoa(gardenID) + "/images"
|
||||
if encoded := values.Encode(); encoded != "" {
|
||||
path += "?" + encoded
|
||||
}
|
||||
var envelope struct {
|
||||
Images []Image `json:"images"`
|
||||
}
|
||||
response, err := c.do(ctx, http.MethodGet, path, nil, &envelope)
|
||||
return envelope.Images, response, err
|
||||
}
|
||||
|
||||
// ImageData downloads an image body and returns its media type.
|
||||
func (c *Client) ImageData(ctx context.Context, gardenID, imageID int) ([]byte, string, *Response, error) {
|
||||
path := "v1/gardens/" + strconv.Itoa(gardenID) + "/images/" + strconv.Itoa(imageID)
|
||||
relativeURL, err := url.Parse(strings.TrimPrefix(path, "/"))
|
||||
if err != nil {
|
||||
return nil, "", nil, err
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL.ResolveReference(relativeURL).String(), nil)
|
||||
if err != nil {
|
||||
return nil, "", nil, err
|
||||
}
|
||||
request.Header = c.headers.Clone()
|
||||
request.Header.Set("Accept", "image/*")
|
||||
if c.bearerToken != "" {
|
||||
request.Header.Set("Authorization", "Bearer "+c.bearerToken)
|
||||
}
|
||||
response, err := c.httpClient.Do(request)
|
||||
if err != nil {
|
||||
return nil, "", nil, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
wrapped := &Response{Response: response}
|
||||
data, err := io.ReadAll(response.Body)
|
||||
if err == nil && (response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices) {
|
||||
return nil, response.Header.Get("Content-Type"), wrapped, newAPIError(response, data)
|
||||
}
|
||||
return data, response.Header.Get("Content-Type"), wrapped, err
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/textproto"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func journalCollectionPath(gardenID int) string {
|
||||
return "v1/gardens/" + strconv.Itoa(gardenID) + "/journal"
|
||||
}
|
||||
|
||||
// Supported journal entry types.
|
||||
const (
|
||||
JournalEntryTypeJournal = "journal"
|
||||
JournalEntryTypePinboard = "pinboard"
|
||||
)
|
||||
|
||||
func journalEntryPath(gardenID, entryID int) string {
|
||||
return journalCollectionPath(gardenID) + "/" + strconv.Itoa(entryID)
|
||||
}
|
||||
func journalAttachmentPath(gardenID, entryID, attachmentID int) string {
|
||||
return journalEntryPath(gardenID, entryID) + "/attachments/" + strconv.Itoa(attachmentID)
|
||||
}
|
||||
|
||||
// CreateJournalEntry adds a journal or pinboard entry to a garden.
|
||||
func (c *Client) CreateJournalEntry(ctx context.Context, gardenID int, input JournalEntryInput) (JournalEntry, *Response, error) {
|
||||
return c.writeJournalEntry(ctx, http.MethodPost, journalCollectionPath(gardenID), input)
|
||||
}
|
||||
|
||||
// JournalEntries lists all journal and pinboard entries in a garden.
|
||||
func (c *Client) JournalEntries(ctx context.Context, gardenID int) ([]JournalEntry, *Response, error) {
|
||||
return c.JournalEntriesByType(ctx, gardenID, JournalEntryTypeJournal)
|
||||
}
|
||||
|
||||
// JournalEntriesByType lists garden entries of one journal entry type.
|
||||
func (c *Client) JournalEntriesByType(ctx context.Context, gardenID int, entryType string) ([]JournalEntry, *Response, error) {
|
||||
var envelope struct {
|
||||
JournalEntries []JournalEntry `json:"journal_entries"`
|
||||
}
|
||||
path := journalCollectionPath(gardenID)
|
||||
if entryType != "" && entryType != JournalEntryTypeJournal {
|
||||
path += "?type=" + url.QueryEscape(entryType)
|
||||
}
|
||||
response, err := c.do(ctx, http.MethodGet, path, nil, &envelope)
|
||||
return envelope.JournalEntries, response, err
|
||||
}
|
||||
|
||||
// GardenTags lists tag names available within a garden.
|
||||
func (c *Client) GardenTags(ctx context.Context, gardenID int) ([]string, *Response, error) {
|
||||
var envelope struct {
|
||||
Tags []string `json:"tags"`
|
||||
}
|
||||
response, err := c.do(ctx, http.MethodGet, "v1/gardens/"+strconv.Itoa(gardenID)+"/tags", nil, &envelope)
|
||||
return envelope.Tags, response, err
|
||||
}
|
||||
|
||||
// JournalEntry returns one entry within its garden.
|
||||
func (c *Client) JournalEntry(ctx context.Context, gardenID, entryID int) (JournalEntry, *Response, error) {
|
||||
var envelope struct {
|
||||
JournalEntry JournalEntry `json:"journal_entry"`
|
||||
}
|
||||
response, err := c.do(ctx, http.MethodGet, journalEntryPath(gardenID, entryID), nil, &envelope)
|
||||
return envelope.JournalEntry, response, err
|
||||
}
|
||||
|
||||
// UpdateJournalEntry changes an entry within its garden.
|
||||
func (c *Client) UpdateJournalEntry(ctx context.Context, gardenID, entryID int, input JournalEntryInput) (JournalEntry, *Response, error) {
|
||||
return c.writeJournalEntry(ctx, http.MethodPatch, journalEntryPath(gardenID, entryID), input)
|
||||
}
|
||||
|
||||
func (c *Client) writeJournalEntry(ctx context.Context, method, path string, input JournalEntryInput) (JournalEntry, *Response, error) {
|
||||
var envelope struct {
|
||||
JournalEntry JournalEntry `json:"journal_entry"`
|
||||
}
|
||||
response, err := c.do(ctx, method, path, input, &envelope)
|
||||
return envelope.JournalEntry, response, err
|
||||
}
|
||||
|
||||
// DeleteJournalEntry removes an entry and its attachment records.
|
||||
func (c *Client) DeleteJournalEntry(ctx context.Context, gardenID, entryID int) (*Response, error) {
|
||||
return c.do(ctx, http.MethodDelete, journalEntryPath(gardenID, entryID), nil, nil)
|
||||
}
|
||||
|
||||
// UploadJournalAttachment adds binary media to an entry.
|
||||
func (c *Client) UploadJournalAttachment(ctx context.Context, gardenID, entryID int, fileName, mediaType string, data []byte) (JournalAttachment, *Response, error) {
|
||||
var body bytes.Buffer
|
||||
w := multipart.NewWriter(&body)
|
||||
header := make(textproto.MIMEHeader)
|
||||
header.Set("Content-Disposition", mime.FormatMediaType("form-data", map[string]string{"name": "file", "filename": fileName}))
|
||||
header.Set("Content-Type", mediaType)
|
||||
part, err := w.CreatePart(header)
|
||||
if err != nil {
|
||||
return JournalAttachment{}, nil, err
|
||||
}
|
||||
if _, err = part.Write(data); err != nil {
|
||||
return JournalAttachment{}, nil, err
|
||||
}
|
||||
if err = w.Close(); err != nil {
|
||||
return JournalAttachment{}, nil, err
|
||||
}
|
||||
response, responseBody, err := c.doBinary(ctx, http.MethodPost, journalEntryPath(gardenID, entryID)+"/attachments", &body, w.FormDataContentType(), 2<<20)
|
||||
if err != nil {
|
||||
return JournalAttachment{}, response, err
|
||||
}
|
||||
var envelope struct {
|
||||
Attachment JournalAttachment `json:"attachment"`
|
||||
}
|
||||
if err = json.Unmarshal(responseBody, &envelope); err != nil {
|
||||
return JournalAttachment{}, response, fmt.Errorf("client: decode response: %w", err)
|
||||
}
|
||||
return envelope.Attachment, response, nil
|
||||
}
|
||||
|
||||
// AttachJournalLibraryImage links an existing garden image to an entry.
|
||||
func (c *Client) AttachJournalLibraryImage(ctx context.Context, gardenID, entryID, imageID int) (JournalAttachment, *Response, error) {
|
||||
var envelope struct {
|
||||
Attachment JournalAttachment `json:"attachment"`
|
||||
}
|
||||
response, err := c.do(ctx, http.MethodPost, journalEntryPath(gardenID, entryID)+"/attachments/library", map[string]int{"image_id": imageID}, &envelope)
|
||||
return envelope.Attachment, response, err
|
||||
}
|
||||
|
||||
// JournalAttachment downloads attachment metadata and binary data.
|
||||
func (c *Client) JournalAttachment(ctx context.Context, gardenID, entryID, attachmentID int) (JournalAttachmentData, *Response, error) {
|
||||
response, data, err := c.doBinary(ctx, http.MethodGet, journalAttachmentPath(gardenID, entryID, attachmentID), nil, "", (25<<20)+1)
|
||||
if err != nil {
|
||||
return JournalAttachmentData{}, response, err
|
||||
}
|
||||
attachment := JournalAttachmentData{Data: data}
|
||||
attachment.ID, attachment.EntryID = attachmentID, entryID
|
||||
attachment.MediaType = strings.Split(response.Header.Get("Content-Type"), ";")[0]
|
||||
if _, params, parseErr := mime.ParseMediaType(response.Header.Get("Content-Disposition")); parseErr == nil {
|
||||
attachment.FileName = params["filename"]
|
||||
}
|
||||
attachment.Size = int64(len(data))
|
||||
return attachment, response, nil
|
||||
}
|
||||
|
||||
// DeleteJournalAttachment removes an attachment from an entry.
|
||||
func (c *Client) DeleteJournalAttachment(ctx context.Context, gardenID, entryID, attachmentID int) (*Response, error) {
|
||||
return c.do(ctx, http.MethodDelete, journalAttachmentPath(gardenID, entryID, attachmentID), nil, nil)
|
||||
}
|
||||
|
||||
func (c *Client) doBinary(ctx context.Context, method, path string, body io.Reader, contentType string, limit int64) (*Response, []byte, error) {
|
||||
relativeURL, err := url.Parse(strings.TrimPrefix(path, "/"))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, method, c.baseURL.ResolveReference(relativeURL).String(), body)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
request.Header = c.headers.Clone()
|
||||
request.Header.Set("Accept", "application/json, image/*, video/*, audio/*")
|
||||
if contentType != "" {
|
||||
request.Header.Set("Content-Type", contentType)
|
||||
}
|
||||
if c.bearerToken != "" {
|
||||
request.Header.Set("Authorization", "Bearer "+c.bearerToken)
|
||||
}
|
||||
httpResponse, err := c.httpClient.Do(request)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("client: execute request: %w", err)
|
||||
}
|
||||
response := &Response{httpResponse}
|
||||
defer httpResponse.Body.Close()
|
||||
data, err := io.ReadAll(io.LimitReader(httpResponse.Body, limit+1))
|
||||
if err != nil {
|
||||
return response, nil, err
|
||||
}
|
||||
if int64(len(data)) > limit {
|
||||
return response, nil, fmt.Errorf("client: response exceeds %d bytes", limit)
|
||||
}
|
||||
if httpResponse.StatusCode < 200 || httpResponse.StatusCode >= 300 {
|
||||
return response, nil, newAPIError(httpResponse, data)
|
||||
}
|
||||
return response, data, nil
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestJournalClientAndAttachments(t *testing.T) {
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/v1/gardens/4/journal":
|
||||
_, _ = w.Write([]byte(`{"journal_entries":[{"id":8,"title":"Ernte"}]}`))
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/v1/gardens/4/journal/8/attachments":
|
||||
if err := r.ParseMultipartForm(1024); err != nil {
|
||||
t.Error(err)
|
||||
http.Error(w, "bad multipart", 400)
|
||||
return
|
||||
}
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
http.Error(w, "missing file", 400)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
data, _ := io.ReadAll(file)
|
||||
if header.Filename != "ernte.jpg" || string(data) != "jpeg" {
|
||||
t.Errorf("unexpected upload: %q %q", header.Filename, data)
|
||||
}
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = w.Write([]byte(`{"attachment":{"id":9,"entry_id":8,"file_name":"ernte.jpg","media_type":"image/jpeg","size":4}}`))
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/v1/gardens/4/journal/8/attachments/9":
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
w.Header().Set("Content-Disposition", `inline; filename="ernte.jpg"`)
|
||||
_, _ = w.Write([]byte("jpeg"))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
})
|
||||
apiClient := newTestClient(t, handler)
|
||||
entries, _, err := apiClient.JournalEntries(context.Background(), 4)
|
||||
if err != nil || len(entries) != 1 || entries[0].Title != "Ernte" {
|
||||
t.Fatalf("entries: %#v, %v", entries, err)
|
||||
}
|
||||
attachment, _, err := apiClient.UploadJournalAttachment(context.Background(), 4, 8, "ernte.jpg", "image/jpeg", []byte("jpeg"))
|
||||
if err != nil || attachment.ID != 9 {
|
||||
t.Fatalf("upload: %#v, %v", attachment, err)
|
||||
}
|
||||
download, _, err := apiClient.JournalAttachment(context.Background(), 4, 8, 9)
|
||||
if err != nil || download.FileName != "ernte.jpg" || !strings.EqualFold(string(download.Data), "jpeg") {
|
||||
t.Fatalf("download: %#v, %v", download, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// CreateLocation creates a location within a garden.
|
||||
func (c *Client) CreateLocation(ctx context.Context, gardenID int, input LocationInput) (Location, *Response, error) {
|
||||
return c.writeLocation(ctx, http.MethodPost, locationCollectionPath(gardenID), input)
|
||||
}
|
||||
|
||||
// Locations lists all locations in a garden.
|
||||
func (c *Client) Locations(ctx context.Context, gardenID int) ([]Location, *Response, error) {
|
||||
var envelope struct {
|
||||
Locations []Location `json:"locations"`
|
||||
}
|
||||
response, err := c.do(ctx, http.MethodGet, locationCollectionPath(gardenID), nil, &envelope)
|
||||
return envelope.Locations, response, err
|
||||
}
|
||||
|
||||
// Location returns one location from a garden.
|
||||
func (c *Client) Location(ctx context.Context, gardenID, locationID int) (Location, *Response, error) {
|
||||
var envelope struct {
|
||||
Location Location `json:"location"`
|
||||
}
|
||||
response, err := c.do(ctx, http.MethodGet, locationPath(gardenID, locationID), nil, &envelope)
|
||||
return envelope.Location, response, err
|
||||
}
|
||||
|
||||
// UpdateLocation partially updates a location.
|
||||
func (c *Client) UpdateLocation(ctx context.Context, gardenID, locationID int, input LocationInput) (Location, *Response, error) {
|
||||
return c.writeLocation(ctx, http.MethodPatch, locationPath(gardenID, locationID), input)
|
||||
}
|
||||
|
||||
// DeleteLocation deletes a location.
|
||||
func (c *Client) DeleteLocation(ctx context.Context, gardenID, locationID int) (*Response, error) {
|
||||
return c.do(ctx, http.MethodDelete, locationPath(gardenID, locationID), nil, nil)
|
||||
}
|
||||
|
||||
func (c *Client) writeLocation(ctx context.Context, method, path string, input LocationInput) (Location, *Response, error) {
|
||||
var envelope struct {
|
||||
Location Location `json:"location"`
|
||||
}
|
||||
response, err := c.do(ctx, method, path, input, &envelope)
|
||||
return envelope.Location, response, err
|
||||
}
|
||||
|
||||
func locationCollectionPath(gardenID int) string {
|
||||
return "v1/gardens/" + strconv.Itoa(gardenID) + "/locations"
|
||||
}
|
||||
|
||||
func locationPath(gardenID, locationID int) string {
|
||||
return locationCollectionPath(gardenID) + "/" + strconv.Itoa(locationID)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLocationAndAssignmentClientPaths(t *testing.T) {
|
||||
t.Parallel()
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/v1/gardens/4/locations":
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = w.Write([]byte(`{"location":{"id":8,"garden_id":4,"name":"Beet","version":1}}`))
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/v1/gardens/4/locations":
|
||||
_, _ = w.Write([]byte(`{"locations":[{"id":8,"garden_id":4,"name":"Beet"}]}`))
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/v1/gardens/4/locations/8":
|
||||
_, _ = w.Write([]byte(`{"location":{"id":8,"garden_id":4,"name":"Beet"}}`))
|
||||
case r.Method == http.MethodPatch && r.URL.Path == "/v1/gardens/4/locations/8":
|
||||
_, _ = w.Write([]byte(`{"location":{"id":8,"garden_id":4,"name":"Beet","version":2}}`))
|
||||
case r.Method == http.MethodDelete && r.URL.Path == "/v1/gardens/4/locations/8":
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/v1/gardens/4/plants/9/locations":
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = w.Write([]byte(`{"plant_location":{"id":10,"plant_id":9,"location_id":8,"quantity":2,"version":1}}`))
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/v1/gardens/4/plants/9/locations":
|
||||
_, _ = w.Write([]byte(`{"plant_locations":[{"id":10,"plant_id":9,"location_id":8,"quantity":2}]}`))
|
||||
case r.Method == http.MethodPatch && r.URL.Path == "/v1/gardens/4/plants/9/locations/10":
|
||||
_, _ = w.Write([]byte(`{"plant_location":{"id":10,"plant_id":9,"location_id":8,"quantity":3,"version":2}}`))
|
||||
case r.Method == http.MethodDelete && r.URL.Path == "/v1/gardens/4/plants/9/locations/10":
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
})
|
||||
apiClient := newTestClient(t, handler)
|
||||
name := "Beet"
|
||||
location, _, err := apiClient.CreateLocation(context.Background(), 4, LocationInput{Name: &name})
|
||||
if err != nil || location.ID != 8 {
|
||||
t.Fatalf("CreateLocation(): location=%+v err=%v", location, err)
|
||||
}
|
||||
locations, _, err := apiClient.Locations(context.Background(), 4)
|
||||
if err != nil || len(locations) != 1 {
|
||||
t.Fatalf("Locations(): locations=%+v err=%v", locations, err)
|
||||
}
|
||||
if _, _, err = apiClient.Location(context.Background(), 4, 8); err != nil {
|
||||
t.Fatalf("Location(): %v", err)
|
||||
}
|
||||
updated, _, err := apiClient.UpdateLocation(context.Background(), 4, 8, LocationInput{Name: &name})
|
||||
if err != nil || updated.Version != 2 {
|
||||
t.Fatalf("UpdateLocation(): location=%+v err=%v", updated, err)
|
||||
}
|
||||
if _, err = apiClient.DeleteLocation(context.Background(), 4, 8); err != nil {
|
||||
t.Fatalf("DeleteLocation(): %v", err)
|
||||
}
|
||||
locationID, quantity := 8, 2
|
||||
assignment, _, err := apiClient.CreatePlantLocation(context.Background(), 4, 9, PlantLocationInput{LocationID: &locationID, Quantity: &quantity})
|
||||
if err != nil || assignment.ID != 10 {
|
||||
t.Fatalf("CreatePlantLocation(): assignment=%+v err=%v", assignment, err)
|
||||
}
|
||||
assignments, _, err := apiClient.PlantLocations(context.Background(), 4, 9)
|
||||
if err != nil || len(assignments) != 1 {
|
||||
t.Fatalf("PlantLocations(): assignments=%+v err=%v", assignments, err)
|
||||
}
|
||||
quantity = 3
|
||||
assignment, _, err = apiClient.UpdatePlantLocation(context.Background(), 4, 9, 10, PlantLocationInput{Quantity: &quantity})
|
||||
if err != nil || assignment.Version != 2 {
|
||||
t.Fatalf("UpdatePlantLocation(): assignment=%+v err=%v", assignment, err)
|
||||
}
|
||||
if _, err = apiClient.DeletePlantLocation(context.Background(), 4, 9, 10); err != nil {
|
||||
t.Fatalf("DeletePlantLocation(): %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,634 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
// User is the public user representation returned by the API.
|
||||
type User struct {
|
||||
ID int `json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
Color string `json:"color"`
|
||||
Activated bool `json:"activated"`
|
||||
Role string `json:"role"`
|
||||
Permissions []string `json:"permissions"`
|
||||
}
|
||||
|
||||
// AdminUserInviteInput contains the identity fields for an administrator-created
|
||||
// account invitation.
|
||||
type AdminUserInviteInput struct {
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
// IsAdmin reports whether the user can manage application roles.
|
||||
func (user User) IsAdmin() bool { return user.Can("roles:manage") }
|
||||
|
||||
// Can reports whether the API-resolved application permissions contain permission.
|
||||
func (user User) Can(permission string) bool {
|
||||
for _, granted := range user.Permissions {
|
||||
if granted == permission || granted == "*" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// AccountSession describes one server-side login session for the current user.
|
||||
type AccountSession struct {
|
||||
ID string `json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
Current bool `json:"current"`
|
||||
}
|
||||
|
||||
// Garden is the public garden representation returned by the API.
|
||||
type Garden struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
ImageData string `json:"image_data,omitempty"`
|
||||
ImageID *int `json:"image_id,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Version int `json:"version"`
|
||||
Role string `json:"role"`
|
||||
Permissions []string `json:"permissions"`
|
||||
}
|
||||
|
||||
// Can reports whether the authenticated member has a concrete permission in
|
||||
// this garden. The API resolves role defaults and garden-specific overrides.
|
||||
func (garden Garden) Can(permission string) bool {
|
||||
for _, granted := range garden.Permissions {
|
||||
if granted == permission || granted == "*" || granted == "garden:*" && permission != "garden:delete" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if garden.Permissions != nil {
|
||||
return false
|
||||
}
|
||||
// Compatibility for older API responses. Custom roles deliberately have no
|
||||
// fallback: their effective permissions must always come from the API.
|
||||
switch garden.Role {
|
||||
case "owner":
|
||||
return validLegacyGardenPermission(permission)
|
||||
case "admin":
|
||||
return validLegacyGardenPermission(permission) && permission != "garden:delete"
|
||||
case "member":
|
||||
switch permission {
|
||||
case "garden:read", "content:write",
|
||||
"plants:create", "plants:read:own", "plants:read:other", "plants:update:own", "plants:delete:own",
|
||||
"locations:create", "locations:read:own", "locations:read:other", "locations:update:own", "locations:delete:own",
|
||||
"tasks:create", "tasks:read:own", "tasks:read:other", "tasks:update:own", "tasks:delete:own", "tasks:complete:own", "tasks:complete:other":
|
||||
return true
|
||||
}
|
||||
case "viewer":
|
||||
return permission == "garden:read" || permission == "plants:read:own" || permission == "plants:read:other" ||
|
||||
permission == "locations:read:own" || permission == "locations:read:other" || permission == "tasks:read:own" || permission == "tasks:read:other"
|
||||
case "worker":
|
||||
return permission == "garden:read" || permission == "tasks:read:own" || permission == "tasks:read:other" ||
|
||||
permission == "tasks:complete:own" || permission == "tasks:complete:other"
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func validLegacyGardenPermission(permission string) bool {
|
||||
switch permission {
|
||||
case "garden:read", "garden:update", "garden:delete", "content:write", "members:write", "species:write",
|
||||
"plants:create", "plants:read:own", "plants:read:other", "plants:update:own", "plants:update:other", "plants:delete:own", "plants:delete:other",
|
||||
"locations:create", "locations:read:own", "locations:read:other", "locations:update:own", "locations:update:other", "locations:delete:own", "locations:delete:other",
|
||||
"tasks:create", "tasks:read:own", "tasks:read:other", "tasks:update:own", "tasks:update:other", "tasks:delete:own", "tasks:delete:other", "tasks:complete:own", "tasks:complete:other":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// GardenMember describes a user's role within one garden.
|
||||
type GardenMember struct {
|
||||
GardenID int `json:"garden_id"`
|
||||
UserID int `json:"user_id"`
|
||||
Role string `json:"role"`
|
||||
JoinedAt time.Time `json:"joined_at"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
// GardenInvite describes a pending invitation to a garden.
|
||||
type GardenInvite struct {
|
||||
ID int `json:"id"`
|
||||
GardenID int `json:"garden_id"`
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
InvitedBy int `json:"invited_by"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// GardenInviteInput contains the recipient and role for a garden invitation.
|
||||
type GardenInviteInput struct {
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
// Role is an application or garden role with its base permissions.
|
||||
type Role struct {
|
||||
Name string `json:"name"`
|
||||
Scope string `json:"scope"`
|
||||
GardenID *int `json:"garden_id,omitempty"`
|
||||
Label string `json:"label"`
|
||||
System bool `json:"system"`
|
||||
Permissions []string `json:"permissions"`
|
||||
}
|
||||
|
||||
// RoleInput contains writable role fields.
|
||||
type RoleInput struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
Label string `json:"label"`
|
||||
Permissions []string `json:"permissions"`
|
||||
}
|
||||
|
||||
// GardenRoleSetting combines a role template with its effective permissions in
|
||||
// one garden.
|
||||
type GardenRoleSetting struct {
|
||||
Role Role `json:"role"`
|
||||
EffectivePermissions []string `json:"effective_permissions"`
|
||||
}
|
||||
|
||||
// CreateGardenInput contains writable fields for a new garden.
|
||||
type CreateGardenInput struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
ImageData string `json:"image_data,omitempty"`
|
||||
ImageID *int `json:"image_id,omitempty"`
|
||||
}
|
||||
|
||||
// UpdateGardenInput contains optional garden fields for a partial update.
|
||||
type UpdateGardenInput struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
ImageData *string `json:"image_data,omitempty"`
|
||||
ImageID *int `json:"image_id,omitempty"`
|
||||
}
|
||||
|
||||
// Species describes global or garden-specific plant master data.
|
||||
type Species struct {
|
||||
ID int `json:"id"`
|
||||
GardenID *int `json:"garden_id,omitempty"`
|
||||
CommonName string `json:"common_name"`
|
||||
Cultivar string `json:"cultivar"`
|
||||
BotanicalName string `json:"botanical_name"`
|
||||
CategoryID *int `json:"category_id,omitempty"`
|
||||
Category string `json:"category"`
|
||||
SunExposure *string `json:"sun_exposure,omitempty"`
|
||||
SoilCondition *string `json:"soil_condition,omitempty"`
|
||||
SoilReaction *string `json:"soil_reaction,omitempty"`
|
||||
WinterProtection *string `json:"winter_protection,omitempty"`
|
||||
SpacingCM *int `json:"spacing_cm,omitempty"`
|
||||
HeightCM *int `json:"height_cm,omitempty"`
|
||||
SowMonthFrom *int `json:"sow_month_from,omitempty"`
|
||||
SowDayFrom *int `json:"sow_day_from,omitempty"`
|
||||
SowMonthTo *int `json:"sow_month_to,omitempty"`
|
||||
SowDayTo *int `json:"sow_day_to,omitempty"`
|
||||
PlantingMonthFrom *int `json:"planting_month_from,omitempty"`
|
||||
PlantingDayFrom *int `json:"planting_day_from,omitempty"`
|
||||
PlantingMonthTo *int `json:"planting_month_to,omitempty"`
|
||||
PlantingDayTo *int `json:"planting_day_to,omitempty"`
|
||||
HarvestMonthFrom *int `json:"harvest_month_from,omitempty"`
|
||||
HarvestDayFrom *int `json:"harvest_day_from,omitempty"`
|
||||
HarvestMonthTo *int `json:"harvest_month_to,omitempty"`
|
||||
HarvestDayTo *int `json:"harvest_day_to,omitempty"`
|
||||
Notes string `json:"notes"`
|
||||
ImageData string `json:"image_data,omitempty"`
|
||||
ImageID *int `json:"image_id,omitempty"`
|
||||
Attributes json.RawMessage `json:"attributes"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Version int `json:"version"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
CreatedBy int `json:"created_by"`
|
||||
UpdatedBy int `json:"updated_by"`
|
||||
}
|
||||
|
||||
// SpeciesInput contains optional species fields used for creation and updates.
|
||||
type SpeciesInput struct {
|
||||
Global bool `json:"global,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
CommonName *string `json:"common_name,omitempty"`
|
||||
Cultivar *string `json:"cultivar,omitempty"`
|
||||
BotanicalName *string `json:"botanical_name,omitempty"`
|
||||
CategoryID *int `json:"category_id,omitempty"`
|
||||
ClearCategoryID bool `json:"clear_category_id,omitempty"`
|
||||
SunExposure *string `json:"sun_exposure,omitempty"`
|
||||
SoilCondition *string `json:"soil_condition,omitempty"`
|
||||
SoilReaction *string `json:"soil_reaction,omitempty"`
|
||||
WinterProtection *string `json:"winter_protection,omitempty"`
|
||||
SpacingCM *int `json:"spacing_cm,omitempty"`
|
||||
HeightCM *int `json:"height_cm,omitempty"`
|
||||
SowMonthFrom *int `json:"sow_month_from,omitempty"`
|
||||
SowDayFrom *int `json:"sow_day_from,omitempty"`
|
||||
ClearSowDayFrom bool `json:"clear_sow_day_from,omitempty"`
|
||||
SowMonthTo *int `json:"sow_month_to,omitempty"`
|
||||
SowDayTo *int `json:"sow_day_to,omitempty"`
|
||||
ClearSowDayTo bool `json:"clear_sow_day_to,omitempty"`
|
||||
PlantingMonthFrom *int `json:"planting_month_from,omitempty"`
|
||||
PlantingDayFrom *int `json:"planting_day_from,omitempty"`
|
||||
ClearPlantingDayFrom bool `json:"clear_planting_day_from,omitempty"`
|
||||
PlantingMonthTo *int `json:"planting_month_to,omitempty"`
|
||||
PlantingDayTo *int `json:"planting_day_to,omitempty"`
|
||||
ClearPlantingDayTo bool `json:"clear_planting_day_to,omitempty"`
|
||||
HarvestMonthFrom *int `json:"harvest_month_from,omitempty"`
|
||||
HarvestDayFrom *int `json:"harvest_day_from,omitempty"`
|
||||
ClearHarvestDayFrom bool `json:"clear_harvest_day_from,omitempty"`
|
||||
HarvestMonthTo *int `json:"harvest_month_to,omitempty"`
|
||||
HarvestDayTo *int `json:"harvest_day_to,omitempty"`
|
||||
ClearHarvestDayTo bool `json:"clear_harvest_day_to,omitempty"`
|
||||
ClearSowRange bool `json:"clear_sow_range,omitempty"`
|
||||
ClearPlantingRange bool `json:"clear_planting_range,omitempty"`
|
||||
ClearHarvestRange bool `json:"clear_harvest_range,omitempty"`
|
||||
Notes *string `json:"notes,omitempty"`
|
||||
ImageData *string `json:"image_data,omitempty"`
|
||||
ImageID *int `json:"image_id,omitempty"`
|
||||
Attributes json.RawMessage `json:"attributes,omitempty"`
|
||||
}
|
||||
|
||||
// SpeciesCategory classifies species and optionally supplies a lifecycle.
|
||||
type SpeciesCategory struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
Active bool `json:"active"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Version int `json:"version"`
|
||||
Lifecycle *string `json:"lifecycle,omitempty"`
|
||||
}
|
||||
|
||||
// SpeciesCategoryInput contains writable category fields.
|
||||
type SpeciesCategoryInput struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
SortOrder *int `json:"sort_order,omitempty"`
|
||||
Active *bool `json:"active,omitempty"`
|
||||
Lifecycle *string `json:"lifecycle,omitempty"`
|
||||
}
|
||||
|
||||
// TaskPriority maps a display name to the numeric priority stored on tasks.
|
||||
type TaskPriority struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Value int `json:"value"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
Active bool `json:"active"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Version int `json:"version"`
|
||||
}
|
||||
|
||||
// TaskPriorityInput contains writable priority-catalogue fields.
|
||||
type TaskPriorityInput struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Value *int `json:"value,omitempty"`
|
||||
SortOrder *int `json:"sort_order,omitempty"`
|
||||
Active *bool `json:"active,omitempty"`
|
||||
}
|
||||
|
||||
// Plant represents a plant instance owned by a garden.
|
||||
type Plant struct {
|
||||
ID int `json:"id"`
|
||||
GardenID int `json:"garden_id"`
|
||||
SpeciesID *int `json:"species_id,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Notes string `json:"notes"`
|
||||
ImageData string `json:"image_data,omitempty"`
|
||||
ImageID *int `json:"image_id,omitempty"`
|
||||
AcquiredAt *time.Time `json:"acquired_at,omitempty"`
|
||||
Status string `json:"status"`
|
||||
RemovedAt *time.Time `json:"removed_at,omitempty"`
|
||||
Attributes json.RawMessage `json:"attributes"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Version int `json:"version"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
CreatedBy int `json:"created_by"`
|
||||
UpdatedBy int `json:"updated_by"`
|
||||
PlantedBy int `json:"planted_by"`
|
||||
PlantedByName string `json:"planted_by_name,omitempty"`
|
||||
}
|
||||
|
||||
// PlantInput contains optional plant fields used for creation and updates.
|
||||
type PlantInput struct {
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
SpeciesID *int `json:"species_id,omitempty"`
|
||||
ClearSpeciesID bool `json:"clear_species_id,omitempty"`
|
||||
Name *string `json:"name,omitempty"`
|
||||
Notes *string `json:"notes,omitempty"`
|
||||
ImageData *string `json:"image_data,omitempty"`
|
||||
ImageID *int `json:"image_id,omitempty"`
|
||||
AcquiredAt *time.Time `json:"acquired_at,omitempty"`
|
||||
ClearAcquiredAt bool `json:"clear_acquired_at,omitempty"`
|
||||
Status *string `json:"status,omitempty"`
|
||||
RemovedAt *time.Time `json:"removed_at,omitempty"`
|
||||
Attributes json.RawMessage `json:"attributes,omitempty"`
|
||||
}
|
||||
|
||||
// Location describes a hierarchical place within a garden.
|
||||
type Location struct {
|
||||
ID int `json:"id"`
|
||||
GardenID int `json:"garden_id"`
|
||||
ParentID *int `json:"parent_id,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
ImageData string `json:"image_data,omitempty"`
|
||||
ImageID *int `json:"image_id,omitempty"`
|
||||
Kind string `json:"kind"`
|
||||
AreaSQM *float64 `json:"area_sqm,omitempty"`
|
||||
SunExposure *string `json:"sun_exposure,omitempty"`
|
||||
SoilCondition *string `json:"soil_condition,omitempty"`
|
||||
SoilReaction *string `json:"soil_reaction,omitempty"`
|
||||
Attributes json.RawMessage `json:"attributes"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Version int `json:"version"`
|
||||
CreatedBy int `json:"created_by"`
|
||||
UpdatedBy int `json:"updated_by"`
|
||||
}
|
||||
|
||||
// LocationInput contains optional location fields used for creation and updates.
|
||||
type LocationInput struct {
|
||||
ParentID *int `json:"parent_id,omitempty"`
|
||||
ClearParentID bool `json:"clear_parent_id,omitempty"`
|
||||
Name *string `json:"name,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
ImageData *string `json:"image_data,omitempty"`
|
||||
ImageID *int `json:"image_id,omitempty"`
|
||||
Kind *string `json:"kind,omitempty"`
|
||||
AreaSQM *float64 `json:"area_sqm,omitempty"`
|
||||
SunExposure *string `json:"sun_exposure,omitempty"`
|
||||
SoilCondition *string `json:"soil_condition,omitempty"`
|
||||
SoilReaction *string `json:"soil_reaction,omitempty"`
|
||||
Attributes json.RawMessage `json:"attributes,omitempty"`
|
||||
}
|
||||
|
||||
// PlantLocation represents a plant's assignment to a location.
|
||||
type PlantLocation struct {
|
||||
ID int `json:"id"`
|
||||
PlantID int `json:"plant_id"`
|
||||
LocationID int `json:"location_id"`
|
||||
Quantity int `json:"quantity"`
|
||||
PlantedAt *time.Time `json:"planted_at,omitempty"`
|
||||
RemovedAt *time.Time `json:"removed_at,omitempty"`
|
||||
Notes string `json:"notes"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Version int `json:"version"`
|
||||
}
|
||||
|
||||
// PlantLocationInput contains optional assignment fields used for creation and updates.
|
||||
type PlantLocationInput struct {
|
||||
LocationID *int `json:"location_id,omitempty"`
|
||||
Quantity *int `json:"quantity,omitempty"`
|
||||
PlantedAt *time.Time `json:"planted_at,omitempty"`
|
||||
ClearPlantedAt bool `json:"clear_planted_at,omitempty"`
|
||||
RemovedAt *time.Time `json:"removed_at,omitempty"`
|
||||
Notes *string `json:"notes,omitempty"`
|
||||
}
|
||||
|
||||
// Task represents a manual or generated garden work item.
|
||||
type Task struct {
|
||||
ID int `json:"id"`
|
||||
GardenID int `json:"garden_id"`
|
||||
PlantID *int `json:"plant_id,omitempty"`
|
||||
LocationID *int `json:"location_id,omitempty"`
|
||||
TemplateID *int `json:"template_id,omitempty"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
DueAtStart *time.Time `json:"due_at_start,omitempty"`
|
||||
DueAtEnd *time.Time `json:"due_at_end,omitempty"`
|
||||
GeneratedFor *time.Time `json:"generated_for,omitempty"`
|
||||
Recurrence string `json:"recurrence,omitempty"`
|
||||
RecurrenceInterval int `json:"recurrence_interval"`
|
||||
RepeatFromID *int `json:"repeat_from_id,omitempty"`
|
||||
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||
CompletedBy *int `json:"completed_by,omitempty"`
|
||||
Priority int `json:"priority"`
|
||||
Active *bool `json:"active,omitempty"`
|
||||
CreatedBy int `json:"created_by"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Version int `json:"version"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
PlantStatusOnCompletion *string `json:"plant_status_on_completion,omitempty"`
|
||||
}
|
||||
|
||||
// TaskInput contains writable fields for task creation and partial updates.
|
||||
type TaskInput struct {
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
PlantID *int `json:"plant_id,omitempty"`
|
||||
LocationID *int `json:"location_id,omitempty"`
|
||||
ClearPlantID bool `json:"clear_plant_id,omitempty"`
|
||||
ClearLocationID bool `json:"clear_location_id,omitempty"`
|
||||
Title *string `json:"title,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
DueAtStart *time.Time `json:"due_at_start,omitempty"`
|
||||
DueAtEnd *time.Time `json:"due_at_end,omitempty"`
|
||||
ClearDueAtStart bool `json:"clear_due_at_start,omitempty"`
|
||||
ClearDueAtEnd bool `json:"clear_due_at_end,omitempty"`
|
||||
Recurrence *string `json:"recurrence,omitempty"`
|
||||
RecurrenceInterval *int `json:"recurrence_interval,omitempty"`
|
||||
Priority *int `json:"priority,omitempty"`
|
||||
Active *bool `json:"active,omitempty"`
|
||||
Completed *bool `json:"completed,omitempty"`
|
||||
PlantStatusOnCompletion *string `json:"plant_status_on_completion,omitempty"`
|
||||
}
|
||||
|
||||
// CareInstruction is garden-specific cultivation guidance for a species.
|
||||
type CareInstruction struct {
|
||||
ID int `json:"id"`
|
||||
SpeciesID int `json:"species_id"`
|
||||
Text string `json:"text"`
|
||||
Status string `json:"status"`
|
||||
CreatedBy int `json:"created_by"`
|
||||
UpdatedBy int `json:"updated_by"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Version int `json:"version"`
|
||||
}
|
||||
|
||||
// CareInstructionInput contains writable care-instruction fields.
|
||||
type CareInstructionInput struct {
|
||||
Text *string `json:"text,omitempty"`
|
||||
Status *string `json:"status,omitempty"`
|
||||
}
|
||||
|
||||
// JournalEntry is a garden journal or pinboard entry with related media.
|
||||
type JournalEntry struct {
|
||||
ID int `json:"id"`
|
||||
GardenID int `json:"garden_id"`
|
||||
AuthorID int `json:"author_id"`
|
||||
AuthorName string `json:"author_name"`
|
||||
AuthorColor string `json:"author_color"`
|
||||
EntryType string `json:"entry_type"`
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Version int `json:"version"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Attachments []JournalAttachment `json:"attachments,omitempty"`
|
||||
}
|
||||
|
||||
// JournalEntryInput contains writable journal-entry fields.
|
||||
type JournalEntryInput struct {
|
||||
Title *string `json:"title,omitempty"`
|
||||
Body *string `json:"body,omitempty"`
|
||||
CreatedAt *time.Time `json:"created_at,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
EntryType *string `json:"entry_type,omitempty"`
|
||||
}
|
||||
|
||||
// JournalAttachment describes attachment metadata without its binary body.
|
||||
type JournalAttachment struct {
|
||||
ID int `json:"id"`
|
||||
EntryID int `json:"entry_id"`
|
||||
FileName string `json:"file_name"`
|
||||
MediaType string `json:"media_type"`
|
||||
Size int64 `json:"size"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ImageID *int `json:"image_id,omitempty"`
|
||||
}
|
||||
|
||||
// JournalAttachmentData combines attachment metadata with its binary body.
|
||||
type JournalAttachmentData struct {
|
||||
JournalAttachment
|
||||
Data []byte
|
||||
}
|
||||
|
||||
// SpeciesTaskTemplate describes a rule for generating tasks from species data.
|
||||
type SpeciesTaskTemplate struct {
|
||||
ID int `json:"id"`
|
||||
SpeciesID int `json:"species_id"`
|
||||
Origin string `json:"origin"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
TriggerType string `json:"trigger_type"`
|
||||
MonthFrom *int `json:"month_from,omitempty"`
|
||||
DayFrom *int `json:"day_from,omitempty"`
|
||||
MonthTo *int `json:"month_to,omitempty"`
|
||||
DayTo *int `json:"day_to,omitempty"`
|
||||
OffsetDaysFrom *int `json:"offset_days_from,omitempty"`
|
||||
OffsetDaysTo *int `json:"offset_days_to,omitempty"`
|
||||
IntervalDays *int `json:"interval_days,omitempty"`
|
||||
TriggerOffset int `json:"trigger_offset"`
|
||||
TriggerOffsetUnit string `json:"trigger_offset_unit"`
|
||||
Duration int `json:"duration"`
|
||||
DurationUnit string `json:"duration_unit"`
|
||||
Recurrence string `json:"recurrence,omitempty"`
|
||||
RecurrenceInterval int `json:"recurrence_interval"`
|
||||
Priority int `json:"priority"`
|
||||
Active bool `json:"active"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Version int `json:"version"`
|
||||
}
|
||||
|
||||
// ApplicationSettings contains application-wide lifecycle automation settings.
|
||||
type ApplicationSettings struct {
|
||||
LifecycleStatusEnabled bool `json:"lifecycle_status_enabled"`
|
||||
LifecycleRemovalMonth int `json:"lifecycle_removal_month"`
|
||||
LifecycleRemovalDay int `json:"lifecycle_removal_day"`
|
||||
Timezone string `json:"timezone"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Version int `json:"version"`
|
||||
}
|
||||
|
||||
// ApplicationSettingsInput contains writable lifecycle automation settings.
|
||||
type ApplicationSettingsInput struct {
|
||||
LifecycleStatusEnabled *bool `json:"lifecycle_status_enabled,omitempty"`
|
||||
LifecycleRemovalMonth *int `json:"lifecycle_removal_month,omitempty"`
|
||||
LifecycleRemovalDay *int `json:"lifecycle_removal_day,omitempty"`
|
||||
Timezone *string `json:"timezone,omitempty"`
|
||||
}
|
||||
|
||||
// EnvironmentVariable describes one effective process configuration value.
|
||||
// Sensitive values are masked by the API before they leave the process.
|
||||
type EnvironmentVariable struct {
|
||||
Component string `json:"component"`
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
// SpeciesTaskTemplateInput contains writable task-template fields.
|
||||
type SpeciesTaskTemplateInput struct {
|
||||
Title *string `json:"title,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
TriggerType *string `json:"trigger_type,omitempty"`
|
||||
MonthFrom *int `json:"month_from,omitempty"`
|
||||
DayFrom *int `json:"day_from,omitempty"`
|
||||
ClearDayFrom bool `json:"clear_day_from,omitempty"`
|
||||
MonthTo *int `json:"month_to,omitempty"`
|
||||
DayTo *int `json:"day_to,omitempty"`
|
||||
ClearDayTo bool `json:"clear_day_to,omitempty"`
|
||||
OffsetDaysFrom *int `json:"offset_days_from,omitempty"`
|
||||
OffsetDaysTo *int `json:"offset_days_to,omitempty"`
|
||||
IntervalDays *int `json:"interval_days,omitempty"`
|
||||
ClearIntervalDays bool `json:"clear_interval_days,omitempty"`
|
||||
TriggerOffset *int `json:"trigger_offset,omitempty"`
|
||||
TriggerOffsetUnit *string `json:"trigger_offset_unit,omitempty"`
|
||||
Duration *int `json:"duration,omitempty"`
|
||||
DurationUnit *string `json:"duration_unit,omitempty"`
|
||||
Recurrence *string `json:"recurrence,omitempty"`
|
||||
RecurrenceInterval *int `json:"recurrence_interval,omitempty"`
|
||||
Priority *int `json:"priority,omitempty"`
|
||||
Active *bool `json:"active,omitempty"`
|
||||
}
|
||||
|
||||
// Credentials contains email and password authentication input.
|
||||
type Credentials struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// RegisterUserInput contains the fields required to register an account.
|
||||
type RegisterUserInput struct {
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// TokenInput wraps a plaintext activation or reset token.
|
||||
type TokenInput struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
// EmailInput wraps an email address for token requests.
|
||||
type EmailInput struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
// UpdatePasswordInput contains a new password and its reset token.
|
||||
type UpdatePasswordInput struct {
|
||||
Password string `json:"password"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
// AuthenticationToken is a bearer token and its expiration time.
|
||||
type AuthenticationToken struct {
|
||||
Token string `json:"token"`
|
||||
Expiry time.Time `json:"expiry"`
|
||||
}
|
||||
|
||||
// Health describes API availability and build information.
|
||||
type Health struct {
|
||||
Status string `json:"status"`
|
||||
ServerTime time.Time `json:"server_time"`
|
||||
SystemInfo SystemInfo `json:"system_info"`
|
||||
}
|
||||
|
||||
// SystemInfo identifies the running API environment and version.
|
||||
type SystemInfo struct {
|
||||
Environment string `json:"environment"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// CreatePlantLocation assigns a plant to a location.
|
||||
func (c *Client) CreatePlantLocation(ctx context.Context, gardenID, plantID int, input PlantLocationInput) (PlantLocation, *Response, error) {
|
||||
return c.writePlantLocation(ctx, http.MethodPost, plantLocationCollectionPath(gardenID, plantID), input)
|
||||
}
|
||||
|
||||
// PlantLocations lists a plant's location assignments.
|
||||
func (c *Client) PlantLocations(ctx context.Context, gardenID, plantID int) ([]PlantLocation, *Response, error) {
|
||||
var envelope struct {
|
||||
PlantLocations []PlantLocation `json:"plant_locations"`
|
||||
}
|
||||
response, err := c.do(ctx, http.MethodGet, plantLocationCollectionPath(gardenID, plantID), nil, &envelope)
|
||||
return envelope.PlantLocations, response, err
|
||||
}
|
||||
|
||||
// UpdatePlantLocation updates one location assignment.
|
||||
func (c *Client) UpdatePlantLocation(ctx context.Context, gardenID, plantID, assignmentID int, input PlantLocationInput) (PlantLocation, *Response, error) {
|
||||
return c.writePlantLocation(ctx, http.MethodPatch, plantLocationPath(gardenID, plantID, assignmentID), input)
|
||||
}
|
||||
|
||||
// DeletePlantLocation deletes one location assignment.
|
||||
func (c *Client) DeletePlantLocation(ctx context.Context, gardenID, plantID, assignmentID int) (*Response, error) {
|
||||
return c.do(ctx, http.MethodDelete, plantLocationPath(gardenID, plantID, assignmentID), nil, nil)
|
||||
}
|
||||
|
||||
func (c *Client) writePlantLocation(ctx context.Context, method, path string, input PlantLocationInput) (PlantLocation, *Response, error) {
|
||||
var envelope struct {
|
||||
PlantLocation PlantLocation `json:"plant_location"`
|
||||
}
|
||||
response, err := c.do(ctx, method, path, input, &envelope)
|
||||
return envelope.PlantLocation, response, err
|
||||
}
|
||||
|
||||
func plantLocationCollectionPath(gardenID, plantID int) string {
|
||||
return plantPath(gardenID, plantID) + "/locations"
|
||||
}
|
||||
|
||||
func plantLocationPath(gardenID, plantID, assignmentID int) string {
|
||||
return plantLocationCollectionPath(gardenID, plantID) + "/" + strconv.Itoa(assignmentID)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// CreatePlant creates a plant instance in a garden.
|
||||
func (c *Client) CreatePlant(ctx context.Context, gardenID int, input PlantInput) (Plant, *Response, error) {
|
||||
return c.writePlant(ctx, http.MethodPost, plantCollectionPath(gardenID), input)
|
||||
}
|
||||
|
||||
// Plants lists all plant instances in a garden.
|
||||
func (c *Client) Plants(ctx context.Context, gardenID int) ([]Plant, *Response, error) {
|
||||
var envelope struct {
|
||||
Plants []Plant `json:"plants"`
|
||||
}
|
||||
response, err := c.do(ctx, http.MethodGet, plantCollectionPath(gardenID), nil, &envelope)
|
||||
return envelope.Plants, response, err
|
||||
}
|
||||
|
||||
// Plant returns one plant from a garden.
|
||||
func (c *Client) Plant(ctx context.Context, gardenID, plantID int) (Plant, *Response, error) {
|
||||
var envelope struct {
|
||||
Plant Plant `json:"plant"`
|
||||
}
|
||||
response, err := c.do(ctx, http.MethodGet, plantPath(gardenID, plantID), nil, &envelope)
|
||||
return envelope.Plant, response, err
|
||||
}
|
||||
|
||||
// UpdatePlant partially updates a plant in a garden.
|
||||
func (c *Client) UpdatePlant(ctx context.Context, gardenID, plantID int, input PlantInput) (Plant, *Response, error) {
|
||||
return c.writePlant(ctx, http.MethodPatch, plantPath(gardenID, plantID), input)
|
||||
}
|
||||
|
||||
// DeletePlant deletes a plant from a garden.
|
||||
func (c *Client) DeletePlant(ctx context.Context, gardenID, plantID int) (*Response, error) {
|
||||
return c.do(ctx, http.MethodDelete, plantPath(gardenID, plantID), nil, nil)
|
||||
}
|
||||
|
||||
func (c *Client) writePlant(ctx context.Context, method, path string, input PlantInput) (Plant, *Response, error) {
|
||||
var envelope struct {
|
||||
Plant Plant `json:"plant"`
|
||||
}
|
||||
response, err := c.do(ctx, method, path, input, &envelope)
|
||||
return envelope.Plant, response, err
|
||||
}
|
||||
|
||||
func plantCollectionPath(gardenID int) string {
|
||||
return "v1/gardens/" + strconv.Itoa(gardenID) + "/plants"
|
||||
}
|
||||
|
||||
func plantPath(gardenID, plantID int) string {
|
||||
return plantCollectionPath(gardenID) + "/" + strconv.Itoa(plantID)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSpeciesAndPlantClientPaths(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
commonName := "Tomate"
|
||||
plantName := "Tomate am Zaun"
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/v1/gardens/4/species":
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = w.Write([]byte(`{"species":{"id":8,"garden_id":4,"common_name":"Tomate","version":1}}`))
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/v1/gardens/4/species":
|
||||
_, _ = w.Write([]byte(`{"species":[{"id":8,"garden_id":4,"common_name":"Tomate"}]}`))
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/v1/gardens/4/species/8":
|
||||
_, _ = w.Write([]byte(`{"species":{"id":8,"garden_id":4,"common_name":"Tomate"}}`))
|
||||
case r.Method == http.MethodPatch && r.URL.Path == "/v1/gardens/4/species/8":
|
||||
_, _ = w.Write([]byte(`{"species":{"id":8,"garden_id":4,"common_name":"Tomate","version":2}}`))
|
||||
case r.Method == http.MethodDelete && r.URL.Path == "/v1/gardens/4/species/8":
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/v1/gardens/4/plants":
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = w.Write([]byte(`{"plant":{"id":9,"garden_id":4,"species_id":8,"name":"Tomate am Zaun","status":"active","version":1}}`))
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/v1/gardens/4/plants":
|
||||
_, _ = w.Write([]byte(`{"plants":[{"id":9,"garden_id":4,"name":"Tomate am Zaun","status":"active"}]}`))
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/v1/gardens/4/plants/9":
|
||||
_, _ = w.Write([]byte(`{"plant":{"id":9,"garden_id":4,"name":"Tomate am Zaun","status":"active"}}`))
|
||||
case r.Method == http.MethodPatch && r.URL.Path == "/v1/gardens/4/plants/9":
|
||||
_, _ = w.Write([]byte(`{"plant":{"id":9,"garden_id":4,"name":"Tomate am Zaun","status":"active","version":2}}`))
|
||||
case r.Method == http.MethodDelete && r.URL.Path == "/v1/gardens/4/plants/9":
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
})
|
||||
|
||||
apiClient := newTestClient(t, handler)
|
||||
species, _, err := apiClient.CreateSpecies(context.Background(), 4, SpeciesInput{CommonName: &commonName})
|
||||
if err != nil || species.ID != 8 {
|
||||
t.Fatalf("CreateSpecies(): species=%+v err=%v", species, err)
|
||||
}
|
||||
allSpecies, _, err := apiClient.SpeciesForGarden(context.Background(), 4)
|
||||
if err != nil || len(allSpecies) != 1 {
|
||||
t.Fatalf("SpeciesForGarden(): species=%+v err=%v", allSpecies, err)
|
||||
}
|
||||
if _, _, err := apiClient.Species(context.Background(), 4, 8); err != nil {
|
||||
t.Fatalf("Species(): %v", err)
|
||||
}
|
||||
updatedSpecies, _, err := apiClient.UpdateSpecies(context.Background(), 4, 8, SpeciesInput{CommonName: &commonName})
|
||||
if err != nil || updatedSpecies.Version != 2 {
|
||||
t.Fatalf("UpdateSpecies(): species=%+v err=%v", updatedSpecies, err)
|
||||
}
|
||||
if _, err := apiClient.DeleteSpecies(context.Background(), 4, 8); err != nil {
|
||||
t.Fatalf("DeleteSpecies(): %v", err)
|
||||
}
|
||||
|
||||
speciesID := 8
|
||||
plant, _, err := apiClient.CreatePlant(context.Background(), 4, PlantInput{Name: &plantName, SpeciesID: &speciesID})
|
||||
if err != nil || plant.ID != 9 {
|
||||
t.Fatalf("CreatePlant(): plant=%+v err=%v", plant, err)
|
||||
}
|
||||
plants, _, err := apiClient.Plants(context.Background(), 4)
|
||||
if err != nil || len(plants) != 1 {
|
||||
t.Fatalf("Plants(): plants=%+v err=%v", plants, err)
|
||||
}
|
||||
if _, _, err := apiClient.Plant(context.Background(), 4, 9); err != nil {
|
||||
t.Fatalf("Plant(): %v", err)
|
||||
}
|
||||
updatedPlant, _, err := apiClient.UpdatePlant(context.Background(), 4, 9, PlantInput{Name: &plantName})
|
||||
if err != nil || updatedPlant.Version != 2 {
|
||||
t.Fatalf("UpdatePlant(): plant=%+v err=%v", updatedPlant, err)
|
||||
}
|
||||
if _, err := apiClient.DeletePlant(context.Background(), 4, 9); err != nil {
|
||||
t.Fatalf("DeletePlant(): %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
// AdminRoles lists application and shared garden role templates.
|
||||
func (c *Client) AdminRoles(ctx context.Context) ([]Role, []Role, *Response, error) {
|
||||
var envelope struct {
|
||||
ApplicationRoles []Role `json:"application_roles"`
|
||||
GardenRoles []Role `json:"garden_roles"`
|
||||
}
|
||||
response, err := c.do(ctx, http.MethodGet, "v1/admin/roles", nil, &envelope)
|
||||
return envelope.ApplicationRoles, envelope.GardenRoles, response, err
|
||||
}
|
||||
|
||||
// CreateAdminRole adds a shared role template.
|
||||
func (c *Client) CreateAdminRole(ctx context.Context, input RoleInput) (Role, *Response, error) {
|
||||
var envelope struct {
|
||||
Role Role `json:"role"`
|
||||
}
|
||||
response, err := c.do(ctx, http.MethodPost, "v1/admin/roles", input, &envelope)
|
||||
return envelope.Role, response, err
|
||||
}
|
||||
|
||||
// UpdateAdminRole changes a shared role template.
|
||||
func (c *Client) UpdateAdminRole(ctx context.Context, name string, input RoleInput) (Role, *Response, error) {
|
||||
var envelope struct {
|
||||
Role Role `json:"role"`
|
||||
}
|
||||
response, err := c.do(ctx, http.MethodPatch, "v1/admin/roles/"+url.PathEscape(name), input, &envelope)
|
||||
return envelope.Role, response, err
|
||||
}
|
||||
|
||||
// DeleteAdminRole removes an unused shared role template.
|
||||
func (c *Client) DeleteAdminRole(ctx context.Context, name string) (*Response, error) {
|
||||
return c.do(ctx, http.MethodDelete, "v1/admin/roles/"+url.PathEscape(name), nil, nil)
|
||||
}
|
||||
|
||||
// GardenRoleSettings lists roles and their effective permissions in a garden.
|
||||
func (c *Client) GardenRoleSettings(ctx context.Context, gardenID int) ([]GardenRoleSetting, *Response, error) {
|
||||
var envelope struct {
|
||||
Roles []GardenRoleSetting `json:"roles"`
|
||||
}
|
||||
response, err := c.do(ctx, http.MethodGet, gardenPath(gardenID)+"/roles", nil, &envelope)
|
||||
return envelope.Roles, response, err
|
||||
}
|
||||
|
||||
// UpdateGardenRoleSettings replaces a role's effective permissions in a garden.
|
||||
func (c *Client) UpdateGardenRoleSettings(ctx context.Context, gardenID int, name string, permissions []string) ([]string, *Response, error) {
|
||||
var envelope struct {
|
||||
EffectivePermissions []string `json:"effective_permissions"`
|
||||
}
|
||||
response, err := c.do(ctx, http.MethodPut, gardenPath(gardenID)+"/roles/"+url.PathEscape(name), map[string][]string{"permissions": permissions}, &envelope)
|
||||
return envelope.EffectivePermissions, response, err
|
||||
}
|
||||
|
||||
// CreateGardenRole adds a custom role owned by one garden.
|
||||
func (c *Client) CreateGardenRole(ctx context.Context, gardenID int, input RoleInput) (Role, *Response, error) {
|
||||
var envelope struct {
|
||||
Role Role `json:"role"`
|
||||
}
|
||||
response, err := c.do(ctx, http.MethodPost, gardenPath(gardenID)+"/roles", input, &envelope)
|
||||
return envelope.Role, response, err
|
||||
}
|
||||
|
||||
// DeleteGardenRole removes an unused custom role from a garden.
|
||||
func (c *Client) DeleteGardenRole(ctx context.Context, gardenID int, name string) (*Response, error) {
|
||||
return c.do(ctx, http.MethodDelete, gardenPath(gardenID)+"/roles/"+url.PathEscape(name), nil, nil)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// CreateSession logs in and stores the returned cookie when the client was
|
||||
// configured with sessions. response.Cookies() can be forwarded by a web
|
||||
// frontend to its browser.
|
||||
func (c *Client) CreateSession(ctx context.Context, credentials Credentials) (User, *Response, error) {
|
||||
var envelope struct {
|
||||
User User `json:"user"`
|
||||
}
|
||||
response, err := c.do(ctx, http.MethodPost, "v1/session", credentials, &envelope)
|
||||
return envelope.User, response, err
|
||||
}
|
||||
|
||||
// Session returns the user associated with the current session cookie.
|
||||
func (c *Client) Session(ctx context.Context) (User, *Response, error) {
|
||||
var envelope struct {
|
||||
User User `json:"user"`
|
||||
}
|
||||
response, err := c.do(ctx, http.MethodGet, "v1/session", nil, &envelope)
|
||||
return envelope.User, response, err
|
||||
}
|
||||
|
||||
// DeleteSession logs out. Forward response.Cookies() to the browser so its
|
||||
// session cookie is removed as well.
|
||||
func (c *Client) DeleteSession(ctx context.Context) (*Response, error) {
|
||||
return c.do(ctx, http.MethodDelete, "v1/session", nil, nil)
|
||||
}
|
||||
|
||||
// ForwardCookies copies API Set-Cookie headers to a frontend response. It is
|
||||
// intended for the Response returned by CreateSession or DeleteSession.
|
||||
func ForwardCookies(w http.ResponseWriter, response *Response) {
|
||||
if w == nil || response == nil || response.Response == nil {
|
||||
return
|
||||
}
|
||||
for _, cookie := range response.Cookies() {
|
||||
http.SetCookie(w, cookie)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// CreateSpecies creates garden-specific plant master data.
|
||||
func (c *Client) CreateSpecies(ctx context.Context, gardenID int, input SpeciesInput) (Species, *Response, error) {
|
||||
return c.writeSpecies(ctx, http.MethodPost, speciesCollectionPath(gardenID), input)
|
||||
}
|
||||
|
||||
// SpeciesForGarden lists global and garden-specific species available to a garden.
|
||||
func (c *Client) SpeciesForGarden(ctx context.Context, gardenID int) ([]Species, *Response, error) {
|
||||
var envelope struct {
|
||||
Species []Species `json:"species"`
|
||||
}
|
||||
response, err := c.do(ctx, http.MethodGet, speciesCollectionPath(gardenID), nil, &envelope)
|
||||
return envelope.Species, response, err
|
||||
}
|
||||
|
||||
// Species returns species data available to a garden.
|
||||
func (c *Client) Species(ctx context.Context, gardenID, speciesID int) (Species, *Response, error) {
|
||||
var envelope struct {
|
||||
Species Species `json:"species"`
|
||||
}
|
||||
response, err := c.do(ctx, http.MethodGet, speciesPath(gardenID, speciesID), nil, &envelope)
|
||||
return envelope.Species, response, err
|
||||
}
|
||||
|
||||
// UpdateSpecies partially updates garden-specific species data.
|
||||
func (c *Client) UpdateSpecies(ctx context.Context, gardenID, speciesID int, input SpeciesInput) (Species, *Response, error) {
|
||||
return c.writeSpecies(ctx, http.MethodPatch, speciesPath(gardenID, speciesID), input)
|
||||
}
|
||||
|
||||
// DeleteSpecies deletes garden-specific species data.
|
||||
func (c *Client) DeleteSpecies(ctx context.Context, gardenID, speciesID int) (*Response, error) {
|
||||
return c.do(ctx, http.MethodDelete, speciesPath(gardenID, speciesID), nil, nil)
|
||||
}
|
||||
|
||||
func (c *Client) writeSpecies(ctx context.Context, method, path string, input SpeciesInput) (Species, *Response, error) {
|
||||
var envelope struct {
|
||||
Species Species `json:"species"`
|
||||
}
|
||||
response, err := c.do(ctx, method, path, input, &envelope)
|
||||
return envelope.Species, response, err
|
||||
}
|
||||
|
||||
func speciesCollectionPath(gardenID int) string {
|
||||
return "v1/gardens/" + strconv.Itoa(gardenID) + "/species"
|
||||
}
|
||||
|
||||
func speciesPath(gardenID, speciesID int) string {
|
||||
return speciesCollectionPath(gardenID) + "/" + strconv.Itoa(speciesID)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// SpeciesCategories lists active categories available to ordinary users.
|
||||
func (c *Client) SpeciesCategories(ctx context.Context) ([]SpeciesCategory, *Response, error) {
|
||||
return c.speciesCategories(ctx, "v1/species-categories")
|
||||
}
|
||||
|
||||
// AdminSpeciesCategories lists all categories, including inactive ones.
|
||||
func (c *Client) AdminSpeciesCategories(ctx context.Context) ([]SpeciesCategory, *Response, error) {
|
||||
return c.speciesCategories(ctx, "v1/admin/species-categories")
|
||||
}
|
||||
|
||||
func (c *Client) speciesCategories(ctx context.Context, path string) ([]SpeciesCategory, *Response, error) {
|
||||
var envelope struct {
|
||||
Categories []SpeciesCategory `json:"categories"`
|
||||
}
|
||||
response, err := c.do(ctx, http.MethodGet, path, nil, &envelope)
|
||||
return envelope.Categories, response, err
|
||||
}
|
||||
|
||||
// CreateAdminSpeciesCategory adds an application-wide species category.
|
||||
func (c *Client) CreateAdminSpeciesCategory(ctx context.Context, input SpeciesCategoryInput) (SpeciesCategory, *Response, error) {
|
||||
return c.writeSpeciesCategory(ctx, http.MethodPost, "v1/admin/species-categories", input)
|
||||
}
|
||||
|
||||
// UpdateAdminSpeciesCategory changes an application-wide species category.
|
||||
func (c *Client) UpdateAdminSpeciesCategory(ctx context.Context, id int, input SpeciesCategoryInput) (SpeciesCategory, *Response, error) {
|
||||
return c.writeSpeciesCategory(ctx, http.MethodPatch, "v1/admin/species-categories/"+strconv.Itoa(id), input)
|
||||
}
|
||||
|
||||
// DeleteAdminSpeciesCategory removes an unused species category.
|
||||
func (c *Client) DeleteAdminSpeciesCategory(ctx context.Context, id int) (*Response, error) {
|
||||
return c.do(ctx, http.MethodDelete, "v1/admin/species-categories/"+strconv.Itoa(id), nil, nil)
|
||||
}
|
||||
|
||||
func (c *Client) writeSpeciesCategory(ctx context.Context, method, path string, input SpeciesCategoryInput) (SpeciesCategory, *Response, error) {
|
||||
var envelope struct {
|
||||
Category SpeciesCategory `json:"category"`
|
||||
}
|
||||
response, err := c.do(ctx, method, path, input, &envelope)
|
||||
return envelope.Category, response, err
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSpeciesCategoryClientPaths(t *testing.T) {
|
||||
apiClient := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/v1/species-categories":
|
||||
_, _ = w.Write([]byte(`{"categories":[{"id":2,"name":"Gemüse","active":true}]}`))
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/v1/admin/species-categories":
|
||||
_, _ = w.Write([]byte(`{"categories":[{"id":2,"name":"Gemüse","active":true}]}`))
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/v1/admin/species-categories":
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = w.Write([]byte(`{"category":{"id":3,"name":"Obst","active":true}}`))
|
||||
case r.Method == http.MethodPatch && r.URL.Path == "/v1/admin/species-categories/3":
|
||||
_, _ = w.Write([]byte(`{"category":{"id":3,"name":"Beerenobst","active":true}}`))
|
||||
case r.Method == http.MethodDelete && r.URL.Path == "/v1/admin/species-categories/3":
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
|
||||
if values, _, err := apiClient.SpeciesCategories(context.Background()); err != nil || len(values) != 1 || values[0].Name != "Gemüse" {
|
||||
t.Fatalf("list categories: values=%+v err=%v", values, err)
|
||||
}
|
||||
if values, _, err := apiClient.AdminSpeciesCategories(context.Background()); err != nil || len(values) != 1 {
|
||||
t.Fatalf("admin list categories: values=%+v err=%v", values, err)
|
||||
}
|
||||
name := "Obst"
|
||||
if value, _, err := apiClient.CreateAdminSpeciesCategory(context.Background(), SpeciesCategoryInput{Name: &name}); err != nil || value.ID != 3 {
|
||||
t.Fatalf("create category: value=%+v err=%v", value, err)
|
||||
}
|
||||
name = "Beerenobst"
|
||||
if value, _, err := apiClient.UpdateAdminSpeciesCategory(context.Background(), 3, SpeciesCategoryInput{Name: &name}); err != nil || value.Name != name {
|
||||
t.Fatalf("update category: value=%+v err=%v", value, err)
|
||||
}
|
||||
if _, err := apiClient.DeleteAdminSpeciesCategory(context.Background(), 3); err != nil {
|
||||
t.Fatalf("delete category: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// CreateSpeciesTaskTemplate adds an automatic task rule to a species.
|
||||
func (c *Client) CreateSpeciesTaskTemplate(ctx context.Context, gardenID, speciesID int, input SpeciesTaskTemplateInput) (SpeciesTaskTemplate, *Response, error) {
|
||||
return c.writeSpeciesTaskTemplate(ctx, http.MethodPost, speciesTaskTemplateCollectionPath(gardenID, speciesID), input)
|
||||
}
|
||||
|
||||
// SpeciesTaskTemplates lists task-generation rules for a species.
|
||||
func (c *Client) SpeciesTaskTemplates(ctx context.Context, gardenID, speciesID int) ([]SpeciesTaskTemplate, *Response, error) {
|
||||
var envelope struct {
|
||||
TaskTemplates []SpeciesTaskTemplate `json:"task_templates"`
|
||||
}
|
||||
response, err := c.do(ctx, http.MethodGet, speciesTaskTemplateCollectionPath(gardenID, speciesID), nil, &envelope)
|
||||
return envelope.TaskTemplates, response, err
|
||||
}
|
||||
|
||||
// SpeciesTaskTemplate returns one task-generation rule within its species.
|
||||
func (c *Client) SpeciesTaskTemplate(ctx context.Context, gardenID, speciesID, templateID int) (SpeciesTaskTemplate, *Response, error) {
|
||||
var envelope struct {
|
||||
TaskTemplate SpeciesTaskTemplate `json:"task_template"`
|
||||
}
|
||||
response, err := c.do(ctx, http.MethodGet, speciesTaskTemplatePath(gardenID, speciesID, templateID), nil, &envelope)
|
||||
return envelope.TaskTemplate, response, err
|
||||
}
|
||||
|
||||
// UpdateSpeciesTaskTemplate changes a task-generation rule.
|
||||
func (c *Client) UpdateSpeciesTaskTemplate(ctx context.Context, gardenID, speciesID, templateID int, input SpeciesTaskTemplateInput) (SpeciesTaskTemplate, *Response, error) {
|
||||
return c.writeSpeciesTaskTemplate(ctx, http.MethodPatch, speciesTaskTemplatePath(gardenID, speciesID, templateID), input)
|
||||
}
|
||||
|
||||
// DeleteSpeciesTaskTemplate removes a task-generation rule.
|
||||
func (c *Client) DeleteSpeciesTaskTemplate(ctx context.Context, gardenID, speciesID, templateID int) (*Response, error) {
|
||||
return c.do(ctx, http.MethodDelete, speciesTaskTemplatePath(gardenID, speciesID, templateID), nil, nil)
|
||||
}
|
||||
func (c *Client) writeSpeciesTaskTemplate(ctx context.Context, method, path string, input SpeciesTaskTemplateInput) (SpeciesTaskTemplate, *Response, error) {
|
||||
var envelope struct {
|
||||
TaskTemplate SpeciesTaskTemplate `json:"task_template"`
|
||||
}
|
||||
response, err := c.do(ctx, method, path, input, &envelope)
|
||||
return envelope.TaskTemplate, response, err
|
||||
}
|
||||
func speciesTaskTemplateCollectionPath(gardenID, speciesID int) string {
|
||||
return speciesPath(gardenID, speciesID) + "/task-templates"
|
||||
}
|
||||
func speciesTaskTemplatePath(gardenID, speciesID, templateID int) string {
|
||||
return speciesTaskTemplateCollectionPath(gardenID, speciesID) + "/" + strconv.Itoa(templateID)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSpeciesTaskTemplateClientPaths(t *testing.T) {
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/v1/gardens/3/species/2/task-templates":
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = w.Write([]byte(`{"task_template":{"id":7,"species_id":2,"title":"Schneiden"}}`))
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/v1/gardens/3/species/2/task-templates":
|
||||
_, _ = w.Write([]byte(`{"task_templates":[{"id":7,"species_id":2,"title":"Schneiden"}]}`))
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/v1/gardens/3/species/2/task-templates/7":
|
||||
_, _ = w.Write([]byte(`{"task_template":{"id":7,"species_id":2,"title":"Schneiden"}}`))
|
||||
case r.Method == http.MethodPatch && r.URL.Path == "/v1/gardens/3/species/2/task-templates/7":
|
||||
_, _ = w.Write([]byte(`{"task_template":{"id":7,"species_id":2,"title":"Schneiden","version":2}}`))
|
||||
case r.Method == http.MethodDelete && r.URL.Path == "/v1/gardens/3/species/2/task-templates/7":
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
})
|
||||
apiClient := newTestClient(t, handler)
|
||||
title := "Schneiden"
|
||||
if value, _, err := apiClient.CreateSpeciesTaskTemplate(context.Background(), 3, 2, SpeciesTaskTemplateInput{Title: &title}); err != nil || value.ID != 7 {
|
||||
t.Fatalf("create: %+v %v", value, err)
|
||||
}
|
||||
if values, _, err := apiClient.SpeciesTaskTemplates(context.Background(), 3, 2); err != nil || len(values) != 1 {
|
||||
t.Fatalf("list: %+v %v", values, err)
|
||||
}
|
||||
if _, _, err := apiClient.SpeciesTaskTemplate(context.Background(), 3, 2, 7); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if value, _, err := apiClient.UpdateSpeciesTaskTemplate(context.Background(), 3, 2, 7, SpeciesTaskTemplateInput{Title: &title}); err != nil || value.Version != 2 {
|
||||
t.Fatalf("update: %+v %v", value, err)
|
||||
}
|
||||
if _, err := apiClient.DeleteSpeciesTaskTemplate(context.Background(), 3, 2, 7); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// TaskPriorities lists active priority choices for task forms.
|
||||
func (c *Client) TaskPriorities(ctx context.Context) ([]TaskPriority, *Response, error) {
|
||||
return c.taskPriorities(ctx, "v1/task-priorities")
|
||||
}
|
||||
|
||||
// AdminTaskPriorities lists all priority catalogue entries.
|
||||
func (c *Client) AdminTaskPriorities(ctx context.Context) ([]TaskPriority, *Response, error) {
|
||||
return c.taskPriorities(ctx, "v1/admin/task-priorities")
|
||||
}
|
||||
func (c *Client) taskPriorities(ctx context.Context, path string) ([]TaskPriority, *Response, error) {
|
||||
var envelope struct {
|
||||
Priorities []TaskPriority `json:"priorities"`
|
||||
}
|
||||
response, err := c.do(ctx, http.MethodGet, path, nil, &envelope)
|
||||
return envelope.Priorities, response, err
|
||||
}
|
||||
|
||||
// CreateAdminTaskPriority adds an application-wide priority choice.
|
||||
func (c *Client) CreateAdminTaskPriority(ctx context.Context, input TaskPriorityInput) (TaskPriority, *Response, error) {
|
||||
return c.writeTaskPriority(ctx, http.MethodPost, "v1/admin/task-priorities", input)
|
||||
}
|
||||
|
||||
// UpdateAdminTaskPriority changes an application-wide priority choice.
|
||||
func (c *Client) UpdateAdminTaskPriority(ctx context.Context, id int, input TaskPriorityInput) (TaskPriority, *Response, error) {
|
||||
return c.writeTaskPriority(ctx, http.MethodPatch, "v1/admin/task-priorities/"+strconv.Itoa(id), input)
|
||||
}
|
||||
|
||||
// DeleteAdminTaskPriority removes an unused priority choice.
|
||||
func (c *Client) DeleteAdminTaskPriority(ctx context.Context, id int) (*Response, error) {
|
||||
return c.do(ctx, http.MethodDelete, "v1/admin/task-priorities/"+strconv.Itoa(id), nil, nil)
|
||||
}
|
||||
func (c *Client) writeTaskPriority(ctx context.Context, method, path string, input TaskPriorityInput) (TaskPriority, *Response, error) {
|
||||
var envelope struct {
|
||||
Priority TaskPriority `json:"priority"`
|
||||
}
|
||||
response, err := c.do(ctx, method, path, input, &envelope)
|
||||
return envelope.Priority, response, err
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// TaskTemplateOptOuts lists template IDs suppressed for a plant.
|
||||
func (c *Client) TaskTemplateOptOuts(ctx context.Context, gardenID, plantID int) ([]int, *Response, error) {
|
||||
var envelope struct {
|
||||
TemplateIDs []int `json:"template_ids"`
|
||||
}
|
||||
response, err := c.do(ctx, http.MethodGet, gardenPath(gardenID)+"/plants/"+strconv.Itoa(plantID)+"/task-template-opt-outs", nil, &envelope)
|
||||
return envelope.TemplateIDs, response, err
|
||||
}
|
||||
|
||||
// SetTaskTemplateOptOut enables or disables automatic generation from a template.
|
||||
func (c *Client) SetTaskTemplateOptOut(ctx context.Context, gardenID, plantID, templateID int, optedOut bool) (*Response, error) {
|
||||
method := http.MethodPost
|
||||
if !optedOut {
|
||||
method = http.MethodDelete
|
||||
}
|
||||
return c.do(ctx, method, gardenPath(gardenID)+"/plants/"+strconv.Itoa(plantID)+"/task-template-opt-outs/"+strconv.Itoa(templateID), nil, nil)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// CreateTask creates a work item in a garden.
|
||||
func (c *Client) CreateTask(ctx context.Context, gardenID int, input TaskInput) (Task, *Response, error) {
|
||||
return c.writeTask(ctx, http.MethodPost, taskCollectionPath(gardenID), input)
|
||||
}
|
||||
|
||||
// Tasks lists work items in a garden.
|
||||
func (c *Client) Tasks(ctx context.Context, gardenID int) ([]Task, *Response, error) {
|
||||
var envelope struct {
|
||||
Tasks []Task `json:"tasks"`
|
||||
}
|
||||
response, err := c.do(ctx, http.MethodGet, taskCollectionPath(gardenID), nil, &envelope)
|
||||
return envelope.Tasks, response, err
|
||||
}
|
||||
|
||||
// Task returns one work item within its garden.
|
||||
func (c *Client) Task(ctx context.Context, gardenID, taskID int) (Task, *Response, error) {
|
||||
var envelope struct {
|
||||
Task Task `json:"task"`
|
||||
}
|
||||
response, err := c.do(ctx, http.MethodGet, taskPath(gardenID, taskID), nil, &envelope)
|
||||
return envelope.Task, response, err
|
||||
}
|
||||
|
||||
// UpdateTask partially updates a work item within its garden.
|
||||
func (c *Client) UpdateTask(ctx context.Context, gardenID, taskID int, input TaskInput) (Task, *Response, error) {
|
||||
return c.writeTask(ctx, http.MethodPatch, taskPath(gardenID, taskID), input)
|
||||
}
|
||||
|
||||
// DeleteTask removes a work item from its garden.
|
||||
func (c *Client) DeleteTask(ctx context.Context, gardenID, taskID int) (*Response, error) {
|
||||
return c.do(ctx, http.MethodDelete, taskPath(gardenID, taskID), nil, nil)
|
||||
}
|
||||
|
||||
func (c *Client) writeTask(ctx context.Context, method, path string, input TaskInput) (Task, *Response, error) {
|
||||
var envelope struct {
|
||||
Task Task `json:"task"`
|
||||
}
|
||||
response, err := c.do(ctx, method, path, input, &envelope)
|
||||
return envelope.Task, response, err
|
||||
}
|
||||
|
||||
func taskCollectionPath(gardenID int) string {
|
||||
return "v1/gardens/" + strconv.Itoa(gardenID) + "/tasks"
|
||||
}
|
||||
|
||||
func taskPath(gardenID, taskID int) string {
|
||||
return taskCollectionPath(gardenID) + "/" + strconv.Itoa(taskID)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTaskClientPaths(t *testing.T) {
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/v1/gardens/4/tasks":
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = w.Write([]byte(`{"task":{"id":8,"garden_id":4,"title":"Gießen"}}`))
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/v1/gardens/4/tasks":
|
||||
_, _ = w.Write([]byte(`{"tasks":[{"id":8,"garden_id":4,"title":"Gießen"}]}`))
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/v1/gardens/4/tasks/8":
|
||||
_, _ = w.Write([]byte(`{"task":{"id":8,"garden_id":4,"title":"Gießen"}}`))
|
||||
case r.Method == http.MethodPatch && r.URL.Path == "/v1/gardens/4/tasks/8":
|
||||
_, _ = w.Write([]byte(`{"task":{"id":8,"garden_id":4,"title":"Gießen","version":2}}`))
|
||||
case r.Method == http.MethodDelete && r.URL.Path == "/v1/gardens/4/tasks/8":
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
})
|
||||
apiClient := newTestClient(t, handler)
|
||||
title := "Gießen"
|
||||
if task, _, err := apiClient.CreateTask(context.Background(), 4, TaskInput{Title: &title}); err != nil || task.ID != 8 {
|
||||
t.Fatalf("CreateTask: %+v %v", task, err)
|
||||
}
|
||||
if tasks, _, err := apiClient.Tasks(context.Background(), 4); err != nil || len(tasks) != 1 {
|
||||
t.Fatalf("Tasks: %+v %v", tasks, err)
|
||||
}
|
||||
if _, _, err := apiClient.Task(context.Background(), 4, 8); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if task, _, err := apiClient.UpdateTask(context.Background(), 4, 8, TaskInput{Title: &title}); err != nil || task.Version != 2 {
|
||||
t.Fatalf("UpdateTask: %+v %v", task, err)
|
||||
}
|
||||
if _, err := apiClient.DeleteTask(context.Background(), 4, 8); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// CreateAuthenticationToken exchanges credentials for a bearer token.
|
||||
func (c *Client) CreateAuthenticationToken(ctx context.Context, credentials Credentials) (AuthenticationToken, *Response, error) {
|
||||
var envelope struct {
|
||||
Token AuthenticationToken `json:"authentication_token"`
|
||||
}
|
||||
response, err := c.do(ctx, http.MethodPost, "v1/tokens/authentication", credentials, &envelope)
|
||||
return envelope.Token, response, err
|
||||
}
|
||||
|
||||
// CreateActivationToken requests a new account activation email.
|
||||
func (c *Client) CreateActivationToken(ctx context.Context, email string) (string, *Response, error) {
|
||||
var envelope struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
response, err := c.do(ctx, http.MethodPost, "v1/tokens/activation", EmailInput{Email: email}, &envelope)
|
||||
return envelope.Message, response, err
|
||||
}
|
||||
|
||||
// CreatePasswordResetToken requests password reset instructions for email.
|
||||
func (c *Client) CreatePasswordResetToken(ctx context.Context, email string) (string, *Response, error) {
|
||||
var envelope struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
response, err := c.do(ctx, http.MethodPost, "v1/tokens/password-reset", EmailInput{Email: email}, &envelope)
|
||||
return envelope.Message, response, err
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// RegisterUser creates an inactive user account.
|
||||
func (c *Client) RegisterUser(ctx context.Context, input RegisterUserInput) (User, *Response, error) {
|
||||
var envelope struct {
|
||||
User User `json:"user"`
|
||||
}
|
||||
response, err := c.do(ctx, http.MethodPost, "v1/users", input, &envelope)
|
||||
return envelope.User, response, err
|
||||
}
|
||||
|
||||
// ActivateUser activates an account using a plaintext activation token.
|
||||
func (c *Client) ActivateUser(ctx context.Context, token string) (User, *Response, error) {
|
||||
return c.activateUser(ctx, map[string]string{"token": token})
|
||||
}
|
||||
|
||||
// ActivateInvitedUser activates an account and sets its initial password.
|
||||
func (c *Client) ActivateInvitedUser(ctx context.Context, token, password string) (User, *Response, error) {
|
||||
return c.activateUser(ctx, map[string]string{"token": token, "password": password})
|
||||
}
|
||||
|
||||
func (c *Client) activateUser(ctx context.Context, input any) (User, *Response, error) {
|
||||
var envelope struct {
|
||||
User User `json:"user"`
|
||||
}
|
||||
response, err := c.do(ctx, http.MethodPut, "v1/users/activated", input, &envelope)
|
||||
return envelope.User, response, err
|
||||
}
|
||||
|
||||
// UpdatePassword replaces a password using a valid reset token.
|
||||
func (c *Client) UpdatePassword(ctx context.Context, input UpdatePasswordInput) (string, *Response, error) {
|
||||
var envelope struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
response, err := c.do(ctx, http.MethodPut, "v1/users/password", input, &envelope)
|
||||
return envelope.Message, response, err
|
||||
}
|
||||
|
||||
// AdminUsers lists all non-deleted user accounts.
|
||||
func (c *Client) AdminUsers(ctx context.Context) ([]User, *Response, error) {
|
||||
var envelope struct {
|
||||
Users []User `json:"users"`
|
||||
}
|
||||
response, err := c.do(ctx, http.MethodGet, "v1/admin/users", nil, &envelope)
|
||||
return envelope.Users, response, err
|
||||
}
|
||||
|
||||
// InviteAdminUser creates an inactive account and sends its invitation.
|
||||
func (c *Client) InviteAdminUser(ctx context.Context, input AdminUserInviteInput) (User, *Response, error) {
|
||||
var envelope struct {
|
||||
User User `json:"user"`
|
||||
}
|
||||
response, err := c.do(ctx, http.MethodPost, "v1/admin/users", input, &envelope)
|
||||
return envelope.User, response, err
|
||||
}
|
||||
|
||||
// UpdateAdminUserRole changes an account's application role.
|
||||
func (c *Client) UpdateAdminUserRole(ctx context.Context, userID int, role string) (User, *Response, error) {
|
||||
var envelope struct {
|
||||
User User `json:"user"`
|
||||
}
|
||||
response, err := c.do(ctx, http.MethodPatch, "v1/admin/users/"+strconv.Itoa(userID), map[string]string{"role": role}, &envelope)
|
||||
return envelope.User, response, err
|
||||
}
|
||||
|
||||
// DeleteAdminUser permanently anonymizes and deactivates an account.
|
||||
func (c *Client) DeleteAdminUser(ctx context.Context, userID int) (*Response, error) {
|
||||
return c.do(ctx, http.MethodDelete, "v1/admin/users/"+strconv.Itoa(userID), nil, nil)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAdminInvitationAndInvitedUserActivationRequests(t *testing.T) {
|
||||
var inviteSeen, activationSeen bool
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/v1/admin/users":
|
||||
var input AdminUserInviteInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
inviteSeen = input.Name == "Ada" && input.Email == "ada@example.com"
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
_, _ = w.Write([]byte(`{"user":{"id":42,"name":"Ada","email":"ada@example.com"}}`))
|
||||
case r.Method == http.MethodPut && r.URL.Path == "/v1/users/activated":
|
||||
var input map[string]string
|
||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
activationSeen = input["token"] == "activation-token" && input["password"] == "new-password"
|
||||
_, _ = w.Write([]byte(`{"user":{"id":42,"activated":true}}`))
|
||||
case r.Method == http.MethodDelete && r.URL.Path == "/v1/admin/users/42":
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
})
|
||||
apiClient := newTestClient(t, handler)
|
||||
|
||||
if _, _, err := apiClient.InviteAdminUser(t.Context(), AdminUserInviteInput{Name: "Ada", Email: "ada@example.com"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := apiClient.ActivateInvitedUser(t.Context(), "activation-token", "new-password"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := apiClient.DeleteAdminUser(t.Context(), 42); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !inviteSeen || !activationSeen {
|
||||
t.Fatalf("request payloads not received: invite=%t activation=%t", inviteSeen, activationSeen)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user