tommy

Rendered from docs/clients.md in the repository.

On this page

Pointing official SDKs at tommy

Every snippet below is written against the ports in the repo's own tommy.toml: bind = "127.0.0.1", host = "localhost", ingress on 8822. Run tommy providers (or check your own config) if you have moved a port — every value here is otherwise exactly what a fresh tommy serve binds.

Running tommy in a container changes only one thing here, and it is the address: point the SDK at the published port on the docker host, not at the port inside the container, and remember that only the ports you published exist as far as the client is concerned. The exception is FTP, whose passive mode tells the client where to connect next — that address is the one value tommy cannot work out for itself, so it has to be configured. docs/docker.md has both, with the compose stack that wires them up.

Five SDKs, five different amounts of cooperation:

SDK Base URL support What you do
resend-go (github.com/resend/resend-go/v4) First class, and the easy case Set the exported Client.BaseURL field — a plain *url.URL — after construction
mailjet-apiv3-go (github.com/mailjet/mailjet-apiv3-go/v4) First class Pass the base URL as the third argument to NewMailjetClient, or call SetBaseURL/SetURL after construction
sendgrid-go (github.com/sendgrid/sendgrid-go) First class, via a different entry point Build the request with sendgrid.GetRequest(key, endpoint, host) instead of sendgrid.NewSendClient(key), which hardcodes the real host
twilio-go (github.com/twilio/twilio-go) None There is no field, flag or env var that lets api.twilio.com become anything else. Inject a custom *http.Client instead, via clienthelp
aws-sdk-go-v2 (github.com/aws/aws-sdk-go-v2/service/s3) First class, plus a second setting Set s3.Options.BaseEndpoint and UsePathStyle: true — tommy's s3 plugin has no virtual-host addressing, only /bucket/key

Reading the captures back is a different job from sending them, and it has its own description: GET /api/v1/openapi.json, or docs/openapi.json in this repository, is a generated OpenAPI 3.1 document of the events API — enough to generate a client, in whatever language your tests are in, that lists what was captured, fetches one in full, or follows the live stream. Each plugin's own read-back API is described separately — docs/openapi-mail.json, GET /api/v1/mail/openapi.json — for when the assertion is about the content type rather than about the event.

Whichever way you point an SDK at tommy, the response it gets back names what tommy captured: X-Tommy-Event-URL carries the link to the event's own page, once per event the request produced. Most SDKs hide response headers, but a *http.Client wrapper or a proxy log will show them, and the same link is the url field of everything /api/v1/events and the plugin read-back APIs return.

clienthelp is tommy's own package for the twilio-go case and anything like it: an SDK that accepts a custom *http.Client but refuses a custom base URL. It is pure standard library — importing it never pulls a vendor SDK into your own go.mod, and it never gets pulled into tommy's. See the package doc in clienthelp/clienthelp.go for how the rewrite works. Do not point a clienthelp transport at a real vendor host — it redirects every request that passes through it, unconditionally.

Resend — resend-go

This is the easy case. resend.NewClient builds a *Client whose BaseURL field is a plain, exported *url.URL — no constructor argument to remember, no hardcoded-host workaround like sendgrid.NewSendClient, and none of the twilio-go problem below where no field, flag or env var reaches the host at all. Parse the URL and assign it after construction; every call the SDK makes resolves against it from then on.

package main

import (
	"context"
	"fmt"
	"net/url"

	"github.com/resend/resend-go/v4"
)

func main() {
	// Any API key is accepted; tommy just records it.
	client := resend.NewClient("re_fake_key")

	base, err := url.Parse("http://localhost:8822")
	if err != nil {
		panic(err)
	}
	client.BaseURL = base

	params := &resend.SendEmailRequest{
		From:    "Acme <alice@example.com>",
		To:      []string{"bob@example.com"},
		Subject: "Hello from tommy",
		Html:    "<p>It <b>works</b>.</p>",
		Text:    "It works.",
	}

	ctx := context.Background()

	sent, err := client.Emails.SendWithContext(ctx, params)
	if err != nil {
		panic(err)
	}
	fmt.Println(sent.Id)

	got, err := client.Emails.GetWithContext(ctx, sent.Id)
	if err != nil {
		panic(err)
	}
	fmt.Printf("%+v\n", got)
}

Run against a tommy mail --enabled-providers resend, this prints the id the send call minted and then the full Email the SDK's own read-back decoded — &{Id:01a0727e-... Object:email ... To:[bob@example.com] From:Acme <alice@example.com> ... Subject:Hello from tommy Html:<p>It <b>works</b>.</p> Text:It works. Bcc:[] Cc:[] ReplyTo:[] LastEvent:delivered} — confirming the SDK sees its own write back through GetWithContext, unmodified.

