Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/acme-commerce-lenders/docs/scenarios.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ There is one scenario. It is the whole demo.
2. **Accept the offer and fund it.** One click. The lender registers the merchant's bank account in Stripe using the numbers Plaid holds, moves the advance to the merchant's Stripe account and pays it out to their bank. It then records the advance in the merchant's Xero books as money received against a loan.
3. **Reset.** One click puts everything back at the start, ready for the next audience.

**The second tab.** Open the inspector beside the demo (see [the inspector](usage.md#the-inspector)). Each click fills it with the calls behind it, in plain language: "Read the merchant's card sales from Stripe", "Fetched the bank account and routing numbers from Plaid", "Recorded the advance in the merchant's books". Open a row to show the request and the response. The badge on each row says whether LocalStack or a process on the laptop served it.
**The inspector.** Open it in a second browser tab beside the demo, from the arrow beside the header's tabs (see [the inspector](usage.md#the-inspector)). Each click fills it with the calls behind it, in plain language: "Read the merchant's card sales from Stripe", "Fetched the bank account and routing numbers from Plaid", "Recorded the advance in the merchant's books". Open a row to show the request and the response. The badge on each row says whether LocalStack or a process on the laptop served it.

**What it proves.** An app that depends on three SaaS products can be built, run and shown end to end on a laptop, against app emulators that behave like the real vendors, with no vendor accounts and no sandbox quotas. Two clicks do real work across all three systems, and the seeded world is the same every run, so the demo never surprises the presenter.

Expand Down
17 changes: 15 additions & 2 deletions apps/acme-commerce-lenders/docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ A vendor with no `<VENDOR>_EMULATOR_URL` set uses LocalStack. When your LocalSta

Beside the vendor, method, path and bodies, each call the inspector shows records which app emulator answered it and at which release:

- **`origin`** is who produced the response. It is `emulator` for every call the demo makes.
- **`origin`** is who produced the response. It is `emulator` for every call the demo makes in Demo Mode. `would_send_to` is set on a call made in Production Mode: the app emulator answered it, and it is the vendor's own base URL the call would have gone to. Nothing is sent there.
- **`emulator_version`** is the release id of the app emulator, such as `0123456789ab`. An app emulator does not announce its release, because the id is derived from the built binaries. Set `<VENDOR>_EMULATOR_VERSION` (for example `PLAID_EMULATOR_VERSION`) to the id of the release you installed, which is in its tag, `twin-<name>-v<id>`. Unset means unknown, and the field is left out.
- **`artifact_version`** is the SDK or API version the calls were written against. The demo calls the vendors over plain HTTP and sets none, so it is left out.

Expand Down Expand Up @@ -142,6 +142,8 @@ For the seeded merchant the offer is an advance of **$30,000** for a flat 8% fee
| `POST /api/applications` | Starts an application |
| `POST /api/applications/{id}/connect`, `review`, `offer`, `accept`, `fund`, `settle` | The steps, which must happen in that order. A declined offer cannot be accepted. |
| `GET /api/applications/{id}` | The application so far |
| `GET /api/targets` | The base URL each vendor is pointed at, and the mode (`demo` or `production`) |
| `PUT /api/targets` | Points vendors at base URLs: `{"urls":{"stripe":"https://api.stripe.com"}}`. A URL that cannot be used is reported in the state and fails the call that uses it. |
| `GET /api/events?since=N` | Every vendor call made so far, newest after `N`, with request and response bodies. Credentials are masked. |

Plaid's balance is shown with the offer but does not change it, because the Plaid app emulator's data is too thin to price against.
Expand Down Expand Up @@ -172,7 +174,7 @@ This needs a Xero app emulator that can create accounts and bank transactions, w

### The inspector

The demo's header has an **Open Demo Inspector** button, which opens <http://127.0.0.1:8080/inspector> in a second tab. Put it beside the demo to show what the lender asks Stripe, Plaid and Xero as each step runs.
The header has two tabs, **Demo** and **Demo Inspector**. Switching keeps the demo where it was. To show the calls as each step runs, press the arrow icon beside the tabs: it opens <http://127.0.0.1:8080/inspector> in a second browser tab, to put beside the demo.

Each call is one row, newest first:

Expand All @@ -185,6 +187,17 @@ The inspector shows the calls the lender makes while the demo runs. The calls th

The binary keeps the most recent 2,000 calls. Reading a merchant's sales is paged, so one run makes dozens of calls to Stripe.

## Demo Mode and Production Mode

A banner under the header says which mode the demo is in. It cannot be dismissed.

- **Demo Mode** is the default. Every vendor is called at its app emulator.
- **Production Mode** starts when any vendor's base URL is changed to something other than its app emulator. Open the gear in the banner, edit a vendor's URL, or press **Use service** to fill in the vendor's own base URL (`https://api.stripe.com`, `https://production.plaid.com`, `https://api.xero.com`). **Use emulator** puts the app emulator's URL back, so a URL deleted by mistake is one click to restore.

The point is to show the mechanism: the lender's code is the same in both modes and only the base URL changes. The demo holds no credential for a vendor's service, and nobody should connect one, so **nothing is ever sent to a vendor's service**. In Production Mode the demo works exactly as before. The app emulator still answers every call, and each call the inspector shows is tagged **Dry run: would go to** the vendor's URL.

If a URL cannot be used, such as one that is empty or does not start with `http://` or `https://`, the banner says why and **Apply** stays disabled until it is fixed. Reset only ever wipes the app emulators.

## Reset

```sh
Expand Down
37 changes: 36 additions & 1 deletion apps/acme-commerce-lenders/internal/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,18 @@ type Status struct {

// New returns the API handler, to be mounted at /api/.
func New(apps Applications, admin Admin, rec *wire.Recorder) http.Handler {
h := &handler{apps: apps, admin: admin, rec: rec}
return NewWithTargets(apps, admin, rec, nil)
}

// NewWithTargets is New with the routes that read and change where each vendor's
// calls go. A nil sw leaves those routes out.
func NewWithTargets(apps Applications, admin Admin, rec *wire.Recorder, sw *wire.Switch) http.Handler {
h := &handler{apps: apps, admin: admin, rec: rec, sw: sw}
mux := http.NewServeMux()
if sw != nil {
mux.HandleFunc("GET /api/targets", h.targets)
mux.HandleFunc("PUT /api/targets", h.setTargets)
}
mux.HandleFunc("GET /api/status", h.status)
mux.HandleFunc("POST /api/reset", h.reset)
mux.HandleFunc("POST /api/applications", h.apply)
Expand All @@ -67,6 +77,7 @@ type handler struct {
apps Applications
admin Admin
rec *wire.Recorder
sw *wire.Switch
// A reset wipes everything the other calls read, so it waits for them and
// they wait for it.
mu sync.RWMutex
Expand Down Expand Up @@ -137,6 +148,30 @@ func (h *handler) events(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"events": evs, "last": last, "generation": h.rec.Generation()})
}

// targets says which base URL each vendor has been pointed at, and whether the
// demo is in demo mode (every vendor at its app emulator) or production mode.
func (h *handler) targets(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, h.sw.State())
}

// setTargets points vendors at base URLs. A URL that cannot be used is accepted
// and reported in the state, so the UI can say why a run will fail before it
// starts.
func (h *handler) setTargets(w http.ResponseWriter, r *http.Request) {
var u struct {
URLs map[string]string `json:"urls"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(&u); err != nil {
writeError(w, http.StatusBadRequest, errors.New("the body must be JSON like {\"urls\":{\"stripe\":\"https://api.stripe.com\"}}"))
return
}
if err := h.sw.Apply(u.URLs); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
writeJSON(w, http.StatusOK, h.sw.State())
}

// statusFor maps an error to an HTTP status. A failure the lender could not
// have avoided is a vendor failure, so it is a bad gateway.
func statusFor(err error) int {
Expand Down
39 changes: 39 additions & 0 deletions apps/acme-commerce-lenders/internal/api/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -258,3 +258,42 @@ func TestWorldAdminReportsReadinessAndResetsTheRecorder(t *testing.T) {
t.Fatalf("after a reset the world is ready: %+v, %v", st, err)
}
}

func TestTargetsReportAndChangeWhichBaseURLEachVendorIsPointedAt(t *testing.T) {
sw := wire.NewSwitch(
wire.Lane{Vendor: "stripe", Name: "Stripe", Emulator: wire.LocalStack("stripe"), ServiceURL: "https://api.stripe.com"},
)
h := NewWithTargets(&fakeApps{}, &fakeAdmin{}, wire.NewRecorder(1), sw)

get := func() wire.State {
rec := do(h, "GET", "/api/targets")
var st wire.State
if err := json.Unmarshal(rec.Body.Bytes(), &st); err != nil || rec.Code != 200 {
t.Fatalf("GET /api/targets: %d %s", rec.Code, rec.Body)
}
return st
}
put := func(body string) *httptest.ResponseRecorder {
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest("PUT", "/api/targets", strings.NewReader(body)))
return rec
}

if st := get(); st.Mode != wire.ModeDemo || len(st.Lanes) != 1 {
t.Fatalf("start: %+v", st)
}
if rec := put(`{"urls":{"stripe":"https://api.stripe.com"}}`); rec.Code != 200 {
t.Fatalf("PUT: %d %s", rec.Code, rec.Body)
}
if st := get(); st.Mode != wire.ModeProduction || !st.Lanes[0].Service {
t.Fatalf("after PUT: %+v", st)
}
if rec := put(`{"urls":{"stripe":"stripe"}}`); rec.Code != 200 || get().Lanes[0].Error == "" {
t.Fatalf("an invalid URL is reported in the state: %d %+v", rec.Code, get())
}
for _, body := range []string{`{"urls":{"nope":"http://x"}}`, `not json`} {
if rec := put(body); rec.Code != 400 {
t.Errorf("PUT %s: %d, want 400", body, rec.Code)
}
}
}
5 changes: 3 additions & 2 deletions apps/acme-commerce-lenders/internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,10 +99,11 @@ func (a *app) handler() (http.Handler, error) {
return nil, err
}
rec := wire.NewRecorder(2000)
svc := &lender.Service{Vendors: lender.NewVendors(rec, a.targets), Store: store}
sw := wire.NewSwitch(a.targets.Lanes()...)
svc := &lender.Service{Vendors: lender.NewSwitchedVendors(rec, sw), Store: store}
admin := api.WorldAdmin{Targets: a.targets, HC: a.httpClient(), Store: store, Rec: rec}
mux := http.NewServeMux()
mux.Handle("/api/", api.New(svc, admin, rec))
mux.Handle("/api/", api.NewWithTargets(svc, admin, rec, sw))
mux.Handle("/", ui.Handler())
return mux, nil
}
Expand Down
41 changes: 41 additions & 0 deletions apps/acme-commerce-lenders/lender/reads_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -390,3 +390,44 @@ func TestParseXeroDate(t *testing.T) {
t.Error("an unrecognised date must be an error")
}
}

