Wanderland

Wanderland Core: Adapters

External services — HTTP, eventually Redis, S3, message brokers — sit outside the runtime. Adapters wrap them in a uniform Wanderland-shaped facade. A boundary declares which adapters it needs in its contract, pulls a configured instance from the runtime at call time, and uses it through that facade. The instance the boundary receives carries a mode (live, replay, mock) decided by config; the boundary's call body is identical across all three.

Contract

A boundary lists its adapters as part of registration:

class BabyLookup
  include Wanderland::Boundary

  boundary :baby_lookup,
    identity: IDENTITY,
    requirements: [:read],
    capabilities: [:baby_lookup],
    description: "Fetch one baby name from babynames.netstudy.in",
    input_shape: { params: true },
    adapters: [:http]
  # ...
end

adapters: accepts an array of symbols. Each entry names a logical adapter the boundary expects to find on the runtime registry. The contract is part of Boundary.info(:baby_lookup).adapters — introspectable through /inspect/boundary/:name alongside the rest of the boundary's manifest.

A boundary that asks for an adapter the runtime hasn't mounted raises Wanderland::Adapters::UnknownAdapterError at the call site. The error message includes the list of adapters that did mount, so a missing adapters: config block surfaces with the names the runtime knows about.

Call surface

Inside #call, boundaries reach the configured instance through input.adapter(:name):

def call(input)
  name = input.find_or_fail("params.name")
  response = input.adapter(:http).get("#{ENDPOINT}/#{URI.encode_www_form_component(name)}")

  case response["status"]
  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["status"]}")
  end
end

input.adapter(name) delegates to input["runtime"].adapters.lookup(name). The instance returned is the same object across calls within a runtime — adapters are mounted once at boot, not allocated per request.

Facade

The HTTP adapter exposes get, post, and a generic request(method, url, body:, headers:). All three return a uniform response hash:

{
  "status": 200,
  "body": "<response body as String>",
  "headers": { "content-type": "application/json" }
}

Header keys are returned as Net::HTTP delivered them (case as the server sent). Cassette and mock-mode lookups normalize incoming header keys to lowercase for digest stability — see Cassettes below.

Escape hatch

http = input.adapter(:http)
client = http.client  # bare Net::HTTP handle for the host of the last live request

#client returns the underlying Net::HTTP handle in :live mode, after the first request, for the host of that last request. In :replay and :mock modes there is no underlying client and #client returns nil. The escape hatch exists for cases where the facade is too narrow — streaming reads, multipart uploads, custom timeouts beyond what the facade exposes. Boundaries that reach for #client are coupling themselves to the underlying gem; the trade is intentional and visible.

Modes

adapters:
  http:
    mode: live              # or replay, or mock
    cassette_dir: cassettes # for replay mode (relative to config_dir)

Mode is chosen per-environment by setting mode: on the adapter's config block. The site declares the logical adapters it has; mode selects the implementation. A test environment's adapters: block typically reads mode: replay while production reads mode: live.

Cassettes

Replay mode reads from YAML cassettes laid out under <root>/<adapter>/<digest>.yml. One file per interaction, so a regenerated cassette produces a git diff showing exactly which interaction drifted.

Cassette file shape:

request:
  method: GET
  url: https://babynames.netstudy.in/api/name/aarav
  body: null
  headers:
    accept: application/json
response:
  status: 200
  headers:
    content-type: application/json
  body: |
    {
      "name": "Aarav",
      "meaning": "Peaceful",
      "origin": "Hindu",
      "gender": "Boy"
    }

The literal-block scalar (body: |) keeps the response body readable as itself instead of as an escaped string on a single line. The format pays off when the body is what changed — a git diff on the cassette shows the upstream's response delta the way a developer would read it, not the way JSON encodes it.

The digest is sha1(YAML.dump({method, url, body, headers})) with method upcased, body coerced to string, and header keys normalized to lowercase. The same request shape always lands at the same path; a body or method change moves it.

Cassettes are hand-editable. When the upstream's contract changes and the boundary needs to follow without a re-record, opening the file and editing the body: block is the fastest path.

Mock seeding

Mock mode skips the cassette layer; tests register canned responses directly:

http = Wanderland::Adapters::HTTP.new(mode: :mock)
http.register_mock(:get, "https://example.com/baby/aarav",
  "status" => 200,
  "body" => '{"name":"Aarav","origin":"Indian"}',
  "headers" => { "content-type" => "application/json" })

runtime.adapters.mount(:http, http)

Mock keys are method + url only. A test that needs body-discriminated mocks reaches for replay mode and a hand-edited cassette instead. The simpler key keeps mock setup terse for the common case.

Registry

Each runtime owns a Wanderland::Adapters::Registry. The registry is a name → instance map. Boundaries reach it via runtime.adapters and call lookup, mount, mounted?, names, or size.

runtime.adapters.mount(:http, Wanderland::Adapters::HTTP.new(mode: :live))
runtime.adapters.lookup(:http)       # Wanderland::Adapters::HTTP instance
runtime.adapters.mounted?(:redis)    # false
runtime.adapters.names               # ["http"]

The registry is per-runtime — a fresh Wanderland.boot produces a fresh registry. No global adapter state leaks across runtimes.

Boot

boot_mount_adapters runs as a slot in the boot chain, between boot_mount_storage and boot_mount_triggers. It reads adapters: from site config, dispatches each entry to a kind-specific factory, and mounts the resulting instance on the runtime registry.

- name: adapters
  boundary: boot_mount_adapters
  args:
    config:
      adapters: !UserConfig adapters

The boot step uses the same per-item resilience pattern the rest of the chain uses — one bad adapter doesn't take down the rest. A misconfigured entry is recorded in Wanderland::Diagnostics with the adapter's name and the failing class, then boot moves on. The recording surface lives at /inspect/diagnostics.

A site with no adapters: block boots cleanly with an empty registry. Boundaries that don't declare any adapters never reach the registry and don't notice.

Adding an adapter kind

The HTTP adapter is the first. New kinds (Redis, S3, message brokers) follow the same shape:

The registry doesn't care what an adapter looks like beyond responding to whatever method the calling boundary expects. The shared shape across kinds is a convention, not enforced — a boundary declares the contract it depends on by importing the adapter module and calling its methods.

Source