The one sharp edge worth knowing, not in wiring the client but in what it puts on the wire: resend-go marshals to/cc/bcc as JSON arrays but reply_to as a bare string, and its Attachment.MarshalJSON encodes attachment content as a JSON array of integers rather than base64. Both are handled — see plugins/mail/providers/resend/README.md for the decoding rules — but they are why a hand-rolled fake that only accepts the REST reference's documented shapes breaks against this SDK specifically. test/integration/resend_test.go drives both cases, plus a batch send and the Emails.GetWithContext read-back, against a live tommy.

Mailjet — mailjet-apiv3-go

NewMailjetClient takes the base URL as an optional third argument. The one sharp edge: the SDK's SendMailV31 builds its URL as apiBase + ".1/send", so the base URL must already end in /v3 for the arithmetic to land on tommy's mounted route, /v3.1/send — passing plain http://localhost:8822 here sends the request to http://localhost:8822.1/send, which tommy does not mount.

package main

import (
	"fmt"

	mailjet "github.com/mailjet/mailjet-apiv3-go/v4"
)

func main() {
	// Any public/private key pair is accepted; tommy just records it.
	client := mailjet.NewMailjetClient("any-key", "any-secret", "http://localhost:8822/v3")

	messages := mailjet.MessagesV31{
		Info: []mailjet.InfoMessagesV31{{
			From:     &mailjet.RecipientV31{Email: "alice@example.com", Name: "Alice"},
			To:       &mailjet.RecipientsV31{{Email: "bob@example.com"}},
			Subject:  "Hello from tommy",
			TextPart: "It works.",
		}},
	}

	res, err := client.SendMailV31(&messages)
	if err != nil {
		panic(err)
	}
	fmt.Printf("%+v\n", res)
}

A client already built for production also works, unchanged — call client.SetBaseURL("http://localhost:8822/v3") (or the equivalent SetURL) in a test's setup and put it back (or just construct a second client) for anything that must reach the real Mailjet API.

SendGrid — sendgrid-go

sendgrid.NewSendClient(key) hardcodes https://api.sendgrid.com and has no override. The documented way around it is one level down: build the rest.Request yourself with sendgrid.GetRequest, which takes the host as its third argument, and hand that to sendgrid.API.

package main

import (
	"fmt"

	"github.com/sendgrid/sendgrid-go"
	"github.com/sendgrid/sendgrid-go/helpers/mail"
)

func main() {
	m := mail.NewV3MailInit(
		mail.NewEmail("Alice", "alice@example.com"),
		"Hello from tommy",
		mail.NewEmail("Bob", "bob@example.com"),
		mail.NewContent("text/plain", "It works."),
	)

	// Any bearer token is accepted; tommy just records it.
	req := sendgrid.GetRequest("SG.fake-key", "/v3/mail/send", "http://localhost:8822")
	req.Method = "POST"
	req.Body = mail.GetRequestBody(m)

	resp, err := sendgrid.API(req)
	if err != nil {
		panic(err)
	}
	// tommy answers with the real contract: 202, empty body, X-Message-Id set.
	fmt.Println(resp.StatusCode, resp.Headers["X-Message-Id"])
}

If your code is already built around sendgrid.NewSendClient, the smallest change is to replace it with the GetRequest/API pair above only in the test path — Client is just struct{ rest.Request }, so &sendgrid.Client{ Request: req} gets you back a value with the same .Send(...) method if you need to keep that call shape.

Twilio — twilio-go

RequestHandler.BuildUrl reparses whatever host you give it and rebuilds it as product[.edge][.region].twilio.com; TWILIO_EDGE/TWILIO_REGION are the only knobs, and neither one can produce a non-twilio.com host. The supported extension point is a custom client.BaseClient — concretely, a *twilio-go/client.Client with its own *http.Client — passed through twilio.NewRestClientWithParams. That *http.Client is exactly what clienthelp.HTTPClient builds.

package main

import (
	"fmt"

	"github.com/twilio/twilio-go"
	twclient "github.com/twilio/twilio-go/client"
	openapi "github.com/twilio/twilio-go/rest/api/v2010"

	"github.com/can3p/tommy/clienthelp"
)

func main() {
	accountSid := "ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
	authToken := "authtokenxxxxxxxxxxxxxxxxxxxxxxxx" // any value; tommy records it

	tc := &twclient.Client{
		Credentials: twclient.NewCredentials(accountSid, authToken),
		HTTPClient:  clienthelp.HTTPClient("http://localhost:8822"),
	}
	tc.SetAccountSid(accountSid)

	rc := twilio.NewRestClientWithParams(twilio.ClientParams{Client: tc})

	params := &openapi.CreateMessageParams{}
	params.SetTo("+15558675310")
	params.SetFrom("+15557122661")
	params.SetBody("It works.")

	msg, err := rc.Api.CreateMessage(params)
	if err != nil {
		panic(err)
	}
	fmt.Println(*msg.Sid, *msg.Status)
}