func TestSwitchedVendorsStayOnTheEmulatorAndRecordWhereADryRunWouldGo(t *testing.T) {
f := newFakeVendors(t)
rec := wire.NewRecorder(10)
sw := wire.NewSwitch(
wire.Lane{Vendor: "stripe", Name: "Stripe", Emulator: wire.Direct("stripe", f.srv.URL), ServiceURL: "https://api.stripe.com"},
wire.Lane{Vendor: "plaid", Name: "Plaid", Emulator: wire.Direct("plaid", f.srv.URL), ServiceURL: "https://production.plaid.com"},
wire.Lane{Vendor: "xero", Name: "Xero", Emulator: wire.Direct("xero", f.srv.URL), ServiceURL: "https://api.xero.com"},
)
v := NewSwitchedVendors(rec, sw)
ctx := context.Background()
last := func() wire.Event { evs := rec.Events(); return evs[len(evs)-1] }

if _, err := v.connectXero(ctx, facts()); err != nil || last().WouldSend != "" {
t.Fatalf("at the emulator: %v, %+v", err, last())
}

// Pointed at Xero's own service, the call is still answered by the app
// emulator, and the recording says where it would have gone.
if err := sw.Apply(map[string]string{"xero": "https://api.xero.com"}); err != nil {
t.Fatal(err)
}
if _, err := v.connectXero(ctx, facts()); err != nil {
t.Fatalf("a dry run must still work: %v", err)
}
if got := last(); got.WouldSend != "https://api.xero.com" || got.Origin != wire.OriginEmulator || got.Status != 200 {
t.Fatalf("recorded %+v", got)
}

// An invalid URL fails with a message that says what to do.
_ = sw.Apply(map[string]string{"xero": "xero"})
if _, err := v.connectXero(ctx, facts()); err == nil || !strings.Contains(err.Error(), "Use emulator") {
t.Fatalf("an invalid URL: %v", err)
}

// Back at the emulator, nothing is recorded as a dry run.
_ = sw.Apply(map[string]string{"xero": f.srv.URL})
if _, err := v.connectXero(ctx, facts()); err != nil || last().WouldSend != "" {
t.Fatalf("back at the emulator: %v, %+v", err, last())
}
}
12 changes: 12 additions & 0 deletions apps/acme-commerce-lenders/lender/vendors.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,18 @@ func NewVendors(rec *wire.Recorder, tg world.Targets) Vendors {
return Vendors{Stripe: apiFor(rec, tg.Stripe), Plaid: apiFor(rec, tg.Plaid), Xero: apiFor(rec, tg.Xero)}
}

