Initial commit
CI / test (push) Canceled after 0s

This commit is contained in:
2026-09-12 22:22:17 +02:00
commit 904d14b64c
314 changed files with 31884 additions and 0 deletions
+48
View File
@@ -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)
}
+38
View File
@@ -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)
}
+42
View File
@@ -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
}
+262
View File
@@ -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
}
+233
View File
@@ -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))
}
}
+16
View File
@@ -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
}
+2
View File
@@ -0,0 +1,2 @@
// Package client provides a typed HTTP client for the Gardomatic JSON API.
package client
+49
View File
@@ -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
}
+67
View File
@@ -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
}
+52
View File
@@ -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)
}
+89
View File
@@ -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)
}
}
+13
View File
@@ -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
}
+71
View File
@@ -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
}
+188
View File
@@ -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
}
+56
View File
@@ -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)
}
}
+56
View File
@@ -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)
}
+75
View File
@@ -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)
}
}
+634
View File
@@ -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"`
}
+47
View File
@@ -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)
}
+56
View File
@@ -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)
}
+83
View File
@@ -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)
}
}
+72
View File
@@ -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)
}
+43
View File
@@ -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)
}
}
+56
View File
@@ -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)
}
+48
View File
@@ -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
}
+46
View File
@@ -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)
}
}
+53
View File
@@ -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)
}
+45
View File
@@ -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)
}
}
+46
View File
@@ -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
}
+25
View File
@@ -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)
}
+56
View File
@@ -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)
}
+45
View File
@@ -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)
}
}
+33
View File
@@ -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
}
+75
View File
@@ -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)
}
+49
View File
@@ -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)
}
}