Repository: jomei/notionapi
Pull request #55 (2021-12-02 21:06:31)

Added Blocks type for Unmarshalling
Currently there is a problem with unmarshalling into a Block interface, since the decodeBlock function is unexported. This is an issue when trying to use the Block type outside of the API, such as loading a Block from a JSON file. A Block itself cannot be defined to unmarshal into, as it is an interface and can't be a receiver, but a []Block can be, as done here.

[]Block cannot be unmarshalled into as it is an interface. This defines a Blocks type, which is just a []Block, and an UnmarshallJSON method so that a JSON string/byte slice can unmarshall into a []Block.

kertox662: This type can be used in the other requests as well (and defined as part of the request/response structs) to also use this unmarshalling code.
kertox662: Go 1.14 doesn't have `os.ReadFile` I guess. Will update that momentarily.
kertox662: Ahh, it used to be just `ioutil.ReadFile`
kertox662: The problem this is trying to resolve is loading block arrays from JSON strings/files. At the moment this will cause the same error as the Property arrays. The main reason for this is that `decodeBlock` is unexported, so I'm not able to use that outside of the package. 

This does not solve single block unmarshalling though. A way to possibly solve this is by having the Block interface also implement the `json.Unmarshaler` interface: https://pkg.go.dev/encoding/json#Unmarshaler. This would also solve the array problem and technically make this redundant, but it would require that each block type struct implement `UnmarshalJSON` so I'm not sure if that's the best idea.

Patch:
diff --git a/block.go b/block.go
index 348e89d..e02a73d 100644
--- a/block.go
+++ b/block.go
@@ -35,36 +35,18 @@ func (bc *BlockClient) GetChildren(ctx context.Context, id BlockID, pagination *
 		return nil, err
 	}
 
-	var response struct {
-		Object     ObjectType `json:"object"`
-		NextCursor string     `json:"next_cursor"`
-		HasMore    bool       `json:"has_more"`
-		Results    []map[string]interface{}
-	}
-	err = json.NewDecoder(res.Body).Decode(&response)
+	response := &GetChildrenResponse{}
+	err = json.NewDecoder(res.Body).Decode(response)
 	if err != nil {
 		return nil, err
 	}
-	results := make([]Block, len(response.Results))
-	for i, r := range response.Results {
-		b, err := decodeBlock(r)
-		if err != nil {
-			return nil, err
-		}
-		results[i] = b
-	}
 
-	return &GetChildrenResponse{
-		Object:     response.Object,
-		Results:    results,
-		NextCursor: response.NextCursor,
-		HasMore:    response.HasMore,
-	}, nil
+	return response, nil
 }
 
 type GetChildrenResponse struct {
 	Object     ObjectType `json:"object"`
-	Results    []Block    `json:"results"`
+	Results    Blocks     `json:"results"`
 	NextCursor string     `json:"next_cursor"`
 	HasMore    bool       `json:"has_more"`
 }
@@ -149,6 +131,26 @@ type Block interface {
 	GetArchived() bool
 }
 
+type Blocks []Block
+
+func (b *Blocks) UnmarshalJSON(data []byte) error {
+	var err error
+	mapArr := make([]map[string]interface{}, 0)
+	if err = json.Unmarshal(data, &mapArr); err != nil {
+		return err
+	}
+
+	result := make([]Block, len(mapArr))
+	for i, prop := range mapArr {
+		if result[i], err = decodeBlock(prop); err != nil {
+			return err
+		}
+	}
+
+	*b = result
+	return nil
+}
+
 // BasicBlock defines the common fields of all Notion block types.
 // See https://developers.notion.com/reference/block for the list.
 // BasicBlock implements the Block interface.
