api: initial implementation with support for creating EventProposals.
This commit is contained in:
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"
|
Reference in New Issue
Block a user