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
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,6 @@ jobs:

- name: Run the canonical gate
run: just ci

- name: SDK conformance (real provider SDKs vs adapters)
run: just conformance
38 changes: 38 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,44 @@ All notable changes to **stunt** are documented here. The format is based on

## [Unreleased]

## [0.47.0] — 2026-08-17

### Testing

- **SDK conformance harness — real provider SDKs against the adapters.**
A new nested Go module (`conformance/`, wired into CI via
`just conformance`) boots stunt adapters and drives them with the
official SDKs, asserting business outcomes rather than wire shapes:
- **stripe-go** (customer create with form+bracket bodies, PaymentIntent
create+confirm → succeeded, SDK-iterator pagination walking
`has_more`, and webhook verification through the SDK's own
`ConstructEvent` HMAC validator — signature verifies AND
`data.object` parses).
- **aws-sdk-go-v2** — STS `GetCallerIdentity`/`AssumeRole` with REAL
SigV4 signatures from the documented synthetic credentials, and the
full S3 lifecycle (CreateBucket, PutObject binary body, byte-exact
GetObject incl. non-UTF-8, HeadObject, the ListObjectsV2 paginator
following continuations, DeleteObject) over path-style addressing.
- **go-github** — issue CRUD on the seeded repo, `Link`-header
pagination through `resp.NextPage`, comments, and state transitions.
- First-run findings, all fixed: **stripe PaymentIntents returned
`amount` as a JSON string** (typed SDKs reject it — real Stripe
returns money fields as numbers; coerced at create and render); the
**router had no greedy path params**, so S3 keys containing slashes
(`photos/2024/a.jpg`) 404'd — a terminal `{key+}` segment now
captures the remaining path verbatim and the S3 object routes use
it; and **S3 XML `LastModified` rendered as `...T05Z.000Z`**
(millis appended after the zone), which the AWS SDK's time parser
rejects — now `...T05.000Z` like real S3.

### Engine

- **Greedy route params (`{name+}`).** A route pattern whose last
segment is `{key+}` captures the rest of the URL-decoded path, slashes
included (outer slashes trimmed, so `dir/` and `dir` address the same
key) — the object-key shape S3-style providers need. Whole-segment
`{name}` and embedded `prefix{p}suffix` matching are unchanged.

## [0.46.0] — 2026-08-17

### Adapters
Expand Down
2 changes: 1 addition & 1 deletion adapters/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -353,7 +353,7 @@ Every handler receives a `req` argument with:
| `req.headers` | `dict[str, str]` | Request headers (case-insensitive lookups; `req.headers.get("authorization")` finds `Authorization`). `req` also supports dict access (`req["method"]`, `req.get("query")`) |
| `req.body` | `dict` | Parsed JSON body (empty dict if no body) |
| `req.raw_body` | `str` | The verbatim request body bytes (as a string). Use it for non-JSON / binary content (e.g. an S3 object upload) where the parsed `body` map is meaningless — store it via `store_blob` so it round-trips byte-exact |
| `req.params` | `dict[str, str]` | Path parameters extracted from route (e.g. `{id}` → `{"id": "..."}`) |
| `req.params` | `dict[str, str]` | Path parameters extracted from route. `{id}` matches one segment; a terminal `{key+}` is greedy and captures the remaining URL-decoded path (outer slashes trimmed, so a trailing slash collapses — `dir/` and `dir` address the same key), slashes included (the S3 object-key shape: `/{bucket}/{key+}` matches `photos/2024/a.jpg` as one key) |
| `req.query` | `dict[str, str]` | Query parameters (first value of each key) |

## Serializing concurrent handler calls (`concurrency_key`)
Expand Down
10 changes: 5 additions & 5 deletions adapters/aws-s3-style/adapter.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -41,27 +41,27 @@ endpoints:
# concurrency_key serialises the read-modify-write (find existing doc +
# blob id, then insert/update) against concurrent writers of the same
# bucket (both simple PUTs and part uploads).
- route: /{bucket}/{key}
- route: /{bucket}/{key+}
method: PUT
handler: scripts/objects.star#on_put_object
concurrency_key: bucket
# Download object: GET /{bucket}/{key} — or ListParts with ?uploadId=...
- route: /{bucket}/{key}
- route: /{bucket}/{key+}
method: GET
handler: scripts/objects.star#on_get_object
# Head object: HEAD /{bucket}/{key}
- route: /{bucket}/{key}
- route: /{bucket}/{key+}
method: HEAD
handler: scripts/objects.star#on_head_object
# Delete object: DELETE /{bucket}/{key} — or AbortMultipartUpload with
# ?uploadId=... (both mutate object/part state for the bucket).
- route: /{bucket}/{key}
- route: /{bucket}/{key+}
method: DELETE
handler: scripts/objects.star#on_delete_object
concurrency_key: bucket
# Multipart upload POST verb: ?uploads creates (CreateMultipartUpload),
# ?uploadId=... completes (assembles the object — read-modify-write).
- route: /{bucket}/{key}
- route: /{bucket}/{key+}
method: POST
handler: scripts/multipart.star#on_post_multipart
concurrency_key: bucket
Expand Down
9 changes: 7 additions & 2 deletions adapters/aws-s3-style/scripts/lib.star
Original file line number Diff line number Diff line change
Expand Up @@ -411,9 +411,14 @@ def _as_int(v):
return int(v)

