-
Notifications
You must be signed in to change notification settings - Fork 119
Expand file tree
/
Copy pathHackerNews.scala
More file actions
135 lines (120 loc) · 5.17 KB
/
Copy pathHackerNews.scala
File metadata and controls
135 lines (120 loc) · 5.17 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
package demo
import kyo.*
/** Hacker News API proxy.
*
* Fetches stories from the official HN API and the Algolia HN Search API. Demonstrates baseUrl config, parallel fetching of individual
* story details, and typed routes with query params.
*/
object HackerNews extends KyoApp:
// HN API models
case class HnItem(
id: Int,
title: Option[String],
url: Option[String],
by: Option[String],
score: Option[Int],
time: Option[Long],
descendants: Option[Int]
) derives Schema
// Algolia HN Search models
case class AlgoliaResponse(hits: List[AlgoliaHit]) derives Schema
case class AlgoliaHit(
objectID: String,
title: Option[String],
url: Option[String],
author: String,
points: Option[Int],
num_comments: Option[Int]
) derives Schema
// Our response models
case class Story(id: Int, title: String, url: String, author: String, score: Int, comments: Int) derives Schema
case class SearchStory(id: String, title: String, url: String, author: String, points: Int, comments: Int) derives Schema
case class ApiError(error: String) derives Schema
def hnItemToStory(item: HnItem): Story =
Story(
item.id,
item.title.getOrElse("(untitled)"),
item.url.getOrElse(s"https://news.ycombinator.com/item?id=${item.id}"),
item.by.getOrElse("unknown"),
item.score.getOrElse(0),
item.descendants.getOrElse(0)
)
def fetchTopStories(limit: Int): Seq[Story] < (Async & Abort[HttpException]) =
HttpClient.withConfig(_.timeout(10.seconds)) {
for
ids <- HttpClient.getJson[Seq[Int]]("https://hacker-news.firebaseio.com/v0/topstories.json")
top = ids.take(limit)
stories <- Async.foreach(top, top.size) { id =>
HttpClient.getJson[HnItem](s"https://hacker-news.firebaseio.com/v0/item/$id.json")
.map(hnItemToStory)
}
yield stories
}
def searchStories(query: String, limit: Int): Seq[SearchStory] < (Async & Abort[HttpException]) =
val url = s"https://hn.algolia.com/api/v1/search?query=${java.net.URLEncoder.encode(query, "UTF-8")}&hitsPerPage=$limit"
HttpClient.withConfig(_.timeout(10.seconds)) {
HttpClient.getJson[AlgoliaResponse](url).map { resp =>
resp.hits.map { hit =>
SearchStory(
hit.objectID,
hit.title.getOrElse("(untitled)"),
hit.url.getOrElse(s"https://news.ycombinator.com/item?id=${hit.objectID}"),
hit.author,
hit.points.getOrElse(0),
hit.num_comments.getOrElse(0)
)
}
}
}
end searchStories
def fetchStory(id: Int): Story < (Async & Abort[HttpException]) =
HttpClient.withConfig(_.timeout(10.seconds)) {
HttpClient.getJson[HnItem](s"https://hacker-news.firebaseio.com/v0/item/$id.json")
.map(hnItemToStory)
}
val loggingFilter = HttpFilter.server.logging
val topRoute = HttpRoute
.getRaw("top")
.filter(loggingFilter)
.request(_.query[Int]("limit", default = Present(10)))
.response(_.bodyJson[Seq[Story]].error[ApiError](HttpStatus.BadRequest))
.metadata(_.summary("Top HN stories").tag("stories"))
.handler { req =>
fetchTopStories(req.fields.limit).map(HttpResponse.ok(_))
}
val searchRoute = HttpRoute
.getRaw("search")
.filter(loggingFilter)
.request(
_.query[String]("q")
.query[Int]("limit", default = Present(10))
)
.response(_.bodyJson[Seq[SearchStory]].error[ApiError](HttpStatus.BadRequest))
.metadata(_.summary("Search HN stories").tag("search"))
.handler { req =>
searchStories(req.fields.q, req.fields.limit).map(HttpResponse.ok(_))
}
val storyRoute = HttpRoute
.getRaw("story" / HttpPath.Capture[Int]("id"))
.filter(loggingFilter)
.response(_.bodyJson[Story].error[ApiError](HttpStatus.NotFound))
.metadata(_.summary("Get story by ID").tag("stories"))
.handler { req =>
fetchStory(req.fields.id).map(HttpResponse.ok(_))
}
val health = HttpHandler.health()
run {
val port = args.headOption.flatMap(_.toIntOption).getOrElse(0)
HttpServer.init(
HttpServerConfig.default.port(port).openApi("/openapi.json", "Hacker News API")
)(topRoute, searchRoute, storyRoute, health).map { server =>
for
_ <- Console.printLine(s"HackerNews API running on http://localhost:${server.port}")
_ <- Console.printLine(s" curl http://localhost:${server.port}/top?limit=5")
_ <- Console.printLine(s" curl http://localhost:${server.port}/search?q=scala")
_ <- Console.printLine(s" curl http://localhost:${server.port}/story/1")
_ <- server.await
yield ()
}
}
end HackerNews