diff --git a/README.md b/README.md index 23aadcc..5284a13 100644 --- a/README.md +++ b/README.md @@ -12,10 +12,11 @@ ## Features The client supports all (non-deprecated) endpoints available in the Notion API, -as of May 15, 2021: +as of July 20, 2021: - [x] [Retrieve a database](https://pkg.go.dev/github.com/dstotijn/go-notion#Client.FindDatabaseByID) - [x] [Query a database](https://pkg.go.dev/github.com/dstotijn/go-notion#Client.QueryDatabase) +- [x] [Create a database](https://pkg.go.dev/github.com/dstotijn/go-notion#Client.CreateDatabase) - [x] [Retrieve a page](https://pkg.go.dev/github.com/dstotijn/go-notion#Client.FindPageByID) - [x] [Create a page](https://pkg.go.dev/github.com/dstotijn/go-notion#Client.CreatePage) - [x] [Update page properties](https://pkg.go.dev/github.com/dstotijn/go-notion#Client.UpdatePageProps) diff --git a/client.go b/client.go index 1a37553..1edaa7e 100644 --- a/client.go +++ b/client.go @@ -125,6 +125,43 @@ func (c *Client) QueryDatabase(ctx context.Context, id string, query *DatabaseQu return result, nil } +// CreateDatabase creates a new database as a child of an existing page. +// See: https://developers.notion.com/reference/create-a-database +func (c *Client) CreateDatabase(ctx context.Context, params CreateDatabaseParams) (db Database, err error) { + if err := params.Validate(); err != nil { + return Database{}, fmt.Errorf("notion: invalid database params: %w", err) + } + + body := &bytes.Buffer{} + + err = json.NewEncoder(body).Encode(params) + if err != nil { + return Database{}, fmt.Errorf("notion: failed to encode body params to JSON: %w", err) + } + + req, err := c.newRequest(ctx, http.MethodPost, "/databases", body) + if err != nil { + return Database{}, fmt.Errorf("notion: invalid request: %w", err) + } + + res, err := c.httpClient.Do(req) + if err != nil { + return Database{}, fmt.Errorf("notion: failed to make HTTP request: %w", err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return Database{}, fmt.Errorf("notion: failed to create database: %w", parseErrorResponse(res)) + } + + err = json.NewDecoder(res.Body).Decode(&db) + if err != nil { + return Database{}, fmt.Errorf("notion: failed to parse HTTP response: %w", err) + } + + return db, nil +} + // FindPageByID fetches a page by ID. // See: https://developers.notion.com/reference/get-page func (c *Client) FindPageByID(ctx context.Context, id string) (page Page, err error) { diff --git a/client_test.go b/client_test.go index 5ce3ed2..806ceb2 100644 --- a/client_test.go +++ b/client_test.go @@ -242,16 +242,18 @@ func TestFindDatabaseByID(t *testing.T) { }, Properties: notion.DatabaseProperties{ "Name": notion.DatabaseProperty{ - ID: "title", - Type: notion.DBPropTypeTitle, + ID: "title", + Type: notion.DBPropTypeTitle, + Title: ¬ion.EmptyMetadata{}, }, "Description": notion.DatabaseProperty{ ID: "J@cS", Type: notion.DBPropTypeRichText, }, "In stock": notion.DatabaseProperty{ - ID: "{xYx", - Type: notion.DBPropTypeCheckbox, + ID: "{xYx", + Type: notion.DBPropTypeCheckbox, + Checkbox: ¬ion.EmptyMetadata{}, }, "Food group": notion.DatabaseProperty{ ID: "TJmr", @@ -293,6 +295,7 @@ func TestFindDatabaseByID(t *testing.T) { "Last ordered": notion.DatabaseProperty{ ID: "]\\R[", Type: notion.DBPropTypeDate, + Date: ¬ion.EmptyMetadata{}, }, "Meals": notion.DatabaseProperty{ ID: "lV]M", @@ -333,12 +336,14 @@ func TestFindDatabaseByID(t *testing.T) { }, }, "+1": notion.DatabaseProperty{ - ID: "aGut", - Type: notion.DBPropTypePeople, + ID: "aGut", + Type: notion.DBPropTypePeople, + People: ¬ion.EmptyMetadata{}, }, "Photo": { - ID: "aTIT", - Type: "files", + ID: "aTIT", + Type: "files", + Files: ¬ion.EmptyMetadata{}, }, }, Parent: notion.Parent{ @@ -901,6 +906,249 @@ func TestQueryDatabase(t *testing.T) { }) } } +func TestCreateDatabase(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + params notion.CreateDatabaseParams + respBody func(r *http.Request) io.Reader + respStatusCode int + expPostBody map[string]interface{} + expResponse notion.Database + expError error + }{ + { + name: "successful response", + params: notion.CreateDatabaseParams{ + ParentPageID: "b0668f48-8d66-4733-9bdb-2f82215707f7", + Title: []notion.RichText{ + { + Text: ¬ion.Text{ + Content: "Foobar", + }, + }, + }, + Properties: notion.DatabaseProperties{ + "Title": notion.DatabaseProperty{ + Type: notion.DBPropTypeTitle, + Title: ¬ion.EmptyMetadata{}, + }, + }, + }, + respBody: func(_ *http.Request) io.Reader { + return strings.NewReader( + `{ + "object": "database", + "id": "b89664e3-30b4-474a-9cce-c72a4827d1e4", + "created_time": "2021-07-20T20:09:00.000Z", + "last_edited_time": "2021-07-20T20:09:00.000Z", + "title": [ + { + "type": "text", + "text": { + "content": "Foobar", + "link": null + }, + "annotations": { + "bold": false, + "italic": false, + "strikethrough": false, + "underline": false, + "code": false, + "color": "default" + }, + "plain_text": "Foobar", + "href": null + } + ], + "properties": { + "Title": { + "id": "title", + "type": "title", + "title": {} + } + }, + "parent": { + "type": "page_id", + "page_id": "b0668f48-8d66-4733-9bdb-2f82215707f7" + } + }`, + ) + }, + respStatusCode: http.StatusOK, + expPostBody: map[string]interface{}{ + "parent": map[string]interface{}{ + "type": "page_id", + "page_id": "b0668f48-8d66-4733-9bdb-2f82215707f7", + }, + "title": []interface{}{ + map[string]interface{}{ + "text": map[string]interface{}{ + "content": "Foobar", + }, + }, + }, + "properties": map[string]interface{}{ + "Title": map[string]interface{}{ + "type": "title", + "title": map[string]interface{}{}, + }, + }, + }, + expResponse: notion.Database{ + ID: "b89664e3-30b4-474a-9cce-c72a4827d1e4", + CreatedTime: mustParseTime(time.RFC3339Nano, "2021-07-20T20:09:00Z"), + LastEditedTime: mustParseTime(time.RFC3339Nano, "2021-07-20T20:09:00Z"), + Parent: notion.Parent{ + Type: notion.ParentTypePage, + PageID: "b0668f48-8d66-4733-9bdb-2f82215707f7", + }, + Title: []notion.RichText{ + { + Type: notion.RichTextTypeText, + Text: ¬ion.Text{ + Content: "Foobar", + }, + Annotations: ¬ion.Annotations{ + Color: notion.ColorDefault, + }, + PlainText: "Foobar", + }, + }, + Properties: notion.DatabaseProperties{ + "Title": notion.DatabaseProperty{ + ID: "title", + Type: notion.DBPropTypeTitle, + Title: ¬ion.EmptyMetadata{}, + }, + }, + }, + expError: nil, + }, + { + name: "error response", + params: notion.CreateDatabaseParams{ + ParentPageID: "b0668f48-8d66-4733-9bdb-2f82215707f7", + Title: []notion.RichText{ + { + Text: ¬ion.Text{ + Content: "Foobar", + }, + }, + }, + Properties: notion.DatabaseProperties{ + "Title": notion.DatabaseProperty{ + Type: notion.DBPropTypeTitle, + Title: ¬ion.EmptyMetadata{}, + }, + }, + }, + respBody: func(_ *http.Request) io.Reader { + return strings.NewReader( + `{ + "object": "error", + "status": 400, + "code": "validation_error", + "message": "foobar" + }`, + ) + }, + respStatusCode: http.StatusBadRequest, + expPostBody: map[string]interface{}{ + "parent": map[string]interface{}{ + "type": "page_id", + "page_id": "b0668f48-8d66-4733-9bdb-2f82215707f7", + }, + "title": []interface{}{ + map[string]interface{}{ + "text": map[string]interface{}{ + "content": "Foobar", + }, + }, + }, + "properties": map[string]interface{}{ + "Title": map[string]interface{}{ + "type": "title", + "title": map[string]interface{}{}, + }, + }, + }, + expResponse: notion.Database{}, + expError: errors.New("notion: failed to create database: foobar (code: validation_error, status: 400)"), + }, + { + name: "parent id required error", + params: notion.CreateDatabaseParams{ + Properties: notion.DatabaseProperties{}, + }, + expResponse: notion.Database{}, + expError: errors.New("notion: invalid database params: parent page ID is required"), + }, + { + name: "database properties required error", + params: notion.CreateDatabaseParams{ + ParentPageID: "b0668f48-8d66-4733-9bdb-2f82215707f7", + }, + expResponse: notion.Database{}, + expError: errors.New("notion: invalid database params: database properties are required"), + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + httpClient := &http.Client{ + Transport: &mockRoundtripper{fn: func(r *http.Request) (*http.Response, error) { + postBody := make(map[string]interface{}) + + err := json.NewDecoder(r.Body).Decode(&postBody) + if err != nil && err != io.EOF { + t.Fatal(err) + } + + if len(tt.expPostBody) == 0 && len(postBody) != 0 { + t.Errorf("unexpected post body: %#v", postBody) + } + + if len(tt.expPostBody) != 0 && len(postBody) == 0 { + t.Errorf("post body not equal (expected %+v, got: nil)", tt.expPostBody) + } + + if len(tt.expPostBody) != 0 && len(postBody) != 0 { + if diff := cmp.Diff(tt.expPostBody, postBody); diff != "" { + t.Errorf("post body not equal (-exp, +got):\n%v", diff) + } + } + + return &http.Response{ + StatusCode: tt.respStatusCode, + Status: http.StatusText(tt.respStatusCode), + Body: ioutil.NopCloser(tt.respBody(r)), + }, nil + }}, + } + client := notion.NewClient("secret-api-key", notion.WithHTTPClient(httpClient)) + page, err := client.CreateDatabase(context.Background(), tt.params) + + if tt.expError == nil && err != nil { + t.Fatalf("unexpected error: %v", err) + } + if tt.expError != nil && err == nil { + t.Fatalf("error not equal (expected: %v, got: nil)", tt.expError) + } + if tt.expError != nil && err != nil && tt.expError.Error() != err.Error() { + t.Fatalf("error not equal (expected: %v, got: %v)", tt.expError, err) + } + + if diff := cmp.Diff(tt.expResponse, page); diff != "" { + t.Fatalf("response not equal (-exp, +got):\n%v", diff) + } + }) + } +} func TestFindPageByID(t *testing.T) { t.Parallel() @@ -2504,8 +2752,9 @@ func TestSearch(t *testing.T) { }, Properties: notion.DatabaseProperties{ "Name": notion.DatabaseProperty{ - ID: "title", - Type: notion.DBPropTypeTitle, + ID: "title", + Type: notion.DBPropTypeTitle, + Title: ¬ion.EmptyMetadata{}, }, }, }, diff --git a/database.go b/database.go index 471beca..935085d 100644 --- a/database.go +++ b/database.go @@ -1,6 +1,8 @@ package notion import ( + "encoding/json" + "errors" "time" ) @@ -20,6 +22,7 @@ type DatabaseProperties map[string]DatabaseProperty // Database property metadata types. type ( + EmptyMetadata struct{} NumberMetadata struct { Format NumberFormat `json:"format"` } @@ -79,9 +82,23 @@ type File struct { } type DatabaseProperty struct { - ID string `json:"id"` + ID string `json:"id,omitempty"` Type DatabasePropertyType `json:"type"` + Title *EmptyMetadata `json:"title,omitempty"` + RichText *EmptyMetadata `json:"rich_text,omitempty"` + Date *EmptyMetadata `json:"date,omitempty"` + People *EmptyMetadata `json:"people,omitempty"` + Files *EmptyMetadata `json:"files,omitempty"` + Checkbox *EmptyMetadata `json:"checkbox,omitempty"` + URL *EmptyMetadata `json:"url,omitempty"` + Email *EmptyMetadata `json:"email,omitempty"` + PhoneNumber *EmptyMetadata `json:"phone_number,omitempty"` + CreatedTime *EmptyMetadata `json:"created_time,omitempty"` + CreatedBy *EmptyMetadata `json:"created_by,omitempty"` + LastEditedTime *EmptyMetadata `json:"last_edited_time,omitempty"` + LastEditedBy *EmptyMetadata `json:"last_edited_by,omitempty"` + Number *NumberMetadata `json:"number,omitempty"` Select *SelectMetadata `json:"select,omitempty"` MultiSelect *SelectMetadata `json:"multi_select,omitempty"` @@ -213,6 +230,13 @@ type DatabaseQuerySort struct { Direction SortDirection `json:"direction,omitempty"` } +// CreateDatabaseParams are the params used for creating a database. +type CreateDatabaseParams struct { + ParentPageID string + Title []RichText + Properties DatabaseProperties +} + type ( DatabasePropertyType string NumberFormat string @@ -281,6 +305,8 @@ const ( // When type is unknown/unmapped or doesn't have additional properies, `nil` is returned. func (prop DatabaseProperty) Metadata() interface{} { switch prop.Type { + case "title": + return prop.Title case "number": return prop.Number case "select": @@ -327,3 +353,37 @@ func (r RollupResult) Value() interface{} { return nil } } + +// Validate validates params for creating a database. +func (p CreateDatabaseParams) Validate() error { + if p.ParentPageID == "" { + return errors.New("parent page ID is required") + } + if p.Properties == nil { + return errors.New("database properties are required") + } + + return nil +} + +// MarshalJSON implements json.Marshaler. +func (p CreateDatabaseParams) MarshalJSON() ([]byte, error) { + type CreatePageParamsDTO struct { + Parent Parent `json:"parent"` + Title []RichText `json:"title,omitempty"` + Properties DatabaseProperties `json:"properties"` + } + + parent := Parent{ + Type: ParentTypePage, + PageID: p.ParentPageID, + } + + dto := CreatePageParamsDTO{ + Parent: parent, + Title: p.Title, + Properties: p.Properties, + } + + return json.Marshal(dto) +}