Skip to content

Commit 0073e2c

Browse files
committed
Handle join-table list responses gracefully
1 parent 8a65a15 commit 0073e2c

4 files changed

Lines changed: 160 additions & 0 deletions

File tree

client/client.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ package client
22

33
import (
44
"errors"
5+
"fmt"
56
"log/slog"
7+
"strings"
68

79
daptinClient "github.com/daptin/daptin-go-client"
810
"github.com/go-resty/resty/v2"
@@ -81,6 +83,38 @@ func (e *ExtendedClient) FindOne(tableName, referenceId string, parameters dapti
8183
return ParseSingleResponse(resp.Body())
8284
}
8385

86+
// FindAll overrides the upstream to handle unsupported/malformed API responses
87+
// without panicking on JSON:API data shape assertions.
88+
func (e *ExtendedClient) FindAll(tableName string, parameters daptinClient.DaptinQueryParameters) ([]daptinClient.JsonApiObject, error) {
89+
u := BuildFindAllURL(e.Endpoint, tableName, parameters)
90+
slog.Debug("FindAll", "url", u)
91+
92+
resp, err := e.nextRequest().Get(u)
93+
if err := e.checkResponse(resp, err); err != nil {
94+
if errors.Is(err, ErrNotFound) && looksLikeJoinTable(tableName) {
95+
return nil, unsupportedJoinTableError(tableName)
96+
}
97+
return nil, err
98+
}
99+
100+
items, err := ParseListResponse(resp.Body())
101+
if err != nil {
102+
return nil, err
103+
}
104+
if items == nil {
105+
if looksLikeJoinTable(tableName) {
106+
return nil, unsupportedJoinTableError(tableName)
107+
}
108+
return nil, fmt.Errorf("unexpected list response for %q: expected JSON:API data array", tableName)
109+
}
110+
111+
result := make([]daptinClient.JsonApiObject, 0, len(items))
112+
for _, item := range items {
113+
result = append(result, daptinClient.JsonApiObject(item))
114+
}
115+
return result, nil
116+
}
117+
84118
// Update overrides the upstream to handle error responses without panicking.
85119
func (e *ExtendedClient) Update(tableName, referenceId string, object daptinClient.JsonApiObject) (daptinClient.JsonApiObject, error) {
86120
u := e.Endpoint + "/api/" + tableName + "/" + referenceId
@@ -121,3 +155,11 @@ func MapArray(objects []daptinClient.JsonApiObject, keyName string) []map[string
121155
}
122156
return result
123157
}
158+
159+
func looksLikeJoinTable(tableName string) bool {
160+
return strings.Contains(tableName, "_has_")
161+
}
162+
163+
func unsupportedJoinTableError(tableName string) error {
164+
return fmt.Errorf("%q looks like a generated join table. Daptin join tables are internal storage and are not exposed as API entities; use the owning API entity and relation/permission commands instead", tableName)
165+
}

client/client_test.go

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
package client
2+
3+
import (
4+
"errors"
5+
"net/http"
6+
"net/http/httptest"
7+
"strings"
8+
"testing"
9+
10+
daptinClient "github.com/daptin/daptin-go-client"
11+
)
12+
13+
func TestFindAllParsesValidListResponse(t *testing.T) {
14+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
15+
if r.URL.Path != "/api/usergroup" {
16+
t.Fatalf("unexpected path: %s", r.URL.Path)
17+
}
18+
if r.URL.Query().Get("page[size]") != "10" {
19+
t.Fatalf("expected page[size]=10, got %q", r.URL.Query().Get("page[size]"))
20+
}
21+
w.Header().Set("Content-Type", "application/json")
22+
_, _ = w.Write([]byte(`{"data":[{"id":"1","type":"usergroup","attributes":{"name":"users"}}]}`))
23+
}))
24+
defer server.Close()
25+
26+
c := New(server.URL, "", false)
27+
rows, err := c.FindAll("usergroup", daptinClient.DaptinQueryParameters{"page[size]": 10})
28+
if err != nil {
29+
t.Fatalf("unexpected error: %v", err)
30+
}
31+
if len(rows) != 1 {
32+
t.Fatalf("expected 1 row, got %d", len(rows))
33+
}
34+
if rows[0]["id"] != "1" {
35+
t.Fatalf("expected row id 1, got %v", rows[0]["id"])
36+
}
37+
}
38+
39+
func TestFindAllJoinTableNotFoundReturnsHelpfulError(t *testing.T) {
40+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
41+
http.Error(w, `{"error":"not found"}`, http.StatusNotFound)
42+
}))
43+
defer server.Close()
44+
45+
c := New(server.URL, "", false)
46+
_, err := c.FindAll("oauth_connect_oauth_connect_id_has_usergroup_usergroup_id", nil)
47+
if err == nil {
48+
t.Fatal("expected error")
49+
}
50+
if !strings.Contains(err.Error(), "generated join table") {
51+
t.Fatalf("expected join table guidance, got: %v", err)
52+
}
53+
if errors.Is(err, ErrNotFound) {
54+
t.Fatalf("expected contextual join table error, got ErrNotFound: %v", err)
55+
}
56+
}
57+
58+
func TestFindAllJoinTableMalformedResponseReturnsHelpfulError(t *testing.T) {
59+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
60+
w.Header().Set("Content-Type", "application/json")
61+
_, _ = w.Write([]byte(`{"data":null}`))
62+
}))
63+
defer server.Close()
64+
65+
c := New(server.URL, "", false)
66+
_, err := c.FindAll("user_account_user_account_id_has_usergroup_usergroup_id", nil)
67+
if err == nil {
68+
t.Fatal("expected error")
69+
}
70+
if !strings.Contains(err.Error(), "not exposed as API entities") {
71+
t.Fatalf("expected API entity guidance, got: %v", err)
72+
}
73+
}
74+
75+
func TestFindAllMalformedResponseReturnsError(t *testing.T) {
76+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
77+
w.Header().Set("Content-Type", "application/json")
78+
_, _ = w.Write([]byte(`{"data":null}`))
79+
}))
80+
defer server.Close()
81+
82+
c := New(server.URL, "", false)
83+
_, err := c.FindAll("document", nil)
84+
if err == nil {
85+
t.Fatal("expected error")
86+
}
87+
if !strings.Contains(err.Error(), "expected JSON:API data array") {
88+
t.Fatalf("expected malformed response error, got: %v", err)
89+
}
90+
}

