Wanderland

Writing Boundaries

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 the shape is the shape every boundary follows. This tutorial walks two larger ones, both wrapping an external HTTP API, both with their own scenarios and halts. By the end you'll have walked the full registration, identity, halt, and scenario surface end to end.

What a boundary is

A boundary is the framework's unit of work. Each one is a class that mixes in Wanderland::Boundary, declares its identity (who it speaks as when it signs a crossing), declares its contract (what scopes it requires of the caller, what capabilities it provides, what input shape it expects), and implements #call(input). The runtime treats each boundary as a first-class addressable entity: registered under a symbol, looked up by route or by chain composition, traced into the audit log on every invocation, signed when an identity is declared.

A single boundary :name, ... declaration buys a lot. Registration into the global manifest. Dispatch from named routes. Identity threading into outbound crossings. Halt-signal propagation through the chain walker. Scenario-driven testing under three lenses. Inspection through /inspect/boundary/:name. The boundary itself stays small — usually a #call body that reads input, does the work, and returns a hash or a Signal. Everything else is the runtime's responsibility.

Boundaries that wrap external services follow a recurring shape. The #call body validates required input (often via input.find_or_fail("params.key")), makes the upstream call, and either returns the parsed body on success or returns Signal.halt(status:, error:) on a failure mode the caller should know about. The two boundaries this tutorial walks are both that shape, against the same upstream, with the second adding query-param plumbing and an extra halt for upstream errors.

The example API is babynames.netstudy.in — no auth, JSON in JSON out.

1. Pick the endpoints

Two:

You'll wrap each in its own boundary. The first lookup proves the call shape; the second adds query-param plumbing and an upstream-error halt.

2. Add 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:

3. Wire the route

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 stuffs it into params["name"] before the boundary runs.

4. Try it

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.

5. Add lib/my_app/boundaries/babies_by_origin.rb

The list endpoint takes filters. origin= is required; gender= 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:

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

6. Add scenarios

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"

A halt scenario's expected: block is matched against the halt's payload (status, error, and any extra fields the boundary stamped). The 400 status itself is the halt indicator — scenarios don't assert on _type_addr because 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.

7. Run the lenses

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.

8. Live calls in scenarios

Every lens above hits the real API. That's fine for a tutorial and acceptable for a smoke check, but a scenario that fails because babynames.netstudy.in is down isn't telling you anything about your code. Two options when this matters:

Both keep the boundary's call body and scenario contract intact. Pick the one that matches how often you expect the upstream to wobble.

9. Directory shape now

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.

What's next