commit 588454d668af38bf40119697a5f687e22d3f3f3a Author: Jonathan Bernard Date: Sun Oct 24 05:53:22 2021 -0500 api: initial implementation with support for creating EventProposals. diff --git a/api/hff_entry_forms_api.config.json b/api/hff_entry_forms_api.config.json new file mode 100644 index 0000000..053ee7d --- /dev/null +++ b/api/hff_entry_forms_api.config.json @@ -0,0 +1,7 @@ +{ + "eventParentId": "ffff5ca5cb0547b6a99fb78cdb9b0170", + "knownOrigins": [ "http://curl.localhost" ], + "notionApiBaseUrl": "https://api.notion.com/v1", + "notionVersion": "2021-08-16", + "port": 8300 +} diff --git a/api/hff_entry_forms_api.nimble b/api/hff_entry_forms_api.nimble new file mode 100644 index 0000000..ab9e5fd --- /dev/null +++ b/api/hff_entry_forms_api.nimble @@ -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'" \ No newline at end of file diff --git a/api/src/config.nims b/api/src/config.nims new file mode 100644 index 0000000..ae5be83 --- /dev/null +++ b/api/src/config.nims @@ -0,0 +1 @@ +switch("d", "ssl") diff --git a/api/src/hff_entry_forms_api.nim b/api/src/hff_entry_forms_api.nim new file mode 100644 index 0000000..f81d5ef --- /dev/null +++ b/api/src/hff_entry_forms_api.nim @@ -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 Location of the config file to use (defaults to + hff_entry_forms_api.config.json) + + -d, --debug Log debugging information. + + -p, --port 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) diff --git a/api/src/hff_entry_forms_apipkg/api.nim b/api/src/hff_entry_forms_apipkg/api.nim new file mode 100644 index 0000000..b54d6ca --- /dev/null +++ b/api/src/hff_entry_forms_apipkg/api.nim @@ -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") diff --git a/api/src/hff_entry_forms_apipkg/models.nim b/api/src/hff_entry_forms_apipkg/models.nim new file mode 100644 index 0000000..25992da --- /dev/null +++ b/api/src/hff_entry_forms_apipkg/models.nim @@ -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) } + } + ] + } diff --git a/api/src/hff_entry_forms_apipkg/notion_client.nim b/api/src/hff_entry_forms_apipkg/notion_client.nim new file mode 100644 index 0000000..0abb52c --- /dev/null +++ b/api/src/hff_entry_forms_apipkg/notion_client.nim @@ -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") diff --git a/api/src/hff_entry_forms_apipkg/service.nim b/api/src/hff_entry_forms_apipkg/service.nim new file mode 100644 index 0000000..ec3063f --- /dev/null +++ b/api/src/hff_entry_forms_apipkg/service.nim @@ -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 diff --git a/api/src/hff_entry_forms_apipkg/version.nim b/api/src/hff_entry_forms_apipkg/version.nim new file mode 100755 index 0000000..1cc9630 --- /dev/null +++ b/api/src/hff_entry_forms_apipkg/version.nim @@ -0,0 +1 @@ +const HFF_ENTRY_FORMS_API_VERSION* = "0.1.0" \ No newline at end of file diff --git a/api/test/sample-page-content.json b/api/test/sample-page-content.json new file mode 100644 index 0000000..d12dde1 --- /dev/null +++ b/api/test/sample-page-content.json @@ -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} \ No newline at end of file diff --git a/api/test/sample-page.json b/api/test/sample-page.json new file mode 100644 index 0000000..64b0c19 --- /dev/null +++ b/api/test/sample-page.json @@ -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"} \ No newline at end of file diff --git a/api/test/test.json b/api/test/test.json new file mode 100644 index 0000000..a15228c --- /dev/null +++ b/api/test/test.json @@ -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"}} \ No newline at end of file