client/parse.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,3 +96,18 @@ func BuildFindOneURL(endpoint, tableName, referenceId string, parameters map[str
9696
slog.Debug("built FindOne URL", "url", u)
9797
return u
9898
}
99+
100+
// BuildFindAllURL constructs the URL for a list request.
101+
// Pure function.
102+
func BuildFindAllURL(endpoint, tableName string, parameters map[string]interface{}) string {
103+
u := endpoint + "/api/" + tableName
104+
if len(parameters) > 0 {
105+
params := url.Values{}
106+
for k, v := range parameters {
107+
params.Set(k, fmt.Sprintf("%v", v))
108+
}
109+
u = u + "?" + params.Encode()
110+
}
111+
slog.Debug("built FindAll URL", "url", u)
112+
return u
113+
}

client/parse_test.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,19 @@ func TestBuildFindOneURL_WithParams(t *testing.T) {
137137
}
138138
}
139139

140+
func TestBuildFindAllURL_WithParams(t *testing.T) {
141+
params := map[string]interface{}{
142+
"page[size]": 50,
143+
"page[number]": 2,
144+
"query": `{"name":"admin"}`,
145+
}
146+
u := BuildFindAllURL("http://localhost:6336", "usergroup", params)
147+
expected := `http://localhost:6336/api/usergroup?page%5Bnumber%5D=2&page%5Bsize%5D=50&query=%7B%22name%22%3A%22admin%22%7D`
148+
if u != expected {
149+
t.Errorf("expected %s, got %s", expected, u)
150+
}
151+
}
152+
140153
func TestMapArray(t *testing.T) {
141154
objects := []daptinClient.JsonApiObject{
142155
{"id": "1", "attributes": map[string]interface{}{"name": "a"}},

0 commit comments

Comments
 (0)