diff --git a/README.md b/README.md index 7b88d8b..a3d4d4a 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,7 @@ Go client for the [Notion API](https://developers.notion.com/reference). ### Databases - [x] [Retrieve a database](database.go) -- [ ] Query a database -- [ ] List databases +- [x] [Query a database](database.go) ## Pages diff --git a/client.go b/client.go index a54ccae..501e90e 100644 --- a/client.go +++ b/client.go @@ -52,5 +52,9 @@ func (c *Client) newRequest(method, url string, body io.Reader) (*http.Request, req.Header.Set("Notion-Version", apiVersion) req.Header.Set("User-Agent", "go-notion/"+clientVersion) + if method == http.MethodPost || method == http.MethodPatch { + req.Header.Set("Content-Type", "application/json") + } + return req, nil } diff --git a/database.go b/database.go index 959b5b1..23414e9 100644 --- a/database.go +++ b/database.go @@ -1,8 +1,10 @@ package notion import ( + "bytes" "encoding/json" "fmt" + "net/http" "time" ) @@ -16,6 +18,7 @@ type Database struct { Properties DatabaseProperties `json:"properties"` } +// DatabaseProperties is a mapping of properties defined on a database. type DatabaseProperties map[string]DatabaseProperty // Database property metadata types. @@ -61,6 +64,144 @@ type DatabaseProperty struct { Rollup *RollupMetadata `json:"rollup"` } +// DatabaseQuery is used for quering a database. +type DatabaseQuery struct { + Filter DatabaseQueryFilter `json:"filter,omitempty"` + Sorts []DatabaseQuerySort `json:"sorts,omitempty"` + StartCursor string `json:"start_cursor,omitempty"` + PageSize int `json:"page_size,omitempty"` +} + +// DatabaseQueryResponse contains the results and pagination data from a query request. +type DatabaseQueryResponse struct { + Results []Page `json:"results"` + HasMore bool `json:"has_more"` + NextCursor *string `json:"next_cursor"` +} + +// DatabaseQueryFilter is used to filter database contents. +// See: https://developers.notion.com/reference/post-database-query#post-database-query-filter +type DatabaseQueryFilter struct { + Property string `json:"property,omitempty"` + + Text TextDatabaseQueryFilter `json:"text,omitempty"` + Number NumberDatabaseQueryFilter `json:"number,omitempty"` + Checkbox CheckboxDatabaseQueryFilter `json:"checkbox,omitempty"` + Select SelectDatabaseQueryFilter `json:"select,omitempty"` + MultiSelect MultiSelectDatabaseQueryFilter `json:"multi_select,omitempty"` + Date DateDatabaseQueryFilter `json:"date,omitempty"` + People PeopleDatabaseQueryFilter `json:"people,omitempty"` + Files FilesDatabaseQueryFilter `json:"files,omitempty"` + Relation RelationDatabaseQueryFilter `json:"relation,omitempty"` + + Or []DatabaseQueryFilter `json:"or,omitempty"` + And []DatabaseQueryFilter `json:"and,omitempty"` +} + +type TextDatabaseQueryFilter struct { + Equals string `json:"equals,omitempty"` + DoesNotEqual string `json:"does_not_equal,omitempty"` + Contains string `json:"contains,omitempty"` + DoesNotContain string `json:"does_not_contain,omitempty"` + StartsWith string `json:"starts_with,omitempty"` + EndsWith string `json:"ends_with,omitempty"` + IsEmpty bool `json:"is_empty,omitempty"` + IsNotEmpty bool `json:"is_not_empty,omitempty"` +} + +type NumberDatabaseQueryFilter struct { + Equals *int `json:"equals,omitempty"` + DoesNotEqual *int `json:"does_not_equal,omitempty"` + GreaterThan *int `json:"greater_than,omitempty"` + LessThan *int `json:"less_than,omitempty"` + GreaterThanOrEqualTo *int `json:"greater_than_or_equal_to,omitempty"` + LessThanOrEqualTo *int `json:"less_than_or_equal_to,omitempty"` + IsEmpty bool `json:"is_empty,omitempty"` + IsNotEmpty bool `json:"is_not_empty,omitempty"` +} + +type CheckboxDatabaseQueryFilter struct { + Equals *bool `json:"equals,omitempty"` + DoesNotEqual *bool `json:"does_not_equal,omitempty"` +} + +type SelectDatabaseQueryFilter struct { + Equals string `json:"equals,omitempty"` + DoesNotEqual string `json:"does_not_equal,omitempty"` + IsEmpty bool `json:"is_empty,omitempty"` + IsNotEmpty bool `json:"is_not_empty,omitempty"` +} + +type MultiSelectDatabaseQueryFilter struct { + Contains string `json:"contains,omitempty"` + DoesNotContain string `json:"does_not_contain,omitempty"` + IsEmpty bool `json:"is_empty,omitempty"` + IsNotEmpty bool `json:"is_not_empty,omitempty"` +} + +type DateDatabaseQueryFilter struct { + Equals time.Time `json:"equals,omitempty"` + Before time.Time `json:"before,omitempty"` + After time.Time `json:"after,omitempty"` + OnOrBefore time.Time `json:"on_or_before,omitempty"` + OnOrAfter time.Time `json:"on_or_after,omitempty"` + IsEmpty bool `json:"is_empty,omitempty"` + IsNotEmpty bool `json:"is_not_empty,omitempty"` + PastWeek *struct{} `json:"past_week,omitempty"` + PastMonth *struct{} `json:"past_month,omitempty"` + PastYear *struct{} `json:"past_year,omitempty"` + NextWeek *struct{} `json:"next_week,omitempty"` + NextMonth *struct{} `json:"next_month,omitempty"` + NextYear *struct{} `json:"next_year,omitempty"` +} + +type PeopleDatabaseQueryFilter struct { + Contains string `json:"contains,omitempty"` + DoesNotContain string `json:"does_not_contain,omitempty"` + IsEmpty bool `json:"is_empty,omitempty"` + IsNotEmpty bool `json:"is_not_empty,omitempty"` +} + +type FilesDatabaseQueryFilter struct { + IsEmpty bool `json:"is_empty,omitempty"` + IsNotEmpty bool `json:"is_not_empty,omitempty"` +} + +type RelationDatabaseQueryFilter struct { + Contains string `json:"contains,omitempty"` + DoesNotContain string `json:"does_not_contain,omitempty"` + IsEmpty bool `json:"is_empty,omitempty"` + IsNotEmpty bool `json:"is_not_empty,omitempty"` +} + +type FormulaDatabaseQueryFilter struct { + Text TextDatabaseQueryFilter `json:"text,omitempty"` + Checkbox CheckboxDatabaseQueryFilter `json:"checkbox,omitempty"` + Number NumberDatabaseQueryFilter `json:"number,omitempty"` + Date DateDatabaseQueryFilter `json:"date,omitempty"` +} + +type DatabaseQuerySort struct { + Property string `json:"property,omitempty"` + Timestamp SortTimestamp `json:"timestamp,omitempty"` + Direction SortDirection `json:"direction,omitempty"` +} + +type ( + SortTimestamp string + SortDirection string +) + +const ( + // Sort timestamp enums. + SortTimeStampCreatedTime SortTimestamp = "created_time" + SortTimeStampLastEditedTime SortTimestamp = "last_edited_time" + + // Sort direction enums. + SortDirAsc SortDirection = "ascending" + SortDirDesc SortDirection = "descending" +) + // Metadata returns the underlying property metadata, based on its `type` field. // When type is unknown/unmapped or doesn't have additional properies, `nil` is returned. func (prop DatabaseProperty) Metadata() interface{} { @@ -82,8 +223,10 @@ func (prop DatabaseProperty) Metadata() interface{} { } } +// FindDatabaseByID fetches a database by ID. +// See: https://developers.notion.com/reference/get-database func (c *Client) FindDatabaseByID(id string) (db Database, err error) { - req, err := c.newRequest("GET", "/databases/"+id, nil) + req, err := c.newRequest(http.MethodGet, "/databases/"+id, nil) if err != nil { return Database{}, fmt.Errorf("notion: invalid URL: %w", err) } @@ -94,7 +237,7 @@ func (c *Client) FindDatabaseByID(id string) (db Database, err error) { } defer res.Body.Close() - if res.StatusCode != 200 { + if res.StatusCode != http.StatusOK { return Database{}, fmt.Errorf("notion: failed to find database: %w", parseErrorResponse(res)) } @@ -105,3 +248,36 @@ func (c *Client) FindDatabaseByID(id string) (db Database, err error) { return db, nil } + +// QueryDatabase returns database contents, with optional filters, sorts and pagination. +// See: https://developers.notion.com/reference/post-database-query +func (c *Client) QueryDatabase(id string, query DatabaseQuery) (result DatabaseQueryResponse, err error) { + body := &bytes.Buffer{} + + err = json.NewEncoder(body).Encode(query) + if err != nil { + return DatabaseQueryResponse{}, fmt.Errorf("notion: failed to encode filter to JSON: %w", err) + } + + req, err := c.newRequest(http.MethodPost, fmt.Sprintf("/databases/%v/query", id), body) + if err != nil { + return DatabaseQueryResponse{}, fmt.Errorf("notion: invalid URL: %w", err) + } + + res, err := c.httpClient.Do(req) + if err != nil { + return DatabaseQueryResponse{}, fmt.Errorf("notion: failed to make HTTP request: %w", err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return DatabaseQueryResponse{}, fmt.Errorf("notion: failed to find database: %w", parseErrorResponse(res)) + } + + err = json.NewDecoder(res.Body).Decode(&result) + if err != nil { + return DatabaseQueryResponse{}, fmt.Errorf("notion: failed to parse HTTP response: %w", err) + } + + return result, nil +} diff --git a/page.go b/page.go new file mode 100644 index 0000000..9308f5c --- /dev/null +++ b/page.go @@ -0,0 +1,95 @@ +package notion + +import ( + "encoding/json" + "fmt" + "time" +) + +// Page is a resource on the Notion platform. Its parent is either a workspace, +// another page, or a database. +// See: https://developers.notion.com/reference/page +type Page struct { + ID string `json:"id"` + CreatedTime time.Time `json:"created_time"` + LastEditedTime time.Time `json:"last_edited_time"` + Parent PageParent `json:"parent"` + Archived bool `json:"archived"` + + // Properties differ between parent type. + // See the `UnmarshalJSON` method. + Properties interface{} `json:"properties"` +} + +type PageParent struct { + Type string `json:"type"` + + PageID *string `json:"page_id"` + DatabaseID *string `json:"database_id"` +} + +// PageProperties are properties of a page whose parent is a page or a workspace. +type PageProperties struct { + Title struct { + Title []RichText `json:"title"` + } `json:"title"` +} + +// DatabasePageProperties are properties of a page whose parent is a database. +type DatabasePageProperties map[string]DatabasePageProperty + +type DatabasePageProperty struct { + DatabaseProperty + RichText []RichText `json:"rich_text"` + Select *SelectMetadata `json:"select"` + MultiSelect []SelectMetadata `json:"multi_select"` +} + +// UnmarshalJSON implements json.Unmarshaler. +// +// Pages get a different Properties type based on the parent of the page. +// If parent type is `workspace` or `page_id`, PageProperties is used. Else if +// parent type is `database_id`, DatabasePageProperties is used. +func (p *Page) UnmarshalJSON(b []byte) error { + type ( + PageAlias Page + PageDTO struct { + PageAlias + Properties json.RawMessage `json:"properties"` + } + ) + + var dto PageDTO + + err := json.Unmarshal(b, &dto) + if err != nil { + return err + } + + page := dto.PageAlias + + switch dto.Parent.Type { + case "workspace": + fallthrough + case "page_id": + var props PageProperties + err := json.Unmarshal(dto.Properties, &props) + if err != nil { + return err + } + page.Properties = props + case "database_id": + var props DatabasePageProperties + err := json.Unmarshal(dto.Properties, &props) + if err != nil { + return err + } + page.Properties = props + default: + return fmt.Errorf("unknown page parent type %q", dto.Parent.Type) + } + + *p = Page(page) + + return nil +}