mirror of
https://github.com/bitechdev/ResolveSpec.git
synced 2026-10-01 19:20:31 +00:00
feat(clients): add Go, Rust, C# and Dart clients for ResolveSpec and FunctionSpec
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
# resolvespec-go
|
||||
|
||||
Go client for ResolveSpec (JSON body) and FunctionSpec. Module: `github.com/bitechdev/ResolveSpec/clients/resolvespec-go`. Stdlib only.
|
||||
|
||||
## Clients
|
||||
|
||||
| Type | Constructor | Methods |
|
||||
|---|---|---|
|
||||
| `Client` | `NewClient(baseURL, opts...)` | `GetMetadata` `Read` `Create` `Update` `Delete` |
|
||||
| `FuncSpecClient` | `NewFuncSpecClient(baseURL, opts...)` | `Query` `QueryList` `Do` |
|
||||
|
||||
Client options: `WithToken`, `WithHeader`, `WithHTTPClient`. Precedence: Content-Type < custom headers < bearer token.
|
||||
|
||||
## ResolveSpec
|
||||
|
||||
- All methods take `ctx`; `Read`/`Update`/`Delete` take `RecordID` (`nil`, int/string → URL, `[]string` → body).
|
||||
- `Options` fields use pointers for optional ints/bools (`Int(n)`, `Bool(b)`).
|
||||
- Result: `*Response{Success, Data (raw JSON), Metadata}`; `resp.Decode(&v)`.
|
||||
|
||||
## FunctionSpec
|
||||
|
||||
- Routes are server-defined: pass the `path`.
|
||||
- `Params` → query string (slice → repeated keys, bool → `true`/`false`).
|
||||
- `FuncSpecOptions` → `X-*` headers: `Filters`, `SearchFilters`, `CustomSQLWhere`, `CustomSQLOr`, `Sort`, `Limit`, `Offset`, `Distinct`, `SkipCount`, `SkipCache`, `ResponseFormat`.
|
||||
- `QueryList` fills `Metadata` from `Content-Range` (`items a-b/total`); 206 is success.
|
||||
|
||||
## Server quirks
|
||||
|
||||
- `Sort` is raw SQL in ORDER BY (client sends `col ASC|DESC`).
|
||||
- One search operator per column.
|
||||
- Values starting `ZIP_` / `__` are base64-decoded by the server.
|
||||
- Non-ASCII, control chars and edge spaces are auto-encoded (`ZIP_`).
|
||||
|
||||
## Errors
|
||||
|
||||
`*Error{StatusCode, APIError{Code, Message, Detail, SQL}}`.
|
||||
|
||||
## Test
|
||||
|
||||
`go test ./...`
|
||||
@@ -0,0 +1,121 @@
|
||||
package resolvespec
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// APIError is the server error object.
|
||||
type APIError struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Details any `json:"details,omitempty"`
|
||||
Detail string `json:"detail,omitempty"` // server-side reason (funcspec / restheadspec)
|
||||
SQL string `json:"sql,omitempty"`
|
||||
}
|
||||
|
||||
// Error is returned on a non-2xx response or an unsuccessful result.
|
||||
type Error struct {
|
||||
StatusCode int
|
||||
APIError
|
||||
}
|
||||
|
||||
func (e *Error) Error() string {
|
||||
if e.Message != "" {
|
||||
return e.Message
|
||||
}
|
||||
return fmt.Sprintf("http %d", e.StatusCode)
|
||||
}
|
||||
|
||||
type config struct {
|
||||
baseURL string
|
||||
token string
|
||||
headers http.Header
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
// Option configures a client.
|
||||
type Option func(*config)
|
||||
|
||||
func WithToken(token string) Option { return func(c *config) { c.token = token } }
|
||||
func WithHTTPClient(h *http.Client) Option { return func(c *config) { c.http = h } }
|
||||
func WithHeader(name, value string) Option {
|
||||
return func(c *config) { c.headers.Set(name, value) }
|
||||
}
|
||||
|
||||
func newConfig(baseURL string, opts []Option) config {
|
||||
c := config{baseURL: strings.TrimRight(baseURL, "/"), headers: http.Header{}, http: &http.Client{Timeout: 30 * time.Second}}
|
||||
for _, o := range opts {
|
||||
o(&c)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// headers: Content-Type < custom headers < bearer token.
|
||||
func (c *config) newRequest(ctx context.Context, method, u string, body []byte) (*http.Request, error) {
|
||||
var r io.Reader
|
||||
if body != nil {
|
||||
r = bytes.NewReader(body)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, u, r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
for k, vs := range c.headers {
|
||||
req.Header[k] = append([]string(nil), vs...)
|
||||
}
|
||||
if c.token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
}
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func (c *config) do(req *http.Request) (*http.Response, []byte, error) {
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
return resp, b, err
|
||||
}
|
||||
|
||||
func errorFrom(status int, body []byte) *Error {
|
||||
e := &Error{StatusCode: status}
|
||||
var env struct {
|
||||
Error *APIError `json:"error"`
|
||||
}
|
||||
if json.Unmarshal(body, &env) == nil && env.Error != nil {
|
||||
e.APIError = *env.Error
|
||||
}
|
||||
if e.Message == "" {
|
||||
text := ""
|
||||
if !json.Valid(body) {
|
||||
text = strings.TrimSpace(string(body))
|
||||
if len(text) > 200 {
|
||||
text = text[:200]
|
||||
}
|
||||
}
|
||||
if text == "" {
|
||||
text = fmt.Sprintf("%s (%d)", http.StatusText(status), status)
|
||||
}
|
||||
e.Message = text
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
func buildURL(base, schema, entity string, id string) string {
|
||||
u := base + "/" + url.PathEscape(schema) + "/" + url.PathEscape(entity)
|
||||
if id != "" {
|
||||
u += "/" + url.PathEscape(id)
|
||||
}
|
||||
return u
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
package resolvespec
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// FuncSpecOptions are sent to funcspec endpoints as X-* headers.
|
||||
//
|
||||
// Server behaviour (pkg/funcspec): Sort is inserted raw into ORDER BY (so it is sent as SQL
|
||||
// terms); only one search operator per column is kept; values starting with "ZIP_" or "__"
|
||||
// are base64-decoded by the server, so such plaintext values cannot be sent faithfully.
|
||||
type FuncSpecOptions struct {
|
||||
Filters []FilterOption // eq+AND -> X-FieldFilter; others X-SearchOp / X-SearchOr
|
||||
SearchFilters map[string]string // X-SearchFilter-{col}: text ILIKE
|
||||
CustomSQLWhere string // X-Custom-SQL-W
|
||||
CustomSQLOr string // X-Custom-SQL-Or
|
||||
Sort []SortOption
|
||||
Limit *int
|
||||
Offset *int
|
||||
Distinct *bool
|
||||
SkipCount *bool
|
||||
SkipCache *bool
|
||||
ResponseFormat string // simple | detail | syncfusion
|
||||
}
|
||||
|
||||
// Params are query-string values. Slice values are sent as repeated keys (server: IN filter).
|
||||
type Params map[string]any
|
||||
|
||||
// FuncSpecClient calls user-defined SQL endpoints. Routes are defined by the server app.
|
||||
type FuncSpecClient struct{ cfg config }
|
||||
|
||||
func NewFuncSpecClient(baseURL string, opts ...Option) *FuncSpecClient {
|
||||
return &FuncSpecClient{cfg: newConfig(baseURL, opts)}
|
||||
}
|
||||
|
||||
var operatorMap = map[string]string{
|
||||
"eq": "equals", "neq": "notequals", "gt": "greaterthan", "gte": "greaterthanorequal",
|
||||
"lt": "lessthan", "lte": "lessthanorequal", "like": "contains", "ilike": "contains",
|
||||
"contains": "contains", "startswith": "beginswith", "endswith": "endswith", "in": "in",
|
||||
"between": "between", "between_inclusive": "betweeninclusive",
|
||||
"is_null": "empty", "is_not_null": "notempty",
|
||||
}
|
||||
|
||||
func scalar(v any) string {
|
||||
switch x := v.(type) {
|
||||
case nil:
|
||||
return ""
|
||||
case bool:
|
||||
return strconv.FormatBool(x)
|
||||
case string:
|
||||
return x
|
||||
case fmt.Stringer:
|
||||
return x.String()
|
||||
}
|
||||
return fmt.Sprint(v)
|
||||
}
|
||||
|
||||
func filterValue(v any) string {
|
||||
switch x := v.(type) {
|
||||
case nil:
|
||||
return ""
|
||||
case []string:
|
||||
return strings.Join(x, ",")
|
||||
case []int:
|
||||
parts := make([]string, len(x))
|
||||
for i, n := range x {
|
||||
parts[i] = strconv.Itoa(n)
|
||||
}
|
||||
return strings.Join(parts, ",")
|
||||
case []any:
|
||||
parts := make([]string, len(x))
|
||||
for i, n := range x {
|
||||
parts[i] = scalar(n)
|
||||
}
|
||||
return strings.Join(parts, ",")
|
||||
}
|
||||
return scalar(v)
|
||||
}
|
||||
|
||||
// EncodeHeaderValue base64-encodes (UTF-8) with the ZIP_ prefix.
|
||||
func EncodeHeaderValue(v string) string { return "ZIP_" + base64.StdEncoding.EncodeToString([]byte(v)) }
|
||||
|
||||
// DecodeHeaderValue decodes a value that may carry a ZIP_ or __ prefix (nested allowed).
|
||||
func DecodeHeaderValue(v string) string {
|
||||
for _, p := range []string{"ZIP_", "__"} {
|
||||
if strings.HasPrefix(v, p) {
|
||||
b64 := strings.NewReplacer("\n", "", "\r", "", " ", "").Replace(v[len(p):])
|
||||
for len(b64)%4 != 0 {
|
||||
b64 += "="
|
||||
}
|
||||
raw, err := base64.StdEncoding.DecodeString(b64)
|
||||
if err != nil {
|
||||
return v
|
||||
}
|
||||
return DecodeHeaderValue(string(raw))
|
||||
}
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// safe encodes values that are unsafe as raw header/query text (non-ASCII, control chars, edge spaces).
|
||||
func safe(v string) string {
|
||||
if v != strings.TrimSpace(v) {
|
||||
return EncodeHeaderValue(v)
|
||||
}
|
||||
for _, r := range v {
|
||||
if r > unicode.MaxASCII || !unicode.IsPrint(r) {
|
||||
return EncodeHeaderValue(v)
|
||||
}
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// BuildHeaders builds the X-* headers understood by funcspec.ParseParameters.
|
||||
func BuildHeaders(o *FuncSpecOptions) map[string]string {
|
||||
h := map[string]string{}
|
||||
if o == nil {
|
||||
return h
|
||||
}
|
||||
for _, f := range o.Filters {
|
||||
logic := f.LogicOperator
|
||||
if logic == "" {
|
||||
logic = "AND"
|
||||
}
|
||||
v := safe(filterValue(f.Value))
|
||||
if f.Operator == "eq" && logic == "AND" {
|
||||
h["X-FieldFilter-"+f.Column] = v
|
||||
continue
|
||||
}
|
||||
op := operatorMap[f.Operator]
|
||||
if op == "" {
|
||||
op = f.Operator
|
||||
}
|
||||
kind := "X-SearchOp"
|
||||
if logic == "OR" {
|
||||
kind = "X-SearchOr"
|
||||
}
|
||||
h[kind+"-"+op+"-"+f.Column] = v
|
||||
}
|
||||
for col, text := range o.SearchFilters {
|
||||
h["X-SearchFilter-"+col] = safe(text)
|
||||
}
|
||||
if o.CustomSQLWhere != "" {
|
||||
h["X-Custom-SQL-W"] = safe(o.CustomSQLWhere)
|
||||
}
|
||||
if o.CustomSQLOr != "" {
|
||||
h["X-Custom-SQL-Or"] = safe(o.CustomSQLOr)
|
||||
}
|
||||
if len(o.Sort) > 0 {
|
||||
terms := make([]string, len(o.Sort))
|
||||
for i, s := range o.Sort {
|
||||
dir := "ASC"
|
||||
if strings.EqualFold(s.Direction, "desc") {
|
||||
dir = "DESC"
|
||||
}
|
||||
terms[i] = s.Column + " " + dir // funcspec puts this verbatim into ORDER BY
|
||||
}
|
||||
h["X-Sort"] = safe(strings.Join(terms, ","))
|
||||
}
|
||||
if o.Limit != nil {
|
||||
h["X-Limit"] = strconv.Itoa(*o.Limit)
|
||||
}
|
||||
if o.Offset != nil {
|
||||
h["X-Offset"] = strconv.Itoa(*o.Offset)
|
||||
}
|
||||
for name, v := range map[string]*bool{"X-Distinct": o.Distinct, "X-SkipCount": o.SkipCount, "X-SkipCache": o.SkipCache} {
|
||||
if v != nil {
|
||||
h[name] = strconv.FormatBool(*v)
|
||||
}
|
||||
}
|
||||
switch o.ResponseFormat {
|
||||
case "simple":
|
||||
h["X-SimpleApi"] = "true"
|
||||
case "detail":
|
||||
h["X-DetailApi"] = "true"
|
||||
case "syncfusion":
|
||||
h["X-Syncfusion"] = "true"
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// BuildQuery builds query-string values: bools -> true/false, slices -> repeated keys, nil skipped.
|
||||
func BuildQuery(p Params) url.Values {
|
||||
q := url.Values{}
|
||||
for k, v := range p {
|
||||
switch x := v.(type) {
|
||||
case nil:
|
||||
case []string:
|
||||
for _, e := range x {
|
||||
q.Add(k, safe(e))
|
||||
}
|
||||
case []int:
|
||||
for _, e := range x {
|
||||
q.Add(k, strconv.Itoa(e))
|
||||
}
|
||||
case []any:
|
||||
for _, e := range x {
|
||||
q.Add(k, safe(scalar(e)))
|
||||
}
|
||||
default:
|
||||
q.Add(k, safe(scalar(v)))
|
||||
}
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
||||
var contentRange = regexp.MustCompile(`(\d+)-(\d+)/(\d+)`)
|
||||
|
||||
func metadata(h http.Header, o *FuncSpecOptions) *Metadata {
|
||||
m := &Metadata{}
|
||||
if g := contentRange.FindStringSubmatch(h.Get("Content-Range")); g != nil {
|
||||
start, _ := strconv.ParseInt(g[1], 10, 64)
|
||||
end, _ := strconv.ParseInt(g[2], 10, 64)
|
||||
total, _ := strconv.ParseInt(g[3], 10, 64)
|
||||
m.Total, m.Count, m.Filtered, m.Offset = total, end-start, total, int(start)
|
||||
}
|
||||
if o != nil && o.Limit != nil {
|
||||
m.Limit = *o.Limit
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (c *FuncSpecClient) call(ctx context.Context, method, path string, p Params, o *FuncSpecOptions, withMeta bool) (*Response, error) {
|
||||
u := c.cfg.baseURL + "/" + strings.TrimLeft(path, "/")
|
||||
if q := BuildQuery(p); len(q) > 0 {
|
||||
u += "?" + q.Encode()
|
||||
}
|
||||
req, err := c.cfg.newRequest(ctx, strings.ToUpper(method), u, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for k, v := range BuildHeaders(o) {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
resp, b, err := c.cfg.do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode > 299 { // 206 is success
|
||||
return nil, errorFrom(resp.StatusCode, b)
|
||||
}
|
||||
out := &Response{Success: true, Data: json.RawMessage(b)}
|
||||
if len(b) == 0 {
|
||||
out.Data = json.RawMessage("null")
|
||||
}
|
||||
if withMeta {
|
||||
out.Metadata = metadata(resp.Header, o)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Query calls a single-record endpoint (SqlQuery). Data is the row object.
|
||||
func (c *FuncSpecClient) Query(ctx context.Context, path string, p Params, o *FuncSpecOptions) (*Response, error) {
|
||||
return c.call(ctx, http.MethodGet, path, p, o, false)
|
||||
}
|
||||
|
||||
// QueryList calls a list endpoint (SqlQueryList). Metadata comes from Content-Range.
|
||||
func (c *FuncSpecClient) QueryList(ctx context.Context, path string, p Params, o *FuncSpecOptions) (*Response, error) {
|
||||
return c.call(ctx, http.MethodGet, path, p, o, true)
|
||||
}
|
||||
|
||||
// Do is like Query/QueryList with an explicit HTTP method (routes are app-defined).
|
||||
func (c *FuncSpecClient) Do(ctx context.Context, method, path string, p Params, o *FuncSpecOptions, list bool) (*Response, error) {
|
||||
return c.call(ctx, method, path, p, o, list)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package resolvespec
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBuildHeadersFilters(t *testing.T) {
|
||||
got := BuildHeaders(&FuncSpecOptions{Filters: []FilterOption{
|
||||
{Column: "status", Operator: "eq", Value: "active"},
|
||||
{Column: "age", Operator: "gte", Value: 18},
|
||||
{Column: "name", Operator: "contains", Value: "x", LogicOperator: "OR"},
|
||||
{Column: "deleted", Operator: "is_null"},
|
||||
{Column: "id", Operator: "in", Value: []int{1, 2}},
|
||||
{Column: "p", Operator: "between_inclusive", Value: []any{1, 5}},
|
||||
}})
|
||||
want := map[string]string{
|
||||
"X-FieldFilter-status": "active",
|
||||
"X-SearchOp-greaterthanorequal-age": "18",
|
||||
"X-SearchOr-contains-name": "x",
|
||||
"X-SearchOp-empty-deleted": "",
|
||||
"X-SearchOp-in-id": "1,2",
|
||||
"X-SearchOp-betweeninclusive-p": "1,5",
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("%v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildHeadersMisc(t *testing.T) {
|
||||
got := BuildHeaders(&FuncSpecOptions{
|
||||
SearchFilters: map[string]string{"name": "bob"}, CustomSQLWhere: "a = 1", CustomSQLOr: "b = 2",
|
||||
Sort: []SortOption{{"name", "asc"}, {"created_at", "DESC"}},
|
||||
Limit: Int(5), Offset: Int(10), Distinct: Bool(true), SkipCount: Bool(true), SkipCache: Bool(false),
|
||||
ResponseFormat: "syncfusion",
|
||||
})
|
||||
want := map[string]string{
|
||||
"X-SearchFilter-name": "bob", "X-Custom-SQL-W": "a = 1", "X-Custom-SQL-Or": "b = 2",
|
||||
"X-Sort": "name ASC,created_at DESC", "X-Limit": "5", "X-Offset": "10", "X-Distinct": "true",
|
||||
"X-SkipCount": "true", "X-SkipCache": "false", "X-Syncfusion": "true",
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("%v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeUnsafe(t *testing.T) {
|
||||
h := BuildHeaders(&FuncSpecOptions{Filters: []FilterOption{{Column: "n", Operator: "eq", Value: "héllo"}, {Column: "m", Operator: "eq", Value: " pad"}}})
|
||||
for _, k := range []string{"X-FieldFilter-n", "X-FieldFilter-m"} {
|
||||
if len(h[k]) < 4 || h[k][:4] != "ZIP_" {
|
||||
t.Fatalf("%s=%q", k, h[k])
|
||||
}
|
||||
}
|
||||
if DecodeHeaderValue(h["X-FieldFilter-n"]) != "héllo" || DecodeHeaderValue(h["X-FieldFilter-m"]) != " pad" {
|
||||
t.Fatal("roundtrip")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildQuery(t *testing.T) {
|
||||
q := BuildQuery(Params{"a": true, "b": []string{"x", "y"}, "c": nil, "d": 3})
|
||||
if q.Get("a") != "true" || !reflect.DeepEqual(q["b"], []string{"x", "y"}) || q.Has("c") || q.Get("d") != "3" {
|
||||
t.Fatalf("%v", q)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryListMetadata(t *testing.T) {
|
||||
srv, s := server(t, 206, `[{"id":1},{"id":2}]`, map[string]string{"Content-Range": "items 10-12/50"})
|
||||
c := NewFuncSpecClient(srv.URL, WithToken("tok"))
|
||||
resp, err := c.QueryList(context.Background(), "/api/users", Params{"org": 1}, &FuncSpecOptions{Limit: Int(2)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.method != "GET" || s.path != "/api/users?org=1" || s.header.Get("X-Limit") != "2" {
|
||||
t.Fatalf("%s %v", s.path, s.header)
|
||||
}
|
||||
m := resp.Metadata
|
||||
if m.Total != 50 || m.Count != 2 || m.Offset != 10 || m.Limit != 2 || m.Filtered != 50 {
|
||||
t.Fatalf("%+v", m)
|
||||
}
|
||||
var rows []map[string]any
|
||||
if err := resp.Decode(&rows); err != nil || len(rows) != 2 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuerySingleNoMetadataAndError(t *testing.T) {
|
||||
srv, _ := server(t, 200, `{"id":1}`, nil)
|
||||
resp, err := NewFuncSpecClient(srv.URL).Query(context.Background(), "api/u", nil, nil)
|
||||
if err != nil || resp.Metadata != nil {
|
||||
t.Fatalf("%v %v", resp, err)
|
||||
}
|
||||
srv2, _ := server(t, 400, `{"success":false,"error":{"code":"hook_error","message":"Hook execution failed","detail":"authentication required"}}`, nil)
|
||||
_, err = NewFuncSpecClient(srv2.URL).Query(context.Background(), "api/u", nil, nil)
|
||||
if e := err.(*Error); e.Code != "hook_error" || e.Detail != "authentication required" {
|
||||
t.Fatalf("%#v", e)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
module github.com/bitechdev/ResolveSpec/clients/resolvespec-go
|
||||
|
||||
go 1.22
|
||||
@@ -0,0 +1,94 @@
|
||||
package resolvespec
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// Client speaks the ResolveSpec JSON body protocol: POST {operation, data, options}.
|
||||
type Client struct{ cfg config }
|
||||
|
||||
func NewClient(baseURL string, opts ...Option) *Client {
|
||||
return &Client{cfg: newConfig(baseURL, opts)}
|
||||
}
|
||||
|
||||
// RecordID is a single id (int or string, sent in the URL) or a []string (sent in the body).
|
||||
type RecordID any
|
||||
|
||||
func urlID(id RecordID) string {
|
||||
switch v := id.(type) {
|
||||
case nil:
|
||||
return ""
|
||||
case []string:
|
||||
return ""
|
||||
case string:
|
||||
return v
|
||||
default:
|
||||
return fmt.Sprint(v)
|
||||
}
|
||||
}
|
||||
|
||||
func bodyID(id RecordID) []string {
|
||||
ids, _ := id.([]string)
|
||||
return ids
|
||||
}
|
||||
|
||||
type request struct {
|
||||
Operation string `json:"operation"`
|
||||
ID []string `json:"id,omitempty"`
|
||||
Data any `json:"data,omitempty"`
|
||||
Options *Options `json:"options,omitempty"`
|
||||
}
|
||||
|
||||
func (c *Client) send(ctx context.Context, method, schema, entity, id string, body any) (*Response, error) {
|
||||
var payload []byte
|
||||
if body != nil {
|
||||
var err error
|
||||
if payload, err = json.Marshal(body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
req, err := c.cfg.newRequest(ctx, method, buildURL(c.cfg.baseURL, schema, entity, id), payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, b, err := c.cfg.do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode > 299 {
|
||||
return nil, errorFrom(resp.StatusCode, b)
|
||||
}
|
||||
var out Response
|
||||
if err := json.Unmarshal(b, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !out.Success && out.Error != nil {
|
||||
return nil, &Error{StatusCode: resp.StatusCode, APIError: *out.Error}
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// GetMetadata returns table metadata (GET /{schema}/{entity}).
|
||||
func (c *Client) GetMetadata(ctx context.Context, schema, entity string) (*Response, error) {
|
||||
return c.send(ctx, http.MethodGet, schema, entity, "", nil)
|
||||
}
|
||||
|
||||
// Read reads records; id may be nil, an int/string (URL) or []string (body).
|
||||
func (c *Client) Read(ctx context.Context, schema, entity string, id RecordID, opts *Options) (*Response, error) {
|
||||
return c.send(ctx, http.MethodPost, schema, entity, urlID(id), request{Operation: "read", ID: bodyID(id), Options: opts})
|
||||
}
|
||||
|
||||
func (c *Client) Create(ctx context.Context, schema, entity string, data any, opts *Options) (*Response, error) {
|
||||
return c.send(ctx, http.MethodPost, schema, entity, "", request{Operation: "create", Data: data, Options: opts})
|
||||
}
|
||||
|
||||
func (c *Client) Update(ctx context.Context, schema, entity string, data any, id RecordID, opts *Options) (*Response, error) {
|
||||
return c.send(ctx, http.MethodPost, schema, entity, urlID(id), request{Operation: "update", ID: bodyID(id), Data: data, Options: opts})
|
||||
}
|
||||
|
||||
func (c *Client) Delete(ctx context.Context, schema, entity string, id RecordID) (*Response, error) {
|
||||
return c.send(ctx, http.MethodPost, schema, entity, urlID(id), request{Operation: "delete"})
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package resolvespec
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type seen struct {
|
||||
method, path string
|
||||
header http.Header
|
||||
body map[string]any
|
||||
}
|
||||
|
||||
func server(t *testing.T, status int, body string, hdr map[string]string) (*httptest.Server, *seen) {
|
||||
t.Helper()
|
||||
s := &seen{}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
s.method, s.path, s.header = r.Method, r.URL.EscapedPath()+"?"+r.URL.RawQuery, r.Header
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
if len(b) > 0 {
|
||||
_ = json.Unmarshal(b, &s.body)
|
||||
}
|
||||
for k, v := range hdr {
|
||||
w.Header().Set(k, v)
|
||||
}
|
||||
w.WriteHeader(status)
|
||||
_, _ = w.Write([]byte(body))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv, s
|
||||
}
|
||||
|
||||
func TestReadBody(t *testing.T) {
|
||||
srv, s := server(t, 200, `{"success":true,"data":[{"id":1}]}`, nil)
|
||||
c := NewClient(srv.URL+"/", WithToken("tok"), WithHeader("X-Tenant", "a"))
|
||||
resp, err := c.Read(context.Background(), "public", "users", nil, &Options{Limit: Int(5), Filters: []FilterOption{{Column: "a", Operator: "eq", Value: 1}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.method != "POST" || s.path != "/public/users?" {
|
||||
t.Fatalf("got %s %s", s.method, s.path)
|
||||
}
|
||||
if s.header.Get("Authorization") != "Bearer tok" || s.header.Get("X-Tenant") != "a" {
|
||||
t.Fatalf("headers %v", s.header)
|
||||
}
|
||||
if s.body["operation"] != "read" || s.body["options"].(map[string]any)["limit"] != float64(5) {
|
||||
t.Fatalf("body %v", s.body)
|
||||
}
|
||||
var rows []map[string]any
|
||||
if err := resp.Decode(&rows); err != nil || len(rows) != 1 {
|
||||
t.Fatalf("decode %v %v", rows, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIDPlacement(t *testing.T) {
|
||||
srv, s := server(t, 200, `{"success":true,"data":{}}`, nil)
|
||||
c := NewClient(srv.URL)
|
||||
ctx := context.Background()
|
||||
_, _ = c.Read(ctx, "s", "e", 7, nil)
|
||||
if s.path != "/s/e/7?" || s.body["id"] != nil {
|
||||
t.Fatalf("%s %v", s.path, s.body)
|
||||
}
|
||||
_, _ = c.Update(ctx, "s", "e", map[string]any{"a": 1}, []string{"1", "2"}, nil)
|
||||
if s.path != "/s/e?" || !reflect.DeepEqual(s.body["id"], []any{"1", "2"}) || s.body["operation"] != "update" {
|
||||
t.Fatalf("%s %v", s.path, s.body)
|
||||
}
|
||||
_, _ = c.Delete(ctx, "s", "e", "a/b")
|
||||
if s.path != "/s/e/a%2Fb?" || s.body["operation"] != "delete" {
|
||||
t.Fatalf("%s %v", s.path, s.body)
|
||||
}
|
||||
_, _ = c.GetMetadata(ctx, "s", "e")
|
||||
if s.method != "GET" {
|
||||
t.Fatal(s.method)
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrors(t *testing.T) {
|
||||
srv, _ := server(t, 400, `{"success":false,"error":{"code":"x","message":"bad","detail":"why"}}`, nil)
|
||||
_, err := NewClient(srv.URL).Read(context.Background(), "s", "e", nil, nil)
|
||||
e, ok := err.(*Error)
|
||||
if !ok || e.StatusCode != 400 || e.Code != "x" || e.Message != "bad" || e.Detail != "why" {
|
||||
t.Fatalf("%#v", err)
|
||||
}
|
||||
srv2, _ := server(t, 502, "bad gateway", nil)
|
||||
_, err = NewClient(srv2.URL).Read(context.Background(), "s", "e", nil, nil)
|
||||
if e := err.(*Error); e.StatusCode != 502 || e.Message != "bad gateway" {
|
||||
t.Fatalf("%#v", e)
|
||||
}
|
||||
srv3, _ := server(t, 200, `{"success":false,"error":{"code":"c","message":"nope"}}`, nil)
|
||||
_, err = NewClient(srv3.URL).Read(context.Background(), "s", "e", nil, nil)
|
||||
if e := err.(*Error); e.Message != "nope" {
|
||||
t.Fatalf("%#v", e)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
// Package resolvespec is a client for ResolveSpec (JSON body) and FunctionSpec endpoints.
|
||||
package resolvespec
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// FilterOption mirrors common.FilterOption. Operator: eq neq gt gte lt lte like ilike in
|
||||
// contains startswith endswith between between_inclusive is_null is_not_null.
|
||||
type FilterOption struct {
|
||||
Column string `json:"column"`
|
||||
Operator string `json:"operator"`
|
||||
Value any `json:"value"`
|
||||
LogicOperator string `json:"logic_operator,omitempty"` // AND | OR
|
||||
}
|
||||
|
||||
type SortOption struct {
|
||||
Column string `json:"column"`
|
||||
Direction string `json:"direction"` // asc | desc
|
||||
}
|
||||
|
||||
type Parameter struct {
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
Sequence int `json:"sequence,omitempty"`
|
||||
}
|
||||
|
||||
type CustomOperator struct {
|
||||
Name string `json:"name"`
|
||||
SQL string `json:"sql"`
|
||||
}
|
||||
|
||||
type ComputedColumn struct {
|
||||
Name string `json:"name"`
|
||||
Expression string `json:"expression"`
|
||||
}
|
||||
|
||||
type PreloadOption struct {
|
||||
Relation string `json:"relation,omitempty"`
|
||||
TableName string `json:"table_name,omitempty"`
|
||||
Columns []string `json:"columns,omitempty"`
|
||||
OmitColumns []string `json:"omit_columns,omitempty"`
|
||||
Sort []SortOption `json:"sort,omitempty"`
|
||||
Filters []FilterOption `json:"filters,omitempty"`
|
||||
Where string `json:"where,omitempty"`
|
||||
Limit *int `json:"limit,omitempty"`
|
||||
Offset *int `json:"offset,omitempty"`
|
||||
Updateable *bool `json:"updateable,omitempty"`
|
||||
ComputedQL map[string]string `json:"computed_ql,omitempty"`
|
||||
Recursive bool `json:"recursive,omitempty"`
|
||||
PrimaryKey string `json:"primary_key,omitempty"`
|
||||
RelatedKey string `json:"related_key,omitempty"`
|
||||
ForeignKey string `json:"foreign_key,omitempty"`
|
||||
RecursiveChildKey string `json:"recursive_child_key,omitempty"`
|
||||
SQLJoins []string `json:"sql_joins,omitempty"`
|
||||
JoinAliases []string `json:"join_aliases,omitempty"`
|
||||
}
|
||||
|
||||
type VectorSearchOption struct {
|
||||
Column string `json:"column"`
|
||||
Vector []float64 `json:"vector"`
|
||||
Metric string `json:"metric,omitempty"` // l2 (default) | cosine | ip
|
||||
As string `json:"as,omitempty"` // distance alias, default _distance
|
||||
Direction string `json:"direction,omitempty"`
|
||||
}
|
||||
|
||||
// Options is the ResolveSpec request options object.
|
||||
type Options struct {
|
||||
Preload []PreloadOption `json:"preload,omitempty"`
|
||||
Columns []string `json:"columns,omitempty"`
|
||||
OmitColumns []string `json:"omit_columns,omitempty"`
|
||||
Filters []FilterOption `json:"filters,omitempty"`
|
||||
Sort []SortOption `json:"sort,omitempty"`
|
||||
Limit *int `json:"limit,omitempty"`
|
||||
Offset *int `json:"offset,omitempty"`
|
||||
CustomOperators []CustomOperator `json:"customOperators,omitempty"`
|
||||
ComputedColumns []ComputedColumn `json:"computedColumns,omitempty"`
|
||||
Parameters []Parameter `json:"parameters,omitempty"`
|
||||
CursorForward string `json:"cursor_forward,omitempty"`
|
||||
CursorBackward string `json:"cursor_backward,omitempty"`
|
||||
FetchRowNumber string `json:"fetch_row_number,omitempty"`
|
||||
VectorSearch *VectorSearchOption `json:"vector_search,omitempty"`
|
||||
}
|
||||
|
||||
// Metadata of a list response.
|
||||
type Metadata struct {
|
||||
Total int64 `json:"total"`
|
||||
Count int64 `json:"count"`
|
||||
Filtered int64 `json:"filtered"`
|
||||
Limit int `json:"limit"`
|
||||
Offset int `json:"offset"`
|
||||
}
|
||||
|
||||
// Response is the ResolveSpec envelope. Data is left raw for the caller to decode.
|
||||
type Response struct {
|
||||
Success bool `json:"success"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
Metadata *Metadata `json:"metadata,omitempty"`
|
||||
Error *APIError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// Decode unmarshals Data into v.
|
||||
func (r *Response) Decode(v any) error { return json.Unmarshal(r.Data, v) }
|
||||
|
||||
// Int returns a pointer to n, for optional Options fields.
|
||||
func Int(n int) *int { return &n }
|
||||
|
||||
// Bool returns a pointer to b.
|
||||
func Bool(b bool) *bool { return &b }
|
||||
Reference in New Issue
Block a user