diataxis: tutorial
style: ASD-STE100
status: active
In this tutorial we build a small wanderland-core application from an empty directory. We boot it two ways from one config file — as an HTTP server and as a command-line tool — and watch the same route answer both. Then we meet the one idea that makes wanderland-core a workflow engine: scenarios.
A scenario names the shape a route's response should have. We write the scenarios once, and three lenses read them: a batch test runner, a live HTTP probe, and an rspec suite.
Before you start: the steps assume a wanderland-core symlink at your workspace root, for example ~/working/sprout/wanderland-core → ~/working/wanderland-core. Your new application is a sibling directory next to it.
mkdir -p ~/working/sprout/my-app
cd ~/working/sprout/my-app
Gemfile# frozen_string_literal: true
source "https://rubygems.org"
gem "puma", "~> 6.0"
gem "wanderland-core", path: "../wanderland-core"
gem "rake"
group :development, :test do
gem "rspec", "~> 3.0"
gem "rack-test", "~> 2.0"
end
bundle install
Bundler resolves the gems and finishes with Bundle complete!. The Gemfile resolves wanderland-core from your local checkout, so you run the engine from source.
config.ymlThis one file configures the whole application — the service name, the port, the storage mounts, and the routes. Every mode you run later reads it: the HTTP server, the CLI, and the tests.
service: my-app
port: 9294
boundary_path: lib/hello_world/boundaries
storage:
mounts:
":pki:":
driver: sqlite
path: ":memory:"
":identities:":
driver: sqlite
path: ":memory:"
routes:
/hello:
method: get
boundary: echo
name: hello
config.ru# frozen_string_literal: true
require "wanderland-core"
run Wanderland.boot(ENV.fetch("CONFIG", File.join(__dir__, "config.yml"))).engine
mkdir -p lib/hello_world/boundaries
lib/hello_world.rb# frozen_string_literal: true
require "wanderland-core"
module HelloWorld
end
lib/hello_world/boundaries/greet.rbThis is your first boundary. Everything wanderland-core serves is a boundary like this one.
# frozen_string_literal: true
module HelloWorld
module Boundaries
class Greet
include Wanderland::Boundary
IDENTITY = Wanderland::Identity.new(
id: "boundary:greet",
name: "Greet",
roles: [:boundary],
type: :service,
scopes: [:read]
).freeze
boundary :greet,
identity: IDENTITY,
requirements: [:read],
capabilities: [:greet],
description: "Greet a named caller"
def call(input)
name = input.dig("params", "name") || "world"
{ "greeting" => "Hello, #{name}!" }
end
end
end
end
The call method receives the request, reads params.name, and returns a greeting. An empty name defaults to world.
config.ymlroutes:
/hello:
method: get
boundary: echo
name: hello
/greet/:name:
method: get
boundary: greet
name: greet
Rakefile# frozen_string_literal: true
require "wanderland-core"
desc "Start the HTTP server"
task :server do
config_path = ENV.fetch("CONFIG", File.join(__dir__, "config.yml"))
port = Wanderland::Config.load(config_path).port
exec "bundle exec puma config.ru -p #{port}"
end
desc "Show loaded routes"
task :routes do
config_path = ENV.fetch("CONFIG", File.join(__dir__, "config.yml"))
runtime = Wanderland.boot(config_path)
runtime.engine.compiled_routes.each do |key, route|
puts "#{key} → #{route[:spec]}"
end
end
desc "Run specs"
task :spec do
exec "bundle exec rspec"
end
task default: :spec
bundle exec rake server
# or directly:
bundle exec puma config.ru -p 9294
Puma boots and prints that it is listening on port 9294. Leave the server running in this terminal. The next steps send requests to it, so open a second terminal for those.
curl http://localhost:9294/health
curl 'http://localhost:9294/hello?message=world'
curl http://localhost:9294/greet/alice
Each call returns JSON. The greeting route builds its reply from the name in the path:
{ "greeting": "Hello, alice!" }
/health returns the engine health payload, and /hello echoes the message you passed.
bundle exec wanderland --type cli config.yml hello message=world
bundle exec wanderland --type cli config.yml greet name=alice
bundle exec wanderland --type cli config.yml help
The CLI runs the same boundaries against the same config, and prints the same JSON to standard output. help lists every route the config exposes.
config.ymlScenarios declare the shape each route's response should produce. Add a top-level scenarios: block, keyed by route name. Each scenario carries input: (the synthetic request) and expected: (the ShapeMatcher shape). description: is optional and surfaces in failure messages.
scenarios:
hello:
default:
description: "Canonical example"
input:
params:
message: "Hello world"
expected:
echoed: "Hello world"
greet:
alice:
description: "Canonical greeting format"
input:
params:
name: alice
expected:
greeting: "Hello, alice!"
default_output:
description: "Empty name defaults to world"
input:
params:
name:
expected:
greeting: "Hello, world!"
polite_tone:
description: "Greeting always starts with Hello"
input:
params:
name: bob
expected:
greeting:
matches: "^Hello, "
The ShapeMatcher vocabulary (matches, contains, keys, has_key, first, any, count, gte/lte, not, empty, and more) is available inside expected:. See wanderland-core-shape-matcher for the full list.
--type test modebundle exec wanderland --type test config.yml
Expected output:
hello
✓ default — Canonical example
greet
✓ alice — Canonical greeting format
✓ default_output — Empty name defaults to world
✓ polite_tone — Greeting always starts with Hello
4 scenarios, 4 passed, 0 failed
Filter by route or by scenario name:
bundle exec wanderland --type test config.yml --route greet
bundle exec wanderland --type test config.yml --scenario alice
bundle exec wanderland --type test config.yml --route greet --scenario polite_tone
Exits 0 on full pass, 1 on any failure — wire into CI directly. See --type test for the full runner.
X-Wanderland-VerifyThe same scenarios attach to live requests via the verify header. On match the response includes _verify. On mismatch the request halts with 422 and names the failing scenario.
bundle exec rake server # if not already running
Match (200 + _verify array):
curl -s -H 'X-Wanderland-Verify: alice' http://localhost:9294/greet/alice | python3 -m json.tool
# {
# "greeting": "Hello, alice!",
# "_verify": [
# { "name": "alice", "description": "Canonical greeting format",
# "passed": true, "failures": [] }
# ]
# }
Mismatch (422 with scenario failures):
curl -s -H 'X-Wanderland-Verify: alice' http://localhost:9294/greet/bob
# {
# "error": "scenario mismatch",
# "route": "greet",
# "verify": "alice",
# "scenarios": [
# { "name": "alice", "description": "Canonical greeting format",
# "passed": false,
# "failures": ["greeting: expected \"Hello, alice!\", got \"Hello, bob!\""] }
# ]
# }
Run every scenario configured for the current route in one shot:
curl -s -H 'X-Wanderland-Verify: route' http://localhost:9294/greet/alice
Combine with X-Wanderland-Trace: 1 and the verify crossing lands in the trace chain alongside the response. See Verify Route for the full probe behaviour.
--wanderland-verifySame option, CLI-side. The verify boundary fires the same way it does under HTTP.
bundle exec wanderland --type cli config.yml greet name=alice --wanderland-verify=alice
bundle exec wanderland --type cli config.yml greet name=bob --wanderland-verify=alice
bundle exec wanderland --type cli config.yml greet name=alice --wanderland-verify=route
On mismatch the process exits non-zero and prints the same {error, route, scenarios} payload the HTTP lens returns.
spec/spec_helper.rb# frozen_string_literal: true
require "wanderland-core"
spec/scenarios_spec.rbOne file, one helper call. rspec_describe! emits a describe block per route and an it block per scenario. Each example calls the same Scenario#verify(runtime:) primitive the --type test runner uses — one source of truth across dev, CI, and live probe.
# frozen_string_literal: true
require "spec_helper"
runtime = Wanderland.boot(File.join(__dir__, "..", "config.yml"))
Wanderland::Scenario.rspec_describe!(runtime)
bundle exec rspec
Expected output:
config scenarios
hello
✓ default
greet
✓ alice
✓ default_output
✓ polite_tone
4 examples, 0 failures
Failures show the ShapeMatcher output verbatim — same shape as --type test and the HTTP probe's 422 body. See RSpec Integration for the full helper surface.
my-app/
├── Gemfile
├── Gemfile.lock
├── Rakefile
├── config.ru
├── config.yml
├── lib/
│ ├── my_app.rb
│ └── my_app/
│ └── boundaries/
│ └── greet.rb
└── spec/
├── spec_helper.rb
└── scenarios_spec.rb
One scenarios block lives in config.yml. Three lenses read it: the live probe, the batch runner, and rspec. Add a scenario, and every lens sees it.
wanderland.dev