├── .github ├── release-drafter.yml └── workflows │ ├── develop.yaml │ ├── draft-new-release.yaml │ └── publish-new-release.yaml ├── .golangci.yml ├── CODE_OF_CONDUCT.md ├── LICENSE.md ├── README.md ├── api.go ├── api_internal_test.go ├── blocks.go ├── blocks_test.go ├── consts.go ├── databases.go ├── databases_test.go ├── errors.go ├── errors_test.go ├── examples ├── append-block-children │ └── main.go ├── create-page │ └── main.go ├── list-block-children │ └── main.go ├── list-databases │ └── main.go ├── list-users │ └── main.go ├── query-database │ └── main.go ├── retrieve-database │ └── main.go ├── retrieve-page │ └── main.go ├── retrieve-user │ └── main.go ├── search │ └── main.go └── update-page │ └── main.go ├── go.mod ├── go.sum ├── pages.go ├── pages_test.go ├── pagination.go ├── rest ├── client.go └── interface.go ├── search.go ├── search_test.go ├── users.go └── users_test.go /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name-template: '$RESOLVED_VERSION' 2 | tag-template: '$RESOLVED_VERSION' 3 | categories: 4 | - title: '🚀 Features' 5 | labels: 6 | - 'feat' 7 | - 'feature' 8 | - 'doc' 9 | - 'enhancement' 10 | - 'test' 11 | - 'refactor' 12 | - title: '🐛 Bug Fixes' 13 | labels: 14 | - 'fix' 15 | - 'bug' 16 | - title: '🧰 Maintenance' 17 | labels: 18 | - 'chore' 19 | - 'ci' 20 | change-template: '- $TITLE (#$NUMBER) @$AUTHOR' 21 | 22 | change-title-escapes: '\<*_&' # You can add # and @ to disable mentions, and add ` to disable code blocks. 23 | template: | 24 | $CHANGES 25 | -------------------------------------------------------------------------------- /.github/workflows/develop.yaml: -------------------------------------------------------------------------------- 1 | name: Lint, Test and Build 2 | on: 3 | push: 4 | branches: 5 | - develop 6 | - main 7 | pull_request: 8 | jobs: 9 | lint: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2.3.3 13 | - name: golangci-lint 14 | uses: golangci/golangci-lint-action@v2 15 | with: 16 | version: v1.28.3 17 | 18 | build: 19 | runs-on: ubuntu-latest 20 | strategy: 21 | matrix: 22 | go-version: 23 | - 1.16.x 24 | steps: 25 | - name: Install Go 26 | uses: actions/setup-go@v1 27 | with: 28 | go-version: ${{ matrix.go-version }} 29 | 30 | - uses: actions/checkout@v2.3.3 31 | 32 | - name: Tesing with coverage 33 | run: go test -race -coverprofile=coverage.txt -covermode=atomic 34 | 35 | - name: Upload coverage report 36 | uses: codecov/codecov-action@v1 37 | 38 | - name: Build binary 39 | run: go build -v 40 | -------------------------------------------------------------------------------- /.github/workflows/draft-new-release.yaml: -------------------------------------------------------------------------------- 1 | name: "Draft new release" 2 | 3 | on: 4 | push: 5 | branches: 6 | - release/v*.*.* 7 | 8 | jobs: 9 | draft-new-release: 10 | name: "Draft new release" 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | 15 | - name: Extract version from branch name 16 | run: | 17 | BRANCH_NAME=${GITHUB_REF#refs/heads/} 18 | VERSION=${BRANCH_NAME#release/} 19 | echo "RELEASE_VERSION=$VERSION" >> $GITHUB_ENV 20 | 21 | - name: Validate the version 22 | id: regex-match 23 | uses: actions-ecosystem/action-regex-match@v2 24 | with: 25 | text: ${{ env.RELEASE_VERSION }} 26 | regex: '^v\d+(\.\d+){2}$' 27 | 28 | - name: Create pull request 29 | if: ${{ steps.regex-match.outputs.match != '' }} 30 | uses: thomaseizinger/create-pull-request@1.0.0 31 | env: 32 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 33 | with: 34 | head: release/${{ env.RELEASE_VERSION }} 35 | base: main 36 | title: Release version ${{ env.RELEASE_VERSION }} 37 | reviewers: ${{ github.actor }} 38 | -------------------------------------------------------------------------------- /.github/workflows/publish-new-release.yaml: -------------------------------------------------------------------------------- 1 | name: "Publish new release" 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | types: 8 | - closed 9 | 10 | jobs: 11 | release: 12 | name: Publish new release 13 | runs-on: ubuntu-latest 14 | 15 | if: github.event.pull_request.merged == true 16 | 17 | steps: 18 | - name: Extract version from branch name 19 | if: startsWith(github.event.pull_request.head.ref, 'release/') 20 | run: | 21 | BRANCH_NAME="${{ github.event.pull_request.head.ref }}" 22 | VERSION=${BRANCH_NAME#release/} 23 | echo "RELEASE_VERSION=$VERSION" >> $GITHUB_ENV 24 | 25 | - name: Validate the version 26 | id: regex-match 27 | uses: actions-ecosystem/action-regex-match@v2 28 | with: 29 | text: ${{ env.RELEASE_VERSION }} 30 | regex: '^v\d+(\.\d+){2}$' 31 | 32 | - name: Create Release 33 | if: ${{ steps.regex-match.outputs.match != '' }} 34 | id: generate_changelog 35 | uses: release-drafter/release-drafter@v5 36 | env: 37 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 38 | with: 39 | version: ${{ env.RELEASE_VERSION }} 40 | tag: ${{ env.RELEASE_VERSION }} 41 | name: ${{ env.RELEASE_VERSION }} 42 | publish: false 43 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | # https://github.com/golangci/golangci-lint#config-file 2 | run: 3 | skip-dirs: 4 | - pkg/orm 5 | - mock_* 6 | # build-tags: 7 | tests: false 8 | deadline: 5m 9 | print-resources-usage: true 10 | 11 | linters: 12 | enable-all: true 13 | disable: 14 | - godox # Tool for detection of FIXME, TODO and other comment keywords [fast: true, auto-fix: false] 15 | - gochecknoglobals # Checks that no globals are present in Go code [fast: true, auto-fix: false] 16 | - gofumpt 17 | - maligned 18 | - interfacer 19 | 20 | linters-settings: 21 | govet: 22 | # https://github.com/golangci/golangci-lint/issues/484 23 | # report about shadowed variables 24 | check-shadowing: false 25 | lll: 26 | line-length: 150 27 | funlen: 28 | lines: 70 29 | statements: 50 30 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | . 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Pei-Ming Wu 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # notion-go 2 | 3 | [![Go Report Card](https://goreportcard.com/badge/github.com/mkfsn/notion-go)](https://goreportcard.com/report/github.com/mkfsn/notion-go) 4 | [![Actions Status](https://github.com/mkfsn/notion-go/actions/workflows/develop.yaml/badge.svg)](https://github.com/mkfsn/notion-go/actions) 5 | [![codecov](https://codecov.io/gh/mkfsn/notion-go/branch/develop/graph/badge.svg?token=NA64P6EPQ0)](https://codecov.io/gh/mkfsn/notion-go) 6 | 7 | [![Go Reference](https://pkg.go.dev/badge/github.com/mkfsn/notion-go.svg)](https://pkg.go.dev/github.com/mkfsn/notion-go) 8 | ![GitHub go.mod Go version](https://img.shields.io/github/go-mod/go-version/mkfsn/notion-go) 9 | [![License](https://img.shields.io/github/license/mkfsn/notion-go.svg)](./LICENSE.md) 10 | 11 | A go client for the [Notion API](https://developers.notion.com/) 12 | 13 | ## Description 14 | 15 | This aims to be an unofficial Go version of [the official SDK](https://github.com/makenotion/notion-sdk-js) 16 | which is written in JavaScript. 17 | 18 | ## Installation 19 | 20 | ``` 21 | go get -u github.com/mkfsn/notion-go 22 | ``` 23 | 24 | ## Usage 25 | 26 | ```go 27 | c := notion.New("") 28 | 29 | // Retrieve block children 30 | c.Blocks().Children().List(context.Background(), notion.BlocksChildrenListParameters{...}) 31 | 32 | // Append block children 33 | c.Blocks().Children().Append(context.Background(), notion.BlocksChildrenAppendParameters{...}) 34 | 35 | // List databases 36 | c.Databases().List(context.Background(), notion.DatabasesListParameters{...}) 37 | 38 | // Query a database 39 | c.Databases().Query(context.Background(), notion.DatabasesQueryParameters{...}) 40 | 41 | // Retrieve a database 42 | c.Databases().Retrieve(context.Background(), notion.DatabasesRetrieveParameters{...}) 43 | 44 | // Create a page 45 | c.Pages().Create(context.Background(), notion.PagesCreateParameters{...}) 46 | 47 | // Retrieve a page 48 | c.Pages().Retreive(context.Background(), notion.PagesRetrieveParameters{...}) 49 | 50 | // Update page properties 51 | c.Pages().Update(context.Background(), notion.PagesUpdateParameters{...}) 52 | 53 | // List all users 54 | c.Users().List(context.Background(), notion.UsersListParameters{...}) 55 | 56 | // Retrieve a user 57 | c.Users().Retrieve(context.Background(), notion.UsersRetrieveParameters{...}) 58 | 59 | // Search 60 | c.Search(context.Background(), notion.SearchParameters{...}) 61 | ``` 62 | 63 | For more information, please see [examples](./examples). 64 | 65 | ## Supported Features 66 | 67 | This client supports all endpoints in the [Notion API](https://developers.notion.com/reference/intro). 68 | 69 | - [x] Users ✅ 70 | * [x] [Retrieve](https://developers.notion.com/reference/get-user) ✅ 71 | * [x] [List](https://developers.notion.com/reference/get-users) ✅ 72 | - [x] Databases ✅ 73 | * [x] [Retrieve](https://developers.notion.com/reference/get-database) ✅ 74 | * [x] [List](https://developers.notion.com/reference/get-databases) ✅ 75 | * [x] [Query](https://developers.notion.com/reference/post-database-query) ✅ 76 | - [x] Pages ✅ 77 | * [x] [Retrieve](https://developers.notion.com/reference/get-page) ✅ 78 | * [x] [Create](https://developers.notion.com/reference/post-page) ✅️ 79 | * [x] [Update](https://developers.notion.com/reference/patch-page) ✅️ 80 | - [x] Blocks ✅️ 81 | * [x] Children ✅ 82 | - [x] [Retrieve](https://developers.notion.com/reference/get-block-children) ✅ 83 | - [x] [Append](https://developers.notion.com/reference/patch-block-children) ✅ 84 | - [x] [Search](https://developers.notion.com/reference/post-search) ✅ 85 | -------------------------------------------------------------------------------- /api.go: -------------------------------------------------------------------------------- 1 | package notion 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | 7 | "github.com/mkfsn/notion-go/rest" 8 | ) 9 | 10 | const ( 11 | APIBaseURL = "https://api.notion.com" 12 | APIUsersListEndpoint = "/v1/users" 13 | APIUsersRetrieveEndpoint = "/v1/users/{user_id}" 14 | APIBlocksListChildrenEndpoint = "/v1/blocks/{block_id}/children" 15 | APIBlocksAppendChildrenEndpoint = "/v1/blocks/{block_id}/children" 16 | APIPagesCreateEndpoint = "/v1/pages" 17 | APIPagesRetrieveEndpoint = "/v1/pages/{page_id}" 18 | APIPagesUpdateEndpoint = "/v1/pages/{page_id}" 19 | APIDatabasesListEndpoint = "/v1/databases" 20 | APIDatabasesRetrieveEndpoint = "/v1/databases/{database_id}" 21 | APIDatabasesQueryEndpoint = "/v1/databases/{database_id}/query" 22 | APISearchEndpoint = "/v1/search" 23 | ) 24 | 25 | const ( 26 | DefaultNotionVersion = "2021-05-13" 27 | DefaultUserAgent = "mkfsn/notion-go" 28 | ) 29 | 30 | var ( 31 | defaultSettings = apiSettings{ 32 | baseURL: APIBaseURL, 33 | notionVersion: DefaultNotionVersion, 34 | userAgent: DefaultUserAgent, 35 | httpClient: http.DefaultClient, 36 | } 37 | ) 38 | 39 | type API struct { 40 | searchClient SearchInterface 41 | usersClient UsersInterface 42 | databasesClient DatabasesInterface 43 | pagesClient PagesInterface 44 | blocksClient BlocksInterface 45 | } 46 | 47 | func New(authToken string, setters ...APISetting) *API { 48 | settings := defaultSettings 49 | 50 | for _, setter := range setters { 51 | setter(&settings) 52 | } 53 | 54 | restClient := rest.New(). 55 | BearerToken(authToken). 56 | BaseURL(settings.baseURL). 57 | UserAgent(settings.userAgent). 58 | Client(settings.httpClient). 59 | Header("Notion-Version", settings.notionVersion) 60 | 61 | return &API{ 62 | searchClient: newSearchClient(restClient), 63 | usersClient: newUsersClient(restClient), 64 | databasesClient: newDatabasesClient(restClient), 65 | pagesClient: newPagesClient(restClient), 66 | blocksClient: newBlocksClient(restClient), 67 | } 68 | } 69 | 70 | func (c *API) Users() UsersInterface { 71 | return c.usersClient 72 | } 73 | 74 | func (c *API) Databases() DatabasesInterface { 75 | return c.databasesClient 76 | } 77 | 78 | func (c *API) Pages() PagesInterface { 79 | return c.pagesClient 80 | } 81 | 82 | func (c *API) Blocks() BlocksInterface { 83 | return c.blocksClient 84 | } 85 | 86 | func (c *API) Search(ctx context.Context, params SearchParameters) (*SearchResponse, error) { 87 | return c.searchClient.Search(ctx, params) 88 | } 89 | 90 | type apiSettings struct { 91 | baseURL string 92 | notionVersion string 93 | userAgent string 94 | httpClient *http.Client 95 | } 96 | 97 | type APISetting func(o *apiSettings) 98 | 99 | func WithBaseURL(baseURL string) APISetting { 100 | return func(o *apiSettings) { 101 | o.baseURL = baseURL 102 | } 103 | } 104 | 105 | func WithNotionVersion(notionVersion string) APISetting { 106 | return func(o *apiSettings) { 107 | o.notionVersion = notionVersion 108 | } 109 | } 110 | 111 | func WithUserAgent(userAgent string) APISetting { 112 | return func(o *apiSettings) { 113 | o.userAgent = userAgent 114 | } 115 | } 116 | 117 | func WithHTTPClient(httpClient *http.Client) APISetting { 118 | return func(o *apiSettings) { 119 | o.httpClient = httpClient 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /api_internal_test.go: -------------------------------------------------------------------------------- 1 | package notion 2 | 3 | import ( 4 | "net/http" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestWithUserAgent(t *testing.T) { 11 | var settings apiSettings 12 | 13 | userAgent := "test-user-agent" 14 | 15 | WithUserAgent("test-user-agent")(&settings) 16 | 17 | assert.Equal(t, settings.userAgent, userAgent) 18 | } 19 | 20 | func TestWithBaseURL(t *testing.T) { 21 | var settings apiSettings 22 | 23 | baseURL := "https://example.com" 24 | 25 | WithBaseURL(baseURL)(&settings) 26 | 27 | assert.Equal(t, settings.baseURL, baseURL) 28 | } 29 | 30 | func TestNotionVersion(t *testing.T) { 31 | var settings apiSettings 32 | 33 | notionVersion := "2021-05-19" 34 | 35 | WithNotionVersion(notionVersion)(&settings) 36 | 37 | assert.Equal(t, settings.notionVersion, notionVersion) 38 | } 39 | 40 | func TestHTTPClient(t *testing.T) { 41 | var settings apiSettings 42 | 43 | httpClient := &http.Client{} 44 | 45 | WithHTTPClient(httpClient)(&settings) 46 | 47 | assert.Same(t, settings.httpClient, httpClient) 48 | } 49 | -------------------------------------------------------------------------------- /blocks.go: -------------------------------------------------------------------------------- 1 | package notion 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "strings" 8 | "time" 9 | 10 | "github.com/mkfsn/notion-go/rest" 11 | ) 12 | 13 | type Block interface { 14 | isBlock() 15 | } 16 | 17 | type BlockBase struct { 18 | // Always "block". 19 | Object ObjectType `json:"object"` 20 | // Identifier for the block. 21 | ID string `json:"id,omitempty"` 22 | // Type of block. 23 | Type BlockType `json:"type"` 24 | // Date and time when this block was created. Formatted as an ISO 8601 date time string. 25 | CreatedTime *time.Time `json:"created_time,omitempty"` 26 | // Date and time when this block was last updated. Formatted as an ISO 8601 date time string. 27 | LastEditedTime *time.Time `json:"last_edited_time,omitempty"` 28 | // Whether or not the block has children blocks nested within it. 29 | HasChildren bool `json:"has_children,omitempty"` 30 | } 31 | 32 | func (b BlockBase) isBlock() {} 33 | 34 | type ParagraphBlock struct { 35 | BlockBase 36 | Paragraph RichTextBlock `json:"paragraph"` 37 | } 38 | 39 | type HeadingBlock struct { 40 | Text []RichText `json:"text"` 41 | } 42 | 43 | func (h *HeadingBlock) UnmarshalJSON(data []byte) error { 44 | var alias struct { 45 | Text []richTextDecoder `json:"text"` 46 | } 47 | 48 | if err := json.Unmarshal(data, &alias); err != nil { 49 | return fmt.Errorf("failed to unmarshal HeadingBlock: %w", err) 50 | } 51 | 52 | h.Text = make([]RichText, 0, len(alias.Text)) 53 | 54 | for _, decoder := range alias.Text { 55 | h.Text = append(h.Text, decoder.RichText) 56 | } 57 | 58 | return nil 59 | } 60 | 61 | type Heading1Block struct { 62 | BlockBase 63 | Heading1 HeadingBlock `json:"heading_1"` 64 | } 65 | 66 | type Heading2Block struct { 67 | BlockBase 68 | Heading2 HeadingBlock `json:"heading_2"` 69 | } 70 | 71 | type Heading3Block struct { 72 | BlockBase 73 | Heading3 HeadingBlock `json:"heading_3"` 74 | } 75 | 76 | type RichTextBlock struct { 77 | Text []RichText `json:"text"` 78 | Children []Block `json:"children,omitempty"` 79 | } 80 | 81 | func (r *RichTextBlock) UnmarshalJSON(data []byte) error { 82 | var alias struct { 83 | Text []richTextDecoder `json:"text"` 84 | Children []blockDecoder `json:"children"` 85 | } 86 | 87 | if err := json.Unmarshal(data, &alias); err != nil { 88 | return fmt.Errorf("failed to unmarshal RichTextBlock: %w", err) 89 | } 90 | 91 | r.Text = make([]RichText, 0, len(r.Text)) 92 | 93 | for _, decoder := range alias.Text { 94 | r.Text = append(r.Text, decoder.RichText) 95 | } 96 | 97 | r.Children = make([]Block, 0, len(r.Children)) 98 | 99 | for _, decoder := range alias.Children { 100 | r.Children = append(r.Children, decoder.Block) 101 | } 102 | 103 | return nil 104 | } 105 | 106 | type BulletedListItemBlock struct { 107 | BlockBase 108 | BulletedListItem RichTextBlock `json:"bulleted_list_item"` 109 | } 110 | 111 | type NumberedListItemBlock struct { 112 | BlockBase 113 | NumberedListItem RichTextBlock `json:"numbered_list_item"` 114 | } 115 | 116 | type RichTextWithCheckBlock struct { 117 | Text []RichText `json:"text"` 118 | Checked bool `json:"checked"` 119 | Children []BlockBase `json:"children"` 120 | } 121 | 122 | type ToDoBlock struct { 123 | BlockBase 124 | ToDo RichTextWithCheckBlock `json:"todo"` 125 | } 126 | 127 | type ToggleBlock struct { 128 | BlockBase 129 | Toggle RichTextBlock `json:"toggle"` 130 | } 131 | 132 | type TitleBlock struct { 133 | Title string `json:"title"` 134 | } 135 | 136 | type ChildPageBlock struct { 137 | BlockBase 138 | ChildPage TitleBlock `json:"child_page"` 139 | } 140 | 141 | type UnsupportedBlock struct { 142 | BlockBase 143 | } 144 | 145 | type BlocksInterface interface { 146 | Children() BlocksChildrenInterface 147 | } 148 | 149 | type blocksClient struct { 150 | childrenClient *blocksChildrenClient 151 | } 152 | 153 | func newBlocksClient(restClient rest.Interface) *blocksClient { 154 | return &blocksClient{ 155 | childrenClient: newBlocksChildrenClient(restClient), 156 | } 157 | } 158 | 159 | func (b *blocksClient) Children() BlocksChildrenInterface { 160 | if b == nil { 161 | return nil 162 | } 163 | 164 | return b.childrenClient 165 | } 166 | 167 | type BlocksChildrenListParameters struct { 168 | PaginationParameters 169 | 170 | // Identifier for a block 171 | BlockID string `json:"-" url:"-"` 172 | } 173 | 174 | type BlocksChildrenListResponse struct { 175 | PaginatedList 176 | Results []Block `json:"results"` 177 | } 178 | 179 | func (b *BlocksChildrenListResponse) UnmarshalJSON(data []byte) error { 180 | type Alias BlocksChildrenListResponse 181 | 182 | alias := struct { 183 | *Alias 184 | Results []blockDecoder `json:"results"` 185 | }{ 186 | Alias: (*Alias)(b), 187 | } 188 | 189 | if err := json.Unmarshal(data, &alias); err != nil { 190 | return fmt.Errorf("failed to unmarshal BlocksChildrenListResponse: %w", err) 191 | } 192 | 193 | b.Results = make([]Block, 0, len(alias.Results)) 194 | 195 | for _, decoder := range alias.Results { 196 | b.Results = append(b.Results, decoder.Block) 197 | } 198 | 199 | return nil 200 | } 201 | 202 | type BlocksChildrenAppendParameters struct { 203 | // Identifier for a block 204 | BlockID string `json:"-" url:"-"` 205 | // Child content to append to a container block as an array of block objects 206 | Children []Block `json:"children" url:"-"` 207 | } 208 | 209 | type BlocksChildrenAppendResponse struct { 210 | Block 211 | } 212 | 213 | func (b *BlocksChildrenAppendResponse) UnmarshalJSON(data []byte) error { 214 | var decoder blockDecoder 215 | 216 | if err := json.Unmarshal(data, &decoder); err != nil { 217 | return fmt.Errorf("failed to unmarshal BlocksChildrenAppendResponse: %w", err) 218 | } 219 | 220 | b.Block = decoder.Block 221 | 222 | return nil 223 | } 224 | 225 | type BlocksChildrenInterface interface { 226 | List(ctx context.Context, params BlocksChildrenListParameters) (*BlocksChildrenListResponse, error) 227 | Append(ctx context.Context, params BlocksChildrenAppendParameters) (*BlocksChildrenAppendResponse, error) 228 | } 229 | 230 | type blocksChildrenClient struct { 231 | restClient rest.Interface 232 | } 233 | 234 | func newBlocksChildrenClient(restClient rest.Interface) *blocksChildrenClient { 235 | return &blocksChildrenClient{ 236 | restClient: restClient, 237 | } 238 | } 239 | 240 | func (b *blocksChildrenClient) List(ctx context.Context, params BlocksChildrenListParameters) (*BlocksChildrenListResponse, error) { 241 | var result BlocksChildrenListResponse 242 | 243 | var failure HTTPError 244 | 245 | err := b.restClient.New().Get(). 246 | Endpoint(strings.Replace(APIBlocksListChildrenEndpoint, "{block_id}", params.BlockID, 1)). 247 | QueryStruct(params). 248 | Receive(ctx, &result, &failure) 249 | 250 | return &result, err // nolint:wrapcheck 251 | } 252 | 253 | func (b *blocksChildrenClient) Append(ctx context.Context, params BlocksChildrenAppendParameters) (*BlocksChildrenAppendResponse, error) { 254 | var result BlocksChildrenAppendResponse 255 | 256 | var failure HTTPError 257 | 258 | err := b.restClient.New().Patch(). 259 | Endpoint(strings.Replace(APIBlocksAppendChildrenEndpoint, "{block_id}", params.BlockID, 1)). 260 | QueryStruct(params). 261 | BodyJSON(params). 262 | Receive(ctx, &result, &failure) 263 | 264 | return &result, err // nolint:wrapcheck 265 | } 266 | 267 | type blockDecoder struct { 268 | Block 269 | } 270 | 271 | // UnmarshalJSON implements json.Unmarshaler 272 | // nolint: cyclop 273 | func (b *blockDecoder) UnmarshalJSON(data []byte) error { 274 | var decoder struct { 275 | Type BlockType `json:"type"` 276 | } 277 | 278 | if err := json.Unmarshal(data, &decoder); err != nil { 279 | return fmt.Errorf("failed to unmarshal Block: %w", err) 280 | } 281 | 282 | switch decoder.Type { 283 | case BlockTypeParagraph: 284 | b.Block = &ParagraphBlock{} 285 | 286 | case BlockTypeHeading1: 287 | b.Block = &Heading1Block{} 288 | 289 | case BlockTypeHeading2: 290 | b.Block = &Heading2Block{} 291 | 292 | case BlockTypeHeading3: 293 | b.Block = &Heading3Block{} 294 | 295 | case BlockTypeBulletedListItem: 296 | b.Block = &BulletedListItemBlock{} 297 | 298 | case BlockTypeNumberedListItem: 299 | b.Block = &NumberedListItemBlock{} 300 | 301 | case BlockTypeToDo: 302 | b.Block = &ToDoBlock{} 303 | 304 | case BlockTypeToggle: 305 | b.Block = &ToggleBlock{} 306 | 307 | case BlockTypeChildPage: 308 | b.Block = &ChildPageBlock{} 309 | 310 | case BlockTypeUnsupported: 311 | b.Block = &UnsupportedBlock{} 312 | } 313 | 314 | return json.Unmarshal(data, &b.Block) 315 | } 316 | -------------------------------------------------------------------------------- /blocks_test.go: -------------------------------------------------------------------------------- 1 | package notion 2 | 3 | import ( 4 | "context" 5 | "io/ioutil" 6 | "net/http" 7 | "net/http/httptest" 8 | "testing" 9 | "time" 10 | 11 | "github.com/mkfsn/notion-go/rest" 12 | "github.com/stretchr/testify/assert" 13 | ) 14 | 15 | func Test_blocksChildrenClient_List(t *testing.T) { 16 | type fields struct { 17 | restClient rest.Interface 18 | mockHTTPHandler http.Handler 19 | authToken string 20 | } 21 | 22 | type args struct { 23 | ctx context.Context 24 | params BlocksChildrenListParameters 25 | } 26 | 27 | type wants struct { 28 | response *BlocksChildrenListResponse 29 | err error 30 | } 31 | 32 | type test struct { 33 | name string 34 | fields fields 35 | args args 36 | wants wants 37 | } 38 | 39 | tests := []test{ 40 | { 41 | name: "List 3 block children in a page", 42 | fields: fields{ 43 | restClient: rest.New(), 44 | authToken: "59642182-381f-458b-8144-4cc0750383c1", 45 | mockHTTPHandler: http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { 46 | assert.Equal(t, DefaultNotionVersion, request.Header.Get("Notion-Version")) 47 | assert.Equal(t, DefaultUserAgent, request.Header.Get("User-Agent")) 48 | assert.Equal(t, "Bearer 59642182-381f-458b-8144-4cc0750383c1", request.Header.Get("Authorization")) 49 | 50 | assert.Equal(t, http.MethodGet, request.Method) 51 | assert.Equal(t, "/v1/blocks/b55c9c91-384d-452b-81db-d1ef79372b75/children?page_size=100", request.RequestURI) 52 | 53 | writer.WriteHeader(http.StatusOK) 54 | 55 | _, err := writer.Write([]byte(`{ 56 | "object": "list", 57 | "results": [ 58 | { 59 | "object": "block", 60 | "id": "9bc30ad4-9373-46a5-84ab-0a7845ee52e6", 61 | "created_time": "2021-03-16T16:31:00.000Z", 62 | "last_edited_time": "2021-03-16T16:32:00.000Z", 63 | "has_children": false, 64 | "type": "heading_2", 65 | "heading_2": { 66 | "text": [ 67 | { 68 | "type": "text", 69 | "text": { 70 | "content": "Lacinato kale", 71 | "link": null 72 | }, 73 | "annotations": { 74 | "bold": false, 75 | "italic": false, 76 | "strikethrough": false, 77 | "underline": false, 78 | "code": false, 79 | "color": "default" 80 | }, 81 | "plain_text": "Lacinato kale", 82 | "href": null 83 | } 84 | ] 85 | } 86 | }, 87 | { 88 | "object": "block", 89 | "id": "7face6fd-3ef4-4b38-b1dc-c5044988eec0", 90 | "created_time": "2021-03-16T16:34:00.000Z", 91 | "last_edited_time": "2021-03-16T16:36:00.000Z", 92 | "has_children": false, 93 | "type": "paragraph", 94 | "paragraph": { 95 | "text": [ 96 | { 97 | "type": "text", 98 | "text": { 99 | "content": "Lacinato kale", 100 | "link": { 101 | "url": "https://en.wikipedia.org/wiki/Lacinato_kale" 102 | } 103 | }, 104 | "annotations": { 105 | "bold": false, 106 | "italic": false, 107 | "strikethrough": false, 108 | "underline": false, 109 | "code": false, 110 | "color": "default" 111 | }, 112 | "plain_text": "Lacinato kale", 113 | "href": "https://en.wikipedia.org/wiki/Lacinato_kale" 114 | }, 115 | { 116 | "type": "text", 117 | "text": { 118 | "content": " is a variety of kale with a long tradition in Italian cuisine, especially that of Tuscany. It is also known as Tuscan kale, Italian kale, dinosaur kale, kale, flat back kale, palm tree kale, or black Tuscan palm.", 119 | "link": null 120 | }, 121 | "annotations": { 122 | "bold": false, 123 | "italic": false, 124 | "strikethrough": false, 125 | "underline": false, 126 | "code": false, 127 | "color": "default" 128 | }, 129 | "plain_text": " is a variety of kale with a long tradition in Italian cuisine, especially that of Tuscany. It is also known as Tuscan kale, Italian kale, dinosaur kale, kale, flat back kale, palm tree kale, or black Tuscan palm.", 130 | "href": null 131 | } 132 | ] 133 | } 134 | }, 135 | { 136 | "object": "block", 137 | "id": "7636e2c9-b6c1-4df1-aeae-3ebf0073c5cb", 138 | "created_time": "2021-03-16T16:35:00.000Z", 139 | "last_edited_time": "2021-03-16T16:36:00.000Z", 140 | "has_children": true, 141 | "type": "toggle", 142 | "toggle": { 143 | "text": [ 144 | { 145 | "type": "text", 146 | "text": { 147 | "content": "Recipes", 148 | "link": null 149 | }, 150 | "annotations": { 151 | "bold": true, 152 | "italic": false, 153 | "strikethrough": false, 154 | "underline": false, 155 | "code": false, 156 | "color": "default" 157 | }, 158 | "plain_text": "Recipes", 159 | "href": null 160 | } 161 | ] 162 | } 163 | } 164 | ], 165 | "next_cursor": null, 166 | "has_more": false 167 | }`)) 168 | assert.NoError(t, err) 169 | }), 170 | }, 171 | args: args{ 172 | ctx: context.Background(), 173 | params: BlocksChildrenListParameters{ 174 | PaginationParameters: PaginationParameters{ 175 | StartCursor: "", 176 | PageSize: 100, 177 | }, 178 | BlockID: "b55c9c91-384d-452b-81db-d1ef79372b75", 179 | }, 180 | }, 181 | wants: wants{ 182 | response: &BlocksChildrenListResponse{ 183 | PaginatedList: PaginatedList{ 184 | Object: ObjectTypeList, 185 | HasMore: false, 186 | NextCursor: "", 187 | }, 188 | Results: []Block{ 189 | &Heading2Block{ 190 | BlockBase: BlockBase{ 191 | Object: ObjectTypeBlock, 192 | ID: "9bc30ad4-9373-46a5-84ab-0a7845ee52e6", 193 | Type: BlockTypeHeading2, 194 | CreatedTime: newTime(time.Date(2021, 3, 16, 16, 31, 0, 0, time.UTC)), 195 | LastEditedTime: newTime(time.Date(2021, 3, 16, 16, 32, 0, 0, time.UTC)), 196 | HasChildren: false, 197 | }, 198 | Heading2: HeadingBlock{ 199 | Text: []RichText{ 200 | &RichTextText{ 201 | BaseRichText: BaseRichText{ 202 | PlainText: "Lacinato kale", 203 | Href: "", 204 | Type: RichTextTypeText, 205 | Annotations: &Annotations{ 206 | Bold: false, 207 | Italic: false, 208 | Strikethrough: false, 209 | Underline: false, 210 | Code: false, 211 | Color: ColorDefault, 212 | }, 213 | }, 214 | Text: TextObject{ 215 | Content: "Lacinato kale", 216 | Link: nil, 217 | }, 218 | }, 219 | }, 220 | }, 221 | }, 222 | &ParagraphBlock{ 223 | BlockBase: BlockBase{ 224 | Object: ObjectTypeBlock, 225 | ID: "7face6fd-3ef4-4b38-b1dc-c5044988eec0", 226 | Type: BlockTypeParagraph, 227 | CreatedTime: newTime(time.Date(2021, 3, 16, 16, 34, 0, 0, time.UTC)), 228 | LastEditedTime: newTime(time.Date(2021, 3, 16, 16, 36, 0, 0, time.UTC)), 229 | HasChildren: false, 230 | }, 231 | Paragraph: RichTextBlock{ 232 | Text: []RichText{ 233 | &RichTextText{ 234 | BaseRichText: BaseRichText{ 235 | PlainText: "Lacinato kale", 236 | Href: "https://en.wikipedia.org/wiki/Lacinato_kale", 237 | Type: RichTextTypeText, 238 | Annotations: &Annotations{ 239 | Bold: false, 240 | Italic: false, 241 | Strikethrough: false, 242 | Underline: false, 243 | Code: false, 244 | Color: ColorDefault, 245 | }, 246 | }, 247 | Text: TextObject{ 248 | Content: "Lacinato kale", 249 | Link: &Link{ 250 | URL: "https://en.wikipedia.org/wiki/Lacinato_kale", 251 | }, 252 | }, 253 | }, 254 | 255 | &RichTextText{ 256 | BaseRichText: BaseRichText{ 257 | PlainText: " is a variety of kale with a long tradition in Italian cuisine, especially that of Tuscany. It is also known as Tuscan kale, Italian kale, dinosaur kale, kale, flat back kale, palm tree kale, or black Tuscan palm.", 258 | Href: "", 259 | Type: RichTextTypeText, 260 | Annotations: &Annotations{ 261 | Bold: false, 262 | Italic: false, 263 | Strikethrough: false, 264 | Underline: false, 265 | Code: false, 266 | Color: ColorDefault, 267 | }, 268 | }, 269 | Text: TextObject{ 270 | Content: " is a variety of kale with a long tradition in Italian cuisine, especially that of Tuscany. It is also known as Tuscan kale, Italian kale, dinosaur kale, kale, flat back kale, palm tree kale, or black Tuscan palm.", 271 | Link: nil, 272 | }, 273 | }, 274 | }, 275 | Children: []Block{}, 276 | }, 277 | }, 278 | &ToggleBlock{ 279 | BlockBase: BlockBase{ 280 | Object: ObjectTypeBlock, 281 | ID: "7636e2c9-b6c1-4df1-aeae-3ebf0073c5cb", 282 | Type: BlockTypeToggle, 283 | CreatedTime: newTime(time.Date(2021, 3, 16, 16, 35, 0, 0, time.UTC)), 284 | LastEditedTime: newTime(time.Date(2021, 3, 16, 16, 36, 0, 0, time.UTC)), 285 | HasChildren: true, 286 | }, 287 | Toggle: RichTextBlock{ 288 | Text: []RichText{ 289 | &RichTextText{ 290 | BaseRichText: BaseRichText{ 291 | PlainText: "Recipes", 292 | Href: "", 293 | Type: RichTextTypeText, 294 | Annotations: &Annotations{ 295 | Bold: true, 296 | Italic: false, 297 | Strikethrough: false, 298 | Underline: false, 299 | Code: false, 300 | Color: ColorDefault, 301 | }, 302 | }, 303 | Text: TextObject{ 304 | Content: "Recipes", 305 | Link: nil, 306 | }, 307 | }, 308 | }, 309 | Children: []Block{}, 310 | }, 311 | }, 312 | }, 313 | }, 314 | }, 315 | }, 316 | } 317 | for _, tt := range tests { 318 | t.Run(tt.name, func(t *testing.T) { 319 | mockHTTPServer := httptest.NewServer(tt.fields.mockHTTPHandler) 320 | defer mockHTTPServer.Close() 321 | 322 | sut := New( 323 | tt.fields.authToken, 324 | WithBaseURL(mockHTTPServer.URL), 325 | ) 326 | 327 | got, err := sut.Blocks().Children().List(tt.args.ctx, tt.args.params) 328 | if tt.wants.err != nil { 329 | assert.ErrorIs(t, err, tt.wants.err) 330 | return 331 | } 332 | 333 | assert.NoError(t, err) 334 | assert.Equal(t, tt.wants.response, got) 335 | }) 336 | } 337 | } 338 | 339 | func Test_blocksChildrenClient_Append(t *testing.T) { 340 | type fields struct { 341 | restClient rest.Interface 342 | mockHTTPHandler http.Handler 343 | authToken string 344 | } 345 | 346 | type args struct { 347 | ctx context.Context 348 | params BlocksChildrenAppendParameters 349 | } 350 | 351 | type wants struct { 352 | response *BlocksChildrenAppendResponse 353 | err error 354 | } 355 | 356 | type test struct { 357 | name string 358 | fields fields 359 | args args 360 | wants wants 361 | } 362 | 363 | tests := []test{ 364 | { 365 | name: "Append children successfully", 366 | fields: fields{ 367 | restClient: rest.New(), 368 | authToken: "54b85fbe-69c3-4726-88a6-0070bcaea59d", 369 | mockHTTPHandler: http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { 370 | assert.Equal(t, DefaultNotionVersion, request.Header.Get("Notion-Version")) 371 | assert.Equal(t, DefaultUserAgent, request.Header.Get("User-Agent")) 372 | assert.Equal(t, "Bearer 54b85fbe-69c3-4726-88a6-0070bcaea59d", request.Header.Get("Authorization")) 373 | 374 | assert.Equal(t, http.MethodPatch, request.Method) 375 | assert.Equal(t, "/v1/blocks/9bd15f8d-8082-429b-82db-e6c4ea88413b/children", request.RequestURI) 376 | assert.Equal(t, "application/json", request.Header.Get("Content-Type")) 377 | 378 | expectedData := `{ 379 | "children": [ 380 | { 381 | "object": "block", 382 | "type": "heading_2", 383 | "heading_2": { 384 | "text": [{ "type": "text", "text": { "content": "Lacinato kale" } }] 385 | } 386 | }, 387 | { 388 | "object": "block", 389 | "type": "paragraph", 390 | "paragraph": { 391 | "text": [ 392 | { 393 | "type": "text", 394 | "text": { 395 | "content": "Lacinato kale is a variety of kale with a long tradition in Italian cuisine, especially that of Tuscany. It is also known as Tuscan kale, Italian kale, dinosaur kale, kale, flat back kale, palm tree kale, or black Tuscan palm.", 396 | "link": { "url": "https://en.wikipedia.org/wiki/Lacinato_kale" } 397 | } 398 | } 399 | ] 400 | } 401 | } 402 | ] 403 | }` 404 | b, err := ioutil.ReadAll(request.Body) 405 | assert.NoError(t, err) 406 | assert.JSONEq(t, expectedData, string(b)) 407 | 408 | writer.WriteHeader(http.StatusOK) 409 | 410 | _, err = writer.Write([]byte(`{ 411 | "object": "block", 412 | "id": "9bd15f8d-8082-429b-82db-e6c4ea88413b", 413 | "created_time": "2020-03-17T19:10:04.968Z", 414 | "last_edited_time": "2020-03-17T21:49:37.913Z", 415 | "has_children": true, 416 | "type": "toggle", 417 | "toggle": { 418 | "text": [ 419 | { 420 | "type": "text", 421 | "text": { 422 | "content": "Recipes", 423 | "link": null 424 | }, 425 | "annotations": { 426 | "bold": true, 427 | "italic": false, 428 | "strikethrough": false, 429 | "underline": false, 430 | "code": false, 431 | "color": "default" 432 | }, 433 | "plain_text": "Recipes", 434 | "href": null 435 | } 436 | ] 437 | } 438 | }`)) 439 | assert.NoError(t, err) 440 | }), 441 | }, 442 | args: args{ 443 | ctx: context.Background(), 444 | params: BlocksChildrenAppendParameters{ 445 | BlockID: "9bd15f8d-8082-429b-82db-e6c4ea88413b", 446 | Children: []Block{ 447 | &Heading2Block{ 448 | BlockBase: BlockBase{ 449 | Object: ObjectTypeBlock, 450 | Type: BlockTypeHeading2, 451 | }, 452 | Heading2: HeadingBlock{ 453 | Text: []RichText{ 454 | &RichTextText{ 455 | BaseRichText: BaseRichText{ 456 | Type: RichTextTypeText, 457 | }, 458 | Text: TextObject{ 459 | Content: "Lacinato kale", 460 | }, 461 | }, 462 | }, 463 | }, 464 | }, 465 | &ParagraphBlock{ 466 | BlockBase: BlockBase{ 467 | Object: ObjectTypeBlock, 468 | Type: BlockTypeParagraph, 469 | }, 470 | Paragraph: RichTextBlock{ 471 | Text: []RichText{ 472 | &RichTextText{ 473 | BaseRichText: BaseRichText{ 474 | Type: RichTextTypeText, 475 | }, 476 | Text: TextObject{ 477 | Content: "Lacinato kale is a variety of kale with a long tradition in Italian cuisine, especially that of Tuscany. It is also known as Tuscan kale, Italian kale, dinosaur kale, kale, flat back kale, palm tree kale, or black Tuscan palm.", 478 | Link: &Link{ 479 | URL: "https://en.wikipedia.org/wiki/Lacinato_kale", 480 | }, 481 | }, 482 | }, 483 | }, 484 | }, 485 | }, 486 | }, 487 | }, 488 | }, 489 | wants: wants{ 490 | response: &BlocksChildrenAppendResponse{ 491 | Block: &ToggleBlock{ 492 | BlockBase: BlockBase{ 493 | Object: ObjectTypeBlock, 494 | ID: "9bd15f8d-8082-429b-82db-e6c4ea88413b", 495 | Type: BlockTypeToggle, 496 | CreatedTime: newTime(time.Date(2020, 3, 17, 19, 10, 4, 968_000_000, time.UTC)), 497 | LastEditedTime: newTime(time.Date(2020, 3, 17, 21, 49, 37, 913_000_000, time.UTC)), 498 | HasChildren: true, 499 | }, 500 | Toggle: RichTextBlock{ 501 | Text: []RichText{ 502 | &RichTextText{ 503 | BaseRichText: BaseRichText{ 504 | PlainText: "Recipes", 505 | Href: "", 506 | Type: RichTextTypeText, 507 | Annotations: &Annotations{ 508 | Bold: true, 509 | Italic: false, 510 | Strikethrough: false, 511 | Underline: false, 512 | Code: false, 513 | Color: ColorDefault, 514 | }, 515 | }, 516 | Text: TextObject{ 517 | Content: "Recipes", 518 | Link: nil, 519 | }, 520 | }, 521 | }, 522 | Children: []Block{}, 523 | }, 524 | }, 525 | }, 526 | err: nil, 527 | }, 528 | }, 529 | } 530 | for _, tt := range tests { 531 | t.Run(tt.name, func(t *testing.T) { 532 | mockHTTPServer := httptest.NewServer(tt.fields.mockHTTPHandler) 533 | defer mockHTTPServer.Close() 534 | 535 | sut := New( 536 | tt.fields.authToken, 537 | WithBaseURL(mockHTTPServer.URL), 538 | ) 539 | 540 | got, err := sut.Blocks().Children().Append(tt.args.ctx, tt.args.params) 541 | if tt.wants.err != nil { 542 | assert.ErrorIs(t, err, tt.wants.err) 543 | return 544 | } 545 | 546 | assert.NoError(t, err) 547 | assert.Equal(t, tt.wants.response, got) 548 | }) 549 | } 550 | } 551 | 552 | func newTime(t time.Time) *time.Time { 553 | return &t 554 | } 555 | -------------------------------------------------------------------------------- /consts.go: -------------------------------------------------------------------------------- 1 | package notion 2 | 3 | type BlockType string 4 | 5 | const ( 6 | BlockTypeParagraph BlockType = "paragraph" 7 | BlockTypeHeading1 BlockType = "heading_1" 8 | BlockTypeHeading2 BlockType = "heading_2" 9 | BlockTypeHeading3 BlockType = "heading_3" 10 | BlockTypeBulletedListItem BlockType = "bulleted_list_item" 11 | BlockTypeNumberedListItem BlockType = "numbered_list_item" 12 | BlockTypeToDo BlockType = "to_do" 13 | BlockTypeToggle BlockType = "toggle" 14 | BlockTypeChildPage BlockType = "child_page" 15 | BlockTypeUnsupported BlockType = "unsupported" 16 | ) 17 | 18 | type Color string 19 | 20 | const ( 21 | ColorDefault Color = "default" 22 | ColorGray Color = "gray" 23 | ColorBrown Color = "brown" 24 | ColorOrange Color = "orange" 25 | ColorYellow Color = "yellow" 26 | ColorGreen Color = "green" 27 | ColorBlue Color = "blue" 28 | ColorPurple Color = "purple" 29 | ColorPink Color = "pink" 30 | ColorRed Color = "red" 31 | BackgroundColorGray Color = "gray_background" 32 | BackgroundColorBrown Color = "brown_background" 33 | BackgroundColorOrange Color = "orange_background" 34 | BackgroundColorYellow Color = "yellow_background" 35 | BackgroundColorGreen Color = "green_background" 36 | BackgroundColorBlue Color = "blue_background" 37 | BackgroundColorPurple Color = "purple_background" 38 | BackgroundColorPink Color = "pink_background" 39 | BackgroundColorRed Color = "red_background" 40 | ) 41 | 42 | type NumberFormat string 43 | 44 | const ( 45 | NumberFormatNumber NumberFormat = "number" 46 | NumberFormatNumberWithCommas NumberFormat = "number_with_commas" 47 | NumberFormatPercent NumberFormat = "percent" 48 | NumberFormatDollar NumberFormat = "dollar" 49 | NumberFormatEuro NumberFormat = "euro" 50 | NumberFormatPound NumberFormat = "pound" 51 | NumberFormatYen NumberFormat = "yen" 52 | NumberFormatRuble NumberFormat = "ruble" 53 | NumberFormatRupee NumberFormat = "rupee" 54 | NumberFormatWon NumberFormat = "won" 55 | NumberFormatYuan NumberFormat = "yuan" 56 | ) 57 | 58 | type FormulaValueType string 59 | 60 | const ( 61 | FormulaValueTypeString FormulaValueType = "string" 62 | FormulaValueTypeNumber FormulaValueType = "number" 63 | FormulaValueTypeBoolean FormulaValueType = "boolean" 64 | FormulaValueTypeDate FormulaValueType = "date" 65 | ) 66 | 67 | type RollupFunction string 68 | 69 | const ( 70 | RollupFunctionCountAll RollupFunction = "count_all" 71 | RollupFunctionCountValues RollupFunction = "count_values" 72 | RollupFunctionCountUniqueValues RollupFunction = "count_unique_values" 73 | RollupFunctionCountEmpty RollupFunction = "count_empty" 74 | RollupFunctionCountNotEmpty RollupFunction = "count_not_empty" 75 | RollupFunctionPercentEmpty RollupFunction = "percent_empty" 76 | RollupFunctionPercentNotEmpty RollupFunction = "percent_not_empty" 77 | RollupFunctionSum RollupFunction = "sum" 78 | RollupFunctionAverage RollupFunction = "average" 79 | RollupFunctionMedian RollupFunction = "median" 80 | RollupFunctionMin RollupFunction = "min" 81 | RollupFunctionMax RollupFunction = "max" 82 | RollupFunctionRange RollupFunction = "range" 83 | ) 84 | 85 | type ObjectType string 86 | 87 | const ( 88 | ObjectTypeBlock ObjectType = "block" 89 | ObjectTypePage ObjectType = "page" 90 | ObjectTypeDatabase ObjectType = "database" 91 | ObjectTypeList ObjectType = "list" 92 | ObjectTypeUser ObjectType = "user" 93 | ) 94 | 95 | type ParentType string 96 | 97 | const ( 98 | ParentTypeDatabase ParentType = "database_id" 99 | ParentTypePage ParentType = "page" 100 | ParentTypeWorkspace ParentType = "workspace" 101 | ) 102 | 103 | type PropertyType string 104 | 105 | const ( 106 | PropertyTypeTitle PropertyType = "title" 107 | PropertyTypeRichText PropertyType = "rich_text" 108 | PropertyTypeNumber PropertyType = "number" 109 | PropertyTypeSelect PropertyType = "select" 110 | PropertyTypeMultiSelect PropertyType = "multi_select" 111 | PropertyTypeDate PropertyType = "date" 112 | PropertyTypePeople PropertyType = "people" 113 | PropertyTypeFile PropertyType = "file" 114 | PropertyTypeCheckbox PropertyType = "checkbox" 115 | PropertyTypeURL PropertyType = "url" 116 | PropertyTypeEmail PropertyType = "email" 117 | PropertyTypePhoneNumber PropertyType = "phone_number" 118 | PropertyTypeFormula PropertyType = "formula" 119 | PropertyTypeRelation PropertyType = "relation" 120 | PropertyTypeRollup PropertyType = "rollup" 121 | PropertyTypeCreatedTime PropertyType = "created_time" 122 | PropertyTypeCreatedBy PropertyType = "created_by" 123 | PropertyTypeLastEditedTime PropertyType = "last_edited_time" 124 | PropertyTypeLastEditedBy PropertyType = "last_edited_by" 125 | ) 126 | 127 | type PropertyValueType string 128 | 129 | const ( 130 | PropertyValueTypeRichText PropertyValueType = "rich_text" 131 | PropertyValueTypeNumber PropertyValueType = "number" 132 | PropertyValueTypeSelect PropertyValueType = "select" 133 | PropertyValueTypeMultiSelect PropertyValueType = "multi_select" 134 | PropertyValueTypeDate PropertyValueType = "date" 135 | PropertyValueTypeFormula PropertyValueType = "formula" 136 | PropertyValueTypeRelation PropertyValueType = "relation" 137 | PropertyValueTypeRollup PropertyValueType = "rollup" 138 | PropertyValueTypeTitle PropertyValueType = "title" 139 | PropertyValueTypePeople PropertyValueType = "people" 140 | PropertyValueTypeFiles PropertyValueType = "files" 141 | PropertyValueTypeCheckbox PropertyValueType = "checkbox" 142 | PropertyValueTypeURL PropertyValueType = "url" 143 | PropertyValueTypeEmail PropertyValueType = "email" 144 | PropertyValueTypePhoneNumber PropertyValueType = "phone_number" 145 | PropertyValueTypeCreatedTime PropertyValueType = "created_time" 146 | PropertyValueTypeCreatedBy PropertyValueType = "created_by" 147 | PropertyValueTypeLastEditedTime PropertyValueType = "last_edited_time" 148 | PropertyValueTypeLastEditedBy PropertyValueType = "last_edited_by" 149 | ) 150 | 151 | type RichTextType string 152 | 153 | const ( 154 | RichTextTypeText RichTextType = "text" 155 | RichTextTypeMention RichTextType = "mention" 156 | RichTextTypeEquation RichTextType = "equation" 157 | ) 158 | 159 | type SearchFilterValue string 160 | 161 | const ( 162 | SearchFilterValuePage SearchFilterValue = "page" 163 | SearchFilterValueDatabase SearchFilterValue = "database" 164 | ) 165 | 166 | type SearchFilterProperty string 167 | 168 | const ( 169 | SearchFilterPropertyObject SearchFilterProperty = "object" 170 | ) 171 | 172 | type SearchSortDirection string 173 | 174 | const ( 175 | SearchSortDirectionAscending SearchSortDirection = "ascending" 176 | SearchSortDirectionDescending SearchSortDirection = " descending" 177 | ) 178 | 179 | type SearchSortTimestamp string 180 | 181 | const ( 182 | SearchSortTimestampLastEditedTime SearchSortTimestamp = "last_edited_time" 183 | ) 184 | 185 | type SortTimestamp string 186 | 187 | const ( 188 | SortTimestampByCreatedTime SortTimestamp = "created_time" 189 | SortTimestampByLastEditedTime SortTimestamp = "last_edited_time" 190 | ) 191 | 192 | type SortDirection string 193 | 194 | const ( 195 | SortDirectionAscending SortDirection = "ascending" 196 | SortDirectionDescending SortDirection = "descending" 197 | ) 198 | 199 | type UserType string 200 | 201 | const ( 202 | UserTypePerson UserType = "person" 203 | UserTypeBot UserType = "bot" 204 | ) 205 | -------------------------------------------------------------------------------- /databases.go: -------------------------------------------------------------------------------- 1 | package notion 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "strings" 8 | "time" 9 | 10 | "github.com/mkfsn/notion-go/rest" 11 | ) 12 | 13 | type Database struct { 14 | Object ObjectType `json:"object"` 15 | ID string `json:"id"` 16 | 17 | CreatedTime time.Time `json:"created_time"` 18 | LastEditedTime time.Time `json:"last_edited_time"` 19 | Title []RichText `json:"title"` 20 | Properties map[string]Property `json:"properties"` 21 | } 22 | 23 | func (d Database) isSearchable() {} 24 | 25 | func (d *Database) UnmarshalJSON(data []byte) error { 26 | type Alias Database 27 | 28 | alias := struct { 29 | *Alias 30 | Title []richTextDecoder `json:"title"` 31 | Properties map[string]propertyDecoder `json:"properties"` 32 | }{ 33 | Alias: (*Alias)(d), 34 | } 35 | 36 | if err := json.Unmarshal(data, &alias); err != nil { 37 | return fmt.Errorf("failed to unmarshal Database: %w", err) 38 | } 39 | 40 | d.Title = make([]RichText, 0, len(alias.Title)) 41 | 42 | for _, decoder := range alias.Title { 43 | d.Title = append(d.Title, decoder.RichText) 44 | } 45 | 46 | d.Properties = make(map[string]Property) 47 | 48 | for name, decoder := range alias.Properties { 49 | d.Properties[name] = decoder.Property 50 | } 51 | 52 | return nil 53 | } 54 | 55 | type Annotations struct { 56 | // Whether the text is **bolded**. 57 | Bold bool `json:"bold"` 58 | // Whether the text is _italicized_. 59 | Italic bool `json:"italic"` 60 | // Whether the text is ~~struck~~ through. 61 | Strikethrough bool `json:"strikethrough"` 62 | // Whether the text is __underlined__. 63 | Underline bool `json:"underline"` 64 | // Whether the text is `code style`. 65 | Code bool `json:"code"` 66 | // Color of the text. 67 | Color Color `json:"color"` 68 | } 69 | 70 | type RichText interface { 71 | isRichText() 72 | } 73 | 74 | type BaseRichText struct { 75 | // The plain text without annotations. 76 | PlainText string `json:"plain_text,omitempty"` 77 | // (Optional) The URL of any link or internal Notion mention in this text, if any. 78 | Href string `json:"href,omitempty"` 79 | // Type of this rich text object. 80 | Type RichTextType `json:"type,omitempty"` 81 | // All annotations that apply to this rich text. 82 | // Annotations include colors and bold/italics/underline/strikethrough. 83 | Annotations *Annotations `json:"annotations,omitempty"` 84 | } 85 | 86 | func (r BaseRichText) isRichText() {} 87 | 88 | type Link struct { 89 | // TODO: What is this? Is this still in used? 90 | Type string `json:"type,omitempty"` 91 | URL string `json:"url"` 92 | } 93 | 94 | type TextObject struct { 95 | Content string `json:"content"` 96 | Link *Link `json:"link,omitempty"` 97 | } 98 | 99 | type RichTextText struct { 100 | BaseRichText 101 | Text TextObject `json:"text"` 102 | } 103 | 104 | type Mention interface { 105 | isMention() 106 | } 107 | 108 | type baseMention struct { 109 | Type string `json:"type"` 110 | } 111 | 112 | func (b baseMention) isMention() {} 113 | 114 | type UserMention struct { 115 | baseMention 116 | User User `json:"user"` 117 | } 118 | 119 | type PageMention struct { 120 | baseMention 121 | Page struct { 122 | ID string `json:"id"` 123 | } `json:"page"` 124 | } 125 | 126 | type DatabaseMention struct { 127 | baseMention 128 | Database struct { 129 | ID string `json:"id"` 130 | } `json:"database"` 131 | } 132 | 133 | type DateMention struct { 134 | baseMention 135 | Date DatePropertyValue `json:"date"` 136 | } 137 | 138 | type RichTextMention struct { 139 | BaseRichText 140 | Mention Mention `json:"mention"` 141 | } 142 | 143 | type EquationObject struct { 144 | Expression string `json:"expression"` 145 | } 146 | 147 | type RichTextEquation struct { 148 | BaseRichText 149 | Equation EquationObject `json:"equation"` 150 | } 151 | 152 | type Property interface { 153 | isProperty() 154 | } 155 | 156 | type baseProperty struct { 157 | // The ID of the property, usually a short string of random letters and symbols. 158 | // Some automatically generated property types have special human-readable IDs. 159 | // For example, all Title properties have an ID of "title". 160 | ID string `json:"id"` 161 | // Type that controls the behavior of the property 162 | Type PropertyType `json:"type"` 163 | } 164 | 165 | func (p baseProperty) isProperty() {} 166 | 167 | type TitleProperty struct { 168 | baseProperty 169 | Title interface{} `json:"title"` 170 | } 171 | 172 | type RichTextProperty struct { 173 | baseProperty 174 | RichText interface{} `json:"rich_text"` 175 | } 176 | 177 | type NumberPropertyOption struct { 178 | Format NumberFormat `json:"format"` 179 | } 180 | 181 | type NumberProperty struct { 182 | baseProperty 183 | Number NumberPropertyOption `json:"number"` 184 | } 185 | 186 | type SelectOption struct { 187 | ID string `json:"id"` 188 | Name string `json:"name"` 189 | Color Color `json:"color"` 190 | } 191 | 192 | type MultiSelectOption struct { 193 | ID string `json:"id"` 194 | Name string `json:"name"` 195 | Color Color `json:"color"` 196 | } 197 | 198 | type SelectPropertyOption struct { 199 | Options []SelectOption `json:"options"` 200 | } 201 | 202 | type SelectProperty struct { 203 | baseProperty 204 | Select SelectPropertyOption `json:"select"` 205 | } 206 | 207 | type MultiSelectPropertyOption struct { 208 | Options []MultiSelectOption `json:"options"` 209 | } 210 | 211 | type MultiSelectProperty struct { 212 | baseProperty 213 | MultiSelect MultiSelectPropertyOption `json:"multi_select"` 214 | } 215 | 216 | type DateProperty struct { 217 | baseProperty 218 | Date interface{} `json:"date"` 219 | } 220 | 221 | type PeopleProperty struct { 222 | baseProperty 223 | People interface{} `json:"people"` 224 | } 225 | 226 | type FileProperty struct { 227 | baseProperty 228 | File interface{} `json:"file"` 229 | } 230 | 231 | type CheckboxProperty struct { 232 | baseProperty 233 | Checkbox interface{} `json:"checkbox"` 234 | } 235 | 236 | type URLProperty struct { 237 | baseProperty 238 | URL interface{} `json:"url"` 239 | } 240 | 241 | type EmailProperty struct { 242 | baseProperty 243 | Email interface{} `json:"email"` 244 | } 245 | 246 | type PhoneNumberProperty struct { 247 | baseProperty 248 | PhoneNumber interface{} `json:"phone_number"` 249 | } 250 | 251 | type Formula struct { 252 | Expression string `json:"expression"` 253 | } 254 | 255 | type FormulaProperty struct { 256 | baseProperty 257 | Formula Formula `json:"formula"` 258 | } 259 | 260 | type Relation struct { 261 | DatabaseID string `json:"database_id"` 262 | SyncedPropertyName *string `json:"synced_property_name"` 263 | SyncedPropertyID *string `json:"synced_property_id"` 264 | } 265 | 266 | type RelationProperty struct { 267 | baseProperty 268 | Relation Relation `json:"relation"` 269 | } 270 | 271 | type RollupPropertyOption struct { 272 | RelationPropertyName string `json:"relation_property_name"` 273 | RelationPropertyID string `json:"relation_property_id"` 274 | RollupPropertyName string `json:"rollup_property_name"` 275 | RollupPropertyID string `json:"rollup_property_id"` 276 | Function RollupFunction `json:"function"` 277 | } 278 | 279 | type RollupProperty struct { 280 | baseProperty 281 | Rollup RollupPropertyOption `json:"rollup"` 282 | } 283 | 284 | type CreatedTimeProperty struct { 285 | baseProperty 286 | CreatedTime interface{} `json:"created_time"` 287 | } 288 | 289 | type CreatedByProperty struct { 290 | baseProperty 291 | CreatedBy interface{} `json:"created_by"` 292 | } 293 | 294 | type LastEditedTimeProperty struct { 295 | baseProperty 296 | LastEditedTime interface{} `json:"last_edited_time"` 297 | } 298 | 299 | type LastEditedByProperty struct { 300 | baseProperty 301 | LastEditedBy interface{} `json:"last_edited_by"` 302 | } 303 | 304 | type DatabasesRetrieveParameters struct { 305 | DatabaseID string `json:"-" url:"-"` 306 | } 307 | 308 | type DatabasesRetrieveResponse struct { 309 | Database 310 | } 311 | 312 | type DatabasesListParameters struct { 313 | PaginationParameters 314 | } 315 | 316 | type DatabasesListResponse struct { 317 | PaginatedList 318 | Results []Database `json:"results"` 319 | } 320 | 321 | type Sort struct { 322 | Property string `json:"property,omitempty"` 323 | Timestamp SortTimestamp `json:"timestamp,omitempty"` 324 | Direction SortDirection `json:"direction,omitempty"` 325 | } 326 | 327 | type Filter interface { 328 | isFilter() 329 | } 330 | 331 | type SinglePropertyFilter struct { 332 | Property string `json:"property"` 333 | } 334 | 335 | func (b SinglePropertyFilter) isFilter() {} 336 | 337 | type TextFilter struct { 338 | Equals *string `json:"equals,omitempty"` 339 | DoesNotEqual *string `json:"does_not_equal,omitempty"` 340 | Contains *string `json:"contains,omitempty"` 341 | DoesNotContain *string `json:"does_not_contain,omitempty"` 342 | StartsWith *string `json:"starts_with,omitempty"` 343 | EndsWith *string `json:"ends_with,omitempty"` 344 | IsEmpty *bool `json:"is_empty,omitempty"` 345 | IsNotEmpty *bool `json:"is_not_empty,omitempty"` 346 | } 347 | 348 | // SingleTextFilter is a text filter condition applies to database properties of types "title", "rich_text", "url", "email", and "phone". 349 | type SingleTextFilter struct { 350 | SinglePropertyFilter 351 | Text *TextFilter `json:"text,omitempty"` 352 | RichText *TextFilter `json:"rich_text,omitempty"` 353 | URL *TextFilter `json:"url,omitempty"` 354 | Email *TextFilter `json:"email,omitempty"` 355 | Phone *TextFilter `json:"phone,omitempty"` 356 | } 357 | 358 | type NumberFilter struct { 359 | Equals *float64 `json:"equals,omitempty"` 360 | DoesNotEqual *float64 `json:"does_not_equal,omitempty"` 361 | GreaterThan *float64 `json:"greater_than,omitempty"` 362 | LessThan *float64 `json:"less_than,omitempty"` 363 | GreaterThanOrEqualTo *float64 `json:"greater_than_or_equal_to,omitempty"` 364 | LessThanOrEqualTo *float64 `json:"less_than_or_equal_to,omitempty"` 365 | IsEmpty bool `json:"is_empty,omitempty"` 366 | IsNotEmpty bool `json:"is_not_empty,omitempty"` 367 | } 368 | 369 | // SingleNumberFilter is a number filter condition applies to database properties of type "number". 370 | type SingleNumberFilter struct { 371 | SinglePropertyFilter 372 | Number NumberFilter `json:"number"` 373 | } 374 | 375 | type CheckboxFilter struct { 376 | Equals bool `json:"equals,omitempty"` 377 | DoesNotEqual bool `json:"does_not_equal,omitempty"` 378 | } 379 | 380 | // SingleCheckboxFilter is a checkbox filter condition applies to database properties of type "checkbox". 381 | type SingleCheckboxFilter struct { 382 | SinglePropertyFilter 383 | Checkbox CheckboxFilter `json:"checkbox"` 384 | } 385 | 386 | type SelectFilter struct { 387 | Equals *string `json:"equals,omitempty"` 388 | DoesNotEqual *string `json:"does_not_equal,omitempty"` 389 | IsEmpty bool `json:"is_empty,omitempty"` 390 | IsNotEmpty bool `json:"is_not_empty,omitempty"` 391 | } 392 | 393 | // SingleSelectFilter is a select filter condition applies to database properties of type "select". 394 | type SingleSelectFilter struct { 395 | SinglePropertyFilter 396 | Select SelectFilter `json:"select"` 397 | } 398 | 399 | type MultiSelectFilter struct { 400 | Contains *string `json:"contains,omitempty"` 401 | DoesNotContain *string `json:"does_not_contain,omitempty"` 402 | IsEmpty bool `json:"is_empty,omitempty"` 403 | IsNotEmpty bool `json:"is_not_empty,omitempty"` 404 | } 405 | 406 | // SingleMultiSelectFilter is a multi-select filter condition applies to database properties of type "multi_select". 407 | type SingleMultiSelectFilter struct { 408 | SinglePropertyFilter 409 | MultiSelect MultiSelectFilter `json:"multi_select"` 410 | } 411 | 412 | type DateFilter struct { 413 | Equals *string `json:"equals,omitempty"` 414 | Before *string `json:"before,omitempty"` 415 | After *string `json:"after,omitempty"` 416 | OnOrBefore *string `json:"on_or_before,omitempty"` 417 | IsEmpty bool `json:"is_empty,omitempty"` 418 | IsNotEmpty bool `json:"is_not_empty,omitempty"` 419 | OnOrAfter *string `json:"on_or_after,omitempty"` 420 | PastWeek map[string]interface{} `json:"past_week,omitempty"` 421 | PastMonth map[string]interface{} `json:"past_month,omitempty"` 422 | PastYear map[string]interface{} `json:"past_year,omitempty"` 423 | NextWeek map[string]interface{} `json:"next_week,omitempty"` 424 | NextMonth map[string]interface{} `json:"next_month,omitempty"` 425 | NextYear map[string]interface{} `json:"next_year,omitempty"` 426 | } 427 | 428 | // SingleDateFilter is a date filter condition applies to database properties of types "date", "created_time", and "last_edited_time". 429 | type SingleDateFilter struct { 430 | SinglePropertyFilter 431 | Date *DateFilter `json:"date,omitempty"` 432 | CreatedTime *DateFilter `json:"created_time,omitempty"` 433 | LastEditedTime *DateFilter `json:"last_edited_time,omitempty"` 434 | } 435 | 436 | type PeopleFilter struct { 437 | Contains *string `json:"contains,omitempty"` 438 | DoesNotContain *string `json:"does_not_contain,omitempty"` 439 | IsEmpty bool `json:"is_empty,omitempty"` 440 | IsNotEmpty bool `json:"is_not_empty,omitempty"` 441 | } 442 | 443 | // SinglePeopleFilter is a people filter condition applies to database properties of types "people", "created_by", and "last_edited_by". 444 | type SinglePeopleFilter struct { 445 | SinglePropertyFilter 446 | People *PeopleFilter `json:"people,omitempty"` 447 | CreatedBy *PeopleFilter `json:"created_by,omitempty"` 448 | LastEditedBy *PeopleFilter `json:"last_edited_by,omitempty"` 449 | } 450 | 451 | type FilesFilter struct { 452 | IsEmpty bool `json:"is_empty,omitempty"` 453 | IsNotEmpty bool `json:"is_not_empty,omitempty"` 454 | } 455 | 456 | // SingleFilesFilter is a files filter condition applies to database properties of type "files". 457 | type SingleFilesFilter struct { 458 | SinglePropertyFilter 459 | Files FilesFilter `json:"files"` 460 | } 461 | 462 | type RelationFilter struct { 463 | Contains *string `json:"contains,omitempty"` 464 | DoesNotContain *string `json:"does_not_contain,omitempty"` 465 | IsEmpty bool `json:"is_empty,omitempty"` 466 | IsNotEmpty bool `json:"is_not_empty,omitempty"` 467 | } 468 | 469 | // SingleRelationFilter is a relation filter condition applies to database properties of type "relation". 470 | type SingleRelationFilter struct { 471 | SinglePropertyFilter 472 | Relation RelationFilter `json:"relation"` 473 | } 474 | 475 | type FormulaFilter struct { 476 | Text *TextFilter `json:"text,omitempty"` 477 | Checkbox *CheckboxFilter `json:"checkbox,omitempty"` 478 | Number *NumberFilter `json:"number,omitempty"` 479 | Date *DateFilter `json:"date,omitempty"` 480 | } 481 | 482 | // SingleFormulaFilter is a formula filter condition applies to database properties of type "formula". 483 | type SingleFormulaFilter struct { 484 | SinglePropertyFilter 485 | Formula FormulaFilter `json:"formula"` 486 | } 487 | 488 | type CompoundFilter struct { 489 | Or []Filter `json:"or,omitempty"` 490 | And []Filter `json:"and,omitempty"` 491 | } 492 | 493 | func (c CompoundFilter) isFilter() {} 494 | 495 | type DatabasesQueryParameters struct { 496 | PaginationParameters 497 | // Identifier for a Notion database. 498 | DatabaseID string `json:"-" url:"-"` 499 | // When supplied, limits which pages are returned based on the 500 | // [filter conditions](https://developers.com/reference-link/post-database-query-filter). 501 | Filter Filter `json:"filter,omitempty" url:"-"` 502 | // When supplied, orders the results based on the provided 503 | // [sort criteria](https://developers.com/reference-link/post-database-query-sort). 504 | Sorts []Sort `json:"sorts,omitempty" url:"-"` 505 | } 506 | 507 | type DatabasesQueryResponse struct { 508 | PaginatedList 509 | Results []Page `json:"results"` 510 | } 511 | 512 | type DatabasesInterface interface { 513 | Retrieve(ctx context.Context, params DatabasesRetrieveParameters) (*DatabasesRetrieveResponse, error) 514 | List(ctx context.Context, params DatabasesListParameters) (*DatabasesListResponse, error) 515 | Query(ctx context.Context, params DatabasesQueryParameters) (*DatabasesQueryResponse, error) 516 | } 517 | 518 | type databasesClient struct { 519 | restClient rest.Interface 520 | } 521 | 522 | func newDatabasesClient(restClient rest.Interface) *databasesClient { 523 | return &databasesClient{ 524 | restClient: restClient, 525 | } 526 | } 527 | 528 | func (d *databasesClient) Retrieve(ctx context.Context, params DatabasesRetrieveParameters) (*DatabasesRetrieveResponse, error) { 529 | var result DatabasesRetrieveResponse 530 | 531 | var failure HTTPError 532 | 533 | err := d.restClient.New().Get(). 534 | Endpoint(strings.Replace(APIDatabasesRetrieveEndpoint, "{database_id}", params.DatabaseID, 1)). 535 | Receive(ctx, &result, &failure) 536 | 537 | return &result, err // nolint:wrapcheck 538 | } 539 | 540 | func (d *databasesClient) List(ctx context.Context, params DatabasesListParameters) (*DatabasesListResponse, error) { 541 | var result DatabasesListResponse 542 | 543 | var failure HTTPError 544 | 545 | err := d.restClient.New().Get(). 546 | Endpoint(APIDatabasesListEndpoint). 547 | QueryStruct(params). 548 | Receive(ctx, &result, &failure) 549 | 550 | return &result, err // nolint:wrapcheck 551 | } 552 | 553 | func (d *databasesClient) Query(ctx context.Context, params DatabasesQueryParameters) (*DatabasesQueryResponse, error) { 554 | var result DatabasesQueryResponse 555 | 556 | var failure HTTPError 557 | 558 | err := d.restClient.New().Post(). 559 | Endpoint(strings.Replace(APIDatabasesQueryEndpoint, "{database_id}", params.DatabaseID, 1)). 560 | QueryStruct(params). 561 | BodyJSON(params). 562 | Receive(ctx, &result, &failure) 563 | 564 | return &result, err // nolint:wrapcheck 565 | } 566 | 567 | type richTextDecoder struct { 568 | RichText 569 | } 570 | 571 | func (r *richTextDecoder) UnmarshalJSON(data []byte) error { 572 | var decoder struct { 573 | Type RichTextType `json:"type"` 574 | } 575 | 576 | if err := json.Unmarshal(data, &decoder); err != nil { 577 | return fmt.Errorf("failed to unmarshal RichText: %w", err) 578 | } 579 | 580 | switch decoder.Type { 581 | case RichTextTypeText: 582 | r.RichText = &RichTextText{} 583 | 584 | case RichTextTypeMention: 585 | r.RichText = &RichTextMention{} 586 | 587 | case RichTextTypeEquation: 588 | r.RichText = &RichTextEquation{} 589 | } 590 | 591 | return json.Unmarshal(data, &r.RichText) 592 | } 593 | 594 | type propertyDecoder struct { 595 | Property 596 | } 597 | 598 | // UnmarshalJSON implements json.Unmarshaler 599 | // nolint: cyclop 600 | func (p *propertyDecoder) UnmarshalJSON(data []byte) error { 601 | var decoder struct { 602 | Type PropertyType `json:"type"` 603 | } 604 | 605 | if err := json.Unmarshal(data, &decoder); err != nil { 606 | return fmt.Errorf("failed to unmarshal Property: %w", err) 607 | } 608 | 609 | switch decoder.Type { 610 | case PropertyTypeTitle: 611 | p.Property = &TitleProperty{} 612 | 613 | case PropertyTypeRichText: 614 | p.Property = &RichTextProperty{} 615 | 616 | case PropertyTypeNumber: 617 | p.Property = &NumberProperty{} 618 | 619 | case PropertyTypeSelect: 620 | p.Property = &SelectProperty{} 621 | 622 | case PropertyTypeMultiSelect: 623 | p.Property = &MultiSelectProperty{} 624 | 625 | case PropertyTypeDate: 626 | p.Property = &DateProperty{} 627 | 628 | case PropertyTypePeople: 629 | p.Property = &PeopleProperty{} 630 | 631 | case PropertyTypeFile: 632 | p.Property = &FileProperty{} 633 | 634 | case PropertyTypeCheckbox: 635 | p.Property = &CheckboxProperty{} 636 | 637 | case PropertyTypeURL: 638 | p.Property = &URLProperty{} 639 | 640 | case PropertyTypeEmail: 641 | p.Property = &EmailProperty{} 642 | 643 | case PropertyTypePhoneNumber: 644 | p.Property = &PhoneNumberProperty{} 645 | 646 | case PropertyTypeFormula: 647 | p.Property = &FormulaProperty{} 648 | 649 | case PropertyTypeRelation: 650 | p.Property = &RelationProperty{} 651 | 652 | case PropertyTypeRollup: 653 | p.Property = &RollupProperty{} 654 | 655 | case PropertyTypeCreatedTime: 656 | p.Property = &CreatedTimeProperty{} 657 | 658 | case PropertyTypeCreatedBy: 659 | p.Property = &CreatedByProperty{} 660 | 661 | case PropertyTypeLastEditedTime: 662 | p.Property = &LastEditedTimeProperty{} 663 | 664 | case PropertyTypeLastEditedBy: 665 | p.Property = &LastEditedByProperty{} 666 | } 667 | 668 | return json.Unmarshal(data, &p.Property) 669 | } 670 | -------------------------------------------------------------------------------- /databases_test.go: -------------------------------------------------------------------------------- 1 | package notion 2 | 3 | import ( 4 | "context" 5 | "io/ioutil" 6 | "net/http" 7 | "net/http/httptest" 8 | "testing" 9 | "time" 10 | 11 | "github.com/mkfsn/notion-go/rest" 12 | "github.com/stretchr/testify/assert" 13 | ) 14 | 15 | func Test_databasesClient_List(t *testing.T) { 16 | type fields struct { 17 | restClient rest.Interface 18 | mockHTTPHandler http.Handler 19 | authToken string 20 | } 21 | 22 | type args struct { 23 | ctx context.Context 24 | params DatabasesListParameters 25 | } 26 | 27 | type wants struct { 28 | response *DatabasesListResponse 29 | err error 30 | } 31 | 32 | type test struct { 33 | name string 34 | fields fields 35 | args args 36 | wants wants 37 | } 38 | 39 | tests := []test{ 40 | { 41 | name: "List two databases in one page", 42 | fields: fields{ 43 | restClient: rest.New(), 44 | authToken: "3e83b541-190b-4450-bfcc-835a7804d5b1", 45 | mockHTTPHandler: http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { 46 | assert.Equal(t, DefaultNotionVersion, request.Header.Get("Notion-Version")) 47 | assert.Equal(t, DefaultUserAgent, request.Header.Get("User-Agent")) 48 | assert.Equal(t, "Bearer 3e83b541-190b-4450-bfcc-835a7804d5b1", request.Header.Get("Authorization")) 49 | 50 | assert.Equal(t, http.MethodGet, request.Method) 51 | assert.Equal(t, "/v1/databases?page_size=2", request.RequestURI) 52 | 53 | writer.WriteHeader(http.StatusOK) 54 | 55 | _, err := writer.Write([]byte(`{ 56 | "results": [ 57 | { 58 | "object": "database", 59 | "id": "668d797c-76fa-4934-9b05-ad288df2d136", 60 | "properties": { 61 | "Name": { 62 | "type": "title", 63 | "title": {} 64 | }, 65 | "Description": { 66 | "type": "rich_text", 67 | "rich_text": {} 68 | } 69 | } 70 | }, 71 | { 72 | "object": "database", 73 | "id": "74ba0cb2-732c-4d2f-954a-fcaa0d93a898", 74 | "properties": { 75 | "Name": { 76 | "type": "title", 77 | "title": {} 78 | }, 79 | "Description": { 80 | "type": "rich_text", 81 | "rich_text": {} 82 | } 83 | } 84 | } 85 | ], 86 | "next_cursor": "MTY3NDE4NGYtZTdiYy00NzFlLWE0NjctODcxOTIyYWU3ZmM3", 87 | "has_more": false 88 | }`)) 89 | assert.NoError(t, err) 90 | }), 91 | }, 92 | args: args{ 93 | ctx: context.Background(), 94 | params: DatabasesListParameters{ 95 | PaginationParameters: PaginationParameters{ 96 | StartCursor: "", 97 | PageSize: 2, 98 | }, 99 | }, 100 | }, 101 | wants: wants{ 102 | response: &DatabasesListResponse{ 103 | PaginatedList: PaginatedList{ 104 | // FIXME: This should be ObjectTypeList but the example does not provide the object key-value 105 | Object: "", 106 | HasMore: false, 107 | NextCursor: "MTY3NDE4NGYtZTdiYy00NzFlLWE0NjctODcxOTIyYWU3ZmM3", 108 | }, 109 | Results: []Database{ 110 | { 111 | Object: ObjectTypeDatabase, 112 | ID: "668d797c-76fa-4934-9b05-ad288df2d136", 113 | // FIXME: The example seems to have a invalid title thus I remove it for now, but need to check 114 | // what is the expected title 115 | Title: []RichText{}, 116 | Properties: map[string]Property{ 117 | "Name": &TitleProperty{ 118 | baseProperty: baseProperty{ 119 | ID: "", 120 | Type: PropertyTypeTitle, 121 | }, 122 | Title: map[string]interface{}{}, 123 | }, 124 | // FIXME: The example seems to have a invalid type of the description thus I change it 125 | // to `rich_text` for now, but need to check what is the expected type 126 | "Description": &RichTextProperty{ 127 | baseProperty: baseProperty{ 128 | ID: "", 129 | Type: PropertyTypeRichText, 130 | }, 131 | RichText: map[string]interface{}{}, 132 | }, 133 | }, 134 | }, 135 | { 136 | Object: ObjectTypeDatabase, 137 | ID: "74ba0cb2-732c-4d2f-954a-fcaa0d93a898", 138 | // FIXME: The example seems to have a invalid title thus I remove it for now, but need to check 139 | // what is the expected title 140 | Title: []RichText{}, 141 | Properties: map[string]Property{ 142 | "Name": &TitleProperty{ 143 | baseProperty: baseProperty{ 144 | ID: "", 145 | Type: PropertyTypeTitle, 146 | }, 147 | Title: map[string]interface{}{}, 148 | }, 149 | "Description": &RichTextProperty{ 150 | baseProperty: baseProperty{ 151 | ID: "", 152 | Type: PropertyTypeRichText, 153 | }, 154 | RichText: map[string]interface{}{}, 155 | }, 156 | }, 157 | }, 158 | }, 159 | }, 160 | err: nil, 161 | }, 162 | }, 163 | } 164 | 165 | for _, tt := range tests { 166 | t.Run(tt.name, func(t *testing.T) { 167 | mockHTTPServer := httptest.NewServer(tt.fields.mockHTTPHandler) 168 | defer mockHTTPServer.Close() 169 | 170 | sut := New( 171 | tt.fields.authToken, 172 | WithBaseURL(mockHTTPServer.URL), 173 | ) 174 | 175 | got, err := sut.Databases().List(tt.args.ctx, tt.args.params) 176 | if tt.wants.err != nil { 177 | assert.ErrorIs(t, err, tt.wants.err) 178 | return 179 | } 180 | 181 | assert.NoError(t, err) 182 | assert.Equal(t, tt.wants.response, got) 183 | }) 184 | } 185 | } 186 | 187 | func Test_databasesClient_Query(t *testing.T) { 188 | type fields struct { 189 | restClient rest.Interface 190 | mockHTTPHandler http.Handler 191 | authToken string 192 | } 193 | 194 | type args struct { 195 | ctx context.Context 196 | params DatabasesQueryParameters 197 | } 198 | 199 | type wants struct { 200 | response *DatabasesQueryResponse 201 | err error 202 | } 203 | 204 | type test struct { 205 | name string 206 | fields fields 207 | args args 208 | wants wants 209 | } 210 | 211 | tests := []test{ 212 | { 213 | name: "Query database with filter and sort", 214 | fields: fields{ 215 | restClient: rest.New(), 216 | authToken: "22e5435c-01f7-4d68-ad8c-203948e96b0b", 217 | mockHTTPHandler: http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { 218 | assert.Equal(t, DefaultNotionVersion, request.Header.Get("Notion-Version")) 219 | assert.Equal(t, DefaultUserAgent, request.Header.Get("User-Agent")) 220 | assert.Equal(t, "Bearer 22e5435c-01f7-4d68-ad8c-203948e96b0b", request.Header.Get("Authorization")) 221 | 222 | assert.Equal(t, http.MethodPost, request.Method) 223 | assert.Equal(t, "/v1/databases/897e5a76ae524b489fdfe71f5945d1af/query", request.RequestURI) 224 | 225 | expectedData := `{ 226 | "filter": { 227 | "or": [ 228 | { 229 | "property": "In stock", 230 | "checkbox": { 231 | "equals": true 232 | } 233 | }, 234 | { 235 | "property": "Cost of next trip", 236 | "number": { 237 | "greater_than_or_equal_to": 2 238 | } 239 | } 240 | ] 241 | }, 242 | "sorts": [ 243 | { 244 | "property": "Last ordered", 245 | "direction": "ascending" 246 | } 247 | ] 248 | }` 249 | 250 | b, err := ioutil.ReadAll(request.Body) 251 | assert.NoError(t, err) 252 | assert.JSONEq(t, expectedData, string(b)) 253 | 254 | writer.WriteHeader(http.StatusOK) 255 | 256 | _, err = writer.Write([]byte(`{ 257 | "object": "list", 258 | "results": [ 259 | { 260 | "object": "page", 261 | "id": "2e01e904-febd-43a0-ad02-8eedb903a82c", 262 | "created_time": "2020-03-17T19:10:04.968Z", 263 | "last_edited_time": "2020-03-17T21:49:37.913Z", 264 | "parent": { 265 | "type": "database_id", 266 | "database_id": "897e5a76-ae52-4b48-9fdf-e71f5945d1af" 267 | }, 268 | "archived": false, 269 | "properties": { 270 | "Recipes": { 271 | "id": "Ai` + "`" + `L", 272 | "type": "relation", 273 | "relation": [ 274 | { 275 | "id": "796659b4-a5d9-4c64-a539-06ac5292779e" 276 | }, 277 | { 278 | "id": "79e63318-f85a-4909-aceb-96a724d1021c" 279 | } 280 | ] 281 | }, 282 | "Cost of next trip": { 283 | "id": "R}wl", 284 | "type": "formula", 285 | "formula": { 286 | "type": "number", 287 | "number": 2 288 | } 289 | }, 290 | "Last ordered": { 291 | "id": "UsKi", 292 | "type": "date", 293 | "date": { 294 | "start": "2020-10-07", 295 | "end": null 296 | } 297 | }, 298 | "In stock": { 299 | "id": "{>U;", 300 | "type": "checkbox", 301 | "checkbox": false 302 | } 303 | } 304 | } 305 | ], 306 | "has_more": false, 307 | "next_cursor": null 308 | }`)) 309 | assert.NoError(t, err) 310 | }), 311 | }, 312 | args: args{ 313 | ctx: context.Background(), 314 | params: DatabasesQueryParameters{ 315 | PaginationParameters: PaginationParameters{}, 316 | DatabaseID: "897e5a76ae524b489fdfe71f5945d1af", 317 | Filter: CompoundFilter{ 318 | Or: []Filter{ 319 | &SingleCheckboxFilter{ 320 | SinglePropertyFilter: SinglePropertyFilter{ 321 | Property: "In stock", 322 | }, 323 | Checkbox: CheckboxFilter{ 324 | Equals: true, 325 | }, 326 | }, 327 | &SingleNumberFilter{ 328 | SinglePropertyFilter: SinglePropertyFilter{ 329 | Property: "Cost of next trip", 330 | }, 331 | Number: NumberFilter{ 332 | GreaterThanOrEqualTo: newFloat64(2), 333 | }, 334 | }, 335 | }, 336 | }, 337 | Sorts: []Sort{ 338 | { 339 | Property: "Last ordered", 340 | Direction: SortDirectionAscending, 341 | }, 342 | }, 343 | }, 344 | }, 345 | wants: wants{ 346 | response: &DatabasesQueryResponse{ 347 | PaginatedList: PaginatedList{ 348 | Object: ObjectTypeList, 349 | HasMore: false, 350 | NextCursor: "", 351 | }, 352 | Results: []Page{ 353 | { 354 | Object: ObjectTypePage, 355 | ID: "2e01e904-febd-43a0-ad02-8eedb903a82c", 356 | Parent: &DatabaseParent{ 357 | baseParent: baseParent{ 358 | Type: ParentTypeDatabase, 359 | }, 360 | DatabaseID: "897e5a76-ae52-4b48-9fdf-e71f5945d1af", 361 | }, 362 | Properties: map[string]PropertyValue{ 363 | "Recipes": &RelationPropertyValue{ 364 | basePropertyValue: basePropertyValue{ 365 | ID: "Ai`L", 366 | Type: PropertyValueTypeRelation, 367 | }, 368 | Relation: []PageReference{ 369 | {ID: "796659b4-a5d9-4c64-a539-06ac5292779e"}, 370 | {ID: "79e63318-f85a-4909-aceb-96a724d1021c"}, 371 | }, 372 | }, 373 | "Cost of next trip": &FormulaPropertyValue{ 374 | basePropertyValue: basePropertyValue{ 375 | ID: "R}wl", 376 | Type: PropertyValueTypeFormula, 377 | }, 378 | Formula: &NumberFormulaValue{ 379 | baseFormulaValue: baseFormulaValue{ 380 | Type: FormulaValueTypeNumber, 381 | }, 382 | Number: newFloat64(2), 383 | }, 384 | }, 385 | "Last ordered": &DatePropertyValue{ 386 | basePropertyValue: basePropertyValue{ 387 | ID: "UsKi", 388 | Type: PropertyValueTypeDate, 389 | }, 390 | Date: Date{ 391 | Start: "2020-10-07", 392 | End: nil, 393 | }, 394 | }, 395 | "In stock": &CheckboxPropertyValue{ 396 | basePropertyValue: basePropertyValue{ 397 | ID: "{>U;", 398 | Type: PropertyValueTypeCheckbox, 399 | }, 400 | Checkbox: false, 401 | }, 402 | }, 403 | CreatedTime: time.Date(2020, 3, 17, 19, 10, 4, 968_000_000, time.UTC), 404 | LastEditedTime: time.Date(2020, 3, 17, 21, 49, 37, 913_000_000, time.UTC), 405 | Archived: false, 406 | }, 407 | }, 408 | }, 409 | }, 410 | }, 411 | } 412 | 413 | for _, tt := range tests { 414 | t.Run(tt.name, func(t *testing.T) { 415 | mockHTTPServer := httptest.NewServer(tt.fields.mockHTTPHandler) 416 | 417 | sut := New( 418 | tt.fields.authToken, 419 | WithBaseURL(mockHTTPServer.URL), 420 | ) 421 | 422 | got, err := sut.Databases().Query(tt.args.ctx, tt.args.params) 423 | if tt.wants.err != nil { 424 | assert.ErrorIs(t, err, tt.wants.err) 425 | return 426 | } 427 | 428 | assert.NoError(t, err) 429 | assert.Equal(t, tt.wants.response, got) 430 | }) 431 | } 432 | } 433 | 434 | func Test_databasesClient_Retrieve(t *testing.T) { 435 | type fields struct { 436 | restClient rest.Interface 437 | mockHTTPHandler http.Handler 438 | authToken string 439 | } 440 | 441 | type args struct { 442 | ctx context.Context 443 | params DatabasesRetrieveParameters 444 | } 445 | 446 | type wants struct { 447 | response *DatabasesRetrieveResponse 448 | err error 449 | } 450 | 451 | type test struct { 452 | name string 453 | fields fields 454 | args args 455 | wants wants 456 | } 457 | 458 | tests := []test{ 459 | { 460 | name: "Retrieve database", 461 | fields: fields{ 462 | restClient: rest.New(), 463 | authToken: "cf0d2546-ac5c-4d2e-8c39-80dad88a5208", 464 | mockHTTPHandler: http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { 465 | assert.Equal(t, DefaultNotionVersion, request.Header.Get("Notion-Version")) 466 | assert.Equal(t, DefaultUserAgent, request.Header.Get("User-Agent")) 467 | assert.Equal(t, "Bearer cf0d2546-ac5c-4d2e-8c39-80dad88a5208", request.Header.Get("Authorization")) 468 | 469 | assert.Equal(t, http.MethodGet, request.Method) 470 | assert.Equal(t, "/v1/databases/668d797c-76fa-4934-9b05-ad288df2d136", request.RequestURI) 471 | 472 | writer.WriteHeader(http.StatusOK) 473 | 474 | _, err := writer.Write([]byte(`{ 475 | "object": "database", 476 | "id": "668d797c-76fa-4934-9b05-ad288df2d136", 477 | "created_time": "2020-03-17T19:10:04.968Z", 478 | "last_edited_time": "2020-03-17T21:49:37.913Z", 479 | "title": [ 480 | { 481 | "type": "text", 482 | "text": { 483 | "content": "Grocery List", 484 | "link": null 485 | }, 486 | "annotations": { 487 | "bold": false, 488 | "italic": false, 489 | "strikethrough": false, 490 | "underline": false, 491 | "code": false, 492 | "color": "default" 493 | }, 494 | "plain_text": "Grocery List", 495 | "href": null 496 | } 497 | ], 498 | "properties": { 499 | "Name": { 500 | "id": "title", 501 | "type": "title", 502 | "title": {} 503 | }, 504 | "Description": { 505 | "id": "J@cS", 506 | "type": "rich_text", 507 | "rich_text": {} 508 | }, 509 | "In stock": { 510 | "id": "{xY` + "`" + `", 511 | "type": "checkbox", 512 | "checkbox": {} 513 | }, 514 | "Food group": { 515 | "id": "TJmr", 516 | "type": "select", 517 | "select": { 518 | "options": [ 519 | { 520 | "id": "96eb622f-4b88-4283-919d-ece2fbed3841", 521 | "name": "🥦Vegetable", 522 | "color": "green" 523 | }, 524 | { 525 | "id": "bb443819-81dc-46fb-882d-ebee6e22c432", 526 | "name": "🍎Fruit", 527 | "color": "red" 528 | }, 529 | { 530 | "id": "7da9d1b9-8685-472e-9da3-3af57bdb221e", 531 | "name": "💪Protein", 532 | "color": "yellow" 533 | } 534 | ] 535 | } 536 | }, 537 | "Price": { 538 | "id": "cU^N", 539 | "type": "number", 540 | "number": { 541 | "format": "dollar" 542 | } 543 | }, 544 | "Cost of next trip": { 545 | "id": "p:sC", 546 | "type": "formula", 547 | "formula": { 548 | "expression": "if(prop(\"In stock\"), 0, prop(\"Price\"))" 549 | } 550 | }, 551 | "Last ordered": { 552 | "id": "]\\R[", 553 | "type": "date", 554 | "date": {} 555 | }, 556 | "Meals": { 557 | "type": "relation", 558 | "relation": { 559 | "database_id": "668d797c-76fa-4934-9b05-ad288df2d136", 560 | "synced_property_name": null 561 | } 562 | }, 563 | "Number of meals": { 564 | "id": "Z\\Eh", 565 | "type": "rollup", 566 | "rollup": { 567 | "rollup_property_name": "Name", 568 | "relation_property_name": "Meals", 569 | "rollup_property_id": "title", 570 | "relation_property_id": "mxp^", 571 | "function": "count" 572 | } 573 | }, 574 | "Store availability": { 575 | "type": "multi_select", 576 | "multi_select": { 577 | "options": [ 578 | { 579 | "id": "d209b920-212c-4040-9d4a-bdf349dd8b2a", 580 | "name": "Duc Loi Market", 581 | "color": "blue" 582 | }, 583 | { 584 | "id": "70104074-0f91-467b-9787-00d59e6e1e41", 585 | "name": "Rainbow Grocery", 586 | "color": "gray" 587 | }, 588 | { 589 | "id": "e6fd4f04-894d-4fa7-8d8b-e92d08ebb604", 590 | "name": "Nijiya Market", 591 | "color": "purple" 592 | }, 593 | { 594 | "id": "6c3867c5-d542-4f84-b6e9-a420c43094e7", 595 | "name": "Gus's Community Market", 596 | "color": "yellow" 597 | } 598 | ] 599 | } 600 | }, 601 | "+1": { 602 | "id": "aGut", 603 | "type": "people", 604 | "people": {} 605 | }, 606 | "Photo": { 607 | "id": "aTIT", 608 | "type": "file", 609 | "file": {} 610 | } 611 | } 612 | }`)) 613 | // FIXME: In the API reference https://developers.notion.com/reference/get-database 614 | // the options in the multi_select property is an array of array which does not make sense 615 | // to me, thus changing it to an array but need to verify this. 616 | 617 | // FIXME: In the API reference https://developers.notion.com/reference/get-database 618 | // the Photo Property has `files` type but in the documentation, 619 | // https://developers.notion.com/reference/database, the type should be `file`, 620 | // Following the documentation, the JSON string above has been changed to type `file`. 621 | assert.NoError(t, err) 622 | }), 623 | }, 624 | args: args{ 625 | ctx: context.Background(), 626 | params: DatabasesRetrieveParameters{ 627 | DatabaseID: "668d797c-76fa-4934-9b05-ad288df2d136", 628 | }, 629 | }, 630 | wants: wants{ 631 | response: &DatabasesRetrieveResponse{ 632 | Database: Database{ 633 | Object: ObjectTypeDatabase, 634 | ID: "668d797c-76fa-4934-9b05-ad288df2d136", 635 | CreatedTime: time.Date(2020, 3, 17, 19, 10, 4, 968_000_000, time.UTC), 636 | LastEditedTime: time.Date(2020, 3, 17, 21, 49, 37, 913_000_000, time.UTC), 637 | Title: []RichText{ 638 | &RichTextText{ 639 | BaseRichText: BaseRichText{ 640 | PlainText: "Grocery List", 641 | Href: "", 642 | Type: RichTextTypeText, 643 | Annotations: &Annotations{ 644 | Bold: false, 645 | Italic: false, 646 | Strikethrough: false, 647 | Underline: false, 648 | Code: false, 649 | Color: ColorDefault, 650 | }, 651 | }, 652 | Text: TextObject{ 653 | Content: "Grocery List", 654 | Link: nil, 655 | }, 656 | }, 657 | }, 658 | Properties: map[string]Property{ 659 | "Name": &TitleProperty{ 660 | baseProperty: baseProperty{ 661 | ID: "title", 662 | Type: "title", 663 | }, 664 | Title: map[string]interface{}{}, 665 | }, 666 | // FIXME: This is different from the example of https://developers.notion.com/reference/get-database 667 | // but in the API reference there's no `text` type, thus changing it to `rich_text`, but need 668 | // to confirm what is the expected type. 669 | "Description": &RichTextProperty{ 670 | baseProperty: baseProperty{ 671 | ID: "J@cS", 672 | Type: PropertyTypeRichText, 673 | }, 674 | RichText: map[string]interface{}{}, 675 | }, 676 | "In stock": &CheckboxProperty{ 677 | baseProperty: baseProperty{ 678 | ID: "{xY`", 679 | Type: PropertyTypeCheckbox, 680 | }, 681 | Checkbox: map[string]interface{}{}, 682 | }, 683 | "Food group": &SelectProperty{ 684 | baseProperty: baseProperty{ 685 | ID: "TJmr", 686 | Type: PropertyTypeSelect, 687 | }, 688 | Select: SelectPropertyOption{ 689 | Options: []SelectOption{ 690 | { 691 | ID: "96eb622f-4b88-4283-919d-ece2fbed3841", 692 | Name: "🥦Vegetable", 693 | Color: ColorGreen, 694 | }, 695 | { 696 | ID: "bb443819-81dc-46fb-882d-ebee6e22c432", 697 | Name: "🍎Fruit", 698 | Color: ColorRed, 699 | }, 700 | { 701 | ID: "7da9d1b9-8685-472e-9da3-3af57bdb221e", 702 | Name: "💪Protein", 703 | Color: ColorYellow, 704 | }, 705 | }, 706 | }, 707 | }, 708 | "Price": &NumberProperty{ 709 | baseProperty: baseProperty{ 710 | ID: "cU^N", 711 | Type: PropertyTypeNumber, 712 | }, 713 | Number: NumberPropertyOption{ 714 | Format: NumberFormatDollar, 715 | }, 716 | }, 717 | "Cost of next trip": &FormulaProperty{ 718 | baseProperty: baseProperty{ 719 | ID: "p:sC", 720 | Type: PropertyTypeFormula, 721 | }, 722 | Formula: Formula{ 723 | // FIXME: The example response in https://developers.notion.com/reference/get-database 724 | // has the key `value` but in the API reference https://developers.notion.com/reference/database#formula-configuration 725 | // the property name should be `expression`, need to check if this is expected. 726 | Expression: `if(prop("In stock"), 0, prop("Price"))`, 727 | }, 728 | }, 729 | "Last ordered": &DateProperty{ 730 | baseProperty: baseProperty{ 731 | ID: "]\\R[", 732 | Type: PropertyTypeDate, 733 | }, 734 | Date: map[string]interface{}{}, 735 | }, 736 | "Meals": &RelationProperty{ 737 | baseProperty: baseProperty{ 738 | // FIXME: The example response in https://developers.notion.com/reference/get-database 739 | // does not contain the ID, need to check if this is expected. 740 | ID: "", 741 | Type: PropertyTypeRelation, 742 | }, 743 | Relation: Relation{ 744 | // FIXME: The key in the example is `database` but should be `database_id` instead, 745 | // and need to check if which one is correct. 746 | DatabaseID: "668d797c-76fa-4934-9b05-ad288df2d136", 747 | }, 748 | }, 749 | "Number of meals": &RollupProperty{ 750 | baseProperty: baseProperty{ 751 | ID: "Z\\Eh", 752 | Type: PropertyTypeRollup, 753 | }, 754 | Rollup: RollupPropertyOption{ 755 | RelationPropertyName: "Meals", 756 | RelationPropertyID: "mxp^", 757 | RollupPropertyName: "Name", 758 | RollupPropertyID: "title", 759 | // FIXME: In the example the returned function is `count` but the possible values 760 | // do not include `count`, thus need to confirm this. 761 | Function: "count", 762 | }, 763 | }, 764 | "Store availability": &MultiSelectProperty{ 765 | baseProperty: baseProperty{ 766 | // FIXME: The example response in https://developers.notion.com/reference/get-database 767 | // does not contain the ID, need to check if this is expected. 768 | ID: "", 769 | Type: PropertyTypeMultiSelect, 770 | }, 771 | MultiSelect: MultiSelectPropertyOption{ 772 | Options: []MultiSelectOption{ 773 | { 774 | ID: "d209b920-212c-4040-9d4a-bdf349dd8b2a", 775 | Name: "Duc Loi Market", 776 | Color: ColorBlue, 777 | }, 778 | { 779 | ID: "70104074-0f91-467b-9787-00d59e6e1e41", 780 | Name: "Rainbow Grocery", 781 | Color: ColorGray, 782 | }, 783 | { 784 | ID: "e6fd4f04-894d-4fa7-8d8b-e92d08ebb604", 785 | Name: "Nijiya Market", 786 | Color: ColorPurple, 787 | }, 788 | { 789 | ID: "6c3867c5-d542-4f84-b6e9-a420c43094e7", 790 | Name: "Gus's Community Market", 791 | Color: ColorYellow, 792 | }, 793 | }, 794 | }, 795 | }, 796 | "+1": &PeopleProperty{ 797 | baseProperty: baseProperty{ 798 | ID: "aGut", 799 | Type: PropertyTypePeople, 800 | }, 801 | People: map[string]interface{}{}, 802 | }, 803 | "Photo": &FileProperty{ 804 | baseProperty: baseProperty{ 805 | ID: "aTIT", 806 | Type: PropertyTypeFile, 807 | }, 808 | File: map[string]interface{}{}, 809 | }, 810 | }, 811 | }, 812 | }, 813 | }, 814 | }, 815 | } 816 | 817 | for _, tt := range tests { 818 | t.Run(tt.name, func(t *testing.T) { 819 | mockHTTPServer := httptest.NewServer(tt.fields.mockHTTPHandler) 820 | defer mockHTTPServer.Close() 821 | 822 | sut := New( 823 | tt.fields.authToken, 824 | WithBaseURL(mockHTTPServer.URL), 825 | ) 826 | 827 | got, err := sut.Databases().Retrieve(tt.args.ctx, tt.args.params) 828 | if tt.wants.err != nil { 829 | assert.ErrorIs(t, err, tt.wants.err) 830 | return 831 | } 832 | 833 | assert.NoError(t, err) 834 | assert.Equal(t, tt.wants.response, got) 835 | }) 836 | } 837 | } 838 | 839 | func newFloat64(f float64) *float64 { 840 | return &f 841 | } 842 | -------------------------------------------------------------------------------- /errors.go: -------------------------------------------------------------------------------- 1 | package notion 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | ) 7 | 8 | var ( 9 | ErrUnknown = errors.New("unknown") 10 | ) 11 | 12 | // ErrorCode https://developers.notion.com/reference/errors 13 | type ErrorCode string 14 | 15 | const ( 16 | ErrorCodeInvalidJSON ErrorCode = "invalid_json" 17 | ErrorCodeInvalidRequestURI ErrorCode = "invalid_request_url" 18 | ErrorCodeInvalidRequest ErrorCode = "invalid_request" 19 | ErrorCodeValidationError ErrorCode = "validation_error" 20 | ErrorCodeUnauthorized ErrorCode = "unauthorized" 21 | ErrorCodeRestrictedResource ErrorCode = "restricted_resource" 22 | ErrorCodeObjectNotFound ErrorCode = "object_not_found" 23 | ErrorCodeConflictError ErrorCode = "conflict_error" 24 | ErrorCodeRateLimited ErrorCode = "rate_limited" 25 | ErrorCodeInternalServerError ErrorCode = "internal_server_error" 26 | ErrorCodeServiceUnavailable ErrorCode = "service_unavailable" 27 | ) 28 | 29 | type HTTPError struct { 30 | Code ErrorCode `json:"code"` 31 | Message string `json:"message"` 32 | } 33 | 34 | func (e HTTPError) Error() string { 35 | return fmt.Sprintf("Code: %s, Message: %s", e.Code, e.Message) 36 | } 37 | -------------------------------------------------------------------------------- /errors_test.go: -------------------------------------------------------------------------------- 1 | package notion 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestHTTPError_Error(t *testing.T) { 10 | type fields struct { 11 | Code ErrorCode 12 | Message string 13 | } 14 | tests := []struct { 15 | fields fields 16 | wantError string 17 | }{ 18 | { 19 | fields: fields{ 20 | Code: ErrorCodeInvalidJSON, 21 | Message: "missing ]", 22 | }, 23 | wantError: "Code: invalid_json, Message: missing ]", 24 | }, 25 | } 26 | for _, tt := range tests { 27 | t.Run(string(tt.fields.Code), func(t *testing.T) { 28 | e := HTTPError{ 29 | Code: tt.fields.Code, 30 | Message: tt.fields.Message, 31 | } 32 | 33 | assert.EqualError(t, e, tt.wantError) 34 | }) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /examples/append-block-children/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "os" 7 | 8 | "github.com/mkfsn/notion-go" 9 | ) 10 | 11 | func main() { 12 | c := notion.New(os.Getenv("NOTION_AUTH_TOKEN")) 13 | 14 | resp, err := c.Blocks().Children().Append(context.Background(), 15 | notion.BlocksChildrenAppendParameters{ 16 | BlockID: "12e1d803ee234651a125c6ce13ccd58d", 17 | Children: []notion.Block{ 18 | notion.Heading2Block{ 19 | BlockBase: notion.BlockBase{ 20 | Object: notion.ObjectTypeBlock, 21 | Type: notion.BlockTypeHeading2, 22 | }, 23 | Heading2: notion.HeadingBlock{ 24 | Text: []notion.RichText{ 25 | notion.RichTextText{ 26 | BaseRichText: notion.BaseRichText{ 27 | Type: notion.RichTextTypeText, 28 | }, 29 | Text: notion.TextObject{ 30 | Content: "Lacinato kale", 31 | }, 32 | }, 33 | }, 34 | }, 35 | }, 36 | 37 | notion.ParagraphBlock{ 38 | BlockBase: notion.BlockBase{ 39 | Object: notion.ObjectTypeBlock, 40 | Type: notion.BlockTypeParagraph, 41 | }, 42 | Paragraph: notion.RichTextBlock{ 43 | Text: []notion.RichText{ 44 | notion.RichTextText{ 45 | BaseRichText: notion.BaseRichText{ 46 | Type: notion.RichTextTypeText, 47 | }, 48 | Text: notion.TextObject{ 49 | Content: "Lacinato kale is a variety of kale with a long tradition in Italian cuisine, especially that of Tuscany. It is also known as Tuscan kale, Italian kale, dinosaur kale, kale, flat back kale, palm tree kale, or black Tuscan palm.", 50 | Link: ¬ion.Link{ 51 | Type: "url", 52 | URL: "https://en.wikipedia.org/wiki/Lacinato_kale", 53 | }, 54 | }, 55 | }, 56 | }, 57 | }, 58 | }, 59 | }, 60 | }, 61 | ) 62 | 63 | if err != nil { 64 | log.Fatal(err) 65 | } 66 | 67 | log.Printf("response: %#v\n", resp) 68 | } 69 | -------------------------------------------------------------------------------- /examples/create-page/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "os" 7 | 8 | "github.com/mkfsn/notion-go" 9 | ) 10 | 11 | func main() { 12 | c := notion.New(os.Getenv("NOTION_AUTH_TOKEN")) 13 | 14 | page, err := c.Pages().Create(context.Background(), 15 | notion.PagesCreateParameters{ 16 | Parent: notion.DatabaseParentInput{ 17 | DatabaseID: "aee104a17e554846bea3536712bfca2c", 18 | }, 19 | 20 | Properties: map[string]notion.PropertyValue{ 21 | "Name": notion.TitlePropertyValue{ 22 | Title: []notion.RichText{ 23 | notion.RichTextText{Text: notion.TextObject{Content: "Tuscan Kale"}}, 24 | }, 25 | }, 26 | 27 | "Description": notion.RichTextPropertyValue{ 28 | RichText: []notion.RichText{ 29 | notion.RichTextText{Text: notion.TextObject{Content: " dark green leafy vegetable"}}, 30 | }, 31 | }, 32 | 33 | "Food group": notion.SelectPropertyValue{ 34 | Select: notion.SelectPropertyValueOption{ 35 | Name: "Vegetable", 36 | }, 37 | }, 38 | 39 | "Price": notion.NumberPropertyValue{ 40 | Number: 2.5, 41 | }, 42 | }, 43 | 44 | Children: []notion.Block{ 45 | notion.Heading2Block{ 46 | BlockBase: notion.BlockBase{ 47 | Object: notion.ObjectTypeBlock, 48 | Type: notion.BlockTypeHeading2, 49 | }, 50 | Heading2: notion.HeadingBlock{ 51 | Text: []notion.RichText{ 52 | notion.RichTextText{ 53 | BaseRichText: notion.BaseRichText{ 54 | Type: notion.RichTextTypeText, 55 | }, 56 | Text: notion.TextObject{ 57 | Content: "Lacinato kale", 58 | }, 59 | }, 60 | }, 61 | }, 62 | }, 63 | 64 | notion.ParagraphBlock{ 65 | BlockBase: notion.BlockBase{ 66 | Object: notion.ObjectTypeBlock, 67 | Type: notion.BlockTypeParagraph, 68 | }, 69 | Paragraph: notion.RichTextBlock{ 70 | Text: []notion.RichText{ 71 | notion.RichTextText{ 72 | BaseRichText: notion.BaseRichText{ 73 | Type: notion.RichTextTypeText, 74 | }, 75 | Text: notion.TextObject{ 76 | Content: "Lacinato kale is a variety of kale with a long tradition in Italian cuisine, especially that of Tuscany. It is also known as Tuscan kale, Italian kale, dinosaur kale, kale, flat back kale, palm tree kale, or black Tuscan palm.", 77 | Link: ¬ion.Link{ 78 | Type: "url", 79 | URL: "https://en.wikipedia.org/wiki/Lacinato_kale", 80 | }, 81 | }, 82 | }, 83 | }, 84 | }, 85 | }, 86 | }, 87 | }, 88 | ) 89 | 90 | if err != nil { 91 | log.Fatal(err) 92 | } 93 | 94 | log.Printf("page: %#v\n", page) 95 | } 96 | -------------------------------------------------------------------------------- /examples/list-block-children/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "os" 7 | 8 | "github.com/mkfsn/notion-go" 9 | ) 10 | 11 | func main() { 12 | c := notion.New(os.Getenv("NOTION_AUTH_TOKEN")) 13 | 14 | resp, err := c.Blocks().Children().List(context.Background(), notion.BlocksChildrenListParameters{ 15 | BlockID: "12e1d803ee234651a125c6ce13ccd58d"}, 16 | ) 17 | 18 | if err != nil { 19 | log.Fatal(err) 20 | } 21 | 22 | log.Printf("response: %#v\n", resp) 23 | } 24 | -------------------------------------------------------------------------------- /examples/list-databases/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "os" 7 | 8 | "github.com/mkfsn/notion-go" 9 | ) 10 | 11 | func main() { 12 | c := notion.New(os.Getenv("NOTION_AUTH_TOKEN")) 13 | 14 | resp, err := c.Databases().List(context.Background(), notion.DatabasesListParameters{ 15 | PaginationParameters: notion.PaginationParameters{ 16 | StartCursor: "", 17 | PageSize: 1, 18 | }, 19 | }) 20 | 21 | if err != nil { 22 | log.Fatal(err) 23 | } 24 | 25 | log.Printf("%#v\n", resp) 26 | } 27 | -------------------------------------------------------------------------------- /examples/list-users/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "os" 7 | 8 | "github.com/mkfsn/notion-go" 9 | ) 10 | 11 | func main() { 12 | c := notion.New(os.Getenv("NOTION_AUTH_TOKEN")) 13 | 14 | resp, err := c.Users().List(context.Background(), notion.UsersListParameters{ 15 | PaginationParameters: notion.PaginationParameters{ 16 | StartCursor: "", 17 | PageSize: 10, 18 | }, 19 | }) 20 | 21 | if err != nil { 22 | log.Fatal(err) 23 | } 24 | 25 | log.Printf("%#v\n", resp) 26 | } 27 | -------------------------------------------------------------------------------- /examples/query-database/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "os" 7 | 8 | "github.com/mkfsn/notion-go" 9 | ) 10 | 11 | func main() { 12 | c := notion.New(os.Getenv("NOTION_AUTH_TOKEN")) 13 | 14 | keyword := "medium.com" 15 | 16 | resp, err := c.Databases().Query(context.Background(), notion.DatabasesQueryParameters{ 17 | DatabaseID: "def72422-ea36-4c8a-a6f1-a34e11a7fe54", 18 | Filter: notion.CompoundFilter{ 19 | Or: []notion.Filter{ 20 | notion.SingleTextFilter{ 21 | SinglePropertyFilter: notion.SinglePropertyFilter{ 22 | Property: "URL", 23 | }, 24 | URL: ¬ion.TextFilter{ 25 | Contains: &keyword, 26 | }, 27 | }, 28 | }, 29 | }, 30 | Sorts: []notion.Sort{ 31 | { 32 | Property: "Created", 33 | Direction: notion.SortDirectionAscending, 34 | }, 35 | }, 36 | }) 37 | 38 | if err != nil { 39 | log.Fatal(err) 40 | } 41 | 42 | log.Printf("%#v\n", resp) 43 | } 44 | -------------------------------------------------------------------------------- /examples/retrieve-database/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "os" 7 | 8 | "github.com/mkfsn/notion-go" 9 | ) 10 | 11 | func main() { 12 | c := notion.New(os.Getenv("NOTION_AUTH_TOKEN")) 13 | 14 | resp, err := c.Databases().Retrieve(context.Background(), notion.DatabasesRetrieveParameters{ 15 | DatabaseID: "def72422-ea36-4c8a-a6f1-a34e11a7fe54", 16 | }) 17 | 18 | if err != nil { 19 | log.Fatal(err) 20 | } 21 | 22 | log.Printf("%#v\n", resp) 23 | } 24 | -------------------------------------------------------------------------------- /examples/retrieve-page/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "os" 7 | 8 | "github.com/mkfsn/notion-go" 9 | ) 10 | 11 | func main() { 12 | c := notion.New(os.Getenv("NOTION_AUTH_TOKEN")) 13 | 14 | page, err := c.Pages().Retrieve(context.Background(), notion.PagesRetrieveParameters{ 15 | PageID: "676aa7b7-2bba-4b5b-9fd6-b43f5543482d"}, 16 | ) 17 | 18 | if err != nil { 19 | log.Fatal(err) 20 | } 21 | 22 | log.Printf("page: %#v\n", page) 23 | } 24 | -------------------------------------------------------------------------------- /examples/retrieve-user/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "os" 7 | 8 | "github.com/mkfsn/notion-go" 9 | ) 10 | 11 | func main() { 12 | c := notion.New(os.Getenv("NOTION_AUTH_TOKEN")) 13 | 14 | resp, err := c.Users().Retrieve(context.Background(), notion.UsersRetrieveParameters{UserID: "8cd69bf3-1532-43d2-9b11-9803c813d607"}) 15 | 16 | if err != nil { 17 | log.Fatal(err) 18 | } 19 | 20 | log.Printf("%#v\n", resp.User) 21 | } 22 | -------------------------------------------------------------------------------- /examples/search/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "os" 7 | 8 | "github.com/mkfsn/notion-go" 9 | ) 10 | 11 | func main() { 12 | c := notion.New(os.Getenv("NOTION_AUTH_TOKEN")) 13 | 14 | resp, err := c.Search(context.Background(), notion.SearchParameters{ 15 | Query: "フィリスのアトリエ", 16 | Sort: notion.SearchSort{ 17 | Direction: notion.SearchSortDirectionAscending, 18 | Timestamp: notion.SearchSortTimestampLastEditedTime, 19 | }, 20 | Filter: notion.SearchFilter{ 21 | Property: notion.SearchFilterPropertyObject, 22 | Value: notion.SearchFilterValuePage, 23 | }, 24 | }) 25 | if err != nil { 26 | log.Fatalf("error: %s\n", err) 27 | } 28 | 29 | for _, object := range resp.Results { 30 | log.Printf("object: %#v\n", object) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /examples/update-page/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "os" 7 | 8 | "github.com/mkfsn/notion-go" 9 | ) 10 | 11 | func main() { 12 | c := notion.New(os.Getenv("NOTION_AUTH_TOKEN")) 13 | 14 | page, err := c.Pages().Update(context.Background(), 15 | notion.PagesUpdateParameters{ 16 | PageID: "6eaac3811afd4f368209b572e13eace4", 17 | Properties: map[string]notion.PropertyValue{ 18 | "In stock": notion.CheckboxPropertyValue{ 19 | Checkbox: true, 20 | }, 21 | 22 | "Price": notion.NumberPropertyValue{ 23 | Number: 30, 24 | }, 25 | }, 26 | }, 27 | ) 28 | 29 | if err != nil { 30 | log.Fatal(err) 31 | } 32 | 33 | log.Printf("page: %#v\n", page) 34 | } 35 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/mkfsn/notion-go 2 | 3 | go 1.16 4 | 5 | require ( 6 | github.com/google/go-querystring v1.1.0 7 | github.com/stretchr/testify v1.7.0 8 | ) 9 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= 2 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/google/go-cmp v0.5.2 h1:X2ev0eStA3AbceY54o37/0PQ/UWqKEiiO2dKL5OPaFM= 4 | github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 5 | github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= 6 | github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= 7 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 8 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 9 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 10 | github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= 11 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 12 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 13 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 14 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 15 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= 16 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 17 | -------------------------------------------------------------------------------- /pages.go: -------------------------------------------------------------------------------- 1 | package notion 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "strings" 8 | "time" 9 | 10 | "github.com/mkfsn/notion-go/rest" 11 | ) 12 | 13 | type Parent interface { 14 | isParent() 15 | } 16 | 17 | type baseParent struct { 18 | Type ParentType `json:"type"` 19 | } 20 | 21 | func (b baseParent) isParent() {} 22 | 23 | type DatabaseParent struct { 24 | baseParent 25 | DatabaseID string `json:"database_id"` 26 | } 27 | 28 | type PageParent struct { 29 | baseParent 30 | PageID string `json:"page_id"` 31 | } 32 | 33 | type WorkspaceParent struct { 34 | baseParent 35 | } 36 | 37 | type ParentInput interface { 38 | isParentInput() 39 | } 40 | 41 | type baseParentInput struct{} 42 | 43 | func (b baseParentInput) isParentInput() {} 44 | 45 | type DatabaseParentInput struct { 46 | baseParentInput 47 | DatabaseID string `json:"database_id"` 48 | } 49 | 50 | type PageParentInput struct { 51 | baseParentInput 52 | PageID string `json:"page_id"` 53 | } 54 | 55 | type Page struct { 56 | // Always "page". 57 | Object ObjectType `json:"object"` 58 | // Unique identifier of the page. 59 | ID string `json:"id"` 60 | // The page's parent 61 | Parent Parent `json:"parent"` 62 | // Property values of this page. 63 | Properties map[string]PropertyValue `json:"properties"` 64 | // Date and time when this page was created. Formatted as an ISO 8601 date time string. 65 | CreatedTime time.Time `json:"created_time"` 66 | // Date and time when this page was updated. Formatted as an ISO 8601 date time string. 67 | LastEditedTime time.Time `json:"last_edited_time"` 68 | // The archived status of the page. 69 | Archived bool `json:"archived"` 70 | } 71 | 72 | func (p *Page) UnmarshalJSON(data []byte) error { 73 | type Alias Page 74 | 75 | alias := struct { 76 | *Alias 77 | Parent parentDecoder `json:"parent"` 78 | Properties map[string]propertyValueDecoder `json:"properties"` 79 | }{ 80 | Alias: (*Alias)(p), 81 | } 82 | 83 | if err := json.Unmarshal(data, &alias); err != nil { 84 | return fmt.Errorf("failed to unmarshal Page: %w", err) 85 | } 86 | 87 | p.Parent = alias.Parent.Parent 88 | 89 | p.Properties = make(map[string]PropertyValue) 90 | 91 | for name, decoder := range alias.Properties { 92 | p.Properties[name] = decoder.PropertyValue 93 | } 94 | 95 | return nil 96 | } 97 | 98 | func (p Page) isSearchable() {} 99 | 100 | type PropertyValue interface { 101 | isPropertyValue() 102 | } 103 | 104 | type basePropertyValue struct { 105 | // Underlying identifier for the property. This identifier is guaranteed to remain constant when the property name changes. 106 | // It may be a UUID, but is often a short random string. 107 | // The id may be used in place of name when creating or updating pages. 108 | ID string `json:"id,omitempty"` 109 | // Type of the property 110 | Type PropertyValueType `json:"type,omitempty"` 111 | } 112 | 113 | func (p basePropertyValue) isPropertyValue() {} 114 | 115 | type TitlePropertyValue struct { 116 | basePropertyValue 117 | Title []RichText `json:"title"` 118 | } 119 | 120 | func (t *TitlePropertyValue) UnmarshalJSON(data []byte) error { 121 | type Alias TitlePropertyValue 122 | 123 | alias := struct { 124 | *Alias 125 | Title []richTextDecoder `json:"title"` 126 | }{ 127 | Alias: (*Alias)(t), 128 | } 129 | 130 | if err := json.Unmarshal(data, &alias); err != nil { 131 | return fmt.Errorf("failed to unmarshal TitlePropertyValue: %w", err) 132 | } 133 | 134 | t.Title = make([]RichText, 0, len(alias.Title)) 135 | 136 | for _, decoder := range alias.Title { 137 | t.Title = append(t.Title, decoder.RichText) 138 | } 139 | 140 | return nil 141 | } 142 | 143 | type RichTextPropertyValue struct { 144 | basePropertyValue 145 | RichText []RichText `json:"rich_text"` 146 | } 147 | 148 | func (r *RichTextPropertyValue) UnmarshalJSON(data []byte) error { 149 | type Alias RichTextPropertyValue 150 | 151 | alias := struct { 152 | *Alias 153 | RichText []richTextDecoder `json:"rich_text"` 154 | }{ 155 | Alias: (*Alias)(r), 156 | } 157 | 158 | if err := json.Unmarshal(data, &alias); err != nil { 159 | return fmt.Errorf("failed to unmarshal RichTextPropertyValue: %w", err) 160 | } 161 | 162 | r.RichText = make([]RichText, 0, len(alias.RichText)) 163 | 164 | for _, decoder := range alias.RichText { 165 | r.RichText = append(r.RichText, decoder.RichText) 166 | } 167 | 168 | return nil 169 | } 170 | 171 | type NumberPropertyValue struct { 172 | basePropertyValue 173 | Number float64 `json:"number"` 174 | } 175 | 176 | type SelectPropertyValueOption struct { 177 | ID string `json:"id,omitempty"` 178 | Name string `json:"name"` 179 | Color Color `json:"color,omitempty"` 180 | } 181 | 182 | type SelectPropertyValue struct { 183 | basePropertyValue 184 | Select SelectPropertyValueOption `json:"select"` 185 | } 186 | 187 | type MultiSelectPropertyValueOption struct { 188 | ID string `json:"id"` 189 | Name string `json:"name"` 190 | Color Color `json:"color"` 191 | } 192 | 193 | type MultiSelectPropertyValue struct { 194 | basePropertyValue 195 | MultiSelect []MultiSelectPropertyValueOption `json:"multi_select"` 196 | } 197 | 198 | type Date struct { 199 | Start string `json:"start"` 200 | End *string `json:"end"` 201 | } 202 | 203 | type DatePropertyValue struct { 204 | basePropertyValue 205 | Date Date `json:"date"` 206 | } 207 | 208 | type FormulaValue interface { 209 | isFormulaValue() 210 | } 211 | 212 | type baseFormulaValue struct { 213 | Type FormulaValueType `json:"type"` 214 | } 215 | 216 | func (b baseFormulaValue) isFormulaValue() {} 217 | 218 | type StringFormulaValue struct { 219 | baseFormulaValue 220 | String *string `json:"string"` 221 | } 222 | 223 | type NumberFormulaValue struct { 224 | baseFormulaValue 225 | Number *float64 `json:"number"` 226 | } 227 | 228 | type BooleanFormulaValue struct { 229 | baseFormulaValue 230 | Boolean bool `json:"boolean"` 231 | } 232 | 233 | type DateFormulaValue struct { 234 | baseFormulaValue 235 | Date DatePropertyValue `json:"date"` 236 | } 237 | 238 | type FormulaPropertyValue struct { 239 | basePropertyValue 240 | Formula FormulaValue `json:"formula"` 241 | } 242 | 243 | func (f *FormulaPropertyValue) UnmarshalJSON(data []byte) error { 244 | type Alias FormulaPropertyValue 245 | 246 | alias := struct { 247 | *Alias 248 | Formula formulaValueDecoder `json:"formula"` 249 | }{ 250 | Alias: (*Alias)(f), 251 | } 252 | 253 | if err := json.Unmarshal(data, &alias); err != nil { 254 | return fmt.Errorf("failed to unmarshal FormulaPropertyValue: %w", err) 255 | } 256 | 257 | f.Formula = alias.Formula.FormulaValue 258 | 259 | return nil 260 | } 261 | 262 | type PageReference struct { 263 | ID string `json:"id"` 264 | } 265 | 266 | type RelationPropertyValue struct { 267 | basePropertyValue 268 | Relation []PageReference `json:"relation"` 269 | } 270 | 271 | type RollupValueType interface { 272 | isRollupValueType() 273 | } 274 | 275 | type baseRollupValueType struct { 276 | Type string `json:"type"` 277 | } 278 | 279 | func (b baseRollupValueType) isRollupValueType() {} 280 | 281 | type NumberRollupValue struct { 282 | baseRollupValueType 283 | Number float64 `json:"number"` 284 | } 285 | 286 | type DateRollupValue struct { 287 | baseRollupValueType 288 | Date DatePropertyValue `json:"date"` 289 | } 290 | 291 | type ArrayRollupValue struct { 292 | baseRollupValueType 293 | Array []interface{} `json:"array"` 294 | } 295 | 296 | type RollupPropertyValue struct { 297 | basePropertyValue 298 | Rollup RollupValueType `json:"rollup"` 299 | } 300 | 301 | type PeoplePropertyValue struct { 302 | basePropertyValue 303 | People []User `json:"people"` 304 | } 305 | 306 | type File struct { 307 | Name string `json:"name"` 308 | } 309 | 310 | type FilesPropertyValue struct { 311 | basePropertyValue 312 | Files []File `json:"files"` 313 | } 314 | 315 | type CheckboxPropertyValue struct { 316 | basePropertyValue 317 | Checkbox bool `json:"checkbox"` 318 | } 319 | 320 | type URLPropertyValue struct { 321 | basePropertyValue 322 | URL string `json:"url"` 323 | } 324 | 325 | type EmailPropertyValue struct { 326 | basePropertyValue 327 | Email string `json:"email"` 328 | } 329 | 330 | type PhoneNumberPropertyValue struct { 331 | basePropertyValue 332 | PhoneNumber string `json:"phone_number"` 333 | } 334 | 335 | type CreatedTimePropertyValue struct { 336 | basePropertyValue 337 | CreatedTime time.Time `json:"created_time"` 338 | } 339 | 340 | type CreatedByPropertyValue struct { 341 | basePropertyValue 342 | CreatedBy User `json:"created_by"` 343 | } 344 | 345 | type LastEditedTimePropertyValue struct { 346 | basePropertyValue 347 | LastEditedTime time.Time `json:"last_edited_time"` 348 | } 349 | 350 | type LastEditedByPropertyValue struct { 351 | basePropertyValue 352 | LastEditedBy User `json:"last_edited_by"` 353 | } 354 | 355 | type PagesRetrieveParameters struct { 356 | PageID string `json:"-" url:"-"` 357 | } 358 | 359 | type PagesRetrieveResponse struct { 360 | Page 361 | } 362 | 363 | type PagesUpdateParameters struct { 364 | PageID string `json:"-" url:"-"` 365 | Properties map[string]PropertyValue `json:"properties" url:"-"` 366 | } 367 | 368 | type PagesUpdateResponse struct { 369 | Page 370 | } 371 | 372 | type PagesCreateParameters struct { 373 | // A DatabaseParentInput or PageParentInput 374 | Parent ParentInput `json:"parent" url:"-"` 375 | // Property values of this page. The keys are the names or IDs of the property and the values are property values. 376 | Properties map[string]PropertyValue `json:"properties" url:"-"` 377 | // Page content for the new page as an array of block objects 378 | Children []Block `json:"children,omitempty" url:"-"` 379 | } 380 | 381 | type PagesCreateResponse struct { 382 | Page 383 | } 384 | 385 | type PagesInterface interface { 386 | Retrieve(ctx context.Context, params PagesRetrieveParameters) (*PagesRetrieveResponse, error) 387 | Update(ctx context.Context, params PagesUpdateParameters) (*PagesUpdateResponse, error) 388 | Create(ctx context.Context, params PagesCreateParameters) (*PagesCreateResponse, error) 389 | } 390 | 391 | type pagesClient struct { 392 | restClient rest.Interface 393 | } 394 | 395 | func newPagesClient(restClient rest.Interface) *pagesClient { 396 | return &pagesClient{ 397 | restClient: restClient, 398 | } 399 | } 400 | 401 | func (p *pagesClient) Retrieve(ctx context.Context, params PagesRetrieveParameters) (*PagesRetrieveResponse, error) { 402 | var result PagesRetrieveResponse 403 | 404 | var failure HTTPError 405 | 406 | err := p.restClient.New().Get(). 407 | Endpoint(strings.Replace(APIPagesRetrieveEndpoint, "{page_id}", params.PageID, 1)). 408 | Receive(ctx, &result, &failure) 409 | 410 | return &result, err // nolint:wrapcheck 411 | } 412 | 413 | func (p *pagesClient) Update(ctx context.Context, params PagesUpdateParameters) (*PagesUpdateResponse, error) { 414 | var result PagesUpdateResponse 415 | 416 | var failure HTTPError 417 | 418 | err := p.restClient.New().Patch(). 419 | Endpoint(strings.Replace(APIPagesUpdateEndpoint, "{page_id}", params.PageID, 1)). 420 | QueryStruct(params). 421 | BodyJSON(params). 422 | Receive(ctx, &result, &failure) 423 | 424 | return &result, err // nolint:wrapcheck 425 | } 426 | 427 | func (p *pagesClient) Create(ctx context.Context, params PagesCreateParameters) (*PagesCreateResponse, error) { 428 | var result PagesCreateResponse 429 | 430 | var failure HTTPError 431 | 432 | err := p.restClient.New().Post(). 433 | Endpoint(APIPagesCreateEndpoint). 434 | QueryStruct(params). 435 | BodyJSON(params). 436 | Receive(ctx, &result, &failure) 437 | 438 | return &result, err // nolint:wrapcheck 439 | } 440 | 441 | type formulaValueDecoder struct { 442 | FormulaValue 443 | } 444 | 445 | func (f *formulaValueDecoder) UnmarshalJSON(data []byte) error { 446 | var decoder struct { 447 | Type FormulaValueType `json:"type"` 448 | } 449 | 450 | if err := json.Unmarshal(data, &decoder); err != nil { 451 | return fmt.Errorf("failed to unmarshal FormulaValue: %w", err) 452 | } 453 | 454 | switch decoder.Type { 455 | case FormulaValueTypeString: 456 | f.FormulaValue = &StringFormulaValue{} 457 | 458 | case FormulaValueTypeNumber: 459 | f.FormulaValue = &NumberFormulaValue{} 460 | 461 | case FormulaValueTypeBoolean: 462 | f.FormulaValue = &BooleanFormulaValue{} 463 | 464 | case FormulaValueTypeDate: 465 | f.FormulaValue = &DateFormulaValue{} 466 | } 467 | 468 | return json.Unmarshal(data, &f.FormulaValue) 469 | } 470 | 471 | type parentDecoder struct { 472 | Parent 473 | } 474 | 475 | func (p *parentDecoder) UnmarshalJSON(data []byte) error { 476 | var decoder struct { 477 | Type ParentType `json:"type"` 478 | } 479 | 480 | if err := json.Unmarshal(data, &decoder); err != nil { 481 | return fmt.Errorf("failed to unmarshal Parent: %w", err) 482 | } 483 | 484 | switch decoder.Type { 485 | case ParentTypeDatabase: 486 | p.Parent = &DatabaseParent{} 487 | 488 | case ParentTypePage: 489 | p.Parent = &PageParent{} 490 | 491 | case ParentTypeWorkspace: 492 | p.Parent = &WorkspaceParent{} 493 | } 494 | 495 | return json.Unmarshal(data, p.Parent) 496 | } 497 | 498 | type propertyValueDecoder struct { 499 | PropertyValue 500 | } 501 | 502 | // UnmarshalJSON implements json.Unmarshaler 503 | // nolint: cyclop 504 | func (p *propertyValueDecoder) UnmarshalJSON(data []byte) error { 505 | var decoder struct { 506 | Type PropertyValueType `json:"type,omitempty"` 507 | } 508 | 509 | if err := json.Unmarshal(data, &decoder); err != nil { 510 | return fmt.Errorf("failed to unmarshal PropertyValue: %w", err) 511 | } 512 | 513 | switch decoder.Type { 514 | case PropertyValueTypeRichText: 515 | p.PropertyValue = &RichTextPropertyValue{} 516 | 517 | case PropertyValueTypeNumber: 518 | p.PropertyValue = &NumberPropertyValue{} 519 | 520 | case PropertyValueTypeSelect: 521 | p.PropertyValue = &SelectPropertyValue{} 522 | 523 | case PropertyValueTypeMultiSelect: 524 | p.PropertyValue = &MultiSelectPropertyValue{} 525 | 526 | case PropertyValueTypeDate: 527 | p.PropertyValue = &DatePropertyValue{} 528 | 529 | case PropertyValueTypeFormula: 530 | p.PropertyValue = &FormulaPropertyValue{} 531 | 532 | case PropertyValueTypeRelation: 533 | p.PropertyValue = &RelationPropertyValue{} 534 | 535 | case PropertyValueTypeRollup: 536 | p.PropertyValue = &RollupPropertyValue{} 537 | 538 | case PropertyValueTypeTitle: 539 | p.PropertyValue = &TitlePropertyValue{} 540 | 541 | case PropertyValueTypePeople: 542 | p.PropertyValue = &PeoplePropertyValue{} 543 | 544 | case PropertyValueTypeFiles: 545 | p.PropertyValue = &FilesPropertyValue{} 546 | 547 | case PropertyValueTypeCheckbox: 548 | p.PropertyValue = &CheckboxPropertyValue{} 549 | 550 | case PropertyValueTypeURL: 551 | p.PropertyValue = &URLPropertyValue{} 552 | 553 | case PropertyValueTypeEmail: 554 | p.PropertyValue = &EmailPropertyValue{} 555 | 556 | case PropertyValueTypePhoneNumber: 557 | p.PropertyValue = &PhoneNumberPropertyValue{} 558 | 559 | case PropertyValueTypeCreatedTime: 560 | p.PropertyValue = &CreatedTimePropertyValue{} 561 | 562 | case PropertyValueTypeCreatedBy: 563 | p.PropertyValue = &CreatedByPropertyValue{} 564 | 565 | case PropertyValueTypeLastEditedTime: 566 | p.PropertyValue = &LastEditedTimePropertyValue{} 567 | 568 | case PropertyValueTypeLastEditedBy: 569 | p.PropertyValue = &LastEditedByPropertyValue{} 570 | } 571 | 572 | return json.Unmarshal(data, p.PropertyValue) 573 | } 574 | -------------------------------------------------------------------------------- /pages_test.go: -------------------------------------------------------------------------------- 1 | package notion 2 | 3 | import ( 4 | "context" 5 | "io/ioutil" 6 | "net/http" 7 | "net/http/httptest" 8 | "testing" 9 | "time" 10 | 11 | "github.com/mkfsn/notion-go/rest" 12 | "github.com/stretchr/testify/assert" 13 | ) 14 | 15 | func Test_pagesClient_Retrieve(t *testing.T) { 16 | type fields struct { 17 | restClient rest.Interface 18 | mockHTTPHandler http.Handler 19 | authToken string 20 | } 21 | 22 | type args struct { 23 | ctx context.Context 24 | params PagesRetrieveParameters 25 | } 26 | 27 | type wants struct { 28 | response *PagesRetrieveResponse 29 | err error 30 | } 31 | 32 | type test struct { 33 | name string 34 | fields fields 35 | args args 36 | wants wants 37 | } 38 | 39 | tests := []test{ 40 | { 41 | name: "Retrieve a page by page_id", 42 | fields: fields{ 43 | restClient: rest.New(), 44 | authToken: "6cf01c0d-3b5e-49ec-a45e-43c1879cf41e", 45 | mockHTTPHandler: http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { 46 | assert.Equal(t, DefaultNotionVersion, request.Header.Get("Notion-Version")) 47 | assert.Equal(t, DefaultUserAgent, request.Header.Get("User-Agent")) 48 | assert.Equal(t, "Bearer 6cf01c0d-3b5e-49ec-a45e-43c1879cf41e", request.Header.Get("Authorization")) 49 | 50 | assert.Equal(t, http.MethodGet, request.Method) 51 | assert.Equal(t, "/v1/pages/b55c9c91-384d-452b-81db-d1ef79372b75", request.RequestURI) 52 | 53 | writer.WriteHeader(http.StatusOK) 54 | 55 | _, err := writer.Write([]byte(`{ 56 | "object": "page", 57 | "id": "b55c9c91-384d-452b-81db-d1ef79372b75", 58 | "created_time": "2020-03-17T19:10:04.968Z", 59 | "last_edited_time": "2020-03-17T21:49:37.913Z", 60 | "properties": { 61 | "Name": { 62 | "id":"title", 63 | "type":"title", 64 | "title": [ 65 | { 66 | "type": "text", 67 | "text": {"content":"Avocado","link":null}, 68 | "annotations": { 69 | "bold":true, 70 | "italic":false, 71 | "strikethrough":false, 72 | "underline":false, 73 | "code":false, 74 | "color":"default" 75 | } 76 | } 77 | ] 78 | }, 79 | "Description": { 80 | "type":"rich_text", 81 | "rich_text": [ 82 | { 83 | "type": "text", 84 | "text": {"content":"Persea americana","link":null}, 85 | "annotations":{ 86 | "bold":false, 87 | "italic":false, 88 | "strikethrough":false, 89 | "underline":false, 90 | "code":false, 91 | "color":"default" 92 | } 93 | } 94 | ] 95 | } 96 | } 97 | }`)) 98 | assert.NoError(t, err) 99 | }), 100 | }, 101 | args: args{ 102 | ctx: context.Background(), 103 | params: PagesRetrieveParameters{ 104 | PageID: "b55c9c91-384d-452b-81db-d1ef79372b75", 105 | }, 106 | }, 107 | wants: wants{ 108 | response: &PagesRetrieveResponse{ 109 | Page: Page{ 110 | Object: ObjectTypePage, 111 | ID: "b55c9c91-384d-452b-81db-d1ef79372b75", 112 | Parent: nil, 113 | Properties: map[string]PropertyValue{ 114 | "Name": &TitlePropertyValue{ 115 | basePropertyValue: basePropertyValue{ 116 | ID: "title", 117 | Type: PropertyValueTypeTitle, 118 | }, 119 | Title: []RichText{ 120 | &RichTextText{ 121 | BaseRichText: BaseRichText{ 122 | Href: "", 123 | Type: RichTextTypeText, 124 | Annotations: &Annotations{ 125 | Bold: true, 126 | Italic: false, 127 | Strikethrough: false, 128 | Underline: false, 129 | Code: false, 130 | Color: ColorDefault, 131 | }, 132 | }, 133 | Text: TextObject{ 134 | Content: "Avocado", 135 | }, 136 | }, 137 | }, 138 | }, 139 | "Description": &RichTextPropertyValue{ 140 | basePropertyValue: basePropertyValue{ 141 | Type: PropertyValueTypeRichText, 142 | }, 143 | RichText: []RichText{ 144 | &RichTextText{ 145 | BaseRichText: BaseRichText{ 146 | Type: RichTextTypeText, 147 | Annotations: &Annotations{ 148 | Bold: false, 149 | Italic: false, 150 | Strikethrough: false, 151 | Underline: false, 152 | Code: false, 153 | Color: ColorDefault, 154 | }, 155 | }, 156 | Text: TextObject{ 157 | Content: "Persea americana", 158 | Link: nil, 159 | }, 160 | }, 161 | }, 162 | }, 163 | }, 164 | CreatedTime: time.Date(2020, 3, 17, 19, 10, 4, 968_000_000, time.UTC), 165 | LastEditedTime: time.Date(2020, 3, 17, 21, 49, 37, 913_000_000, time.UTC), 166 | Archived: false, 167 | }, 168 | }, 169 | }, 170 | }, 171 | } 172 | for _, tt := range tests { 173 | t.Run(tt.name, func(t *testing.T) { 174 | mockHTTPServer := httptest.NewServer(tt.fields.mockHTTPHandler) 175 | defer mockHTTPServer.Close() 176 | 177 | sut := New( 178 | tt.fields.authToken, 179 | WithBaseURL(mockHTTPServer.URL), 180 | ) 181 | 182 | got, err := sut.Pages().Retrieve(tt.args.ctx, tt.args.params) 183 | if tt.wants.err != nil { 184 | assert.ErrorIs(t, err, tt.wants.err) 185 | return 186 | } 187 | 188 | assert.NoError(t, err) 189 | assert.Equal(t, tt.wants.response, got) 190 | }) 191 | } 192 | } 193 | 194 | func Test_pagesClient_Create(t *testing.T) { 195 | type fields struct { 196 | restClient rest.Interface 197 | mockHTTPHandler http.Handler 198 | authToken string 199 | } 200 | 201 | type args struct { 202 | ctx context.Context 203 | params PagesCreateParameters 204 | } 205 | 206 | type wants struct { 207 | response *PagesCreateResponse 208 | err error 209 | } 210 | 211 | type test struct { 212 | name string 213 | fields fields 214 | args args 215 | wants wants 216 | } 217 | 218 | tests := []test{ 219 | { 220 | name: "Create a new page", 221 | fields: fields{ 222 | restClient: rest.New(), 223 | authToken: "0747d2ee-13f0-47b1-950f-511d2c87180d", 224 | mockHTTPHandler: http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { 225 | assert.Equal(t, DefaultNotionVersion, request.Header.Get("Notion-Version")) 226 | assert.Equal(t, DefaultUserAgent, request.Header.Get("User-Agent")) 227 | assert.Equal(t, "Bearer 0747d2ee-13f0-47b1-950f-511d2c87180d", request.Header.Get("Authorization")) 228 | 229 | assert.Equal(t, http.MethodPost, request.Method) 230 | assert.Equal(t, "/v1/pages", request.RequestURI) 231 | assert.Equal(t, "application/json", request.Header.Get("Content-Type")) 232 | 233 | expectedData := `{ 234 | "parent": { "database_id": "48f8fee9cd794180bc2fec0398253067" }, 235 | "properties": { 236 | "Name": { 237 | "title": [ 238 | { 239 | "text": { 240 | "content": "Tuscan Kale" 241 | } 242 | } 243 | ] 244 | }, 245 | "Description": { 246 | "rich_text": [ 247 | { 248 | "text": { 249 | "content": "A dark green leafy vegetable" 250 | } 251 | } 252 | ] 253 | }, 254 | "Food group": { 255 | "select": { 256 | "name": "Vegetable" 257 | } 258 | }, 259 | "Price": { "number": 2.5 } 260 | }, 261 | "children": [ 262 | { 263 | "object": "block", 264 | "type": "heading_2", 265 | "heading_2": { 266 | "text": [{ "type": "text", "text": { "content": "Lacinato kale" } }] 267 | } 268 | }, 269 | { 270 | "object": "block", 271 | "type": "paragraph", 272 | "paragraph": { 273 | "text": [ 274 | { 275 | "type": "text", 276 | "text": { 277 | "content": "Lacinato kale is a variety of kale with a long tradition in Italian cuisine, especially that of Tuscany. It is also known as Tuscan kale, Italian kale, dinosaur kale, kale, flat back kale, palm tree kale, or black Tuscan palm.", 278 | "link": { "url": "https://en.wikipedia.org/wiki/Lacinato_kale" } 279 | } 280 | } 281 | ] 282 | } 283 | } 284 | ] 285 | }` 286 | b, err := ioutil.ReadAll(request.Body) 287 | assert.NoError(t, err) 288 | assert.JSONEq(t, expectedData, string(b)) 289 | 290 | writer.WriteHeader(http.StatusOK) 291 | 292 | _, err = writer.Write([]byte(`{ 293 | "object": "page", 294 | "id": "251d2b5f-268c-4de2-afe9-c71ff92ca95c", 295 | "created_time": "2020-03-17T19:10:04.968Z", 296 | "last_edited_time": "2020-03-17T21:49:37.913Z", 297 | "parent": { 298 | "type": "database_id", 299 | "database_id": "48f8fee9-cd79-4180-bc2f-ec0398253067" 300 | }, 301 | "archived": false, 302 | "properties": { 303 | "Recipes": { 304 | "id": "Ai` + "`" + `L", 305 | "type": "relation", 306 | "relation": [] 307 | }, 308 | "Cost of next trip": { 309 | "id": "R}wl", 310 | "type": "formula", 311 | "formula": { 312 | "type": "number", 313 | "number": null 314 | } 315 | }, 316 | "Photos": { 317 | "id": "d:Cb", 318 | "type": "files", 319 | "files": [] 320 | }, 321 | "Store availability": { 322 | "id": "jrFQ", 323 | "type": "multi_select", 324 | "multi_select": [] 325 | }, 326 | "+1": { 327 | "id": "k?CE", 328 | "type": "people", 329 | "people": [] 330 | }, 331 | "Description": { 332 | "id": "rT{n", 333 | "type": "rich_text", 334 | "rich_text": [] 335 | }, 336 | "In stock": { 337 | "id": "{>U;", 338 | "type": "checkbox", 339 | "checkbox": false 340 | }, 341 | "Name": { 342 | "id": "title", 343 | "type": "title", 344 | "title": [ 345 | { 346 | "type": "text", 347 | "text": { 348 | "content": "Tuscan Kale", 349 | "link": null 350 | }, 351 | "annotations": { 352 | "bold": false, 353 | "italic": false, 354 | "strikethrough": false, 355 | "underline": false, 356 | "code": false, 357 | "color": "default" 358 | }, 359 | "plain_text": "Tuscan Kale", 360 | "href": null 361 | } 362 | ] 363 | } 364 | } 365 | }`)) 366 | assert.NoError(t, err) 367 | }), 368 | }, 369 | args: args{ 370 | ctx: context.Background(), 371 | params: PagesCreateParameters{ 372 | Parent: &DatabaseParentInput{ 373 | DatabaseID: "48f8fee9cd794180bc2fec0398253067", 374 | }, 375 | Properties: map[string]PropertyValue{ 376 | "Name": &TitlePropertyValue{ 377 | Title: []RichText{ 378 | &RichTextText{ 379 | Text: TextObject{ 380 | Content: "Tuscan Kale", 381 | }, 382 | }, 383 | }, 384 | }, 385 | "Description": &RichTextPropertyValue{ 386 | basePropertyValue: basePropertyValue{}, 387 | RichText: []RichText{ 388 | &RichTextText{ 389 | BaseRichText: BaseRichText{}, 390 | Text: TextObject{ 391 | Content: "A dark green leafy vegetable", 392 | }, 393 | }, 394 | }, 395 | }, 396 | "Food group": &SelectPropertyValue{ 397 | basePropertyValue: basePropertyValue{}, 398 | Select: SelectPropertyValueOption{ 399 | Name: "Vegetable", 400 | }, 401 | }, 402 | "Price": &NumberPropertyValue{ 403 | Number: 2.5, 404 | }, 405 | }, 406 | Children: []Block{ 407 | &Heading2Block{ 408 | BlockBase: BlockBase{ 409 | Object: ObjectTypeBlock, 410 | Type: BlockTypeHeading2, 411 | }, 412 | Heading2: HeadingBlock{ 413 | Text: []RichText{ 414 | &RichTextText{ 415 | BaseRichText: BaseRichText{ 416 | Type: RichTextTypeText, 417 | }, 418 | Text: TextObject{ 419 | Content: "Lacinato kale", 420 | }, 421 | }, 422 | }, 423 | }, 424 | }, 425 | &ParagraphBlock{ 426 | BlockBase: BlockBase{ 427 | Object: ObjectTypeBlock, 428 | Type: BlockTypeParagraph, 429 | }, 430 | Paragraph: RichTextBlock{ 431 | Text: []RichText{ 432 | &RichTextText{ 433 | BaseRichText: BaseRichText{ 434 | Type: RichTextTypeText, 435 | }, 436 | Text: TextObject{ 437 | Content: "Lacinato kale is a variety of kale with a long tradition in Italian cuisine, especially that of Tuscany. It is also known as Tuscan kale, Italian kale, dinosaur kale, kale, flat back kale, palm tree kale, or black Tuscan palm.", 438 | Link: &Link{ 439 | URL: "https://en.wikipedia.org/wiki/Lacinato_kale", 440 | }, 441 | }, 442 | }, 443 | }, 444 | }, 445 | }, 446 | }, 447 | }, 448 | }, 449 | wants: wants{ 450 | response: &PagesCreateResponse{ 451 | Page: Page{ 452 | Object: ObjectTypePage, 453 | ID: "251d2b5f-268c-4de2-afe9-c71ff92ca95c", 454 | Parent: &DatabaseParent{ 455 | baseParent: baseParent{ 456 | Type: ParentTypeDatabase, 457 | }, 458 | DatabaseID: "48f8fee9-cd79-4180-bc2f-ec0398253067", 459 | }, 460 | Properties: map[string]PropertyValue{ 461 | "Recipes": &RelationPropertyValue{ 462 | basePropertyValue: basePropertyValue{ 463 | ID: "Ai`L", 464 | Type: PropertyValueTypeRelation, 465 | }, 466 | Relation: []PageReference{}, 467 | }, 468 | "Cost of next trip": &FormulaPropertyValue{ 469 | basePropertyValue: basePropertyValue{ 470 | ID: "R}wl", 471 | Type: PropertyValueTypeFormula, 472 | }, 473 | Formula: &NumberFormulaValue{ 474 | baseFormulaValue: baseFormulaValue{ 475 | Type: FormulaValueTypeNumber, 476 | }, 477 | Number: nil, 478 | }, 479 | }, 480 | "Photos": &FilesPropertyValue{ 481 | basePropertyValue: basePropertyValue{ 482 | ID: "d:Cb", 483 | Type: PropertyValueTypeFiles, 484 | }, 485 | Files: []File{}, 486 | }, 487 | "Store availability": &MultiSelectPropertyValue{ 488 | basePropertyValue: basePropertyValue{ 489 | ID: "jrFQ", 490 | Type: PropertyValueTypeMultiSelect, 491 | }, 492 | MultiSelect: []MultiSelectPropertyValueOption{}, 493 | }, 494 | "+1": &PeoplePropertyValue{ 495 | basePropertyValue: basePropertyValue{ 496 | ID: "k?CE", 497 | Type: PropertyValueTypePeople, 498 | }, 499 | People: []User{}, 500 | }, 501 | "Description": &RichTextPropertyValue{ 502 | basePropertyValue: basePropertyValue{ 503 | ID: "rT{n", 504 | Type: PropertyValueTypeRichText, 505 | }, 506 | RichText: []RichText{}, 507 | }, 508 | "In stock": &CheckboxPropertyValue{ 509 | basePropertyValue: basePropertyValue{ 510 | ID: "{>U;", 511 | Type: PropertyValueTypeCheckbox, 512 | }, 513 | Checkbox: false, 514 | }, 515 | "Name": &TitlePropertyValue{ 516 | basePropertyValue: basePropertyValue{ 517 | ID: "title", 518 | Type: PropertyValueTypeTitle, 519 | }, 520 | Title: []RichText{ 521 | &RichTextText{ 522 | BaseRichText: BaseRichText{ 523 | PlainText: "Tuscan Kale", 524 | Href: "", 525 | Type: RichTextTypeText, 526 | Annotations: &Annotations{ 527 | Bold: false, 528 | Italic: false, 529 | Strikethrough: false, 530 | Underline: false, 531 | Code: false, 532 | Color: ColorDefault, 533 | }, 534 | }, 535 | Text: TextObject{ 536 | Content: "Tuscan Kale", 537 | Link: nil, 538 | }, 539 | }, 540 | }, 541 | }, 542 | }, 543 | CreatedTime: time.Date(2020, 3, 17, 19, 10, 04, 968_000_000, time.UTC), 544 | LastEditedTime: time.Date(2020, 3, 17, 21, 49, 37, 913_000_000, time.UTC), 545 | }, 546 | }, 547 | }, 548 | }, 549 | } 550 | for _, tt := range tests { 551 | t.Run(tt.name, func(t *testing.T) { 552 | mockHTTPServer := httptest.NewServer(tt.fields.mockHTTPHandler) 553 | defer mockHTTPServer.Close() 554 | 555 | sut := New( 556 | tt.fields.authToken, 557 | WithBaseURL(mockHTTPServer.URL), 558 | ) 559 | 560 | got, err := sut.Pages().Create(tt.args.ctx, tt.args.params) 561 | if tt.wants.err != nil { 562 | assert.ErrorIs(t, err, tt.wants.err) 563 | return 564 | } 565 | 566 | assert.NoError(t, err) 567 | assert.Equal(t, tt.wants.response, got) 568 | }) 569 | } 570 | } 571 | 572 | func Test_pagesClient_Update(t *testing.T) { 573 | type fields struct { 574 | restClient rest.Interface 575 | mockHTTPHandler http.Handler 576 | authToken string 577 | } 578 | 579 | type args struct { 580 | ctx context.Context 581 | params PagesUpdateParameters 582 | } 583 | 584 | type wants struct { 585 | response *PagesUpdateResponse 586 | err error 587 | } 588 | 589 | type test struct { 590 | name string 591 | fields fields 592 | args args 593 | wants wants 594 | } 595 | 596 | tests := []test{ 597 | { 598 | name: "Update a page properties", 599 | fields: fields{ 600 | restClient: rest.New(), 601 | authToken: "4ad4d7a9-8b66-4dda-b9a1-2bc98134ee14", 602 | mockHTTPHandler: http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { 603 | assert.Equal(t, DefaultNotionVersion, request.Header.Get("Notion-Version")) 604 | assert.Equal(t, DefaultUserAgent, request.Header.Get("User-Agent")) 605 | assert.Equal(t, "Bearer 4ad4d7a9-8b66-4dda-b9a1-2bc98134ee14", request.Header.Get("Authorization")) 606 | 607 | assert.Equal(t, http.MethodPatch, request.Method) 608 | assert.Equal(t, "/v1/pages/60bdc8bd-3880-44b8-a9cd-8a145b3ffbd7", request.RequestURI) 609 | assert.Equal(t, "application/json", request.Header.Get("Content-Type")) 610 | 611 | expectedData := `{ 612 | "properties": { 613 | "In stock": { "checkbox": true } 614 | } 615 | }` 616 | b, err := ioutil.ReadAll(request.Body) 617 | assert.NoError(t, err) 618 | assert.JSONEq(t, expectedData, string(b)) 619 | 620 | writer.WriteHeader(http.StatusOK) 621 | 622 | _, err = writer.Write([]byte(`{ 623 | "object": "page", 624 | "id": "60bdc8bd-3880-44b8-a9cd-8a145b3ffbd7", 625 | "created_time": "2020-03-17T19:10:04.968Z", 626 | "last_edited_time": "2020-03-17T21:49:37.913Z", 627 | "parent": { 628 | "type": "database_id", 629 | "database_id": "48f8fee9-cd79-4180-bc2f-ec0398253067" 630 | }, 631 | "archived": false, 632 | "properties": { 633 | "In stock": { 634 | "id": "{>U;", 635 | "type": "checkbox", 636 | "checkbox": true 637 | }, 638 | "Name": { 639 | "id": "title", 640 | "type": "title", 641 | "title": [ 642 | { 643 | "type": "text", 644 | "text": { 645 | "content": "Avocado", 646 | "link": null 647 | }, 648 | "annotations": { 649 | "bold": false, 650 | "italic": false, 651 | "strikethrough": false, 652 | "underline": false, 653 | "code": false, 654 | "color": "default" 655 | }, 656 | "plain_text": "Avocado", 657 | "href": null 658 | } 659 | ] 660 | } 661 | } 662 | }`)) 663 | assert.NoError(t, err) 664 | }), 665 | }, 666 | args: args{ 667 | ctx: context.Background(), 668 | params: PagesUpdateParameters{ 669 | PageID: "60bdc8bd-3880-44b8-a9cd-8a145b3ffbd7", 670 | Properties: map[string]PropertyValue{ 671 | "In stock": &CheckboxPropertyValue{ 672 | Checkbox: true, 673 | }, 674 | }, 675 | }, 676 | }, 677 | wants: wants{ 678 | response: &PagesUpdateResponse{ 679 | Page: Page{ 680 | Object: ObjectTypePage, 681 | ID: "60bdc8bd-3880-44b8-a9cd-8a145b3ffbd7", 682 | Parent: &DatabaseParent{ 683 | baseParent: baseParent{ 684 | Type: ParentTypeDatabase, 685 | }, 686 | DatabaseID: "48f8fee9-cd79-4180-bc2f-ec0398253067", 687 | }, 688 | Properties: map[string]PropertyValue{ 689 | "In stock": &CheckboxPropertyValue{ 690 | basePropertyValue: basePropertyValue{ 691 | ID: "{>U;", 692 | Type: PropertyValueTypeCheckbox, 693 | }, 694 | Checkbox: true, 695 | }, 696 | "Name": &TitlePropertyValue{ 697 | basePropertyValue: basePropertyValue{ 698 | ID: "title", 699 | Type: PropertyValueTypeTitle, 700 | }, 701 | Title: []RichText{ 702 | &RichTextText{ 703 | BaseRichText: BaseRichText{ 704 | PlainText: "Avocado", 705 | Href: "", 706 | Type: RichTextTypeText, 707 | Annotations: &Annotations{ 708 | Bold: false, 709 | Italic: false, 710 | Strikethrough: false, 711 | Underline: false, 712 | Code: false, 713 | Color: ColorDefault, 714 | }, 715 | }, 716 | Text: TextObject{ 717 | Content: "Avocado", 718 | Link: nil, 719 | }, 720 | }, 721 | }, 722 | }, 723 | }, 724 | CreatedTime: time.Date(2020, 3, 17, 19, 10, 4, 968_000_000, time.UTC), 725 | LastEditedTime: time.Date(2020, 3, 17, 21, 49, 37, 913_000_000, time.UTC), 726 | Archived: false, 727 | }, 728 | }, 729 | }, 730 | }, 731 | } 732 | for _, tt := range tests { 733 | t.Run(tt.name, func(t *testing.T) { 734 | mockHTTPServer := httptest.NewServer(tt.fields.mockHTTPHandler) 735 | defer mockHTTPServer.Close() 736 | 737 | sut := New( 738 | tt.fields.authToken, 739 | WithBaseURL(mockHTTPServer.URL), 740 | ) 741 | 742 | got, err := sut.Pages().Update(tt.args.ctx, tt.args.params) 743 | if tt.wants.err != nil { 744 | assert.ErrorIs(t, err, tt.wants.err) 745 | return 746 | } 747 | 748 | assert.NoError(t, err) 749 | assert.Equal(t, tt.wants.response, got) 750 | }) 751 | } 752 | } 753 | -------------------------------------------------------------------------------- /pagination.go: -------------------------------------------------------------------------------- 1 | package notion 2 | 3 | type PaginationParameters struct { 4 | // If supplied, this endpoint will return a page of results starting after the cursor provided. 5 | // If not supplied, this endpoint will return the first page of results. 6 | StartCursor string `json:"-" url:"start_cursor,omitempty"` 7 | // The number of items from the full list desired in the response. Maximum: 100 8 | PageSize int32 `json:"-" url:"page_size,omitempty"` 9 | } 10 | 11 | type PaginatedList struct { 12 | Object ObjectType `json:"object"` 13 | HasMore bool `json:"has_more"` 14 | NextCursor string `json:"next_cursor"` 15 | } 16 | -------------------------------------------------------------------------------- /rest/client.go: -------------------------------------------------------------------------------- 1 | package rest 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "fmt" 8 | "io/ioutil" 9 | "net/http" 10 | 11 | "github.com/google/go-querystring/query" 12 | ) 13 | 14 | type restClient struct { 15 | baseURL string 16 | header http.Header 17 | httpClient *http.Client 18 | 19 | method string 20 | endpoint string 21 | queryStruct interface{} 22 | bodyJSON interface{} 23 | } 24 | 25 | func New() Interface { 26 | return &restClient{ 27 | header: make(http.Header), 28 | httpClient: http.DefaultClient, 29 | } 30 | } 31 | 32 | func (r *restClient) New() Interface { 33 | newRestClient := &restClient{ 34 | baseURL: r.baseURL, 35 | header: r.header.Clone(), 36 | httpClient: r.httpClient, // TODO: deep copy 37 | } 38 | 39 | return newRestClient 40 | } 41 | 42 | func (r *restClient) BearerToken(token string) Interface { 43 | r.header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) 44 | 45 | return r 46 | } 47 | 48 | func (r *restClient) BaseURL(baseURL string) Interface { 49 | r.baseURL = baseURL 50 | 51 | return r 52 | } 53 | 54 | func (r *restClient) Client(httpClient *http.Client) Interface { 55 | r.httpClient = httpClient 56 | 57 | return r 58 | } 59 | 60 | func (r *restClient) UserAgent(userAgent string) Interface { 61 | r.header.Set("User-Agent", userAgent) 62 | 63 | return r 64 | } 65 | 66 | func (r *restClient) Header(key, value string) Interface { 67 | r.header.Set(key, value) 68 | 69 | return r 70 | } 71 | 72 | func (r *restClient) Get() Interface { 73 | r.method = http.MethodGet 74 | 75 | return r 76 | } 77 | 78 | func (r *restClient) Post() Interface { 79 | r.method = http.MethodPost 80 | 81 | return r 82 | } 83 | 84 | func (r *restClient) Patch() Interface { 85 | r.method = http.MethodPatch 86 | 87 | return r 88 | } 89 | 90 | func (r *restClient) Endpoint(endpoint string) Interface { 91 | r.endpoint = endpoint 92 | 93 | return r 94 | } 95 | 96 | func (r *restClient) QueryStruct(queryStruct interface{}) Interface { 97 | r.queryStruct = queryStruct 98 | 99 | return r 100 | } 101 | 102 | func (r *restClient) BodyJSON(bodyJSON interface{}) Interface { 103 | r.bodyJSON = bodyJSON 104 | 105 | if r.bodyJSON != nil { 106 | r.header.Add("Content-Type", "application/json") 107 | } 108 | 109 | return r 110 | } 111 | 112 | func (r *restClient) Request(ctx context.Context) (*http.Request, error) { 113 | v, err := query.Values(r.queryStruct) 114 | if err != nil { 115 | return nil, fmt.Errorf("failed to build query parameters: %w", err) 116 | } 117 | 118 | b, err := json.Marshal(r.bodyJSON) 119 | if err != nil { 120 | return nil, fmt.Errorf("failed to marshal body to JSON: %w", err) 121 | } 122 | 123 | req, err := http.NewRequestWithContext(ctx, r.method, r.baseURL+r.endpoint, bytes.NewBuffer(b)) 124 | if err != nil { 125 | return nil, fmt.Errorf("failed to create an HTTP request: %w", err) 126 | } 127 | 128 | req.URL.RawQuery = v.Encode() 129 | 130 | req.Header = r.header 131 | 132 | return req, nil 133 | } 134 | 135 | func (r *restClient) Receive(ctx context.Context, success, failure interface{}) error { 136 | req, err := r.Request(ctx) 137 | if err != nil { 138 | return err 139 | } 140 | 141 | resp, err := r.httpClient.Do(req) 142 | if err != nil { 143 | return fmt.Errorf("failed to process an HTTP request: %w", err) 144 | } 145 | defer resp.Body.Close() 146 | 147 | b, err := ioutil.ReadAll(resp.Body) 148 | if err != nil { 149 | return fmt.Errorf("failed to read data from response body: %w", err) 150 | } 151 | 152 | return r.decodeResponseData(resp.StatusCode, b, success, failure) 153 | } 154 | 155 | func (r *restClient) decodeResponseData(statusCode int, data []byte, success, failure interface{}) error { 156 | if statusCode == http.StatusOK { 157 | if success != nil { 158 | return json.Unmarshal(data, success) 159 | } 160 | 161 | return nil 162 | } 163 | 164 | if failure == nil { 165 | return nil 166 | } 167 | 168 | if err := json.Unmarshal(data, failure); err != nil { 169 | return fmt.Errorf("failed to unmarshal error message from HTTP response body: %w", err) 170 | } 171 | 172 | return failure.(error) 173 | } 174 | -------------------------------------------------------------------------------- /rest/interface.go: -------------------------------------------------------------------------------- 1 | package rest 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | ) 7 | 8 | type Interface interface { 9 | New() Interface 10 | BearerToken(token string) Interface 11 | BaseURL(baseURL string) Interface 12 | Client(httpClient *http.Client) Interface 13 | UserAgent(userAgent string) Interface 14 | Header(key, value string) Interface 15 | Get() Interface 16 | Post() Interface 17 | Patch() Interface 18 | Endpoint(endpoint string) Interface 19 | QueryStruct(queryStruct interface{}) Interface 20 | BodyJSON(bodyJSON interface{}) Interface 21 | Request(ctx context.Context) (*http.Request, error) 22 | Receive(ctx context.Context, success, failure interface{}) error 23 | } 24 | -------------------------------------------------------------------------------- /search.go: -------------------------------------------------------------------------------- 1 | package notion 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | 8 | "github.com/mkfsn/notion-go/rest" 9 | ) 10 | 11 | type SearchFilter struct { 12 | // The value of the property to filter the results by. Possible values for object type include `page` or `database`. 13 | // Limitation: Currently the only filter allowed is object which will filter by type of `object` 14 | // (either `page` or `database`) 15 | Value SearchFilterValue `json:"value"` 16 | // The name of the property to filter by. Currently the only property you can filter by is the object type. 17 | // Possible values include `object`. Limitation: Currently the only filter allowed is `object` which will 18 | // filter by type of object (either `page` or `database`) 19 | Property SearchFilterProperty `json:"property"` 20 | } 21 | 22 | type SearchSort struct { 23 | // The direction to sort. 24 | Direction SearchSortDirection `json:"direction"` 25 | // The name of the timestamp to sort against. Possible values include `last_edited_time`. 26 | Timestamp SearchSortTimestamp `json:"timestamp"` 27 | } 28 | 29 | type SearchParameters struct { 30 | PaginationParameters 31 | Query string `json:"query" url:"-"` 32 | Sort SearchSort `json:"sort" url:"-"` 33 | Filter SearchFilter `json:"filter" url:"-"` 34 | } 35 | 36 | type SearchableObject interface { 37 | isSearchable() 38 | } 39 | 40 | type SearchResponse struct { 41 | PaginatedList 42 | Results []SearchableObject `json:"results"` 43 | } 44 | 45 | func (s *SearchResponse) UnmarshalJSON(data []byte) error { 46 | type Alias SearchResponse 47 | 48 | alias := struct { 49 | *Alias 50 | Results []searchableObjectDecoder `json:"results"` 51 | }{ 52 | Alias: (*Alias)(s), 53 | } 54 | 55 | if err := json.Unmarshal(data, &alias); err != nil { 56 | return fmt.Errorf("failed to unmarshal SearchResponse: %w", err) 57 | } 58 | 59 | s.Results = make([]SearchableObject, 0, len(alias.Results)) 60 | 61 | for _, decoder := range alias.Results { 62 | s.Results = append(s.Results, decoder.SearchableObject) 63 | } 64 | 65 | return nil 66 | } 67 | 68 | type SearchInterface interface { 69 | Search(ctx context.Context, params SearchParameters) (*SearchResponse, error) 70 | } 71 | 72 | type searchClient struct { 73 | restClient rest.Interface 74 | } 75 | 76 | func newSearchClient(restClient rest.Interface) *searchClient { 77 | return &searchClient{ 78 | restClient: restClient, 79 | } 80 | } 81 | 82 | func (s *searchClient) Search(ctx context.Context, params SearchParameters) (*SearchResponse, error) { 83 | var result SearchResponse 84 | 85 | var failure HTTPError 86 | 87 | err := s.restClient.New(). 88 | Post(). 89 | Endpoint(APISearchEndpoint). 90 | QueryStruct(params). 91 | BodyJSON(params). 92 | Receive(ctx, &result, &failure) 93 | 94 | return &result, err // nolint:wrapcheck 95 | } 96 | 97 | type searchableObjectDecoder struct { 98 | SearchableObject 99 | } 100 | 101 | func (s *searchableObjectDecoder) UnmarshalJSON(data []byte) error { 102 | var decoder struct { 103 | Object ObjectType `json:"object"` 104 | } 105 | 106 | if err := json.Unmarshal(data, &decoder); err != nil { 107 | return fmt.Errorf("failed to unmarshal SearchableObject: %w", err) 108 | } 109 | 110 | switch decoder.Object { 111 | case ObjectTypePage: 112 | s.SearchableObject = &Page{} 113 | 114 | case ObjectTypeDatabase: 115 | s.SearchableObject = &Database{} 116 | 117 | case ObjectTypeBlock, ObjectTypeList, ObjectTypeUser: 118 | return ErrUnknown 119 | } 120 | 121 | return json.Unmarshal(data, s.SearchableObject) 122 | } 123 | -------------------------------------------------------------------------------- /search_test.go: -------------------------------------------------------------------------------- 1 | package notion 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "net/http/httptest" 7 | "testing" 8 | "time" 9 | 10 | "github.com/mkfsn/notion-go/rest" 11 | "github.com/stretchr/testify/assert" 12 | ) 13 | 14 | func Test_searchClient_Search(t *testing.T) { 15 | type fields struct { 16 | restClient rest.Interface 17 | mockHTTPHandler http.Handler 18 | authToken string 19 | } 20 | 21 | type args struct { 22 | ctx context.Context 23 | params SearchParameters 24 | } 25 | 26 | type wants struct { 27 | response *SearchResponse 28 | err error 29 | } 30 | 31 | type test struct { 32 | name string 33 | fields fields 34 | args args 35 | wants wants 36 | } 37 | 38 | tests := []test{ 39 | { 40 | name: "Search two objects in one page", 41 | fields: fields{ 42 | restClient: rest.New(), 43 | authToken: "39686a40-3364-4499-8639-185740546d42", 44 | mockHTTPHandler: http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { 45 | assert.Equal(t, DefaultNotionVersion, request.Header.Get("Notion-Version")) 46 | assert.Equal(t, DefaultUserAgent, request.Header.Get("User-Agent")) 47 | assert.Equal(t, "Bearer 39686a40-3364-4499-8639-185740546d42", request.Header.Get("Authorization")) 48 | 49 | assert.Equal(t, http.MethodPost, request.Method) 50 | assert.Equal(t, "/v1/search?page_size=2", request.RequestURI) 51 | 52 | writer.WriteHeader(http.StatusOK) 53 | 54 | _, err := writer.Write([]byte(`{ 55 | "has_more": false, 56 | "next_cursor": null, 57 | "object": "list", 58 | "results": [ 59 | { 60 | "created_time": "2021-04-22T22:23:26.080Z", 61 | "id": "e6c6f8ff-c70e-4970-91ba-98f03e0d7fc6", 62 | "last_edited_time": "2021-04-23T04:21:00.000Z", 63 | "object": "database", 64 | "properties": { 65 | "Name": { 66 | "id": "title", 67 | "title": {}, 68 | "type": "title" 69 | }, 70 | "Task Type": { 71 | "id": "vd@l", 72 | "multi_select": { 73 | "options": [] 74 | }, 75 | "type": "multi_select" 76 | } 77 | }, 78 | "title": [ 79 | { 80 | "annotations": { 81 | "bold": false, 82 | "code": false, 83 | "color": "default", 84 | "italic": false, 85 | "strikethrough": false, 86 | "underline": false 87 | }, 88 | "href": null, 89 | "plain_text": "Tasks", 90 | "text": { 91 | "content": "Tasks", 92 | "link": null 93 | }, 94 | "type": "text" 95 | } 96 | ] 97 | }, 98 | { 99 | "archived": false, 100 | "created_time": "2021-04-23T04:21:00.000Z", 101 | "id": "4f555b50-3a9b-49cb-924c-3746f4ca5522", 102 | "last_edited_time": "2021-04-23T04:21:00.000Z", 103 | "object": "page", 104 | "parent": { 105 | "database_id": "e6c6f8ff-c70e-4970-91ba-98f03e0d7fc6", 106 | "type": "database_id" 107 | }, 108 | "properties": { 109 | "Name": { 110 | "id": "title", 111 | "title": [ 112 | { 113 | "annotations": { 114 | "bold": false, 115 | "code": false, 116 | "color": "default", 117 | "italic": false, 118 | "strikethrough": false, 119 | "underline": false 120 | }, 121 | "href": null, 122 | "plain_text": "Task 1", 123 | "text": { 124 | "content": "Task1 1", 125 | "link": null 126 | }, 127 | "type": "text" 128 | } 129 | ], 130 | "type": "title" 131 | } 132 | } 133 | } 134 | ] 135 | }`, 136 | )) 137 | assert.NoError(t, err) 138 | }), 139 | }, 140 | args: args{ 141 | ctx: context.Background(), 142 | params: SearchParameters{ 143 | PaginationParameters: PaginationParameters{ 144 | StartCursor: "", 145 | PageSize: 2, 146 | }, 147 | Query: "External tasks", 148 | Sort: SearchSort{ 149 | Direction: SearchSortDirectionAscending, 150 | Timestamp: SearchSortTimestampLastEditedTime, 151 | }, 152 | Filter: SearchFilter{}, 153 | }, 154 | }, 155 | wants: wants{ 156 | response: &SearchResponse{ 157 | PaginatedList: PaginatedList{ 158 | Object: ObjectTypeList, 159 | HasMore: false, 160 | NextCursor: "", 161 | }, 162 | Results: []SearchableObject{ 163 | &Database{ 164 | Object: ObjectTypeDatabase, 165 | ID: "e6c6f8ff-c70e-4970-91ba-98f03e0d7fc6", 166 | CreatedTime: time.Date(2021, 4, 22, 22, 23, 26, 80_000_000, time.UTC), 167 | LastEditedTime: time.Date(2021, 4, 23, 4, 21, 0, 0, time.UTC), 168 | Title: []RichText{ 169 | &RichTextText{ 170 | BaseRichText: BaseRichText{ 171 | PlainText: "Tasks", 172 | Href: "", 173 | Type: RichTextTypeText, 174 | Annotations: &Annotations{ 175 | Bold: false, 176 | Italic: false, 177 | Strikethrough: false, 178 | Underline: false, 179 | Code: false, 180 | Color: ColorDefault, 181 | }, 182 | }, 183 | Text: TextObject{ 184 | Content: "Tasks", 185 | Link: nil, 186 | }, 187 | }, 188 | }, 189 | Properties: map[string]Property{ 190 | "Name": &TitleProperty{ 191 | baseProperty: baseProperty{ 192 | ID: "title", 193 | Type: PropertyTypeTitle, 194 | }, 195 | Title: map[string]interface{}{}, 196 | }, 197 | "Task Type": &MultiSelectProperty{ 198 | baseProperty: baseProperty{ 199 | ID: "vd@l", 200 | Type: PropertyTypeMultiSelect, 201 | }, 202 | MultiSelect: MultiSelectPropertyOption{ 203 | Options: []MultiSelectOption{}, 204 | }, 205 | }, 206 | }, 207 | }, 208 | &Page{ 209 | Object: ObjectTypePage, 210 | ID: "4f555b50-3a9b-49cb-924c-3746f4ca5522", 211 | Parent: &DatabaseParent{ 212 | baseParent: baseParent{ 213 | Type: ParentTypeDatabase, 214 | }, 215 | DatabaseID: "e6c6f8ff-c70e-4970-91ba-98f03e0d7fc6", 216 | }, 217 | Properties: map[string]PropertyValue{ 218 | "Name": &TitlePropertyValue{ 219 | basePropertyValue: basePropertyValue{ 220 | ID: "title", 221 | Type: PropertyValueTypeTitle, 222 | }, 223 | Title: []RichText{ 224 | 225 | &RichTextText{ 226 | BaseRichText: BaseRichText{ 227 | PlainText: "Task 1", 228 | Href: "", 229 | Type: RichTextTypeText, 230 | Annotations: &Annotations{ 231 | Bold: false, 232 | Italic: false, 233 | Strikethrough: false, 234 | Underline: false, 235 | Code: false, 236 | Color: ColorDefault, 237 | }, 238 | }, 239 | Text: TextObject{ 240 | Content: "Task1 1", 241 | Link: nil, 242 | }, 243 | }, 244 | }, 245 | }, 246 | }, 247 | CreatedTime: time.Date(2021, 4, 23, 4, 21, 0, 0, time.UTC), 248 | LastEditedTime: time.Date(2021, 4, 23, 4, 21, 0, 0, time.UTC), 249 | Archived: false, 250 | }, 251 | }, 252 | }, 253 | }, 254 | }, 255 | } 256 | 257 | for _, tt := range tests { 258 | t.Run(tt.name, func(t *testing.T) { 259 | mockHTTPServer := httptest.NewServer(tt.fields.mockHTTPHandler) 260 | defer mockHTTPServer.Close() 261 | 262 | sut := New( 263 | tt.fields.authToken, 264 | WithBaseURL(mockHTTPServer.URL), 265 | ) 266 | 267 | got, err := sut.Search(tt.args.ctx, tt.args.params) 268 | if tt.wants.err != nil { 269 | assert.ErrorIs(t, err, tt.wants.err) 270 | return 271 | } 272 | 273 | assert.NoError(t, err) 274 | assert.Equal(t, tt.wants.response, got) 275 | }) 276 | } 277 | } 278 | -------------------------------------------------------------------------------- /users.go: -------------------------------------------------------------------------------- 1 | package notion 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "strings" 8 | 9 | "github.com/mkfsn/notion-go/rest" 10 | ) 11 | 12 | type User interface { 13 | isUser() 14 | } 15 | 16 | type baseUser struct { 17 | Object ObjectType `json:"object"` 18 | ID string `json:"id"` 19 | Type UserType `json:"type"` 20 | Name string `json:"name"` 21 | AvatarURL string `json:"avatar_url"` 22 | } 23 | 24 | func (b baseUser) isUser() {} 25 | 26 | type Person struct { 27 | Email string `json:"email"` 28 | } 29 | 30 | type PersonUser struct { 31 | baseUser 32 | Person Person `json:"person"` 33 | } 34 | 35 | type Bot struct{} 36 | 37 | type BotUser struct { 38 | baseUser 39 | Bot Bot `json:"bot"` 40 | } 41 | 42 | type UsersRetrieveParameters struct { 43 | UserID string `json:"-" url:"-"` 44 | } 45 | 46 | type UsersRetrieveResponse struct { 47 | User 48 | } 49 | 50 | func (u *UsersRetrieveResponse) UnmarshalJSON(data []byte) (err error) { 51 | var decoder userDecoder 52 | 53 | if err := json.Unmarshal(data, &decoder); err != nil { 54 | return fmt.Errorf("failed to unmarshal UsersRetrieveResponse: %w", err) 55 | } 56 | 57 | u.User = decoder.User 58 | 59 | return nil 60 | } 61 | 62 | type UsersListParameters struct { 63 | PaginationParameters 64 | } 65 | 66 | type UsersListResponse struct { 67 | PaginatedList 68 | Results []User `json:"results"` 69 | } 70 | 71 | func (u *UsersListResponse) UnmarshalJSON(data []byte) error { 72 | type Alias UsersListResponse 73 | 74 | alias := struct { 75 | *Alias 76 | Results []userDecoder `json:"results"` 77 | }{ 78 | Alias: (*Alias)(u), 79 | } 80 | 81 | if err := json.Unmarshal(data, &alias); err != nil { 82 | return fmt.Errorf("failed to unmarshal UsersListResponse: %w", err) 83 | } 84 | 85 | u.Results = make([]User, 0, len(alias.Results)) 86 | 87 | for _, decoder := range alias.Results { 88 | u.Results = append(u.Results, decoder.User) 89 | } 90 | 91 | return nil 92 | } 93 | 94 | type UsersInterface interface { 95 | Retrieve(ctx context.Context, params UsersRetrieveParameters) (*UsersRetrieveResponse, error) 96 | List(ctx context.Context, params UsersListParameters) (*UsersListResponse, error) 97 | } 98 | 99 | type usersClient struct { 100 | restClient rest.Interface 101 | } 102 | 103 | func newUsersClient(restClient rest.Interface) *usersClient { 104 | return &usersClient{ 105 | restClient: restClient, 106 | } 107 | } 108 | 109 | func (u *usersClient) Retrieve(ctx context.Context, params UsersRetrieveParameters) (*UsersRetrieveResponse, error) { 110 | var result UsersRetrieveResponse 111 | 112 | var failure HTTPError 113 | 114 | err := u.restClient.New().Get(). 115 | Endpoint(strings.Replace(APIUsersRetrieveEndpoint, "{user_id}", params.UserID, 1)). 116 | Receive(ctx, &result, &failure) 117 | 118 | return &result, err // nolint:wrapcheck 119 | } 120 | 121 | func (u *usersClient) List(ctx context.Context, params UsersListParameters) (*UsersListResponse, error) { 122 | var result UsersListResponse 123 | 124 | var failure HTTPError 125 | 126 | err := u.restClient.New().Get(). 127 | Endpoint(APIUsersListEndpoint). 128 | QueryStruct(params). 129 | Receive(ctx, &result, &failure) 130 | 131 | return &result, err // nolint:wrapcheck 132 | } 133 | 134 | type userDecoder struct { 135 | User 136 | } 137 | 138 | func (u *userDecoder) UnmarshalJSON(data []byte) error { 139 | var decoder struct { 140 | Type UserType `json:"type"` 141 | } 142 | 143 | if err := json.Unmarshal(data, &decoder); err != nil { 144 | return fmt.Errorf("failed to unmarshal User: %w", err) 145 | } 146 | 147 | switch decoder.Type { 148 | case UserTypePerson: 149 | u.User = &PersonUser{} 150 | 151 | case UserTypeBot: 152 | u.User = &BotUser{} 153 | } 154 | 155 | return json.Unmarshal(data, u.User) 156 | } 157 | -------------------------------------------------------------------------------- /users_test.go: -------------------------------------------------------------------------------- 1 | package notion 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "net/http/httptest" 7 | "testing" 8 | 9 | "github.com/mkfsn/notion-go/rest" 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func Test_usersClient_Retrieve(t *testing.T) { 14 | type fields struct { 15 | restClient rest.Interface 16 | mockHTTPHandler http.Handler 17 | authToken string 18 | } 19 | 20 | type args struct { 21 | ctx context.Context 22 | params UsersRetrieveParameters 23 | } 24 | 25 | type wants struct { 26 | response *UsersRetrieveResponse 27 | err error 28 | } 29 | 30 | type test struct { 31 | name string 32 | fields fields 33 | args args 34 | wants wants 35 | } 36 | 37 | tests := []test{ 38 | { 39 | name: "Bot User", 40 | fields: fields{ 41 | restClient: rest.New(), 42 | authToken: "033dcdcf-8252-49f4-826c-e795fcab0ad2", 43 | mockHTTPHandler: http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { 44 | assert.Equal(t, DefaultNotionVersion, request.Header.Get("Notion-Version")) 45 | assert.Equal(t, DefaultUserAgent, request.Header.Get("User-Agent")) 46 | assert.Equal(t, "Bearer 033dcdcf-8252-49f4-826c-e795fcab0ad2", request.Header.Get("Authorization")) 47 | 48 | assert.Equal(t, http.MethodGet, request.Method) 49 | assert.Equal(t, "/v1/users/9a3b5ae0-c6e6-482d-b0e1-ed315ee6dc57", request.RequestURI) 50 | 51 | writer.WriteHeader(http.StatusOK) 52 | 53 | _, err := writer.Write([]byte(`{ 54 | "object": "user", 55 | "id": "9a3b5ae0-c6e6-482d-b0e1-ed315ee6dc57", 56 | "type": "bot", 57 | "bot": {}, 58 | "name": "Doug Engelbot", 59 | "avatar_url": "https://secure.notion-static.com/6720d746-3402-4171-8ebb-28d15144923c.jpg" 60 | }`, 61 | )) 62 | assert.NoError(t, err) 63 | }), 64 | }, 65 | args: args{ 66 | ctx: context.Background(), 67 | params: UsersRetrieveParameters{UserID: "9a3b5ae0-c6e6-482d-b0e1-ed315ee6dc57"}, 68 | }, 69 | wants: wants{ 70 | response: &UsersRetrieveResponse{ 71 | User: &BotUser{ 72 | baseUser: baseUser{ 73 | Object: ObjectTypeUser, 74 | ID: "9a3b5ae0-c6e6-482d-b0e1-ed315ee6dc57", 75 | Type: UserTypeBot, 76 | Name: "Doug Engelbot", 77 | AvatarURL: "https://secure.notion-static.com/6720d746-3402-4171-8ebb-28d15144923c.jpg", 78 | }, 79 | Bot: Bot{}, 80 | }, 81 | }, 82 | }, 83 | }, 84 | 85 | { 86 | name: "Person User", 87 | fields: fields{ 88 | restClient: rest.New(), 89 | authToken: "033dcdcf-8252-49f4-826c-e795fcab0ad2", 90 | mockHTTPHandler: http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { 91 | assert.Equal(t, DefaultNotionVersion, request.Header.Get("Notion-Version")) 92 | assert.Equal(t, DefaultUserAgent, request.Header.Get("User-Agent")) 93 | assert.Equal(t, "Bearer 033dcdcf-8252-49f4-826c-e795fcab0ad2", request.Header.Get("Authorization")) 94 | 95 | assert.Equal(t, http.MethodGet, request.Method) 96 | assert.Equal(t, "/v1/users/d40e767c-d7af-4b18-a86d-55c61f1e39a4", request.RequestURI) 97 | 98 | writer.WriteHeader(http.StatusOK) 99 | 100 | _, err := writer.Write([]byte(`{ 101 | "object": "user", 102 | "id": "d40e767c-d7af-4b18-a86d-55c61f1e39a4", 103 | "type": "person", 104 | "person": { 105 | "email": "avo@example.org" 106 | }, 107 | "name": "Avocado Lovelace", 108 | "avatar_url": "https://secure.notion-static.com/e6a352a8-8381-44d0-a1dc-9ed80e62b53d.jpg" 109 | }`, 110 | )) 111 | assert.NoError(t, err) 112 | }), 113 | }, 114 | args: args{ 115 | ctx: context.Background(), 116 | params: UsersRetrieveParameters{UserID: "d40e767c-d7af-4b18-a86d-55c61f1e39a4"}, 117 | }, 118 | wants: wants{ 119 | response: &UsersRetrieveResponse{ 120 | User: &PersonUser{ 121 | baseUser: baseUser{ 122 | Object: ObjectTypeUser, 123 | ID: "d40e767c-d7af-4b18-a86d-55c61f1e39a4", 124 | Type: UserTypePerson, 125 | Name: "Avocado Lovelace", 126 | AvatarURL: "https://secure.notion-static.com/e6a352a8-8381-44d0-a1dc-9ed80e62b53d.jpg", 127 | }, 128 | Person: Person{Email: "avo@example.org"}, 129 | }, 130 | }, 131 | }, 132 | }, 133 | } 134 | 135 | for _, tt := range tests { 136 | t.Run(tt.name, func(t *testing.T) { 137 | mockHTTPServer := httptest.NewServer(tt.fields.mockHTTPHandler) 138 | defer mockHTTPServer.Close() 139 | 140 | sut := New( 141 | tt.fields.authToken, 142 | WithBaseURL(mockHTTPServer.URL), 143 | ) 144 | 145 | got, err := sut.Users().Retrieve(tt.args.ctx, tt.args.params) 146 | if tt.wants.err != nil { 147 | assert.ErrorIs(t, err, tt.wants.err) 148 | return 149 | } 150 | 151 | assert.NoError(t, err) 152 | assert.Equal(t, tt.wants.response, got) 153 | }) 154 | } 155 | } 156 | 157 | func Test_usersClient_List(t *testing.T) { 158 | type fields struct { 159 | restClient rest.Interface 160 | mockHTTPHandler http.Handler 161 | authToken string 162 | } 163 | 164 | type args struct { 165 | ctx context.Context 166 | params UsersListParameters 167 | } 168 | 169 | type wants struct { 170 | response *UsersListResponse 171 | err error 172 | } 173 | 174 | type test struct { 175 | name string 176 | fields fields 177 | args args 178 | wants wants 179 | } 180 | 181 | tests := []test{ 182 | { 183 | name: "List two users in one page", 184 | fields: fields{ 185 | restClient: rest.New(), 186 | authToken: "2a966a7a-6e97-4b2c-abb2-c0eba4dbcb5f", 187 | mockHTTPHandler: http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { 188 | assert.Equal(t, DefaultNotionVersion, request.Header.Get("Notion-Version")) 189 | assert.Equal(t, DefaultUserAgent, request.Header.Get("User-Agent")) 190 | assert.Equal(t, "Bearer 2a966a7a-6e97-4b2c-abb2-c0eba4dbcb5f", request.Header.Get("Authorization")) 191 | 192 | assert.Equal(t, http.MethodGet, request.Method) 193 | assert.Equal(t, "/v1/users?page_size=2", request.RequestURI) 194 | 195 | writer.WriteHeader(http.StatusOK) 196 | 197 | _, err := writer.Write([]byte(`{ 198 | "results": [ 199 | { 200 | "object": "user", 201 | "id": "d40e767c-d7af-4b18-a86d-55c61f1e39a4", 202 | "type": "person", 203 | "person": { 204 | "email": "avo@example.org" 205 | }, 206 | "name": "Avocado Lovelace", 207 | "avatar_url": "https://secure.notion-static.com/e6a352a8-8381-44d0-a1dc-9ed80e62b53d.jpg" 208 | }, 209 | { 210 | "object": "user", 211 | "id": "9a3b5ae0-c6e6-482d-b0e1-ed315ee6dc57", 212 | "type": "bot", 213 | "bot": {}, 214 | "name": "Doug Engelbot", 215 | "avatar_url": "https://secure.notion-static.com/6720d746-3402-4171-8ebb-28d15144923c.jpg" 216 | } 217 | ], 218 | "next_cursor": "fe2cc560-036c-44cd-90e8-294d5a74cebc", 219 | "has_more": true 220 | }`, 221 | )) 222 | assert.NoError(t, err) 223 | }), 224 | }, 225 | args: args{ 226 | ctx: context.Background(), 227 | params: UsersListParameters{ 228 | PaginationParameters: PaginationParameters{ 229 | StartCursor: "", 230 | PageSize: 2, 231 | }, 232 | }, 233 | }, 234 | wants: wants{ 235 | response: &UsersListResponse{ 236 | PaginatedList: PaginatedList{ 237 | NextCursor: "fe2cc560-036c-44cd-90e8-294d5a74cebc", 238 | HasMore: true, 239 | }, 240 | Results: []User{ 241 | &PersonUser{ 242 | baseUser: baseUser{ 243 | Object: ObjectTypeUser, 244 | ID: "d40e767c-d7af-4b18-a86d-55c61f1e39a4", 245 | Type: UserTypePerson, 246 | Name: "Avocado Lovelace", 247 | AvatarURL: "https://secure.notion-static.com/e6a352a8-8381-44d0-a1dc-9ed80e62b53d.jpg", 248 | }, 249 | Person: Person{Email: "avo@example.org"}, 250 | }, 251 | &BotUser{ 252 | baseUser: baseUser{ 253 | Object: ObjectTypeUser, 254 | ID: "9a3b5ae0-c6e6-482d-b0e1-ed315ee6dc57", 255 | Type: UserTypeBot, 256 | Name: "Doug Engelbot", 257 | AvatarURL: "https://secure.notion-static.com/6720d746-3402-4171-8ebb-28d15144923c.jpg", 258 | }, 259 | Bot: Bot{}, 260 | }, 261 | }, 262 | }, 263 | }, 264 | }, 265 | } 266 | 267 | for _, tt := range tests { 268 | t.Run(tt.name, func(t *testing.T) { 269 | mockHTTPServer := httptest.NewServer(tt.fields.mockHTTPHandler) 270 | defer mockHTTPServer.Close() 271 | 272 | sut := New( 273 | tt.fields.authToken, 274 | WithBaseURL(mockHTTPServer.URL), 275 | ) 276 | 277 | got, err := sut.Users().List(tt.args.ctx, tt.args.params) 278 | if tt.wants.err != nil { 279 | assert.ErrorIs(t, err, tt.wants.err) 280 | return 281 | } 282 | 283 | assert.NoError(t, err) 284 | assert.Equal(t, tt.wants.response, got) 285 | }) 286 | } 287 | } 288 | --------------------------------------------------------------------------------