// NewSwitchedVendors is NewVendors for targets that can change while the demo
// runs. Each call asks sw where its vendor is now.
func NewSwitchedVendors(rec *wire.Recorder, sw *wire.Switch) Vendors {
lane := func(vendor string) seed.API {
return seed.API{
HC: wire.NewSwitchedProxy(func() wire.Target { return sw.Target(vendor) }, rec).Client(),
Target: wire.Target{Vendor: vendor, Upstream: "http://wire.invalid"},
}
}
return Vendors{Stripe: lane("stripe"), Plaid: lane("plaid"), Xero: lane("xero")}
}

// apiFor returns a client whose calls the proxy records and forwards to t. The
// URL host is a placeholder, because the proxy uses its own target.
func apiFor(rec *wire.Recorder, t wire.Target) seed.API {
Expand Down
10 changes: 10 additions & 0 deletions apps/acme-commerce-lenders/world/seed.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,16 @@ func TargetsFromEnv(getenv func(string) string) Targets {
}
}

// Lanes are the three vendors as a wire.Switch can point them: at the app
// emulator the targets name, or at the vendor's own service.
func (t Targets) Lanes() []wire.Lane {
return []wire.Lane{
{Vendor: "stripe", Name: "Stripe", Emulator: t.Stripe, ServiceURL: "https://api.stripe.com"},
{Vendor: "plaid", Name: "Plaid", Emulator: t.Plaid, ServiceURL: "https://production.plaid.com"},
{Vendor: "xero", Name: "Xero", Emulator: t.Xero, ServiceURL: "https://api.xero.com"},
}
}

