feat: add and get records methods (initial commit)
This commit is contained in:
65
.gitignore
vendored
Normal file
65
.gitignore
vendored
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
# File created using '.gitignore Generator' for Visual Studio Code: https://bit.ly/vscode-gig
|
||||||
|
|
||||||
|
# Created by https://www.gitignore.io/api/visualstudiocode,macos,go
|
||||||
|
# Edit at https://www.gitignore.io/?templates=visualstudiocode,macos,go
|
||||||
|
|
||||||
|
### Go ###
|
||||||
|
# Binaries for programs and plugins
|
||||||
|
*.exe
|
||||||
|
*.exe~
|
||||||
|
*.dll
|
||||||
|
*.so
|
||||||
|
*.dylib
|
||||||
|
|
||||||
|
# Test binary, built with `go test -c`
|
||||||
|
*.test
|
||||||
|
|
||||||
|
# Output of the go coverage tool, specifically when used with LiteIDE
|
||||||
|
*.out
|
||||||
|
|
||||||
|
# Dependency directories (remove the comment below to include it)
|
||||||
|
# vendor/
|
||||||
|
|
||||||
|
### Go Patch ###
|
||||||
|
/vendor/
|
||||||
|
/Godeps/
|
||||||
|
|
||||||
|
### macOS ###
|
||||||
|
# General
|
||||||
|
.DS_Store
|
||||||
|
.AppleDouble
|
||||||
|
.LSOverride
|
||||||
|
|
||||||
|
# Icon must end with two \r
|
||||||
|
Icon
|
||||||
|
|
||||||
|
# Thumbnails
|
||||||
|
._*
|
||||||
|
|
||||||
|
# Files that might appear in the root of a volume
|
||||||
|
.DocumentRevisions-V100
|
||||||
|
.fseventsd
|
||||||
|
.Spotlight-V100
|
||||||
|
.TemporaryItems
|
||||||
|
.Trashes
|
||||||
|
.VolumeIcon.icns
|
||||||
|
.com.apple.timemachine.donotpresent
|
||||||
|
|
||||||
|
# Directories potentially created on remote AFP share
|
||||||
|
.AppleDB
|
||||||
|
.AppleDesktop
|
||||||
|
Network Trash Folder
|
||||||
|
Temporary Items
|
||||||
|
.apdisk
|
||||||
|
|
||||||
|
### VisualStudioCode ###
|
||||||
|
.vscode/*
|
||||||
|
|
||||||
|
### VisualStudioCode Patch ###
|
||||||
|
# Ignore all local history of files
|
||||||
|
.history
|
||||||
|
|
||||||
|
# End of https://www.gitignore.io/api/visualstudiocode,macos,go
|
||||||
|
|
||||||
|
# Custom rules (everything added below won't be overriden by 'Generate .gitignore File' if you use 'Update' option)
|
||||||
|
coverage.sh
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2020 Mike Berezin
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
8
README.md
Normal file
8
README.md
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
Golang Airtable API
|
||||||
|
================
|
||||||
|
|
||||||
|
[](https://pkg.go.dev/github.com/mehanizm/airtable)
|
||||||
|
<a href='https://github.com/jpoles1/gopherbadger' target='_blank'></a>
|
||||||
|
<a href='https://goreportcard.com/report/github.com/mehanizm/airtable' target='_blank'></a>
|
||||||
|
|
||||||
|
A simple #golang package to access the [Airtable API](https://airtable.com/api).
|
||||||
145
client.go
Normal file
145
client.go
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
// Copyright © 2020 Mike Berezin
|
||||||
|
//
|
||||||
|
// Use of this source code is governed by an MIT license.
|
||||||
|
// Details in the LICENSE file.
|
||||||
|
|
||||||
|
package airtable
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io/ioutil"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
airtableBaseURL = "https://api.airtable.com/v0"
|
||||||
|
rateLimit = 4
|
||||||
|
)
|
||||||
|
|
||||||
|
// Client client for airtable api
|
||||||
|
type Client struct {
|
||||||
|
client *http.Client
|
||||||
|
rateLimiter <-chan time.Time
|
||||||
|
baseURL string
|
||||||
|
apiKey string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClient airtable client constructor
|
||||||
|
// your API KEY you can get on your account page
|
||||||
|
// https://airtable.com/account
|
||||||
|
func NewClient(apiKey string) *Client {
|
||||||
|
return &Client{
|
||||||
|
client: http.DefaultClient,
|
||||||
|
rateLimiter: time.Tick(time.Second / time.Duration(rateLimit)),
|
||||||
|
apiKey: apiKey,
|
||||||
|
baseURL: airtableBaseURL,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetRateLimit rate limit setter for custom usage
|
||||||
|
// Airtable limit is 5 requests per second (we use 4)
|
||||||
|
// https://airtable.com/{yourDatabaseID}/api/docs#curl/ratelimits
|
||||||
|
func (at *Client) SetRateLimit(customRateLimit int) {
|
||||||
|
at.rateLimiter = time.Tick(time.Second / time.Duration(customRateLimit))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (at *Client) rateLimit() {
|
||||||
|
<-at.rateLimiter
|
||||||
|
}
|
||||||
|
|
||||||
|
func (at *Client) get(db, table, recordID string, params url.Values, target interface{}) error {
|
||||||
|
at.rateLimit()
|
||||||
|
url := fmt.Sprintf("%s/%s/%s", at.baseURL, db, table)
|
||||||
|
if recordID != "" {
|
||||||
|
url += fmt.Sprintf("/%s", recordID)
|
||||||
|
}
|
||||||
|
req, err := http.NewRequest("GET", url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot create request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", at.apiKey))
|
||||||
|
req.URL.RawQuery = params.Encode()
|
||||||
|
err = at.do(req, url, target)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (at *Client) post(db, table string, data, response interface{}) error {
|
||||||
|
at.rateLimit()
|
||||||
|
url := fmt.Sprintf("%s/%s/%s", at.baseURL, db, table)
|
||||||
|
body, err := json.Marshal(data)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot marshal body: %w", err)
|
||||||
|
}
|
||||||
|
req, err := http.NewRequest("POST", url, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot create request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", at.apiKey))
|
||||||
|
return at.do(req, url, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (at *Client) delete(db, table string, recordIDs []string, target interface{}) error {
|
||||||
|
at.rateLimit()
|
||||||
|
rawURL := fmt.Sprintf("%s/%s/%s", at.baseURL, db, table)
|
||||||
|
params := url.Values{}
|
||||||
|
for _, recordID := range recordIDs {
|
||||||
|
params.Set("records[]", recordID)
|
||||||
|
}
|
||||||
|
req, err := http.NewRequest("DELETE", rawURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot create request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", at.apiKey))
|
||||||
|
req.URL.RawQuery = params.Encode()
|
||||||
|
err = at.do(req, rawURL, target)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (at *Client) patch(db, table, data, response interface{}) error {
|
||||||
|
at.rateLimit()
|
||||||
|
url := fmt.Sprintf("%s/%s/%s", at.baseURL, db, table)
|
||||||
|
body, err := json.Marshal(data)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot marshal body: %w", err)
|
||||||
|
}
|
||||||
|
req, err := http.NewRequest("PATCH", url, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot create request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", at.apiKey))
|
||||||
|
return at.do(req, url, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (at *Client) do(req *http.Request, url string, response interface{}) error {
|
||||||
|
resp, err := at.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("HTTP request failure on %s: %w", url, err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode > 299 {
|
||||||
|
return makeHTTPClientError(url, resp)
|
||||||
|
}
|
||||||
|
b, err := ioutil.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("HTTP Read error on response for %s: %w", url, err)
|
||||||
|
}
|
||||||
|
err = json.Unmarshal(b, response)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("JSON decode failed on %s:\n%s\nerror: %w", url, string(b), err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
33
client_test.go
Normal file
33
client_test.go
Normal file
@@ -0,0 +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 (
|
||||||
|
"errors"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func testClient(t *testing.T) *Client {
|
||||||
|
c := NewClient("apiKey")
|
||||||
|
c.SetRateLimit(1000)
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClient_do(t *testing.T) {
|
||||||
|
c := testClient(t)
|
||||||
|
url := mockErrorResponse(404).URL
|
||||||
|
req, err := http.NewRequest("GET", url, nil)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
err = c.do(req, url, nil)
|
||||||
|
var e *HTTPClientError
|
||||||
|
if errors.Is(err, e) {
|
||||||
|
t.Errorf("should be an http error, but was not: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
7
coverage_badge.png
Normal file
7
coverage_badge.png
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<html>
|
||||||
|
<head><title>522 Origin Connection Time-out</title></head>
|
||||||
|
<body bgcolor="white">
|
||||||
|
<center><h1>522 Origin Connection Time-out</h1></center>
|
||||||
|
<hr><center>cloudflare-nginx</center>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
36
errors.go
Normal file
36
errors.go
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
// 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/ioutil"
|
||||||
|
"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
|
||||||
|
body, err := ioutil.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
resError = fmt.Errorf("HTTP request failure on %s with status %d\nCannot parse body with: %w", url, resp.StatusCode, err)
|
||||||
|
} else {
|
||||||
|
resError = fmt.Errorf("HTTP request failure on %s with status %d\nBody: %v", url, resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
return &HTTPClientError{
|
||||||
|
StatusCode: resp.StatusCode,
|
||||||
|
Err: resError,
|
||||||
|
}
|
||||||
|
}
|
||||||
75
get-records.go
Normal file
75
get-records.go
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
// 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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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.Add("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.Add(fmt.Sprintf("sort[%v][field]", queryNum), sortQuery.fieldName)
|
||||||
|
grc.params.Add(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.Add("view", viewNameOrID)
|
||||||
|
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.Add("cellFormat", "string")
|
||||||
|
grc.params.Add("timeZone", timeZone)
|
||||||
|
grc.params.Add("userLocale", userLocale)
|
||||||
|
return grc
|
||||||
|
}
|
||||||
|
|
||||||
|
// Do send the prepared get records request
|
||||||
|
func (grc *GetRecordsConfig) Do() (*Records, error) {
|
||||||
|
return grc.table.GetRecordsWithParams(grc.params)
|
||||||
|
}
|
||||||
37
get-records_test.go
Normal file
37
get-records_test.go
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
// Copyright © 2020 Mike Berezin
|
||||||
|
//
|
||||||
|
// Use of this source code is governed by an MIT license.
|
||||||
|
// Details in the LICENSE file.
|
||||||
|
|
||||||
|
package airtable
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGetRecordsConfig_Do(t *testing.T) {
|
||||||
|
table := testTable(t)
|
||||||
|
table.client.baseURL = mockResponse("get_records_with_filter.json").URL
|
||||||
|
sortQuery1 := struct {
|
||||||
|
fieldName string
|
||||||
|
direction string
|
||||||
|
}{"Field1", "desc"}
|
||||||
|
sortQuery2 := struct {
|
||||||
|
fieldName string
|
||||||
|
direction string
|
||||||
|
}{"Field2", "asc"}
|
||||||
|
|
||||||
|
records, err := table.GetRecords().
|
||||||
|
FromView("view_1").
|
||||||
|
WithFilterFormula("AND({Field1}='value_1',NOT({Field2}='value_2'))").
|
||||||
|
WithSort(sortQuery1, sortQuery2).
|
||||||
|
ReturnFields("Field1", "Field2").
|
||||||
|
InStringFormat("Europe/Moscow", "ru").
|
||||||
|
Do()
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("there should not be an err, but was: %v", err)
|
||||||
|
}
|
||||||
|
if len(records.Records) != 3 {
|
||||||
|
t.Errorf("there should be 3 records, but was %v", len(records.Records))
|
||||||
|
}
|
||||||
|
}
|
||||||
5
go.mod
Normal file
5
go.mod
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
module github.com/mehanizm/airtable
|
||||||
|
|
||||||
|
go 1.13
|
||||||
|
|
||||||
|
require gopkg.in/square/go-jose.v2 v2.5.0 // indirect
|
||||||
2
go.sum
Normal file
2
go.sum
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
gopkg.in/square/go-jose.v2 v2.5.0 h1:OZ4sdq+Y+SHfYB7vfthi1Ei8b0vkP8ZPQgUfUwdUSqo=
|
||||||
|
gopkg.in/square/go-jose.v2 v2.5.0/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI=
|
||||||
34
mock-response_test.go
Normal file
34
mock-response_test.go
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
// Copyright © 2020 Mike Berezin
|
||||||
|
//
|
||||||
|
// Use of this source code is governed by an MIT license.
|
||||||
|
// Details in the LICENSE file.
|
||||||
|
|
||||||
|
package airtable
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io/ioutil"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
func mockResponse(paths ...string) *httptest.Server {
|
||||||
|
parts := []string{".", "testdata"}
|
||||||
|
filename := filepath.Join(append(parts, paths...)...)
|
||||||
|
|
||||||
|
mockData, err := ioutil.ReadFile(filename)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
||||||
|
rw.Write(mockData)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func mockErrorResponse(code int) *httptest.Server {
|
||||||
|
return httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Error(rw, "An error occurred", code)
|
||||||
|
}))
|
||||||
|
}
|
||||||
65
record.go
Normal file
65
record.go
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
// Copyright © 2020 Mike Berezin
|
||||||
|
//
|
||||||
|
// Use of this source code is governed by an MIT license.
|
||||||
|
// Details in the LICENSE file.
|
||||||
|
|
||||||
|
package airtable
|
||||||
|
|
||||||
|
import "net/url"
|
||||||
|
|
||||||
|
// Record base time of airtable record fields
|
||||||
|
type Record struct {
|
||||||
|
client *Client
|
||||||
|
table *Table
|
||||||
|
ID string `json:"id,omitempty"`
|
||||||
|
Fields map[string]interface{} `json:"fields"`
|
||||||
|
CreatedTime string `json:"createdTime,omitempty"`
|
||||||
|
Deleted bool `json:"deleted,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRecord get record from table
|
||||||
|
// https://airtable.com/{yourDatabaseID}/api/docs#curl/table:{yourTableName}:retrieve
|
||||||
|
func (t *Table) GetRecord(recordID string) (*Record, error) {
|
||||||
|
result := new(Record)
|
||||||
|
err := t.client.get(t.dbName, t.tableName, recordID, url.Values{}, result)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result.client = t.client
|
||||||
|
result.table = t
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateRecordPartial updates partial info on record
|
||||||
|
func (r *Record) UpdateRecordPartial(changedFields map[string]interface{}) (*Record, error) {
|
||||||
|
data := &Records{
|
||||||
|
Records: []*Record{
|
||||||
|
{
|
||||||
|
ID: r.ID,
|
||||||
|
Fields: changedFields,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
response := new(Records)
|
||||||
|
err := r.client.patch(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) {
|
||||||
|
response := new(Records)
|
||||||
|
err := r.client.delete(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
|
||||||
|
}
|
||||||
91
record_test.go
Normal file
91
record_test.go
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
// Copyright © 2020 Mike Berezin
|
||||||
|
//
|
||||||
|
// Use of this source code is governed by an MIT license.
|
||||||
|
// Details in the LICENSE file.
|
||||||
|
|
||||||
|
package airtable
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRecord_GetRecord(t *testing.T) {
|
||||||
|
table := testTable(t)
|
||||||
|
table.client.baseURL = mockResponse("get_record.json").URL
|
||||||
|
record, err := table.GetRecord("recnTq6CsvFM6vX2m")
|
||||||
|
if err != nil {
|
||||||
|
t.Error("must be no error")
|
||||||
|
}
|
||||||
|
expected := &Record{
|
||||||
|
client: table.client,
|
||||||
|
table: table,
|
||||||
|
ID: "recnTq6CsvFM6vX2m",
|
||||||
|
CreatedTime: "2020-04-10T11:30:57.000Z",
|
||||||
|
Fields: map[string]interface{}{
|
||||||
|
"Field1": "Field1",
|
||||||
|
"Field2": true,
|
||||||
|
"Field3": "2020-04-06T06:00:00.000Z",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(record, expected) {
|
||||||
|
t.Errorf("expected: %#v\nbut got: %#v\n", expected, record)
|
||||||
|
}
|
||||||
|
table.client.baseURL = mockErrorResponse(404).URL
|
||||||
|
_, err = table.GetRecord("recnTq6CsvFM6vX2m")
|
||||||
|
var e *HTTPClientError
|
||||||
|
if errors.Is(err, e) {
|
||||||
|
t.Errorf("should be an http error, but was not: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecord_DeleteRecord(t *testing.T) {
|
||||||
|
record := testRecord(t)
|
||||||
|
record.client.baseURL = mockResponse("delete_record.json").URL
|
||||||
|
res, err := record.DeleteRecord()
|
||||||
|
if err != nil {
|
||||||
|
t.Error("must be no error")
|
||||||
|
}
|
||||||
|
if !res.Deleted {
|
||||||
|
t.Errorf("expected that record will be deleted, but was: %#v", record.Deleted)
|
||||||
|
}
|
||||||
|
record.client.baseURL = mockErrorResponse(404).URL
|
||||||
|
_, err = record.DeleteRecord()
|
||||||
|
var e *HTTPClientError
|
||||||
|
if errors.Is(err, e) {
|
||||||
|
t.Errorf("should be an http error, but was not: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecord_UpdateRecordPartial(t *testing.T) {
|
||||||
|
record := testRecord(t)
|
||||||
|
record.client.baseURL = mockResponse("get_records_with_filter.json").URL
|
||||||
|
res, err := record.UpdateRecordPartial(map[string]interface{}{"Field_2": true})
|
||||||
|
if err != nil {
|
||||||
|
t.Error("must be no error")
|
||||||
|
}
|
||||||
|
resBool, ok := res.Fields["Field2"].(bool)
|
||||||
|
if !ok {
|
||||||
|
t.Errorf("Field2 should be bool type, but was %#v\n\nFull resp: %#v", res.Fields["Field2"], res)
|
||||||
|
}
|
||||||
|
if !resBool {
|
||||||
|
t.Errorf("expected that Field_2 will be true, but was: %#v", res.Fields["Field2"].(bool))
|
||||||
|
}
|
||||||
|
record.client.baseURL = mockErrorResponse(404).URL
|
||||||
|
_, err = record.UpdateRecordPartial(map[string]interface{}{})
|
||||||
|
var e *HTTPClientError
|
||||||
|
if errors.Is(err, e) {
|
||||||
|
t.Errorf("should be an http error, but was not: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRecord(t *testing.T) *Record {
|
||||||
|
table := testTable(t)
|
||||||
|
table.client.baseURL = mockResponse("get_record.json").URL
|
||||||
|
record, err := table.GetRecord("recordID")
|
||||||
|
if err != nil {
|
||||||
|
t.Error("must be no error")
|
||||||
|
}
|
||||||
|
return record
|
||||||
|
}
|
||||||
90
table.go
Normal file
90
table.go
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
// Copyright © 2020 Mike Berezin
|
||||||
|
//
|
||||||
|
// Use of this source code is governed by an MIT license.
|
||||||
|
// Details in the LICENSE file.
|
||||||
|
|
||||||
|
package airtable
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/url"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Records base type of airtable records
|
||||||
|
type Records struct {
|
||||||
|
Records []*Record `json:"records"`
|
||||||
|
Offset int `offset:"omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Table represents table object
|
||||||
|
type Table struct {
|
||||||
|
client *Client
|
||||||
|
dbName string
|
||||||
|
tableName string
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTable return table object
|
||||||
|
func (c *Client) GetTable(dbName, tableName string) *Table {
|
||||||
|
return &Table{
|
||||||
|
client: c,
|
||||||
|
dbName: dbName,
|
||||||
|
tableName: tableName,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRecordsWithParams get records with url values params
|
||||||
|
// https://airtable.com/{yourDatabaseID}/api/docs#curl/table:{yourTableName}:list
|
||||||
|
func (t *Table) GetRecordsWithParams(params url.Values) (*Records, error) {
|
||||||
|
records := new(Records)
|
||||||
|
err := t.client.get(t.dbName, t.tableName, "", params, records)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, record := range records.Records {
|
||||||
|
record.client = t.client
|
||||||
|
record.table = t
|
||||||
|
}
|
||||||
|
return records, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddRecords method to 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) {
|
||||||
|
result := new(Records)
|
||||||
|
err := t.client.post(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 updates records
|
||||||
|
func (t *Table) UpdateRecords(records *Records) (*Records, error) {
|
||||||
|
response := new(Records)
|
||||||
|
err := t.client.post(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
|
||||||
|
func (t *Table) DeleteRecords(recordIDs []string) (*Records, error) {
|
||||||
|
response := new(Records)
|
||||||
|
err := t.client.delete(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
|
||||||
|
}
|
||||||
74
table_test.go
Normal file
74
table_test.go
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
// Copyright © 2020 Mike Berezin
|
||||||
|
//
|
||||||
|
// Use of this source code is governed by an MIT license.
|
||||||
|
// Details in the LICENSE file.
|
||||||
|
|
||||||
|
package airtable
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTable_DeleteRecords(t *testing.T) {
|
||||||
|
table := testTable(t)
|
||||||
|
table.client.baseURL = mockResponse("delete_records.json").URL
|
||||||
|
records, err := table.DeleteRecords([]string{"recnTq6CsvFM6vX2m", "recr3qAQbM7juKa4o"})
|
||||||
|
if err != nil {
|
||||||
|
t.Error("must be no error")
|
||||||
|
}
|
||||||
|
for _, record := range records.Records {
|
||||||
|
if !record.Deleted {
|
||||||
|
t.Errorf("expected that record will be deleted, but was: %#v", record.Deleted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
table.client.baseURL = mockErrorResponse(404).URL
|
||||||
|
_, err = table.DeleteRecords([]string{})
|
||||||
|
var e *HTTPClientError
|
||||||
|
if errors.Is(err, e) {
|
||||||
|
t.Errorf("should be an http error, but was not: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTable_AddRecords(t *testing.T) {
|
||||||
|
table := testTable(t)
|
||||||
|
table.client.baseURL = mockResponse("get_records_with_filter.json").URL
|
||||||
|
toSend := new(Records)
|
||||||
|
records, err := table.AddRecords(toSend)
|
||||||
|
if err != nil {
|
||||||
|
t.Error("must be no error")
|
||||||
|
}
|
||||||
|
if len(records.Records) != 3 {
|
||||||
|
t.Errorf("should be 3 records in result, but was: %v", len(records.Records))
|
||||||
|
}
|
||||||
|
table.client.baseURL = mockErrorResponse(404).URL
|
||||||
|
_, err = table.AddRecords(toSend)
|
||||||
|
var e *HTTPClientError
|
||||||
|
if errors.Is(err, e) {
|
||||||
|
t.Errorf("should be an http error, but was not: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTable_UpdateRecords(t *testing.T) {
|
||||||
|
table := testTable(t)
|
||||||
|
table.client.baseURL = mockResponse("get_records_with_filter.json").URL
|
||||||
|
toSend := new(Records)
|
||||||
|
records, err := table.UpdateRecords(toSend)
|
||||||
|
if err != nil {
|
||||||
|
t.Error("must be no error")
|
||||||
|
}
|
||||||
|
if len(records.Records) != 3 {
|
||||||
|
t.Errorf("should be 3 records in result, but was: %v", len(records.Records))
|
||||||
|
}
|
||||||
|
table.client.baseURL = mockErrorResponse(404).URL
|
||||||
|
_, err = table.UpdateRecords(toSend)
|
||||||
|
var e *HTTPClientError
|
||||||
|
if errors.Is(err, e) {
|
||||||
|
t.Errorf("should be an http error, but was not: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testTable(t *testing.T) *Table {
|
||||||
|
client := testClient(t)
|
||||||
|
return client.GetTable("dbName", "tableName")
|
||||||
|
}
|
||||||
22
testdata/create_records.json
vendored
Normal file
22
testdata/create_records.json
vendored
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"records": [
|
||||||
|
{
|
||||||
|
"id": "recnTq6CsvFM6vX2m",
|
||||||
|
"fields": {
|
||||||
|
"Field1": "Field1",
|
||||||
|
"Field2": true,
|
||||||
|
"Field3": "2020-04-06T06:00:00.000Z"
|
||||||
|
},
|
||||||
|
"createdTime": "2020-04-10T11:30:57.000Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "recr3qAQbM7juKa4o",
|
||||||
|
"fields": {
|
||||||
|
"Field1": "Field1",
|
||||||
|
"Field2": true,
|
||||||
|
"Field3": "2020-04-06T06:00:00.000Z"
|
||||||
|
},
|
||||||
|
"createdTime": "2020-04-10T11:30:49.000Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
8
testdata/delete_record.json
vendored
Normal file
8
testdata/delete_record.json
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"records": [
|
||||||
|
{
|
||||||
|
"id": "recnTq6CsvFM6vX2m",
|
||||||
|
"deleted": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
12
testdata/delete_records.json
vendored
Normal file
12
testdata/delete_records.json
vendored
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"records": [
|
||||||
|
{
|
||||||
|
"id": "recnTq6CsvFM6vX2m",
|
||||||
|
"deleted": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "recr3qAQbM7juKa4o",
|
||||||
|
"deleted": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
9
testdata/get_record.json
vendored
Normal file
9
testdata/get_record.json
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"id": "recnTq6CsvFM6vX2m",
|
||||||
|
"fields": {
|
||||||
|
"Field1": "Field1",
|
||||||
|
"Field2": true,
|
||||||
|
"Field3": "2020-04-06T06:00:00.000Z"
|
||||||
|
},
|
||||||
|
"createdTime": "2020-04-10T11:30:57.000Z"
|
||||||
|
}
|
||||||
31
testdata/get_records_with_filter.json
vendored
Normal file
31
testdata/get_records_with_filter.json
vendored
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"records": [
|
||||||
|
{
|
||||||
|
"id": "recnTq6CsvFM6vX2m",
|
||||||
|
"fields": {
|
||||||
|
"Field1": "Field1",
|
||||||
|
"Field2": true,
|
||||||
|
"Field3": "2020-04-06T06:00:00.000Z"
|
||||||
|
},
|
||||||
|
"createdTime": "2020-04-10T11:30:57.000Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "recr3qAQbM7juKa4o",
|
||||||
|
"fields": {
|
||||||
|
"Field1": "Field1",
|
||||||
|
"Field2": true,
|
||||||
|
"Field3": "2020-04-06T06:00:00.000Z"
|
||||||
|
},
|
||||||
|
"createdTime": "2020-04-10T11:30:49.000Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "recr3qAQbM7juKa4a",
|
||||||
|
"fields": {
|
||||||
|
"Field1": "Field1",
|
||||||
|
"Field2": false,
|
||||||
|
"Field3": "2020-04-06T06:00:00.000Z"
|
||||||
|
},
|
||||||
|
"createdTime": "2020-04-10T11:30:49.000Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user