diataxis: tutorial
style: ASD-STE100
status: active
This tutorial picks up where Hello World left you: a my-app/ directory with greet, scenarios, and three test lenses. The greet boundary is small — params in, string out — but its shape is the shape every boundary follows. Here we walk two larger boundaries. Both wrap an external HTTP API, and both carry their own scenarios and halts. By the end you will have walked the full registration, identity, halt, and scenario surface end to end.
A boundary is the framework's unit of work. Each one is a class that mixes in Wanderland::Boundary. It declares its identity — who it speaks as when it signs a crossing. It declares its contract: the scopes it requires of the caller, the capabilities it provides, and the input shape it expects. It implements #call(input).
The runtime treats each boundary as a first-class addressable entity. The runtime registers it under a symbol and looks it up by route or by chain composition. It traces every call into the audit log, and signs the boundary's crossings when it declares an identity.
A single boundary :name, ... declaration buys a lot:
/inspect/boundary/:nameThe boundary itself stays small. A #call body reads input, does the work, and returns a hash or a Signal. The runtime handles everything else.
Boundaries that wrap external services follow a recurring shape. The #call body validates required input, often through input.find_or_fail("params.key"). It makes the upstream call. On success it returns the parsed body. On a failure the caller should know about, it returns Signal.halt(status:, error:). The two boundaries in this tutorial both follow that shape against the same upstream. The second one adds query-param plumbing and an extra halt for upstream errors.
The example API is babynames.netstudy.in — no auth, JSON in JSON out.
Two:
GET /api/name/{name} — returns one record: { name, meaning, origin, gender, numerology, source }.GET /api/names?origin={origin}&gender={gender} — returns { count, results: [...] }.You will wrap each endpoint in its own boundary. The first lookup proves the call shape. The second adds query-param plumbing and an upstream-error halt.
lib/my_app/boundaries/baby_lookup.rb# frozen_string_literal: true
require "net/http"
require "json"
module MyApp
module Boundaries
class BabyLookup
include Wanderland::Boundary
IDENTITY = Wanderland::Identity.new(
id: "boundary:baby_lookup",
name: "BabyLookup",
roles: [:boundary],
type: :service,
scopes: [:read]
).freeze
boundary :baby_lookup,
identity: IDENTITY,
requirements: [:read],
capabilities: [:baby_lookup],
description: "Fetch one baby name from babynames.netstudy.in",
input_shape: { "params" => true }
ENDPOINT = "https://babynames.netstudy.in/api/name"
def call(input)
name = input.find_or_fail("params.name")
uri = URI.parse("#{ENDPOINT}/#{URI.encode_www_form_component(name)}")
response = Net::HTTP.get_response(uri)
case response.code.to_i
when 200 then JSON.parse(response.body)
when 404 then Wanderland::Signal.halt(status: 404, error: "no name '#{name}'")
else Wanderland::Signal.halt(status: 502, error: "upstream #{response.code}")
end
end
end
end
end
Anatomy:
include Wanderland::Boundary brings the registration DSL, logging, and tracing.IDENTITY declares who this boundary is when it signs a crossing. The id becomes the from_addr on every crossing the boundary emits. The scopes become the requirements the caller must hold elsewhere in the system.boundary :baby_lookup, ... registers the class under the symbol :baby_lookup. The route config (next step) refers to that symbol. Nothing else does.requirements: — scopes the caller must hold to invoke this. enforce_denials checks this against the caller's identity.capabilities: — what this boundary claims to provide. Other boundaries can read it from context.input_shape: — declarative validation. { "params" => true } says "params must be present, any shape." Replace with a deeper hash to validate per-key.input.find_or_fail("params.name") digs the dot-path and strips strings. On a missing or blank value it throws a halt. The boundary's invoker catches that halt and renders it. The default is Signal.halt(status: 400, error: "name is required"). Override it with signal:, status:, or error: for denied, auth, or upstream-shaped failures.Signal.halt(...). The runtime lifts a plain Hash to Signal.ok. A halt carries a status. The HTTP adapter renders that status as the response code. The CLI adapter prints the halt to stderr and exits non-zero.Edit config.yml:
routes:
/hello:
method: get
boundary: echo
name: hello
/greet/:name:
method: get
boundary: greet
name: greet
/baby/:name:
method: get
boundary: baby_lookup
name: baby_lookup
The :name segment becomes a path capture. The engine puts it into params["name"] before the boundary runs.
HTTP:
bundle exec rake server
curl -s http://localhost:9294/baby/aarav | python3 -m json.tool
# {
# "name": "Aarav",
# "meaning": "Peaceful",
# "origin": "Hindu",
# "gender": "Boy",
# "numerology": 2,
# "source": "https://babynames.netstudy.in"
# }
curl -i http://localhost:9294/baby/zzzzz
# HTTP/1.1 404 Not Found
# {"error":"no name 'zzzzz'","status":404}
CLI:
bundle exec wanderland --type cli config.yml baby_lookup name=aarav
bundle exec wanderland --type cli config.yml baby_lookup name=zzzzz
echo $? # 404
Same boundary, two adapters. adapter_http derives params["name"] from the path capture. adapter_cli derives it from the key=value argv tail. Everything between — the boundary, the halt, the response shape — is one code path.
lib/my_app/boundaries/babies_by_origin.rbThe list endpoint takes filters. It requires origin=. The gender= filter is optional and constrained.
# frozen_string_literal: true
require "net/http"
require "json"
module MyApp
module Boundaries
class BabiesByOrigin
include Wanderland::Boundary
IDENTITY = Wanderland::Identity.new(
id: "boundary:babies_by_origin",
name: "BabiesByOrigin",
roles: [:boundary],
type: :service,
scopes: [:read]
).freeze
boundary :babies_by_origin,
identity: IDENTITY,
requirements: [:read],
capabilities: [:babies_by_origin],
description: "List baby names by origin (and optional gender)"
ENDPOINT = "https://babynames.netstudy.in/api/names"
# for the purposes of this API only...
VALID_GENDERS = %w[male female unisex].freeze
def call(input)
origin = input.find_or_fail("params.origin")
gender = input.find("params.gender")
query = { origin: origin }
query[:gender] = gender if VALID_GENDERS.include?(gender)
uri = URI.parse(ENDPOINT)
uri.query = URI.encode_www_form(query)
response = Net::HTTP.get_response(uri)
return Wanderland::Signal.halt(status: 502, error: "upstream #{response.code}") unless response.code == "200"
JSON.parse(response.body)
end
end
end
end
Two new things over baby_lookup:
find is the lenient sibling of find_or_fail. A missing or blank value returns nil instead of throwing. The optional gender= filter reads through find. The whitelist check then decides whether the value crosses the wire. A typo on the request does not reach the upstream.502. Downstream observability — X-Wanderland-Trace and the audit slot — sees the same crossing either way.Add the route:
/babies:
method: get
boundary: babies_by_origin
name: babies_by_origin
Try it:
curl -s 'http://localhost:9294/babies?origin=Indian&gender=female' | python3 -m json.tool
curl -i 'http://localhost:9294/babies'
# HTTP/1.1 400 Bad Request
# {"error":"origin is required","status":400}
bundle exec wanderland --type cli config.yml babies_by_origin origin=Indian gender=female
One scenarios block, three lenses (--type test, X-Wanderland-Verify, Scenario.rspec_describe!) — same as Hello World. The new boundaries hit a live API, so use matches: and count: to assert on shape rather than exact values.
scenarios:
baby_lookup:
aarav:
description: "Known name returns full record"
input:
params:
name: aarav
expected:
name: { matches: "(?i:^aarav$)" }
gender: { matches: "(?i:male|female|unisex)" }
origin: { matches: "." }
missing_name:
description: "Empty name halts with 400"
input:
params:
name: ""
expected:
status: 400
error: "name is required"
babies_by_origin:
indian_female:
description: "Filtered list returns count + results"
input:
params:
origin: Indian
gender: female
expected:
count: { gte: 1 }
results:
first:
origin: { matches: "(?i:indian)" }
missing_origin:
description: "Missing origin halts with 400"
input:
params: {}
expected:
status: 400
error: "origin is required"
The runtime matches a halt scenario's expected: block against the halt's payload — status, error, and any extra fields the boundary stamped. The 400 status itself is the halt indicator. Scenarios do not assert on _type_addr. Scenario#run reads crossing["result"], which projects the halt's payload but strips the type address. The error string here is what find_or_fail produces by default. If a boundary overrides the message, the scenario asserts on the override. The full ShapeMatcher vocabulary lives at Shape Matcher.
bundle exec wanderland --type test config.yml --route baby_lookup
bundle exec wanderland --type test config.yml --route babies_by_origin
curl -s -H 'X-Wanderland-Verify: aarav' http://localhost:9294/baby/aarav | python3 -m json.tool
curl -s -H 'X-Wanderland-Verify: route' 'http://localhost:9294/babies?origin=Hindu&gender=female'
bundle exec rspec
The probe form (X-Wanderland-Verify) and CLI form (--wanderland-verify=) both run live against the upstream. A failing scenario surfaces as a 422 from HTTP and a non-zero exit + halt payload from CLI.
Every lens above hits the real API. That is fine for a tutorial, and acceptable for a smoke check. But a scenario that fails because babynames.netstudy.in is down tells you nothing about your code. You have two options when this matters:
:net: driver that returns canned responses keyed by URL. Mount it ahead of Net::HTTP. Read it from the boundary through runtime.storage.read(":net:GET:..."). The boundary stays adapter-shaped, and the test substitutes the network.Net::HTTP.get_response behind a method on a small client class that the runtime injects. Stub the client in spec_helper.rb for unit-style coverage. Leave the live API for a tagged integration scenario.Both keep the boundary's call body and scenario contract intact. Pick the one that matches how often you expect the upstream to wobble.
my-app/
├── Gemfile
├── Gemfile.lock
├── Rakefile
├── config.ru
├── config.yml
├── lib/
│ ├── my_app.rb
│ └── my_app/
│ └── boundaries/
│ ├── greet.rb
│ ├── baby_lookup.rb
│ └── babies_by_origin.rb
└── spec/
├── spec_helper.rb
└── scenarios_spec.rb
Two new boundaries, two new routes, four new scenarios. The spec file from Hello World picks them up unchanged — Scenario.rspec_describe! reads whatever the config now declares.
when_shape, serves, output_shape, custom BASE_DEFAULT_WHEN recovery).client, effective_mime, and renderer caps reach your boundary's input.Signal.halt, Signal.denied, and how the chain walker decides what runs after a halt.expected:, including custom matcher registration.