Six lines actually do the work: build a *twclient.Client with its HTTPClient field set to clienthelp.HTTPClient(tommyURL), set its account SID, and pass it as ClientParams.Client. Everything else — rc.Api, CreateMessage, the generated params types — is unmodified twilio-go; it has no idea its requests are landing on tommy instead of api.twilio.com.

The same tc.HTTPClient swap is the general answer for any other twilio-go resource (Verify, Lookups, Voice, …) once tommy grows a fake for it — nothing about the wiring above is specific to Messages.

S3 — aws-sdk-go-v2

The AWS SDK's s3.Options has first-class base-URL support (BaseEndpoint), same as Resend and Mailjet, but S3 has a second setting a base URL alone does not cover: the SDK defaults to virtual-host addressing (bucket.s3.amazonaws.com), and tommy's s3 plugin only routes path-style (/bucket/key — see plugins/s3). Set both.

package main

import (
	"context"
	"fmt"
	"strings"

	"github.com/aws/aws-sdk-go-v2/aws"
	"github.com/aws/aws-sdk-go-v2/config"
	"github.com/aws/aws-sdk-go-v2/credentials"
	"github.com/aws/aws-sdk-go-v2/service/s3"
)

func main() {
	// Any access key / secret is accepted; tommy just records it.
	cfg, err := config.LoadDefaultConfig(context.Background(),
		config.WithRegion("us-east-1"),
		config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider("tommy", "tommy", "")),
	)
	if err != nil {
		panic(err)
	}

	client := s3.NewFromConfig(cfg, func(o *s3.Options) {
		o.BaseEndpoint = aws.String("http://localhost:9000")
		o.UsePathStyle = true // the setting virtual-host addressing needs turned off
	})

	ctx := context.Background()
	if _, err := client.CreateBucket(ctx, &s3.CreateBucketInput{Bucket: aws.String("example")}); err != nil {
		panic(err)
	}
	if _, err := client.PutObject(ctx, &s3.PutObjectInput{
		Bucket: aws.String("example"), Key: aws.String("hello.txt"),
		Body: strings.NewReader("captured by tommy\n"),
	}); err != nil {
		panic(err)
	}

	out, err := client.GetObject(ctx, &s3.GetObjectInput{Bucket: aws.String("example"), Key: aws.String("hello.txt")})
	if err != nil {
		panic(err)
	}
	defer out.Body.Close()
	buf := make([]byte, 64)
	n, _ := out.Body.Read(buf)
	fmt.Printf("read back: %q\n", string(buf[:n]))
}

Run against a tommy s3, this prints read back: "captured by tommy\n" — the SDK's own GetObject sees exactly what its own PutObject sent, same as every other read-back path in this document. The AWS CLI needs the matching setting, spelled --endpoint-url plus path addressing (which aws --endpoint-url already implies): see plugins/s3/providers/http/README.md for a full aws s3/s3api walkthrough, including multipart upload, presigned URLs and bulk delete.

Non-Go SDKs

Most vendor SDKs in other languages have twilio-go's problem to some degree — official Twilio helper libraries in particular tend to hardcode api.twilio.com the same way. Two routes, in order of preference:

  1. The library's own custom-HTTP-client hook, if it has one. Ruby, Python, Node and Java's Twilio SDKs all accept an injectable HTTP client or transport at construction, the same shape as clienthelp fills for Go — check that library's docs for how to point it at http://localhost:8822 before reaching for the next option. Mailjet's and SendGrid's non-Go SDKs generally take a base URL directly, mirroring their Go SDKs above.

  2. A hosts-file entry plus TLS, for a library that truly has no hook. Map api.twilio.com (or whichever host is hardcoded) to 127.0.0.1 in /etc/hosts, and run tommy behind TLS with a certificate the test environment trusts, so the SDK's HTTPS request lands on tommy without noticing. tommy does not have a TLS mode yet — this route needs the --tls flag / [tls] config block on the implementation roadmap, not something you can use today. Track it before relying on this path.

In practice most non-Go test suites talk to the vendor's raw HTTP API rather than a generated SDK, and that always works against tommy with nothing special: point whatever HTTP client the test uses at http://localhost:8822 and the request paths are identical to the real API (/v3.1/send, /v3/mail/send, /2010-04-01/Accounts/{sid}/Messages.json, …).