// All lists the targets in a fixed order.
func (t Targets) All() []wire.Target { return []wire.Target{t.Stripe, t.Plaid, t.Xero} }

Expand Down
9 changes: 9 additions & 0 deletions shared/seed/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,15 @@ type Error struct {
}

func (e *Error) Error() string {
// The wire proxy answers for a call it did not send, or could not make,
// with the reason in "detail". That reason is the message.
var w struct {
Error string `json:"error"`
Detail string `json:"detail"`
}
if json.Unmarshal([]byte(e.Body), &w) == nil && strings.HasPrefix(w.Error, "wire: ") && w.Detail != "" {
return w.Detail
}
return fmt.Sprintf("%s %s %s: HTTP %d: %s", e.Vendor, e.Method, e.Path, e.Status, e.Body)
}

Expand Down
24 changes: 24 additions & 0 deletions shared/seed/error_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package seed

import (
"strings"
"testing"
)

func TestErrorUsesTheProxysDetailAndKeepsAVendorsOwnBody(t *testing.T) {
proxy := &Error{Vendor: "stripe", Method: "GET", Path: "/v1/balance", Status: 503,
Body: `{"detail":"dry run: this GET /v1/balance was not sent","error":"wire: service unavailable","vendor":"stripe"}`}
if got := proxy.Error(); got != "dry run: this GET /v1/balance was not sent" {
t.Errorf("proxy error = %q", got)
}

vendor := &Error{Vendor: "stripe", Method: "GET", Path: "/v1/balance", Status: 401, Body: `{"error":{"message":"No API key provided"}}`}
if got := vendor.Error(); !strings.HasPrefix(got, "stripe GET /v1/balance: HTTP 401: ") || !strings.Contains(got, "No API key provided") {
t.Errorf("vendor error = %q", got)
}

plain := &Error{Vendor: "xero", Method: "GET", Path: "/x", Status: 500, Body: "boom"}
if got := plain.Error(); got != "xero GET /x: HTTP 500: boom" {
t.Errorf("plain error = %q", got)
}
}
Loading
Loading