Skip to main content

Your first HTTP API

Getting started printed a line to the console. This guide builds something you can curl: an HTTP endpoint with a validated request, a typed response, and configuration bound to the environment. About fifteen minutes.

By the end you will have used every piece an ordinary Telo application is made of — a service, a router, a handler, references, expressions, and config.

1. A server, a router, and a handler​

Create greeting-api/telo.yaml:

kind: Telo.Application
metadata:
name: GreetingApi
version: 1.0.0
imports:
Http: oci://ghcr.io/telorun/http-server@0.38.0
Run: oci://ghcr.io/telorun/run@0.28.0
targets:
- !ref Server
ports:
http:
env: PORT
default: 8080
---
kind: Http.Server
metadata:
name: Server
listen:
port: !cel "ports.http"
mounts:
- path: /v1
mount: !ref Api
---
kind: Http.Api
metadata:
name: Api
routes:
- request:
path: /greet
method: GET
handler: !ref Greet
returns:
- status: 200
content:
application/json:
body:
message: !cel "result.message"
---
kind: Run.Value
metadata:
name: Greet
value:
message: Hello!
telo ./greeting-api
curl http://localhost:8080/v1/greet
# {"message":"Hello!"}

That is a complete application. Read it top to bottom:

  • imports: pulls in two modules under aliases you chose. Those aliases are why the kinds below are written Http.Server and Run.Value — alias the first one Web and it would be Web.Server.
  • ports: declares that this application listens, bound to the PORT environment variable with a default, and resolves into the ports.http CEL scope. port: !cel "ports.http" reads it: one declaration, one source of truth, and a runner knows the exposed port without starting the app.
  • targets: is the boot sequence — !ref Server says "start this".
  • A server does not contain routes. It mounts routers, each under a path prefix. That is what makes Http.Api reusable and independently testable.
  • A route is three things: a request matcher, a handler: reference, and a returns: block mapping the handler's result into a response. Inside returns:, result is what the handler produced.
  • Run.Value is the simplest possible handler: it returns a shaped value and nothing else.

The server stays up after it starts — a Telo.Service holds the process open until you stop it. Ctrl-C tears everything down in order.

2. Read something from the request​

Declare what the request must contain, and map it into the handler's inputs:

routes:
- request:
path: /greet
method: GET
schema:
query:
type: object
properties:
name: { type: string }
required: [name]
handler: !ref Greet
inputs:
name: !cel "request.query.name"
returns:
- status: 200
content:
application/json:
body:
message: !cel "result.message"

Two payoffs from that schema: block:

  1. Validation is free. A request without ?name= is rejected with a 400 before your handler is reached — you write no checking code.
  2. CEL knows the shape. request.query.name type-checks. Misspell it as request.query.nmae and telo check fails with CEL_UNKNOWN_FIELD.

Now give the handler a contract and use the input:

kind: Run.Value
metadata:
name: Greet
inputType:
kind: Telo.JsonSchema
schema:
type: object
properties:
name: { type: string }
required: [name]
outputType:
kind: Telo.JsonSchema
schema:
type: object
properties:
message: { type: string }
required: [message]
value:
message: !cel "'Hello, ' + inputs.name + '!'"

inputType and outputType are schemas; inputs and value are values. That distinction holds everywhere in Telo, with no exceptions.

Declaring outputType is what makes result.message in the route type-check — and it is enforced at runtime too, so a handler that returns the wrong shape fails with ERR_OUTPUT_INVALID rather than serving nonsense.

3. Configure it​

Anything that differs between environments is declared on the application and bound to an environment variable:

variables:
greeting:
env: GREETING
type: string
default: Hello

and read like any other value:

value:
message: !cel "variables.greeting + ', ' + inputs.name + '!'"

Values resolve at load. A required variable that is missing fails the whole load with a message naming it — not on the first request that needed it. Use secrets: instead of variables: for anything sensitive: same shape, but the values are redacted from logs automatically.

The finished manifest​

kind: Telo.Application
metadata:
name: GreetingApi
version: 1.0.0
imports:
Http: oci://ghcr.io/telorun/http-server@0.38.0
Run: oci://ghcr.io/telorun/run@0.28.0
targets:
- !ref Server
ports:
http:
env: PORT
default: 8080
variables:
greeting:
env: GREETING
type: string
default: Hello
---
kind: Http.Server
metadata:
name: Server
listen:
port: !cel "ports.http"
openapi:
info:
title: Greeting API
version: 1.0.0
mounts:
- path: /v1
mount: !ref Api
---
kind: Http.Api
metadata:
name: Api
routes:
- request:
path: /greet
method: GET
schema:
query:
type: object
properties:
name: { type: string }
required: [name]
handler: !ref Greet
inputs:
name: !cel "request.query.name"
returns:
- status: 200
content:
application/json:
schema:
type: object
properties:
message: { type: string }
required: [message]
additionalProperties: false
body:
message: !cel "result.message"
---
kind: Run.Value
metadata:
name: Greet
inputType:
kind: Telo.JsonSchema
schema:
type: object
properties:
name: { type: string }
required: [name]
outputType:
kind: Telo.JsonSchema
schema:
type: object
properties:
message: { type: string }
required: [message]
value:
message: !cel "variables.greeting + ', ' + inputs.name + '!'"
telo check ./greeting-api          # static: kinds, refs, CEL types
GREETING=Hej telo ./greeting-api

curl 'http://localhost:8080/v1/greet?name=World'
# {"message":"Hej, World!"}

curl -i 'http://localhost:8080/v1/greet'
# HTTP/1.1 400 Bad Request — the request schema rejected it

The openapi: block means the server also describes itself — the generated OpenAPI document covers every route, matcher and response schema you declared, because they are declarations rather than code.

What to do next​

  • Real logic in the handler — a Run.Sequence when the work is several steps, a Run.Choice when it is a decision, and bindings: on a Run.Value to name the intermediate values of a calculation instead of nesting one unreadable expression: Composing behaviour.
  • Talk to a database — Talking to a database: a connection, migrations, injection-safe statements, and transactions.
  • Test it — Testing your manifests. Tests are manifests too, running on the same kernel.
  • Ship it — Deploy.
  • Understand what just happened — How Telo works.