Wanderland

Creating a new wanderland-core application

Frontmatter

diataxis: tutorial
style: ASD-STE100
status: active

What we build

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.

1. Create the project directory

mkdir -p ~/working/sprout/my-app
cd ~/working/sprout/my-app

2. Create 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

3. Install dependencies

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.

4. Create config.yml

This 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

5. Create config.ru

# frozen_string_literal: true

require "wanderland-core"

run Wanderland.boot(ENV.fetch("CONFIG", File.join(__dir__, "config.yml"))).engine

6. Create the boundary directory

mkdir -p lib/hello_world/boundaries

7. Create lib/hello_world.rb

# frozen_string_literal: true

require "wanderland-core"

module HelloWorld
end

8. Create a first custom boundary at lib/hello_world/boundaries/greet.rb

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

9. Add a route for the new boundary in config.yml

routes:
  /hello:
    method: get
    boundary: echo
    name: hello
  /greet/:name:
    method: get
    boundary: greet
    name: greet

10. Create 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

11. Run the server in HTTP mode

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.

12. Hit the endpoints

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.

13. Run the same config in CLI mode

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.

14. Add scenarios to config.yml

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

15. Run the scenarios in --type test mode

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

16. Probe live HTTP with X-Wanderland-Verify

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

17. Probe CLI mode with --wanderland-verify

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

18. Create spec/spec_helper.rb

# frozen_string_literal: true

require "wanderland-core"

19. Create spec/scenarios_spec.rb

One 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)

20. Run the spec suite

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.

21. Directory shape at this point

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.

Site Audit

wanderland.dev

oculus-view: fence: Fence execute bit is disabled. Use force=true to override.