api: initial implementation with support for creating EventProposals.
This commit is contained in:
commit
588454d668
7
api/hff_entry_forms_api.config.json
Normal file
7
api/hff_entry_forms_api.config.json
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"eventParentId": "ffff5ca5cb0547b6a99fb78cdb9b0170",
|
||||||
|
"knownOrigins": [ "http://curl.localhost" ],
|
||||||
|
"notionApiBaseUrl": "https://api.notion.com/v1",
|
||||||
|
"notionVersion": "2021-08-16",
|
||||||
|
"port": 8300
|
||||||
|
}
|
21
api/hff_entry_forms_api.nimble
Normal file
21
api/hff_entry_forms_api.nimble
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
# Package
|
||||||
|
|
||||||
|
version = "0.1.0"
|
||||||
|
author = "Jonathan Bernard"
|
||||||
|
description = "Hope Family Fellowship entry forms."
|
||||||
|
license = "GPL-3.0-or-later"
|
||||||
|
srcDir = "src"
|
||||||
|
bin = @["hff_entry_forms_api"]
|
||||||
|
|
||||||
|
|
||||||
|
# Dependencies
|
||||||
|
|
||||||
|
requires "nim >= 1.4.8"
|
||||||
|
requires @["docopt", "jester"]
|
||||||
|
|
||||||
|
requires "https://git.jdb-software.com/jdb/nim-cli-utils.git >= 0.6.3"
|
||||||
|
requires "https://git.jdb-software.com/jdb/nim-time-utils.git >= 0.5.0"
|
||||||
|
requires "https://git.jdb-software.com/jdb/update-nim-package-version"
|
||||||
|
|
||||||
|
task updateVersion, "Update the version of this package.":
|
||||||
|
exec "update_nim_package_version hff_entry_forms_api 'src/hff_entry_forms_apipkg/version.nim'"
|
1
api/src/config.nims
Normal file
1
api/src/config.nims
Normal file
@ -0,0 +1 @@
|
|||||||
|
switch("d", "ssl")
|
70
api/src/hff_entry_forms_api.nim
Normal file
70
api/src/hff_entry_forms_api.nim
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
import cliutils, docopt, json, logging, sequtils, strutils, tables
|
||||||
|
|
||||||
|
import hff_entry_forms_apipkg/api
|
||||||
|
import hff_entry_forms_apipkg/version
|
||||||
|
import hff_entry_forms_apipkg/service
|
||||||
|
|
||||||
|
let DEFAULT_CONFIG = HffEntryFormsApiConfig(
|
||||||
|
integrationToken: "CHANGE ME",
|
||||||
|
knownOrigins: @["http://curl.localhost"],
|
||||||
|
notionVersion: "2021-08-16",
|
||||||
|
port: 8300'i16
|
||||||
|
)
|
||||||
|
|
||||||
|
proc loadConfig(args: Table[string, docopt.Value]): HffEntryFormsApiConfig =
|
||||||
|
|
||||||
|
let filePath =
|
||||||
|
if args["--config"]: $args["--config"]
|
||||||
|
else: "hff_entry_forms_api.config.json"
|
||||||
|
|
||||||
|
var json: JsonNode
|
||||||
|
try: json = parseFile(filePath)
|
||||||
|
except:
|
||||||
|
json = %DEFAULT_CONFIG
|
||||||
|
warn "unable to load configuration file (expected at " & filePath & ")"
|
||||||
|
|
||||||
|
let cfg = CombinedConfig(docopt: args, json: json)
|
||||||
|
|
||||||
|
result = HffEntryFormsApiConfig(
|
||||||
|
debug: parseBool(cfg.getVal("debug")),
|
||||||
|
eventParentId: cfg.getVal("event-parent-id"),
|
||||||
|
integrationToken: cfg.getVal("integration-token"),
|
||||||
|
knownOrigins: cfg.getVal("known-origins")[1..^2].split(',').mapIt(it[1..^2]),
|
||||||
|
notionApiBaseUrl: cfg.getVal("notion-api-base-url"),
|
||||||
|
notionVersion: cfg.getVal("notion-version"),
|
||||||
|
port: parseInt(cfg.getVal("port", "8300")))
|
||||||
|
|
||||||
|
when isMainModule:
|
||||||
|
try:
|
||||||
|
let doc = """
|
||||||
|
Usage:
|
||||||
|
hff_entry_forms_api serve [options]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
|
||||||
|
-C, --config <cfgFile> Location of the config file to use (defaults to
|
||||||
|
hff_entry_forms_api.config.json)
|
||||||
|
|
||||||
|
-d, --debug Log debugging information.
|
||||||
|
|
||||||
|
-p, --port <portNumber> Port number for the API server.
|
||||||
|
"""
|
||||||
|
|
||||||
|
let consoleLogger = newConsoleLogger(
|
||||||
|
levelThreshold=lvlInfo,
|
||||||
|
fmtStr="$app - $levelname: ")
|
||||||
|
logging.addHandler(consoleLogger)
|
||||||
|
|
||||||
|
# Initialize our service context
|
||||||
|
let args = docopt(doc, version = HFF_ENTRY_FORMS_API_VERSION)
|
||||||
|
|
||||||
|
if args["--debug"]:
|
||||||
|
consoleLogger.levelThreshold = lvlDebug
|
||||||
|
|
||||||
|
let cfg = loadConfig(args)
|
||||||
|
|
||||||
|
if args["serve"]: start(cfg)
|
||||||
|
|
||||||
|
except:
|
||||||
|
fatal getCurrentExceptionMsg()
|
||||||
|
quit(QuitFailure)
|
141
api/src/hff_entry_forms_apipkg/api.nim
Normal file
141
api/src/hff_entry_forms_apipkg/api.nim
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
import jester, json, logging, options, sequtils, strutils
|
||||||
|
from re import re, find
|
||||||
|
|
||||||
|
import ./models, ./notion_client, ./service, ./version
|
||||||
|
|
||||||
|
const JSON = "application/json"
|
||||||
|
|
||||||
|
## Response Utilities
|
||||||
|
template halt(code: HttpCode,
|
||||||
|
headers: RawHeaders,
|
||||||
|
content: string) =
|
||||||
|
## Immediately replies with the specified request. This means any further
|
||||||
|
## code will not be executed after calling this template in the current
|
||||||
|
## route.
|
||||||
|
bind TCActionSend, newHttpHeaders
|
||||||
|
result[0] = CallbackAction.TCActionSend
|
||||||
|
result[1] = code
|
||||||
|
result[2] = if isSome(result[2]): some(result[2].get() & headers)
|
||||||
|
else: some(headers)
|
||||||
|
result[3] = content
|
||||||
|
result.matched = true
|
||||||
|
break allRoutes
|
||||||
|
|
||||||
|
template jsonResp(code: HttpCode,
|
||||||
|
body: string = "",
|
||||||
|
headersToSend: RawHeaders = @{:} ) =
|
||||||
|
## Immediately send a JSON response and stop processing the request.
|
||||||
|
let reqOrigin =
|
||||||
|
if request.headers.hasKey("Origin"): $(request.headers["Origin"])
|
||||||
|
else: ""
|
||||||
|
|
||||||
|
let corsHeaders =
|
||||||
|
if cfg.knownOrigins.contains(reqOrigin):
|
||||||
|
@{
|
||||||
|
"Access-Control-Allow-Origin": reqOrigin,
|
||||||
|
"Access-Control-Allow-Credentials": "true",
|
||||||
|
"Access-Control-Allow-Methods": $(request.reqMethod),
|
||||||
|
"Access-Control-Allow-Headers": "Authorization,X-CSRF-TOKEN"
|
||||||
|
}
|
||||||
|
else: @{:}
|
||||||
|
|
||||||
|
halt(
|
||||||
|
code,
|
||||||
|
headersToSend & corsHeaders & @{
|
||||||
|
"Content-Type": JSON,
|
||||||
|
"Cache-Control": "no-cache"
|
||||||
|
},
|
||||||
|
body
|
||||||
|
)
|
||||||
|
|
||||||
|
template dataResp(code: HttpCode,
|
||||||
|
data: JsonNode,
|
||||||
|
headersToSend: RawHeaders = @{:} ) =
|
||||||
|
jsonResp(
|
||||||
|
code,
|
||||||
|
$(%* {
|
||||||
|
"details": "",
|
||||||
|
"data": data
|
||||||
|
}),
|
||||||
|
headersToSend)
|
||||||
|
|
||||||
|
template dataResp(data: JsonNode) = dataResp(Http200, data)
|
||||||
|
|
||||||
|
template statusResp(code: HttpCode,
|
||||||
|
details: string = "",
|
||||||
|
headersToSend: RawHeaders = @{:} ) =
|
||||||
|
## Helper to send a JSON response based on a given HTTP code.
|
||||||
|
jsonResp(
|
||||||
|
code,
|
||||||
|
$(%* {
|
||||||
|
"details": details
|
||||||
|
}),
|
||||||
|
headersToSend)
|
||||||
|
|
||||||
|
template errorResp(err: ref ApiError): void =
|
||||||
|
debug err.respMsg & ( if err.msg.len > 0: ": " & err.msg else: "")
|
||||||
|
if not err.parent.isNil: debug " original exception: " & err.parent.msg
|
||||||
|
statusResp(err.respCode, err.respMsg)
|
||||||
|
|
||||||
|
## CORS support
|
||||||
|
template optionsResp(allowedMethods: seq[HttpMethod]) =
|
||||||
|
|
||||||
|
let reqOrigin =
|
||||||
|
if request.headers.hasKey("Origin"): $(request.headers["Origin"])
|
||||||
|
else: ""
|
||||||
|
|
||||||
|
let corsHeaders =
|
||||||
|
if cfg.knownOrigins.contains(reqOrigin):
|
||||||
|
@{
|
||||||
|
"Access-Control-Allow-Origin": reqOrigin,
|
||||||
|
"Access-Control-Allow-Credentials": "true",
|
||||||
|
"Access-Control-Allow-Methods": allowedMethods.mapIt($it).join(", "),
|
||||||
|
"Access-Control-Allow-Headers": "DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization,X-CSRF-TOKEN"
|
||||||
|
}
|
||||||
|
else: @{:}
|
||||||
|
|
||||||
|
halt(
|
||||||
|
Http200,
|
||||||
|
corsHeaders,
|
||||||
|
""
|
||||||
|
)
|
||||||
|
|
||||||
|
template withApiErrors(actions: untyped) =
|
||||||
|
try: actions
|
||||||
|
except JsonParsingError, ValueError:
|
||||||
|
echo getCurrentException().getStackTrace()
|
||||||
|
statusResp(Http400, getCurrentExceptionMsg())
|
||||||
|
except ApiError: errorResp(cast[ref ApiError](getCurrentException()))
|
||||||
|
except:
|
||||||
|
let ex = getCurrentException()
|
||||||
|
debug ex.getStackTrace
|
||||||
|
errorResp(newApiError(ex, Http500, "Internal server error", ex.msg))
|
||||||
|
|
||||||
|
proc start*(cfg: HffEntryFormsApiConfig): void =
|
||||||
|
|
||||||
|
if cfg.debug: setLogFilter(lvlDebug)
|
||||||
|
|
||||||
|
settings:
|
||||||
|
port = Port(cfg.port)
|
||||||
|
appName = "/v1"
|
||||||
|
|
||||||
|
routes:
|
||||||
|
|
||||||
|
options "/version": optionsResp(@[HttpGet])
|
||||||
|
|
||||||
|
get "/version":
|
||||||
|
jsonResp(Http200, $(%("hff_entry_forms_api v" & HFF_ENTRY_FORMS_API_VERSION)))
|
||||||
|
|
||||||
|
options "/add-page": optionsResp(@[HttpPost])
|
||||||
|
|
||||||
|
post "/propose-event":
|
||||||
|
withApiErrors:
|
||||||
|
let ep = parseEventProposal(parseJson(request.body))
|
||||||
|
if createProposedEvent(cfg, ep): statusResp(Http200)
|
||||||
|
else: statusResp(Http500)
|
||||||
|
|
||||||
|
options re".*": statusResp(Http404, "Not Found")
|
||||||
|
get re".*": statusResp(Http404, "Not Found")
|
||||||
|
post re".*": statusResp(Http404, "Not Found")
|
||||||
|
put re".*": statusResp(Http404, "Not Found")
|
||||||
|
delete re".*": statusResp(Http404, "Not Found")
|
77
api/src/hff_entry_forms_apipkg/models.nim
Normal file
77
api/src/hff_entry_forms_apipkg/models.nim
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
import json, times, timeutils
|
||||||
|
|
||||||
|
type
|
||||||
|
EventProposal* = object
|
||||||
|
name*: string
|
||||||
|
description*: string
|
||||||
|
purpose*: string
|
||||||
|
department*: string
|
||||||
|
owner*: string
|
||||||
|
date*: DateTime
|
||||||
|
budgetInDollars*: int
|
||||||
|
|
||||||
|
proc getOrFail(n: JsonNode, key: string): JsonNode =
|
||||||
|
## convenience method to get a key from a JObject or raise an exception
|
||||||
|
if not n.hasKey(key):
|
||||||
|
raise newException(ValueError, "missing key '" & key & "'")
|
||||||
|
|
||||||
|
return n[key]
|
||||||
|
|
||||||
|
proc parseIso8601(n: JsonNode, key: string): DateTime =
|
||||||
|
return parseIso8601(n.getOrFail(key).getStr)
|
||||||
|
|
||||||
|
proc textProp(value: string): JsonNode =
|
||||||
|
return %*[
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"text": { "content": value }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
proc parseEventProposal*(n: JsonNode): EventProposal {.raises: [JsonParsingError].} =
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = EventProposal(
|
||||||
|
name: n.getOrFail("name").getStr,
|
||||||
|
description: n.getOrFail("description").getStr,
|
||||||
|
purpose: n.getOrFail("purpose").getStr,
|
||||||
|
department: n.getOrFail("department").getStr,
|
||||||
|
owner: n.getOrFail("owner").getStr,
|
||||||
|
date: n.parseIso8601("date"),
|
||||||
|
budgetInDollars: n.getOrFail("budgetInDollars").getInt)
|
||||||
|
except:
|
||||||
|
raise newException(JsonParsingError, "Invalid EventProposal: " & getCurrentExceptionMsg())
|
||||||
|
|
||||||
|
proc asNotionPage*(ep: EventProposal): JsonNode =
|
||||||
|
result = %*{
|
||||||
|
"properties": {
|
||||||
|
"Event": { "title": textProp(ep.name) },
|
||||||
|
"Date": { "date": { "start": formatIso8601(ep.date) } },
|
||||||
|
"Department": { "multi_select": [ { "name": ep.department } ] },
|
||||||
|
"Location": { "rich_text": textProp("") },
|
||||||
|
"Owner": { "rich_text": textProp(ep.owner) },
|
||||||
|
"State": { "select": { "name": "Proposed" } }
|
||||||
|
},
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"object": "block",
|
||||||
|
"type": "heading_2",
|
||||||
|
"heading_2": { "text": textProp("Purpose") }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"object": "block",
|
||||||
|
"type": "paragraph",
|
||||||
|
"paragraph": { "text": textProp(ep.purpose) }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"object": "block",
|
||||||
|
"type": "heading_2",
|
||||||
|
"heading_2": { "text": textProp("Description") }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"object": "block",
|
||||||
|
"type": "paragraph",
|
||||||
|
"paragraph": { "text": textProp(ep.description) }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
22
api/src/hff_entry_forms_apipkg/notion_client.nim
Normal file
22
api/src/hff_entry_forms_apipkg/notion_client.nim
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import json, logging, std/httpclient, strutils
|
||||||
|
|
||||||
|
import ./models, ./service
|
||||||
|
|
||||||
|
proc createProposedEvent*(cfg: HffEntryFormsApiConfig, ep: EventProposal): bool =
|
||||||
|
let headers = newHttpHeaders([
|
||||||
|
("Content-Type", "application/json"),
|
||||||
|
("Authorization", "Bearer " & cfg.integrationToken),
|
||||||
|
("Notion-Version", cfg.notionVersion)
|
||||||
|
], true)
|
||||||
|
let http = newHttpClient(headers = headers, )
|
||||||
|
|
||||||
|
debug $headers
|
||||||
|
let epNotionPage = ep.asNotionPage
|
||||||
|
epNotionPage["parent"] = %*{ "database_id": cfg.eventParentId }
|
||||||
|
|
||||||
|
let apiResp = http.post(cfg.notionApiBaseUrl & "/pages", $epNotionPage)
|
||||||
|
|
||||||
|
debug apiResp.status
|
||||||
|
if not apiResp.status.startsWith("2"): debug apiResp.body
|
||||||
|
|
||||||
|
return apiResp.status.startsWith("2")
|
37
api/src/hff_entry_forms_apipkg/service.nim
Normal file
37
api/src/hff_entry_forms_apipkg/service.nim
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import jester, strutils
|
||||||
|
|
||||||
|
type
|
||||||
|
ApiError* = object of CatchableError
|
||||||
|
respMsg*: string
|
||||||
|
respCode*: HttpCode
|
||||||
|
|
||||||
|
HffEntryFormsApiConfig* = ref object
|
||||||
|
debug*: bool
|
||||||
|
eventParentId*: string
|
||||||
|
integrationToken*: string
|
||||||
|
knownOrigins*: seq[string]
|
||||||
|
notionApiBaseUrl*: string
|
||||||
|
notionVersion*: string
|
||||||
|
port*: int
|
||||||
|
|
||||||
|
proc newApiError*(parent: ref Exception = nil, respCode: HttpCode, respMsg: string, msg = ""): ref ApiError =
|
||||||
|
result = newException(ApiError, msg, parent)
|
||||||
|
result.respCode = respCode
|
||||||
|
result.respMsg = respMsg
|
||||||
|
|
||||||
|
proc raiseApiError*(respCode: HttpCode, respMsg: string, msg = "") =
|
||||||
|
var apiError = newApiError(
|
||||||
|
parent = nil,
|
||||||
|
respCode = respCode,
|
||||||
|
respMsg = respMsg,
|
||||||
|
msg = if msg.isEmptyOrWhitespace: respMsg
|
||||||
|
else: msg)
|
||||||
|
raise apiError
|
||||||
|
|
||||||
|
proc raiseApiError*(parent: ref Exception, respCode: HttpCode, respMsg: string, msg = "") =
|
||||||
|
var apiError = newApiError(
|
||||||
|
parent = parent,
|
||||||
|
respCode = respCode,
|
||||||
|
respMsg = respMsg,
|
||||||
|
msg = msg)
|
||||||
|
raise apiError
|
1
api/src/hff_entry_forms_apipkg/version.nim
Executable file
1
api/src/hff_entry_forms_apipkg/version.nim
Executable file
@ -0,0 +1 @@
|
|||||||
|
const HFF_ENTRY_FORMS_API_VERSION* = "0.1.0"
|
1
api/test/sample-page-content.json
Normal file
1
api/test/sample-page-content.json
Normal file
@ -0,0 +1 @@
|
|||||||
|
{"object":"list","results":[{"object":"block","id":"545152f3-790a-430f-a37b-ca87b61d7a52","created_time":"2021-10-23T16:47:00.000Z","last_edited_time":"2021-10-23T16:47:00.000Z","has_children":false,"archived":false,"type":"heading_1","heading_1":{"text":[{"type":"text","text":{"content":"Purpose","link":null},"annotations":{"bold":false,"italic":false,"strikethrough":false,"underline":false,"code":false,"color":"default"},"plain_text":"Purpose","href":null}]}},{"object":"block","id":"c90f1427-ef37-4330-a6a2-c635897fa733","created_time":"2021-10-23T16:48:00.000Z","last_edited_time":"2021-10-23T16:48:00.000Z","has_children":false,"archived":false,"type":"paragraph","paragraph":{"text":[{"type":"text","text":{"content":"Rehearse for the upcoming Sunday worship service and learn new music for future worship.","link":null},"annotations":{"bold":false,"italic":false,"strikethrough":false,"underline":false,"code":false,"color":"default"},"plain_text":"Rehearse for the upcoming Sunday worship service and learn new music for future worship.","href":null}]}},{"object":"block","id":"4e238570-b611-4934-a301-459b1a965f02","created_time":"2021-10-23T16:48:00.000Z","last_edited_time":"2021-10-23T16:48:00.000Z","has_children":false,"archived":false,"type":"heading_1","heading_1":{"text":[{"type":"text","text":{"content":"Description","link":null},"annotations":{"bold":false,"italic":false,"strikethrough":false,"underline":false,"code":false,"color":"default"},"plain_text":"Description","href":null}]}},{"object":"block","id":"8926708a-2ec0-4a7a-9063-7057f626f941","created_time":"2021-10-23T16:48:00.000Z","last_edited_time":"2021-10-23T16:50:00.000Z","has_children":false,"archived":false,"type":"paragraph","paragraph":{"text":[{"type":"text","text":{"content":"Music practice is a time for our band and singers to come together to rehearse, practice, and learn new music as a group.","link":null},"annotations":{"bold":false,"italic":false,"strikethrough":false,"underline":false,"code":false,"color":"default"},"plain_text":"Music practice is a time for our band and singers to come together to rehearse, practice, and learn new music as a group.","href":null}]}}],"next_cursor":null,"has_more":false}
|
1
api/test/sample-page.json
Normal file
1
api/test/sample-page.json
Normal file
@ -0,0 +1 @@
|
|||||||
|
{"object":"page","id":"23573218-0bb9-4f8f-b39a-d27200685100","created_time":"2021-09-26T04:42:00.000Z","last_edited_time":"2021-10-23T16:50:00.000Z","cover":null,"icon":null,"parent":{"type":"database_id","database_id":"ffff5ca5-cb05-47b6-a99f-b78cdb9b0170"},"archived":false,"properties":{"Owner":{"id":"QLpL","type":"rich_text","rich_text":[{"type":"text","text":{"content":"Jonathan Bernard","link":null},"annotations":{"bold":false,"italic":false,"strikethrough":false,"underline":false,"code":false,"color":"default"},"plain_text":"Jonathan Bernard","href":null}]},"Date":{"id":"cjD%5B","type":"date","date":{"start":"2021-10-23T12:00:00.000-05:00","end":"2021-10-23T15:00:00.000-05:00"}},"Location":{"id":"mquy","type":"rich_text","rich_text":[{"type":"text","text":{"content":"at HFF\n303 E Morrow Street, Georgetown, TX, 78626","link":null},"annotations":{"bold":false,"italic":false,"strikethrough":false,"underline":false,"code":false,"color":"default"},"plain_text":"at HFF\n303 E Morrow Street, Georgetown, TX, 78626","href":null}]},"Department":{"id":"ujde","type":"multi_select","multi_select":[{"id":"362f7da9-3f60-42f5-8f52-a00143b9731a","name":"Music","color":"red"}]},"State":{"id":"~lrH","type":"select","select":{"id":"2848aeb4-3198-41fc-b682-7130c3eed2bc","name":"Confirmed","color":"green"}},"Event":{"id":"title","type":"title","title":[{"type":"text","text":{"content":"Music Practice","link":null},"annotations":{"bold":false,"italic":false,"strikethrough":false,"underline":false,"code":false,"color":"default"},"plain_text":"Music Practice","href":null}]}},"url":"https://www.notion.so/Music-Practice-235732180bb94f8fb39ad27200685100"}
|
1
api/test/test.json
Normal file
1
api/test/test.json
Normal file
@ -0,0 +1 @@
|
|||||||
|
{"properties":{"Event":{"title":[{"type":"text","text":{"content":"Test Event"}}]},"Date":{"date":{"start":"2021-10-23T09:20:00.000-05:00"}},"Department":{"multi_select":[{"name":"Men"}]},"Location":{"rich_text":[{"type":"text","text":{"content":""}}]},"Owner":{"rich_text":[{"type":"text","text":{"content":"Jonathan Bernard"}}]},"State":{"select":{"name":"Proposed"}}},"children":[{"object":"block","type":"heading_2","heading_2":{"text":[{"type":"text","text":{"content":"Purpose"}}]}},{"object":"block","type":"paragraph","paragraph":{"text":[{"type":"text","text":{"content":"Test the entry form system."}}]}},{"object":"block","type":"heading_2","heading_2":{"text":[{"type":"text","text":{"content":"Description"}}]}},{"object":"block","type":"paragraph","paragraph":{"text":[{"type":"text","text":{"content":"This is a test event I'm using to test the entry form stuff."}}]}}],"parent":{"database_id":"ffff5ca5cb0547b6a99fb78cdb9b0170"}}
|
Loading…
Reference in New Issue
Block a user