@@ -197,7 +199,7 @@ type ParagraphBlock struct {
 
 type Paragraph struct {
 	Text     []RichText `json:"text"`
-	Children []Block    `json:"children,omitempty"`
+	Children Blocks     `json:"children,omitempty"`
 }
 
 type Heading1Block struct {
@@ -227,7 +229,7 @@ type CalloutBlock struct {
 type Callout struct {
 	Text     []RichText `json:"text"`
 	Icon     *Icon      `json:"icon,omitempty"`
-	Children []Block    `json:"children,omitempty"`
+	Children Blocks     `json:"children,omitempty"`
 }
 
 type QuoteBlock struct {
@@ -237,7 +239,7 @@ type QuoteBlock struct {
 
 type Quote struct {
 	Text     []RichText `json:"text"`
-	Children []Block    `json:"children,omitempty"`
+	Children Blocks     `json:"children,omitempty"`
 }
 
 type BulletedListItemBlock struct {
@@ -247,7 +249,7 @@ type BulletedListItemBlock struct {
 
 type ListItem struct {
 	Text     []RichText `json:"text"`
-	Children []Block    `json:"children,omitempty"`
+	Children Blocks     `json:"children,omitempty"`
 }
 
 type NumberedListItemBlock struct {
@@ -262,20 +264,20 @@ type ToDoBlock struct {
 
 type ToDo struct {
 	Text     []RichText `json:"text"`
-	Children []Block    `json:"children,omitempty"`
+	Children Blocks     `json:"children,omitempty"`
 	Checked  bool       `json:"checked,omitempty"`
 }
 
 type ToggleBlock struct {
 	BasicBlock
 	Text     []RichText `json:"text"`
-	Children []Block    `json:"children,omitempty"`
+	Children Blocks     `json:"children,omitempty"`
 	Toggle   Toggle     `json:"toggle"`
 }
 
 type Toggle struct {
 	Text     []RichText `json:"text"`
-	Children []Block    `json:"children,omitempty"`
+	Children Blocks     `json:"children,omitempty"`
 }
 
 type ChildPageBlock struct {
@@ -424,7 +426,7 @@ type ColumnBlock struct {
 
 type Column struct {
 	// Children should at least have 1 block when appending.
-	Children []Block `json:"children"`
+	Children Blocks `json:"children"`
 }
 
 type ColumnListBlock struct {
@@ -435,7 +437,7 @@ type ColumnListBlock struct {
 type ColumnList struct {
 	// Children can only contain column blocks
 	// Children should have at least 2 blocks when appending.
-	Children []Block `json:"children"`
+	Children Blocks `json:"children"`
 }
 
 // NOTE: will only be returned by the API. Cannot be created by the API.
@@ -466,7 +468,7 @@ type TemplateBlock struct {
 
 type Template struct {
 	Text     []RichText `json:"text"`
-	Children []Block    `json:"children,omitempty"`
+	Children Blocks     `json:"children,omitempty"`
 }
 
 type SyncedBlock struct {
@@ -477,7 +479,7 @@ type SyncedBlock struct {
 type Synced struct {
 	// SyncedFrom is nil for the original block.
 	SyncedFrom *SyncedFrom `json:"synced_from"`
-	Children   []Block     `json:"children,omitempty"`
+	Children   Blocks      `json:"children,omitempty"`
 }
 
 type SyncedFrom struct {


Test patch:
diff --git a/block_test.go b/block_test.go
index 1e32c52..c043b79 100644
--- a/block_test.go
+++ b/block_test.go
@@ -3,6 +3,7 @@ package notionapi_test
 import (
 	"context"
 	"encoding/json"
+	"io/ioutil"
 	"net/http"
 	"reflect"
 	"testing"
@@ -264,3 +265,177 @@ func TestBlockClient(t *testing.T) {
 		}
 	})
 }
+
+func TestBlockArrayUnmarshal(t *testing.T) {
+	timestamp, err := time.Parse(time.RFC3339, "2021-11-04T02:09:00Z")
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	var emoji notionapi.Emoji = "📌"
+	t.Run("BlockArray", func(t *testing.T) {
+		tests := []struct {
+			name     string
+			filePath string
+			want     notionapi.Blocks
+			wantErr  bool
+			err      error
+		}{
+			{
+				name:     "unmarshal",
+				filePath: "testdata/block_array_unmarshal.json",
+				want: notionapi.Blocks{
+					&notionapi.CalloutBlock{
+						BasicBlock: notionapi.BasicBlock{
+							Object:         "block",
+							ID:             "block1",
+							Type:           "callout",
+							CreatedTime:    &timestamp,
+							LastEditedTime: &timestamp,
+						},
+						Callout: notionapi.Callout{
+							Text: []notionapi.RichText{
+								{
+									Type: "text",
+									Text: notionapi.Text{
+										Content: "This page is designed to be shared with students on the web. Click ",
+									},
+									Annotations: &notionapi.Annotations{
+										Color: "default",
+									},
+									PlainText: "This page is designed to be shared with students on the web. Click ",
+								}, {
+									Type: "text",
+									Text: notionapi.Text{
+										Content: "Share",
+									},
+									Annotations: &notionapi.Annotations{
+										Code:  true,
+										Color: "default",
+									},
+									PlainText: "Share",
+								},
+							},
+							Icon: &notionapi.Icon{
+								Type:  "emoji",
+								Emoji: &emoji,
+							},
+						},
+					},
+					&notionapi.Heading1Block{
+						BasicBlock: notionapi.BasicBlock{
+							Object:         "block",
+							ID:             "block2",
+							Type:           "heading_1",
+							CreatedTime:    &timestamp,
+							LastEditedTime: &timestamp,
+						},
+						Heading1: notionapi.Heading{
+							Text: []notionapi.RichText{
+								{
+									Type: "text",
+									Text: notionapi.Text{
+										Content: "History 340",
+									},
+									Annotations: &notionapi.Annotations{
+										Color: "default",
+									},
+									PlainText: "History 340",
+								},
+							},
+						},
+					},
+					&notionapi.ChildDatabaseBlock{
+						BasicBlock: notionapi.BasicBlock{
+							Object:         "block",
+							ID:             "block3",
+							Type:           "child_database",
+							CreatedTime:    &timestamp,
+							LastEditedTime: &timestamp,
+						},
+						ChildDatabase: struct {
+							Title string "json:\"title\""
+						}{
+							Title: "Required Texts",
+						},
+					},
+					&notionapi.ColumnListBlock{
+						BasicBlock: notionapi.BasicBlock{
+							Object:         "block",
+							ID:             "block4",
+							Type:           "column_list",
+							CreatedTime:    &timestamp,
+							LastEditedTime: &timestamp,
+							HasChildren:    true,
+						},
+					},
+					&notionapi.Heading3Block{
+						BasicBlock: notionapi.BasicBlock{
+							Object:         "block",
+							ID:             "block5",
+							Type:           "heading_3",
+							CreatedTime:    &timestamp,
+							LastEditedTime: &timestamp,
+						},
+						Heading3: notionapi.Heading{
+							Text: []notionapi.RichText{
+								{
+									Type: "text",
+									Text: notionapi.Text{
+										Content: "Assignment Submission",
+									},
+									Annotations: &notionapi.Annotations{
+										Bold:  true,
+										Color: "default",
+									},
+									PlainText: "Assignment Submission",
+								},
+							},
+						},
+					},
+					&notionapi.ParagraphBlock{
+						BasicBlock: notionapi.BasicBlock{
+							Object:         "block",
+							ID:             "block6",
+							Type:           "paragraph",
+							CreatedTime:    &timestamp,
+							LastEditedTime: &timestamp,
+						},
+						Paragraph: notionapi.Paragraph{
+							Text: []notionapi.RichText{
+								{
+									Type: "text",
+									Text: notionapi.Text{
+										Content: "All essays and papers are due in lecture (due dates are listed on the schedule). No electronic copies will be accepted!",
+									},
+									Annotations: &notionapi.Annotations{
+										Color: "default",
+									},
+									PlainText: "All essays and papers are due in lecture (due dates are listed on the schedule). No electronic copies will be accepted!",
+								},
+							},
+						},
+					},
+				},
+			},
+		}
+
+		for _, tt := range tests {
+			t.Run(tt.name, func(t *testing.T) {
+				data, err := ioutil.ReadFile(tt.filePath)
+				if err != nil {
+					t.Fatal(err)
+				}
+				got := make(notionapi.Blocks, 0)
+				err = json.Unmarshal(data, &got)
+				if err != nil {
+					t.Fatal(err)
+				}
+
+				if !reflect.DeepEqual(got, tt.want) {
+					t.Errorf("Get() got = %v, want %v", got, tt.want)
+				}
+			})
+		}
+	})
+}
diff --git a/testdata/block_array_unmarshal.json b/testdata/block_array_unmarshal.json
new file mode 100644
index 0000000..c99f1c7
--- /dev/null
+++ b/testdata/block_array_unmarshal.json
@@ -0,0 +1,128 @@
+[{
+    "object": "block",
+    "id": "block1",
+    "type": "callout",
+    "created_time": "2021-11-04T02:09:00Z",
+    "last_edited_time": "2021-11-04T02:09:00Z",
+    "callout": {
+        "text": [{
+            "type": "text",
+            "text": {
+                "content": "This page is designed to be shared with students on the web. Click "
+            },
+            "annotations": {
+                "bold": false,
+                "italic": false,
+                "strikethrough": false,
+                "underline": false,
+                "code": false,
+                "color": "default"
+            },
+            "plain_text": "This page is designed to be shared with students on the web. Click "
+        }, {
+            "type": "text",
+            "text": {
+                "content": "Share"
+            },
+            "annotations": {
+                "bold": false,
+                "italic": false,
+                "strikethrough": false,
+                "underline": false,
+                "code": true,
+                "color": "default"
+            },
+            "plain_text": "Share"
+        }],
+        "icon": {
+            "type": "emoji",
+            "emoji": "📌"
+        }
+    }
+}, {
+    "object": "block",
+    "id": "block2",
+    "type": "heading_1",
+    "created_time": "2021-11-04T02:09:00Z",
+    "last_edited_time": "2021-11-04T02:09:00Z",
+    "heading_1": {
+        "text": [{
+            "type": "text",
+            "text": {
+                "content": "History 340"
+            },
+            "annotations": {
+                "bold": false,
+                "italic": false,
+                "strikethrough": false,
+                "underline": false,
+                "code": false,
+                "color": "default"
+            },
+            "plain_text": "History 340"
+        }]
+    }
+}, {
+    "object": "block",
+    "id": "block3",
+    "type": "child_database",
+    "created_time": "2021-11-04T02:09:00Z",
+    "last_edited_time": "2021-11-04T02:09:00Z",
+    "child_database": {
+        "title": "Required Texts"
+    }
+}, {
+    "object": "block",
+    "id": "block4",
+    "type": "column_list",
+    "created_time": "2021-11-04T02:09:00Z",
+    "last_edited_time": "2021-11-04T02:09:00Z",
+    "has_children": true,
+    "column_list": {}
+}, {
+    "object": "block",
+    "id": "block5",
+    "type": "heading_3",
+    "created_time": "2021-11-04T02:09:00Z",
+    "last_edited_time": "2021-11-04T02:09:00Z",
+    "heading_3": {
+        "text": [{
+            "type": "text",
+            "text": {
+                "content": "Assignment Submission"
+            },
+            "annotations": {
+                "bold": true,
+                "italic": false,
+                "strikethrough": false,
+                "underline": false,
+                "code": false,
+                "color": "default"
+            },
+            "plain_text": "Assignment Submission"
+        }]
+    }
+}, {
+    "object": "block",
+    "id": "block6",
+    "type": "paragraph",
+    "created_time": "2021-11-04T02:09:00Z",
+    "last_edited_time": "2021-11-04T02:09:00Z",
+    "paragraph": {
+        "text": [{
+            "type": "text",
+            "text": {
+                "content": "All essays and papers are due in lecture (due dates are listed on the schedule). No electronic copies will be accepted!"
+            },
+            "annotations": {
+                "bold": false,
+                "italic": false,
+                "strikethrough": false,
+                "underline": false,
+                "code": false,
+                "color": "default"
+            },
+            "plain_text": "All essays and papers are due in lecture (due dates are listed on the schedule). No electronic copies will be accepted!"
+        }]
+    }
+}]
\ No newline at end of file