Build

Build a web API

Build Shorty, a link shortener with a JSON API, redirects, validation, visit counts and a token-protected route group.

7 steps · about 25 minutes · uses serve, requests, result

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.

What you'll have at the end
401 {"error":"unauthorized"}
201 {"code":"l1","url":"https://osl-lang.org"}
302 shorty
200 {"hits":1,"url":"https://osl-lang.org"}
Step 1 of 7

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")
))
Click the code to edit it
Step 5 of 7

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.

Your turn

Say it nicely

Make visits say no visits, 1 visit, or 5 visits.

main.osl
Ctrl Enter runs
Expected output
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)
))
Click the code to edit it

Highlighted lines are new in this step.

Step 6 of 7

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.

Quick check

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)
))
Click the code to edit it

Highlighted lines are new in this step.

Step 7 of 7

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:

osl
log "Shorty is listening on http://localhost:8080"
app.run(":8080").unwrap()

Run it with osl run shorty.osl, then in another terminal:

Terminal
curl -H "Authorization: Bearer secret" -d '{"url":"https://osl-lang.org"}' localhost:8080/api/links
curl -i localhost:8080/l1

Links 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.