Working field fetch

This commit is contained in:
Ian Gulliver
2024-06-22 21:46:58 -07:00
parent b615906097
commit 6abf2d051e
9 changed files with 205 additions and 645 deletions

106
base.go
View File

@@ -2,107 +2,39 @@ package airtable
import (
"context"
"net/url"
"fmt"
)
// Base type of airtable base.
type Base struct {
ID string `json:"id"`
Name string `json:"name"`
PermissionLevel string `json:"permissionLevel"`
c *Client
}
// Base type of airtable bases.
type Bases struct {
Bases []*Base `json:"bases"`
Offset string `json:"offset,omitempty"`
func (c *Client) ListBases(ctx context.Context) ([]*Base, error) {
return listAll[Base](ctx, c, "meta/bases", nil, "bases", func(b *Base) error {
b.c = c
return nil
})
}
type Field struct {
ID string `json:"id"`
Type string `json:"type"`
Name string `json:"name"`
Description string `json:"description"`
Options map[string]any `json:"options"`
}
type View struct {
ID string `json:"id"`
Type string `json:"type"`
Name string `json:"name"`
}
type TableSchema struct {
ID string `json:"id"`
PrimaryFieldID string `json:"primaryFieldId"`
Name string `json:"name"`
Description string `json:"description"`
Fields []*Field `json:"fields"`
Views []*View `json:"views"`
}
type Tables struct {
Tables []*TableSchema `json:"tables"`
}
// GetBasesWithParams get bases with url values params
// https://airtable.com/developers/web/api/list-bases
func (at *Client) GetBasesWithParams(params url.Values) (*Bases, error) {
return at.GetBasesWithParamsContext(context.Background(), params)
}
// getBasesWithParamsContext get bases with url values params
// with custom context
func (at *Client) GetBasesWithParamsContext(ctx context.Context, params url.Values) (*Bases, error) {
bases := new(Bases)
err := at.get(ctx, "meta", "bases", "", params, bases)
func (c *Client) GetBaseByName(ctx context.Context, name string) (*Base, error) {
bases, err := c.ListBases(ctx)
if err != nil {
return nil, err
}
return bases, nil
}
// Table represents table object.
type BaseConfig struct {
client *Client
dbId string
}
// GetBase return Base object.
func (c *Client) GetBaseSchema(dbId string) *BaseConfig {
return &BaseConfig{
client: c,
dbId: dbId,
}
}
// Do send the prepared
func (b *BaseConfig) Do() (*Tables, error) {
return b.GetTables()
}
// Do send the prepared with custom context
func (b *BaseConfig) DoContext(ctx context.Context) (*Tables, error) {
return b.GetTablesContext(ctx)
}
// GetTables get tables from a base with url values params
// https://airtable.com/developers/web/api/get-base-schema
func (b *BaseConfig) GetTables() (*Tables, error) {
return b.GetTablesContext(context.Background())
}
// getTablesContext get tables from a base with url values params
// with custom context
func (b *BaseConfig) GetTablesContext(ctx context.Context) (*Tables, error) {
tables := new(Tables)
err := b.client.get(ctx, "meta/bases", b.dbId, "tables", nil, tables)
if err != nil {
return nil, err
for _, base := range bases {
if base.Name == name {
return base, nil
}
}
return tables, nil
return nil, fmt.Errorf("base '%s' not found", name)
}
func (b Base) String() string {
return b.Name
}

126
client.go
View File

@@ -1,12 +1,9 @@
package airtable
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
"time"
)
@@ -34,122 +31,19 @@ func New(apiKey string) *Client {
return c
}
func (at *Client) SetRateLimit(rateLimit int) {
at.rateLimiter = time.Tick(time.Second / time.Duration(rateLimit))
}
func (at *Client) waitForRateLimit() {
<-at.rateLimiter
}
func (at *Client) get(ctx context.Context, db, table, recordID string, params url.Values, target any) error {
return at.do(ctx, "GET", db, table, recordID, params, nil, target)
}
func (at *Client) post(ctx context.Context, db, table string, data, target any) error {
return at.do(ctx, "POST", db, table, "", nil, data, target)
}
func (at *Client) delete(ctx context.Context, db, table string, recordIDs []string, target any) error {
params := url.Values{}
for _, recordID := range recordIDs {
params.Add("records[]", recordID)
func NewFromEnv() (*Client, error) {
apiKey := os.Getenv("AIRTABLE_TOKEN")
if apiKey == "" {
return nil, fmt.Errorf("please set $AIRTABLE_TOKEN")
}
return at.do(ctx, "DELETE", db, table, "", params, nil, target)
return New(apiKey), nil
}
func (at *Client) patch(ctx context.Context, db, table string, data, target any) error {
return at.do(ctx, "PATCH", db, table, "", nil, data, target)
func (c *Client) SetRateLimit(rateLimit int) {
c.rateLimiter = time.Tick(time.Second / time.Duration(rateLimit))
}
func (at *Client) put(ctx context.Context, db, table string, data, target any) error {
return at.do(ctx, "PUT", db, table, "", nil, data, target)
}
func (at *Client) do(ctx context.Context, method, db, table, recordID string, params url.Values, data, target any) error {
var err error
at.waitForRateLimit()
url := fmt.Sprintf("%s/%s/%s", at.BaseURL, db, table)
if recordID != "" {
url += fmt.Sprintf("/%s", recordID)
}
body := []byte{}
if data != nil {
body, err = json.Marshal(data)
if err != nil {
return fmt.Errorf("marshalling message body: %w", err)
}
}
req, err := http.NewRequestWithContext(ctx, method, url, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("cannot create request: %w", err)
}
req.URL.RawQuery = params.Encode()
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", at.apiKey))
resp, err := at.Client.Do(req)
if err != nil {
return fmt.Errorf("http request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode > 299 {
return makeHTTPClientError(url, resp)
}
dec := json.NewDecoder(resp.Body)
err = dec.Decode(target)
if err != nil {
return fmt.Errorf("json decode failed: %w", err)
}
return nil
}
func listAll[T any](ctx context.Context, c *Client, db, table string, params url.Values, key string) ([]*T, error) {
ret := []*T{}
for {
resp := map[string]any{}
err := c.get(ctx, db, table, "", params, &resp)
if err != nil {
return nil, err
}
subresp, err := json.Marshal(resp[key])
if err != nil {
return nil, err
}
obj := []*T{}
err = json.Unmarshal(subresp, &obj)
if err != nil {
return nil, err
}
ret = append(ret, obj...)
off, found := resp["offset"]
if !found {
return ret, nil
}
params.Set("offset", off.(string))
}
func (c *Client) waitForRateLimit() {
<-c.rateLimiter
}

View File

@@ -1,64 +0,0 @@
// Copyright © 2020 Mike Berezin
//
// Use of this source code is governed by an MIT license.
// Details in the LICENSE file.
package airtable
import (
"fmt"
"io"
"net/http"
)
// HTTPClientError custom error to handle with response status.
type HTTPClientError struct {
StatusCode int
Err error
}
func (e *HTTPClientError) Error() string {
return fmt.Sprintf("status %d, err: %v", e.StatusCode, e.Err)
}
func makeHTTPClientError(url string, resp *http.Response) error {
var resError error
respStatusText := "Unknown status text"
switch resp.StatusCode {
case 400:
respStatusText = "The request encoding is invalid; the request can't be parsed as a valid JSON."
case 401:
respStatusText = "Accessinga protected resource without authorization or with invalid credentials."
case 402:
respStatusText = "The account associated with the API key making requests hits a quota that can be increased by upgrading the Airtable account plan."
case 403:
respStatusText = "Accessing a protected resource with API credentials that don't have access to that resource."
case 404:
respStatusText = "Route or resource is not found. This error is returned when the request hits an undefined route, or if the resource doesn't exist (e.g. has been deleted)."
case 413:
respStatusText = "Too Large The request exceeded the maximum allowed payload size. You shouldn't encounter this under normal use."
case 422:
respStatusText = "The request data is invalid. This includes most of the base-specific validations. You will receive a detailed error message and code pointing to the exact issue."
case 500:
respStatusText = "Error The server encountered an unexpected condition."
case 502:
respStatusText = "Airtable's servers are restarting or an unexpected outage is in progress. You should generally not receive this error, and requests are safe to retry."
case 503:
respStatusText = "The server could not process your request in time. The server could be temporarily unavailable, or it could have timed out processing your request. You should retry the request with backoffs."
}
body, err := io.ReadAll(resp.Body)
if err != nil {
resError = fmt.Errorf("HTTP request failure on %s:\n%d %s\n%s\n\nCannot parse body with err: %w",
url, resp.StatusCode, resp.Status, respStatusText, err)
} else {
resError = fmt.Errorf("HTTP request failure on %s:\n%d %s\n%s\n\nBody: %v",
url, resp.StatusCode, resp.Status, respStatusText, string(body))
}
return &HTTPClientError{
StatusCode: resp.StatusCode,
Err: resError,
}
}

View File

@@ -1,25 +0,0 @@
package airtable
import (
"errors"
"time"
)
const (
dateTimeFormat = "2006-01-02T15:04:05.000Z"
)
var ErrNotDateTime = errors.New("field is not date time")
func ToDateTime(field any) (time.Time, error) {
fS, err := field.(string)
if !err {
return time.Time{}, ErrNotDateTime
}
return time.Parse(dateTimeFormat, fS)
}
func FromDateTime(t time.Time) any {
return t.Format(dateTimeFormat)
}

View File

@@ -1,37 +0,0 @@
package airtable
import (
"net/url"
)
// GetBasesConfig helper type to use in.
// step by step get bases.
type GetBasesConfig struct {
client *Client
params url.Values
}
// GetBases prepare step to get bases.
func (c *Client) GetBases() *GetBasesConfig {
return &GetBasesConfig{
client: c,
params: url.Values{},
}
}
// Pagination
// The server returns one page of bases at a time.
// If there are more records, the response will contain an offset.
// To fetch the next page of records, include offset in the next request's parameters.
// WithOffset Pagination will stop when you've reached the end of your bases.
func (gbc *GetBasesConfig) WithOffset(offset string) *GetBasesConfig {
gbc.params.Set("offset", offset)
return gbc
}
// Do send the prepared get records request.
func (gbc *GetBasesConfig) Do() (*Bases, error) {
return gbc.client.GetBasesWithParams(gbc.params)
}

View File

@@ -1,106 +0,0 @@
// Copyright © 2020 Mike Berezin
//
// Use of this source code is governed by an MIT license.
// Details in the LICENSE file.
package airtable
import (
"fmt"
"net/url"
"strconv"
)
// GetRecordsConfig helper type to use in.
// step by step get records.
type GetRecordsConfig struct {
table *Table
params url.Values
}
// GetRecords prepare step to get records.
func (t *Table) GetRecords() *GetRecordsConfig {
return &GetRecordsConfig{
table: t,
params: url.Values{},
}
}
// ReturnFields set returning field names.
func (grc *GetRecordsConfig) ReturnFields(fieldNames ...string) *GetRecordsConfig {
for _, fieldName := range fieldNames {
grc.params.Add("fields[]", fieldName)
}
return grc
}
// WithFilterFormula add filter to request.
func (grc *GetRecordsConfig) WithFilterFormula(filterFormula string) *GetRecordsConfig {
grc.params.Set("filterByFormula", filterFormula)
return grc
}
// WithSort add sorting to request.
func (grc *GetRecordsConfig) WithSort(sortQueries ...struct {
FieldName string
Direction string
}) *GetRecordsConfig {
for queryNum, sortQuery := range sortQueries {
grc.params.Set(fmt.Sprintf("sort[%v][field]", queryNum), sortQuery.FieldName)
grc.params.Set(fmt.Sprintf("sort[%v][direction]", queryNum), sortQuery.Direction)
}
return grc
}
// FromView add view parameter to get records.
func (grc *GetRecordsConfig) FromView(viewNameOrID string) *GetRecordsConfig {
grc.params.Set("view", viewNameOrID)
return grc
}
// MaxRecords The maximum total number of records that will be returned in your requests.
// If this value is larger than pageSize (which is 100 by default),
// you may have to load multiple pages to reach this total.
// See the Pagination section below for more.
func (grc *GetRecordsConfig) MaxRecords(maxRecords int) *GetRecordsConfig {
grc.params.Set("maxRecords", strconv.Itoa(maxRecords))
return grc
}
// PageSize The number of records returned in each request.
// Must be less than or equal to 100. Default is 100.
// See the Pagination section below for more.
func (grc *GetRecordsConfig) PageSize(pageSize int) *GetRecordsConfig {
grc.params.Set("pageSize", strconv.Itoa(pageSize))
return grc
}
// Pagination
// The server returns one page of records at a time.
// Each page will contain pageSize records, which is 100 by default.
// If there are more records, the response will contain an offset.
// To fetch the next page of records, include offset in the next request's parameters.
// WithOffset Pagination will stop when you've reached the end of your table.
// If the maxRecords parameter is passed, pagination will stop once you've reached this maximum.
func (grc *GetRecordsConfig) WithOffset(offset string) *GetRecordsConfig {
grc.params.Set("offset", offset)
return grc
}
// InStringFormat add parameter to get records in string format.
// it require timezone
// https://support.airtable.com/hc/en-us/articles/216141558-Supported-timezones-for-SET-TIMEZONE
// and user locale data
// https://support.airtable.com/hc/en-us/articles/220340268-Supported-locale-modifiers-for-SET-LOCALE
func (grc *GetRecordsConfig) InStringFormat(timeZone, userLocale string) *GetRecordsConfig {
grc.params.Set("cellFormat", "string")
grc.params.Set("timeZone", timeZone)
grc.params.Set("userLocale", userLocale)
return grc
}
func (grc *GetRecordsConfig) Do() ([]*Record, error) {
return grc.table.GetRecordsWithParams(grc.params)
}

125
http.go Normal file
View File

@@ -0,0 +1,125 @@
package airtable
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
)
func get[T any](ctx context.Context, c *Client, path string, params url.Values) (*T, error) {
return do[T](ctx, c, "GET", path, params, nil)
}
func post[T any](ctx context.Context, c *Client, path string, data any) (*T, error) {
return do[T](ctx, c, "POST", path, nil, data)
}
func del[T any](ctx context.Context, c *Client, path string, params url.Values) (*T, error) {
return do[T](ctx, c, "DELETE", path, params, nil)
}
func patch[T any](ctx context.Context, c *Client, path string, data any) (*T, error) {
return do[T](ctx, c, "PATCH", path, nil, data)
}
func put[T any](ctx context.Context, c *Client, path string, data any) (*T, error) {
return do[T](ctx, c, "PUT", path, nil, data)
}
func do[T any](ctx context.Context, c *Client, method, path string, params url.Values, data any) (*T, error) {
var err error
c.waitForRateLimit()
url := fmt.Sprintf("%s/%s", c.BaseURL, path)
body := []byte{}
if data != nil {
body, err = json.Marshal(data)
if err != nil {
return nil, fmt.Errorf("marshalling message body: %w", err)
}
}
req, err := http.NewRequestWithContext(ctx, method, url, bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("cannot create request: %w", err)
}
req.URL.RawQuery = params.Encode()
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.apiKey))
resp, err := c.Client.Do(req)
if err != nil {
return nil, fmt.Errorf("http request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode > 299 {
return nil, fmt.Errorf("http error: %d %s", resp.StatusCode, http.StatusText(resp.StatusCode))
}
dec := json.NewDecoder(resp.Body)
target := new(T)
err = dec.Decode(target)
if err != nil {
return nil, fmt.Errorf("json decode failed: %w", err)
}
return target, nil
}
func listAll[T any](ctx context.Context, c *Client, path string, params url.Values, key string, cb func(*T) error) ([]*T, error) {
ret := []*T{}
if params == nil {
params = url.Values{}
}
for {
resp, err := get[map[string]any](ctx, c, path, params)
if err != nil {
return nil, err
}
subresp, err := json.Marshal((*resp)[key])
if err != nil {
return nil, err
}
objs := []*T{}
err = json.Unmarshal(subresp, &objs)
if err != nil {
return nil, err
}
if cb != nil {
for _, obj := range objs {
err = cb(obj)
if err != nil {
return nil, err
}
}
}
ret = append(ret, objs...)
off, found := (*resp)["offset"]
if !found {
return ret, nil
}
params.Set("offset", off.(string))
}
}

View File

@@ -1,102 +1,33 @@
// Copyright © 2020 Mike Berezin
//
// Use of this source code is governed by an MIT license.
// Details in the LICENSE file.
package airtable
import (
"context"
"net/url"
"fmt"
)
// Record base time of airtable record fields.
type Record struct {
client *Client
table *Table
ID string `json:"id,omitempty"`
Fields map[string]any `json:"fields"`
CreatedTime string `json:"createdTime,omitempty"`
Deleted bool `json:"deleted,omitempty"`
// The Airtable API will perform best-effort automatic data conversion
// from string values if the typecast parameter is passed in.
// Automatic conversion is disabled by default to ensure data integrity,
// but it may be helpful for integrating with 3rd party data sources.
Typecast bool `json:"typecast,omitempty"`
c *Client
b *Base
t *Table
}
// GetRecord get record from table
// https://airtable.com/{yourDatabaseID}/api/docs#curl/table:{yourTableName}:retrieve
func (t *Table) GetRecord(recordID string) (*Record, error) {
return t.GetRecordContext(context.Background(), recordID)
type ListRecordOptions struct {
}
// GetRecordContext get record from table
// with custom context
func (t *Table) GetRecordContext(ctx context.Context, recordID string) (*Record, error) {
result := new(Record)
err := t.client.get(ctx, t.dbName, t.tableName, recordID, url.Values{}, result)
if err != nil {
return nil, err
}
result.client = t.client
result.table = t
return result, nil
func (t *Table) ListRecords(ctx context.Context, opts *ListRecordOptions) ([]*Record, error) {
return listAll[Record](ctx, t.c, fmt.Sprintf("%s/%s", t.b.ID, t.ID), nil, "records", func(r *Record) error {
r.c = t.c
r.b = t.b
r.t = t
return nil
})
}
// UpdateRecordPartial updates partial info on record.
func (r *Record) UpdateRecordPartial(changedFields map[string]any) (*Record, error) {
return r.UpdateRecordPartialContext(context.Background(), changedFields)
}
// UpdateRecordPartialContext updates partial info on record
// with custom context
func (r *Record) UpdateRecordPartialContext(ctx context.Context, changedFields map[string]any) (*Record, error) {
data := &Records{
Records: []*Record{
{
ID: r.ID,
Fields: changedFields,
},
},
}
response := new(Records)
err := r.client.patch(ctx, r.table.dbName, r.table.tableName, data, response)
if err != nil {
return nil, err
}
result := response.Records[0]
result.client = r.client
result.table = r.table
return result, nil
}
// DeleteRecord delete one record.
func (r *Record) DeleteRecord() (*Record, error) {
return r.DeleteRecordContext(context.Background())
}
// DeleteRecordContext delete one record
// with custom context
func (r *Record) DeleteRecordContext(ctx context.Context) (*Record, error) {
response := new(Records)
err := r.client.delete(ctx, r.table.dbName, r.table.tableName, []string{r.ID}, response)
if err != nil {
return nil, err
}
result := response.Records[0]
result.client = r.client
result.table = r.table
return result, nil
func (r Record) String() string {
return r.Fields[r.t.Fields[0].Name].(string)
}

164
table.go
View File

@@ -1,149 +1,59 @@
// Copyright © 2020 Mike Berezin
//
// Use of this source code is governed by an MIT license.
// Details in the LICENSE file.
package airtable
import (
"context"
"net/url"
"fmt"
)
// Records base type of airtable records.
type Records struct {
Records []*Record `json:"records"`
Offset string `json:"offset,omitempty"`
// The Airtable API will perform best-effort automatic data conversion
// from string values if the typecast parameter is passed in.
// Automatic conversion is disabled by default to ensure data integrity,
// but it may be helpful for integrating with 3rd party data sources.
Typecast bool `json:"typecast,omitempty"`
}
// Table represents table object.
type Table struct {
client *Client
dbName string
tableName string
ID string `json:"id"`
PrimaryFieldID string `json:"primaryFieldId"`
Name string `json:"name"`
Description string `json:"description"`
Fields []*Field `json:"fields"`
Views []*View `json:"views"`
c *Client
b *Base
}
// GetTable return table object.
func (c *Client) GetTable(dbName, tableName string) *Table {
return &Table{
client: c,
dbName: dbName,
tableName: tableName,
}
type Field struct {
ID string `json:"id"`
Type string `json:"type"`
Name string `json:"name"`
Description string `json:"description"`
Options map[string]any `json:"options"`
}
func (t *Table) GetRecordsWithParams(params url.Values) ([]*Record, error) {
return t.GetRecordsWithParamsContext(context.Background(), params)
type View struct {
ID string `json:"id"`
Type string `json:"type"`
Name string `json:"name"`
}
func (t *Table) GetRecordsWithParamsContext(ctx context.Context, params url.Values) ([]*Record, error) {
records, err := listAll[Record](ctx, t.client, t.dbName, t.tableName, params, "records")
func (b *Base) ListTables(ctx context.Context) ([]*Table, error) {
return listAll[Table](ctx, b.c, fmt.Sprintf("meta/bases/%s/tables", b.ID), nil, "tables", func(t *Table) error {
t.c = b.c
t.b = b
return nil
})
}
func (b *Base) GetTableByName(ctx context.Context, name string) (*Table, error) {
tables, err := b.ListTables(ctx)
if err != nil {
return nil, err
}
for _, record := range records {
record.client = t.client
record.table = t
for _, table := range tables {
if table.Name == name {
return table, nil
}
}
return records, nil
return nil, fmt.Errorf("table '%s' not found", name)
}
// AddRecords method to add lines to table (up to 10 in one request)
// https://airtable.com/{yourDatabaseID}/api/docs#curl/table:{yourTableName}:create
func (t *Table) AddRecords(records *Records) (*Records, error) {
return t.AddRecordsContext(context.Background(), records)
}
// AddRecordsContext method to add lines to table (up to 10 in one request)
// with custom context
func (t *Table) AddRecordsContext(ctx context.Context, records *Records) (*Records, error) {
result := new(Records)
err := t.client.post(ctx, t.dbName, t.tableName, records, result)
if err != nil {
return nil, err
}
for _, record := range result.Records {
record.client = t.client
record.table = t
}
return result, err
}
// UpdateRecords full update records.
func (t *Table) UpdateRecords(records *Records) (*Records, error) {
return t.UpdateRecordsContext(context.Background(), records)
}
// UpdateRecordsContext full update records with custom context.
func (t *Table) UpdateRecordsContext(ctx context.Context, records *Records) (*Records, error) {
response := new(Records)
err := t.client.put(ctx, t.dbName, t.tableName, records, response)
if err != nil {
return nil, err
}
for _, record := range response.Records {
record.client = t.client
record.table = t
}
return response, nil
}
// UpdateRecordsPartial partial update records.
func (t *Table) UpdateRecordsPartial(records *Records) (*Records, error) {
return t.UpdateRecordsPartialContext(context.Background(), records)
}
// UpdateRecordsPartialContext partial update records with custom context.
func (t *Table) UpdateRecordsPartialContext(ctx context.Context, records *Records) (*Records, error) {
response := new(Records)
err := t.client.patch(ctx, t.dbName, t.tableName, records, response)
if err != nil {
return nil, err
}
for _, record := range response.Records {
record.client = t.client
record.table = t
}
return response, nil
}
// DeleteRecords delete records by recordID
// up to 10 ids in one request.
func (t *Table) DeleteRecords(recordIDs []string) (*Records, error) {
return t.DeleteRecordsContext(context.Background(), recordIDs)
}
// DeleteRecordsContext delete records by recordID
// with custom context
func (t *Table) DeleteRecordsContext(ctx context.Context, recordIDs []string) (*Records, error) {
response := new(Records)
err := t.client.delete(ctx, t.dbName, t.tableName, recordIDs, response)
if err != nil {
return nil, err
}
for _, record := range response.Records {
record.client = t.client
record.table = t
}
return response, nil
func (t Table) String() string {
return fmt.Sprintf("%s.%s", t.b, t.Name)
}