From 710be0b41372d2a034e5c85964d57e60536a1964 Mon Sep 17 00:00:00 2001 From: Xie Yanbo Date: Fri, 1 Oct 2021 00:43:18 +0800 Subject: [PATCH] API for searching users and posts, user profile, timeline, with_replies and media --- src/restutils.nim | 15 +++++++++++ src/routes/rest.nim | 36 +++++++++++++++++++++++++ src/routes/search.nim | 37 ++++++++++++++++++++++---- src/routes/timeline.nim | 40 ++++++++++++++++++++++------ src/types.nim | 58 +++++++++++++++++++++++++++++++++++++++-- 5 files changed, 171 insertions(+), 15 deletions(-) create mode 100644 src/restutils.nim create mode 100644 src/routes/rest.nim diff --git a/src/restutils.nim b/src/restutils.nim new file mode 100644 index 000000000..92f55d251 --- /dev/null +++ b/src/restutils.nim @@ -0,0 +1,15 @@ +import uri +import jester +import types, query + +proc getLinkHeader*(results: Result, req: Request): string = + let + cursor = results.bottom + query = results.query + var url = if req.secure: "https://" else: "http://" + url &= req.host & req.path + var links = newLinkHeader() + links["first"] = url & "?" & genQueryUrl(query) + if results.content.len > 0 and results.bottom.len > 0: + links["next"] = url & "?" & genQueryUrl(query) & "&cursor=" & encodeUrl(cursor) + result = $links diff --git a/src/routes/rest.nim b/src/routes/rest.nim new file mode 100644 index 000000000..749ec343e --- /dev/null +++ b/src/routes/rest.nim @@ -0,0 +1,36 @@ +import json +import jester +import ".."/[types, restutils] + +export restutils + +template rest*(code: HttpCode; message: string): untyped = + ## Response of RESTful API + mixin resp + resp code, @{"Content-Type": "application/json"}, message + +template rest*(code: HttpCode; message: JsonNode): untyped = + mixin rest + rest code, $message + +template rest*(code: HttpCode; profile: Profile): untyped = + mixin rest + rest code, %profile + +template rest*(code: HttpCode; timeline: Timeline): untyped = + mixin rest + rest code, %timeline + +template rest*[T](code: HttpCode; results: Result[T]): untyped = + mixin rest + rest code, %results + +template rest*[T](code: HttpCode; results: Result[T]; + request: Request): untyped = + mixin resp + resp code, @{"Content-Type": "application/json", + "Link": $getLinkHeader(results, request)}, $ %results + +template restError*(code: HttpCode; message: string): untyped = + mixin rest + rest code, %newRestApiError(message) diff --git a/src/routes/search.nim b/src/routes/search.nim index 329d955a7..6a788e943 100644 --- a/src/routes/search.nim +++ b/src/routes/search.nim @@ -1,20 +1,26 @@ -import strutils, sequtils, uri +import strutils, sequtils, uri, json import jester import router_utils -import ".."/[query, types, api] +import rest +import ".."/[query, types, api, restutils] import ../views/[general, search] include "../views/opensearch.nimf" export search +export rest + +const + searchLimit* = 500 proc createSearchRouter*(cfg: Config) = router search: get "/search/?": - if @"q".len > 500: - resp Http400, showError("Search input too long.", cfg) + if @"q".len > searchLimit: + resp Http400, showError("Search input too long, max limit: " & + $searchLimit, cfg) let prefs = cookiePrefs() @@ -31,10 +37,31 @@ proc createSearchRouter*(cfg: Config) = tweets = await getSearch[Tweet](query, getCursor()) rss = "/search/rss?" & genQueryUrl(query) resp renderMain(renderTweetSearch(tweets, prefs, getPath()), - request, cfg, prefs, rss=rss) + request, cfg, prefs, rss = rss) else: resp Http404, showError("Invalid search", cfg) + get "/api/search/?": + if @"q".len > searchLimit: + restError Http400, "Search input too long, max limit: " & $searchLimit + let + prefs = cookiePrefs() + query = initQuery(params(request)) + + case query.kind + of users: + if "," in @"q": + redirect("/" & @"q") + let users = await getSearch[Profile](query, getCursor()) + rest Http200, users, request + of tweets: + let + tweets = await getSearch[Tweet](query, getCursor()) + rss = "/search/rss?" & genQueryUrl(query) + rest Http200, tweets, request + else: + restError Http404, "Invalid search type: " & $query.kind + get "/hashtag/@hash": redirect("/search?q=" & encodeUrl("#" & @"hash")) diff --git a/src/routes/timeline.nim b/src/routes/timeline.nim index 8d75520d9..5ec30b78b 100644 --- a/src/routes/timeline.nim +++ b/src/routes/timeline.nim @@ -2,6 +2,7 @@ import asyncdispatch, strutils, sequtils, uri, options, times import jester, karax/vdom import router_utils +import rest import ".."/[types, redis_cache, formatters, query, api] import ../views/[general, profile, timeline, status, search] @@ -10,15 +11,16 @@ export uri, sequtils export router_utils export redis_cache, formatters, query, api export profile, timeline, status +export rest proc getQuery*(request: Request; tab, name: string): Query = case tab of "with_replies": getReplyQuery(name) of "media": getMediaQuery(name) - of "search": initQuery(params(request), name=name) + of "search": initQuery(params(request), name = name) else: Query(fromUser: @[name]) -proc fetchSingleTimeline*(after: string; query: Query; skipRail=false): +proc fetchSingleTimeline*(after: string; query: Query; skipRail = false): Future[(Profile, Timeline, PhotoRail)] {.async.} = let name = query.fromUser[0] @@ -33,7 +35,7 @@ proc fetchSingleTimeline*(after: string; query: Query; skipRail=false): else: profile.id if profileId.len > 0: - await cacheProfileId(profile.username, profileId) + await cacheProfileId(profile.username, profileId) fetched = true @@ -54,7 +56,7 @@ proc fetchSingleTimeline*(after: string; query: Query; skipRail=false): var timeline = case query.kind of posts: await getTimeline(profileId, after) - of replies: await getTimeline(profileId, after, replies=true) + of replies: await getTimeline(profileId, after, replies = true) of media: await getMediaTimeline(profileId, after) else: await getSearch[Tweet](query, after) @@ -86,7 +88,7 @@ proc showTimeline*(request: Request; query: Query; cfg: Config; prefs: Prefs; let timeline = await getSearch[Tweet](query, after) html = renderTweetSearch(timeline, prefs, getPath()) - return renderMain(html, request, cfg, prefs, "Multi", rss=rss) + return renderMain(html, request, cfg, prefs, "Multi", rss = rss) var (p, t, r) = await fetchSingleTimeline(after, query) @@ -95,8 +97,8 @@ proc showTimeline*(request: Request; query: Query; cfg: Config; prefs: Prefs; let pHtml = renderProfile(p, t, r, prefs, getPath()) result = renderMain(pHtml, request, cfg, prefs, pageTitle(p), pageDesc(p), - rss=rss, images = @[p.getUserpic("_400x400")], - banner=p.banner) + rss = rss, images = @[p.getUserpic("_400x400")], + banner = p.banner) template respTimeline*(timeline: typed) = let t = timeline @@ -126,7 +128,8 @@ proc createTimelineRouter*(cfg: Config) = timeline.beginning = true resp $renderTweetSearch(timeline, prefs, getPath()) else: - var (_, timeline, _) = await fetchSingleTimeline(after, query, skipRail=true) + var (_, timeline, _) = await fetchSingleTimeline(after, query, + skipRail = true) if timeline.content.len == 0: resp Http404 timeline.beginning = true resp $renderTimelineTweets(timeline, prefs, getPath()) @@ -138,3 +141,24 @@ proc createTimelineRouter*(cfg: Config) = rss &= "?" & genQueryUrl(query) respTimeline(await showTimeline(request, query, cfg, prefs, rss, after)) + + get "/api/@name/@tab/?": + cond '.' notin @"name" + cond @"name" notin ["pic", "gif", "video"] + cond @"tab" in ["profile", "timeline", "with_replies", "media", "search", ""] + let + prefs = cookiePrefs() + after = getCursor() + names = getNames(@"name") + + var query = request.getQuery(@"tab", @"name") + if names.len != 1: + query.fromUser = names + + var (profile, timeline, _) = await fetchSingleTimeline(after, query, + skipRail = true) + + if @"tab" in ["profile", ""]: + rest Http200, profile + else: + rest Http200, timeline diff --git a/src/types.nim b/src/types.nim index d405e2694..d5d93ec1b 100644 --- a/src/types.nim +++ b/src/types.nim @@ -1,4 +1,4 @@ -import times, sequtils, options, tables +import times, sequtils, options, tables, json import prefs_impl genPrefsType() @@ -126,7 +126,7 @@ type videoDirectMessage = "video_direct_message" imageDirectMessage = "image_direct_message" audiospace = "audiospace" - + Card* = object kind*: CardKind id*: string @@ -227,5 +227,59 @@ type Rss* = object feed*, cursor*: string + RestApiError* = object + message*: string + + LinkHeader* = object + links*: TableRef[string, string] + proc contains*(thread: Chain; tweet: Tweet): bool = thread.content.anyIt(it.id == tweet.id) + +proc `%`*[T](p: Result[T]): JsonNode = + result = %p.content + +proc `%`*(t: Time): JsonNode = + result = JsonNode(kind: JString, str: format(t, "yyyy-MM-dd'T'HH:mm:sszzz", utc())) + +proc `%`*(t: Tweet): JsonNode = + let p = t.profile + result = %* { + "id": t.id, + "threadId": t.threadId, + "replyId": t.replyId, + "profile": {"id": p.id, "username": p.username, "fullname": p.fullname}, + "text": t.text, + "time": t.time, + "reply": t.reply, + "pinned": t.pinned, + "hasThread": t.hasThread, + "available": t.available, + "tombstone": t.tombstone, + "location": t.location, + "stats": t.stats, + "retweet": t.retweet, + "attribution": t.attribution, + "mediaTags": t.mediaTags, + "quote": t.quote, + "card": t.card, + "poll": t.poll, + "gif": t.gif, + "video": t.video, + "photos": t.photos, + } + +proc newRestApiError*(message: string): RestApiError = + result.message = message + +proc newLinkHeader*(): LinkHeader = + result.links = newTable[string, string]() + +proc `[]=`*(links: LinkHeader; rel: string; url: sink string) = + links.links[rel] = url + +proc `$`*(links: LinkHeader): string = + for rel, url in links.links: + if len(result) > 0: + add(result, ", ") + add(result, "<" & url & ">; rel=\"" & rel & "\"")