Build a web API
Build Shorty, a link shortener with a JSON API, redirects, validation, visit counts and a token-protected route group.
You'll build Shorty: post a long URL, get back a short code, and visiting the code redirects to the URL. Along the way: routes, path parameters, JSON bodies, validation, middleware and route groups.
Servers run forever, so each try-it tab starts the router with app.start, calls it with std:requests, prints the answer and stops it.
401 {"error":"unauthorized"}
201 {"code":"l1","url":"https://osl-lang.org"}
302 shorty
200 {"hits":1,"url":"https://osl-lang.org"}
Answer a request
serve.new() makes a router and app.get(path, handler) adds a route. A handler gets a *serve.Context, which holds the request and is how you answer: c.text(status, body) sends text. A path with no route gets a 404 from the router.
Try changing the text, or the status number.
import "std:serve"
import "std:requests"
*serve.Router app = serve.new()
app.get("/health", def(*serve.Context c) (
c.text(200, "Shorty is running")
))Create a link
POST /links reads the JSON body into a NewLink with c.bindJSON, picks a code, remembers the URL in a map and answers 201 (created) with c.json. Handlers run on several threads at once, and this shared map has no lock, so a real service would guard it. The concurrency lesson shows how.
Four-digit codes
Make codeFor return l0007 for 7 and l0042 for 42.
l0007 l0042
import "std:serve"
import "std:requests"
class Link (
string url = ""
int hits = 0
)
class NewLink (
string url = ""
)
map<string, Link> links = {}
int made = 0
*serve.Router app = serve.new()
app.get("/health", def(*serve.Context c) (
c.text(200, "Shorty is running")
))
app.post("/links", def(*serve.Context c) (
NewLink input = NewLink()
c.bindJSON(input).unwrap()
made += 1
string code = "l" ++ made
Link link = Link()
link.url = input.url
links[code] = link
c.json(201, {code: code, url: input.url})
))Highlighted lines are new in this step.
Follow a link
/:code matches one path segment, and c.param("code") reads it. Known codes get c.redirect(302, url). Unknown ones get c.notFound(message), which answers 404 with a JSON error. The try-it tab asks requests not to follow the redirect, so we can see it.
Why does Shorty redirect with 302, not 301?
Add an about route
Add GET /about that answers 200 with Shorty shortens links.
200 Shorty shortens links
import "std:serve"
import "std:requests"
class Link (
string url = ""
int hits = 0
)
class NewLink (
string url = ""
)
map<string, Link> links = {}
int made = 0
*serve.Router app = serve.new()
app.get("/health", def(*serve.Context c) (
c.text(200, "Shorty is running")
))
app.post("/links", def(*serve.Context c) (
NewLink input = NewLink()
c.bindJSON(input).unwrap()
made += 1
string code = "l" ++ made
Link link = Link()
link.url = input.url
links[code] = link
c.json(201, {code: code, url: input.url})
))
app.get("/:code", def(*serve.Context c) (
string code = c.param("code")
if !links.contains(code) (
c.notFound("no such link")
return
)
c.redirect(302, links[code].url)
))Highlighted lines are new in this step.
Reject bad links
bindJSON checks the shape of the body, not whether the values make sense. Put the rules in a function that returns a result and switch on it: a good URL is stored, a bad one gets 422. Broken JSON is rejected with a 400 before your code runs. See errors.
Reject spaces
Also reject URLs containing a space, with the message url must not contain spaces.
ok https://osl-lang.org error: url must start with http error: url must not contain spaces
import "std:serve"
import "std:requests"
import "std:result"
class Link (
string url = ""
int hits = 0
)
class NewLink (
string url = ""
)
def validate(NewLink input) result<NewLink, error> (
if !input.url.startsWith("http") (
return result.err(error("url must start with http"))
)
return result.ok(input)
)
map<string, Link> links = {}
int made = 0
*serve.Router app = serve.new()
app.get("/health", def(*serve.Context c) (
c.text(200, "Shorty is running")
))
app.post("/links", def(*serve.Context c) (
NewLink input = NewLink()
c.bindJSON(input).unwrap()
switch validate(input) (
case ok(good)
made += 1
string code = "l" ++ made
Link link = Link()
link.url = good.url
links[code] = link
c.json(201, {code: code, url: good.url})
case err(e)
c.json(422, {error: e.message})
)
))
app.get("/:code", def(*serve.Context c) (
string code = c.param("code")
if !links.contains(code) (
c.notFound("no such link")
return
)
c.redirect(302, links[code].url)
))Highlighted lines are new in this step.
Count visits
The redirect now adds one to the link's hits, and GET /links/:code reports them. Put the stats route before /:code: /links/l1 has two segments, so it can't clash, but keeping specific routes first is a good habit.
Say it nicely
Make visits say no visits, 1 visit, or 5 visits.
no visits 1 visit 5 visits
import "std:serve"
import "std:requests"
import "std:result"
class Link (
string url = ""
int hits = 0
)
class NewLink (
string url = ""
)
def validate(NewLink input) result<NewLink, error> (
if !input.url.startsWith("http") (
return result.err(error("url must start with http"))
)
return result.ok(input)
)
map<string, Link> links = {}
int made = 0
*serve.Router app = serve.new()
app.get("/health", def(*serve.Context c) (
c.text(200, "Shorty is running")
))
app.post("/links", def(*serve.Context c) (
NewLink input = NewLink()
c.bindJSON(input).unwrap()
switch validate(input) (
case ok(good)
made += 1
string code = "l" ++ made
Link link = Link()
link.url = good.url
links[code] = link
c.json(201, {code: code, url: good.url})
case err(e)
c.json(422, {error: e.message})
)
))
app.get("/links/:code", def(*serve.Context c) (
string code = c.param("code")
if !links.contains(code) (
c.notFound("no such link")
return
)
c.json(200, {url: links[code].url, hits: links[code].hits})
))
app.get("/:code", def(*serve.Context c) (
string code = c.param("code")
if !links.contains(code) (
c.notFound("no such link")
return
)
links[code].hits += 1
c.redirect(302, links[code].url)
))Highlighted lines are new in this step.
Protect the API with a group
Anyone may follow a link, but only the owner should create them. app.group("/api", setup) makes a group: its routes get the /api prefix, and middleware added with g.use applies only inside it. serve.requireBearer("secret") answers 401 without the right Authorization: Bearer secret header. A middleware on app.use runs first for everything and calls c.next() to pass the request on.
A request to /api/links has no token. What does the client see?
import "std:serve"
import "std:requests"
import "std:result"
class Link (
string url = ""
int hits = 0
)
class NewLink (
string url = ""
)
def validate(NewLink input) result<NewLink, error> (
if !input.url.startsWith("http") (
return result.err(error("url must start with http"))
)
return result.ok(input)
)
map<string, Link> links = {}
int made = 0
*serve.Router app = serve.new()
app.use(def(*serve.Context c) (
c.setHeader("X-Powered-By", "shorty")
c.next()
))
app.get("/health", def(*serve.Context c) (
c.text(200, "Shorty is running")
))
app.group("/api", def(*serve.Router g) (
g.use(serve.requireBearer("secret"))
g.post("/links", def(*serve.Context c) (
NewLink input = NewLink()
c.bindJSON(input).unwrap()
switch validate(input) (
case ok(good)
made += 1
string code = "l" ++ made
Link link = Link()
link.url = good.url
links[code] = link
c.json(201, {code: code, url: good.url})
case err(e)
c.json(422, {error: e.message})
)
))
g.get("/links/:code", def(*serve.Context c) (
string code = c.param("code")
if !links.contains(code) (
c.notFound("no such link")
return
)
c.json(200, {url: links[code].url, hits: links[code].hits})
))
))
app.get("/:code", def(*serve.Context c) (
string code = c.param("code")
if !links.contains(code) (
c.notFound("no such link")
return
)
links[code].hits += 1
c.redirect(302, links[code].url)
))Highlighted lines are new in this step.
Run it for real
The try-it tab starts and stops the server itself. To run Shorty for real, save the program above without the try-it part and finish it with app.run, which serves until the program stops:
log "Shorty is listening on http://localhost:8080"
app.run(":8080").unwrap()Run it with osl run shorty.osl, then in another terminal:
curl -H "Authorization: Bearer secret" -d '{"url":"https://osl-lang.org"}' localhost:8080/api/links
curl -i localhost:8080/l1Links vanish when the program restarts. To keep them, swap the map for db (db.open, createTable, insert). Next: realtime chat, or the serve and requests pages.