# _unix_to_iso8601 renders Unix seconds in S3 XML millis form
# ("2026-01-20T00:00:00.000Z").
# ("2026-01-20T00:00:00.000Z"). unix_to_rfc3339 already ends in "Z", so
# the millis are spliced in BEFORE it — appending ".000Z" produced
# "...:05Z.000Z", which the AWS SDK's time parser rejects.
def _unix_to_iso8601(u):
return clock.unix_to_rfc3339(_as_int(u)) + ".000Z"
s = clock.unix_to_rfc3339(_as_int(u))
if s != "" and s[len(s)-1] == "Z":
s = s[:len(s)-1]
return s + ".000Z"

# _unix_to_rfc1123 renders Unix seconds as an RFC 1123 Last-Modified
# value ("Mon, 02 Jan 2006 15:04:05 GMT"), like real S3 headers.
Expand Down
4 changes: 3 additions & 1 deletion adapters/stripe-style/scripts/charges.star
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,9 @@ def on_create_charge(req):
body = {}

charge_id = _next_id("ch")
amount = body.get("amount", 0)
# Form bodies deliver amount as a STRING; money fields must render as
# JSON numbers for typed SDKs (stripe-go rejects "amount":"2000").
amount = _num(body.get("amount", 0))
currency = body.get("currency", "usd")
customer = body.get("customer", None)
description = body.get("description", None)
Expand Down
11 changes: 7 additions & 4 deletions adapters/stripe-style/scripts/payment_intents.star
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@ def _pi_public(doc):
return {
"id": doc["id"],
"object": "payment_intent",
"amount": doc.get("amount", 0),
"amount_capturable": doc.get("amount_capturable", 0),
"amount_received": doc.get("amount_received", 0),
"amount": _num(doc.get("amount", 0)),
"amount_capturable": _num(doc.get("amount_capturable", 0)),
"amount_received": _num(doc.get("amount_received", 0)),
"currency": doc.get("currency", "usd"),
"status": doc.get("status", "requires_payment_method"),
"capture_method": doc.get("capture_method", "automatic"),
Expand Down Expand Up @@ -155,7 +155,10 @@ def on_create_payment_intent(req):
if body == None:
body = {}

amount = body.get("amount", 0)
# Form-encoded creates deliver amount as a STRING ("4200"); the real
# API returns money fields as JSON numbers, and typed SDKs (stripe-go)
# reject a string amount — coerce at the door.
amount = _num(body.get("amount", 0))
currency = body.get("currency", "usd")
capture_method = body.get("capture_method", "automatic")
if capture_method not in ["automatic", "manual"]:
Expand Down
165 changes: 165 additions & 0 deletions conformance/aws_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
package conformance

import (
"bytes"
"context"
"testing"

"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"
"github.com/aws/aws-sdk-go-v2/service/sts"
)

// TestAWSSDKConformance drives aws-sdk-go-v2 (STS + S3) against the
// aws-iam-sts-style and aws-s3-style adapters with the adapters'
// documented synthetic credentials. The SDK signs every request with REAL
// SigV4 — passing means the adapters' signature verification accepts the
// genuine algorithm output, not just hand-rolled test vectors.
func TestAWSSDKConformance(t *testing.T) {
ctx := context.Background()

// The long-public example credentials from the AWS docs, which the
// adapters' SigV4 verification is keyed to.
cfgCreds := credentials.NewStaticCredentialsProvider(
"AKIAIOSFODNN7EXAMPLE",
"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
"")

// ===== STS: GetCallerIdentity + AssumeRole (query protocol + SigV4) =====

stsBase := Boot(t, "aws-iam-sts-style")
cfg, err := config.LoadDefaultConfig(ctx,
config.WithRegion("us-east-1"),
config.WithCredentialsProvider(cfgCreds),
)
if err != nil {
t.Fatalf("aws config: %v", err)
}
stsClient := sts.NewFromConfig(cfg, func(o *sts.Options) {
o.BaseEndpoint = &stsBase
})

id, err := stsClient.GetCallerIdentity(ctx, &sts.GetCallerIdentityInput{})
if err != nil {
t.Fatalf("GetCallerIdentity (real SigV4): %v", err)
}
if id.Account == nil || *id.Account == "" {
t.Fatal("GetCallerIdentity Account empty")
}
if id.Arn == nil || *id.Arn == "" {
t.Fatal("GetCallerIdentity Arn empty")
}
Record(t, "aws-sdk-go-v2", "aws-iam-sts-style", "GetCallerIdentity with real SigV4 signature")

role, err := stsClient.AssumeRole(ctx, &sts.AssumeRoleInput{
RoleArn: id.Arn,
RoleSessionName: ptr("conformance-session"),
})
if err != nil {
t.Fatalf("AssumeRole: %v", err)
}
if role.Credentials == nil || role.Credentials.AccessKeyId == nil {
t.Fatal("AssumeRole returned no credentials")
}
Record(t, "aws-sdk-go-v2", "aws-iam-sts-style", "AssumeRole -> credentials")

// ===== S3: bucket + object lifecycle (path-style, SigV4, raw bytes) =====

s3Base := Boot(t, "aws-s3-style")
s3Client := s3.NewFromConfig(cfg, func(o *s3.Options) {
o.BaseEndpoint = &s3Base
o.UsePathStyle = true
})

bucket := "conformance-bucket"
if _, err := s3Client.CreateBucket(ctx, &s3.CreateBucketInput{
Bucket: ptr(bucket),
}); err != nil {
t.Fatalf("CreateBucket: %v", err)
}
Record(t, "aws-sdk-go-v2", "aws-s3-style", "CreateBucket")

content := []byte("stunt conformance payload \x00\xff\x01 — binary round-trip")
if _, err := s3Client.PutObject(ctx, &s3.PutObjectInput{
Bucket: ptr(bucket),
Key: ptr("bin/payload.bin"),
Body: bytes.NewReader(content),
}); err != nil {
t.Fatalf("PutObject: %v", err)
}
Record(t, "aws-sdk-go-v2", "aws-s3-style", "PutObject (binary body)")

for i := 0; i < 3; i++ {
if _, err := s3Client.PutObject(ctx, &s3.PutObjectInput{
Bucket: ptr(bucket),
Key: ptr("keys/k" + string(rune('0'+i)) + ".txt"),
Body: bytes.NewReader([]byte("v")),
}); err != nil {
t.Fatalf("PutObject seed %d: %v", i, err)
}
}

got, err := s3Client.GetObject(ctx, &s3.GetObjectInput{
Bucket: ptr(bucket),
Key: ptr("bin/payload.bin"),
})
if err != nil {
t.Fatalf("GetObject: %v", err)
}
defer got.Body.Close()
buf := new(bytes.Buffer)
if _, err := buf.ReadFrom(got.Body); err != nil {
t.Fatalf("read body: %v", err)
}
if !bytes.Equal(buf.Bytes(), content) {
t.Fatalf("GetObject round-trip mismatch: got %d bytes, want %d", buf.Len(), len(content))
}
Record(t, "aws-sdk-go-v2", "aws-s3-style", "GetObject byte-exact round-trip (incl. non-UTF-8)")

head, err := s3Client.HeadObject(ctx, &s3.HeadObjectInput{
Bucket: ptr(bucket),
Key: ptr("bin/payload.bin"),
})
if err != nil {
t.Fatalf("HeadObject: %v", err)
}
if head.ContentLength == nil || *head.ContentLength != int64(len(content)) {
t.Fatalf("HeadObject ContentLength = %v, want %d", head.ContentLength, len(content))
}
Record(t, "aws-sdk-go-v2", "aws-s3-style", "HeadObject metadata")

// ListObjectsV2 with continuation: 4 objects, page size 2 — the SDK's
// paginator must follow IsTruncated/NextContinuationToken.
var listed int
p := s3.NewListObjectsV2Paginator(s3Client, &s3.ListObjectsV2Input{
Bucket: ptr(bucket),
MaxKeys: ptr[int32](2),
Prefix: ptr(""),
})
for p.HasMorePages() {
page, err := p.NextPage(ctx)
if err != nil {
t.Fatalf("ListObjectsV2 page: %v", err)
}
if page.KeyCount != nil {
listed += int(*page.KeyCount)
} else {
listed += len(page.Contents)
}
}
if listed != 4 {
t.Fatalf("ListObjectsV2 paginator listed %d objects, want 4 (continuation not followed?)", listed)
}
Record(t, "aws-sdk-go-v2", "aws-s3-style", "ListObjectsV2 paginator follows continuation (4 over MaxKeys=2)")

if _, err := s3Client.DeleteObject(ctx, &s3.DeleteObjectInput{
Bucket: ptr(bucket),
Key: ptr("keys/k0.txt"),
}); err != nil {
t.Fatalf("DeleteObject: %v", err)
}
Record(t, "aws-sdk-go-v2", "aws-s3-style", "DeleteObject")
}

func ptr[T any](v T) *T { return &v }
Loading
Loading