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: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ services:
# respond: { status: 200, body: { inline: { message: hi } } }
config: # optional per-service config passed to adapter scripts
webhook_url: http://127.0.0.1:9999/hooks
max_body_bytes: 8388608 # optional request-body cap (default 1 MiB); oversize → 413
```

### Rule fields (inline declarative responses)
Expand Down Expand Up @@ -162,6 +163,7 @@ must **return a response** via `respond(...)` (or a dict shaped `{status, body,
|---|---|---|
| `req["method"]` | string | HTTP method (`GET`, `POST`, ...) |
| `req["path"]` | string | request path |
| `req["host"]` | string | request Host header (`127.0.0.1:8000`); use it to mint self-referential URLs |
| `req["headers"]` | dict | request headers (case-insensitive keys); e.g. `req["headers"]["Authorization"]` |
| `req["body"]` | dict \| list \| None | parsed JSON body (None if no/invalid JSON) |
| `req["raw_body"]` | string | raw body bytes as a string (for non-JSON/binary uploads) |
Expand Down
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,34 @@ All notable changes to **stunt** are documented here. The format is based on
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Engine

- **Configurable request body limit with honest overflow.** Services can set
`max_body_bytes` in `stunt.yaml` (default stays 1 MiB). Oversize bodies now
return **413** instead of being silently truncated; the request-log recorder
tees the body stream (capture stays capped at 64 KB) so handlers always see
the full, untruncated bytes.
- **`req["host"]` in Starlark handlers.** The request Host header is injected
into the request dict, so adapters can mint self-referential URLs (media
`baseUrl`, upload session `uploadUrl`) that point back at the simulator.

### Adapters

- **photos-style:** real media plane. Uploaded bytes are stored and linked to
created media items; `baseUrl` is computed at read time from the request
host; new `GET /v1/media-dl/{id}` with strict `=d`/`=dv` semantics (bare
baseUrl serves a distinct derivative payload); new `GET /v1/mediaItems/{id}`;
list/search honor `pageSize`/`pageToken` and emit `nextPageToken`.
- **microsoft-graph-style:** strict OneDrive write plane. Simple upload
(`PUT root:/{name}:/content` + folder variant) with real conflictBehavior
semantics, createFolder, per-parent child listing, path resolution with
`?select=id`, `GET items/{id}/content`, and the full resumable upload
protocol (`createUploadSession`, self-referential `uploadUrl`, sequential
Content-Range chunks with 416 on violations, 202 + `nextExpectedRanges`,
201 + driveItem on the final range, session invalidation).

## [0.2.2] — 2026-07-24

### Housekeeping
Expand Down
59 changes: 53 additions & 6 deletions adapters/microsoft-graph-style/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,26 @@ real API data is included.
- **Outlook mail:** `GET /v1.0/me/messages`, `GET /v1.0/me/messages/{id}`,
`POST /v1.0/me/sendMail` (202, STATEFUL), `GET /v1.0/me/mailFolders`.
- **Calendar:** `GET /v1.0/me/events`, `POST /v1.0/me/events` (STATEFUL).
- **OneDrive:** `GET /v1.0/me/drive`, `GET /v1.0/me/drive/root/children`.
- **OneDrive (read):** `GET /v1.0/me/drive` (incl. quota),
`GET /v1.0/me/drive/root/children`, `GET /v1.0/me/drive/items/{id}/children`
(listing is per-parent), `GET /v1.0/me/drive/items/{id}/content` (stored
bytes verbatim), `GET /v1.0/me/drive/root:/{path}:/` path resolution
(supports `?select=id`).
- **OneDrive (write):** real Graph colon addressing, implemented strictly.
Simple upload `PUT /v1.0/me/drive/root:/{name}:/content` (and the
`items/{parentId}:/{name}:/content` variant) → 201 driveItem; a repeat PUT
replaces (200), `@microsoft.graph.conflictBehavior=rename` creates
`name (1).ext` siblings, `fail` returns 409. createFolder via
`POST .../root/children` and `POST .../items/{id}/children`.
- **OneDrive resumable uploads:** `POST .../createUploadSession` returns a
self-referential `uploadUrl` (`http://{host}/v1.0/_upload/{session}`);
`PUT` chunks must carry `Content-Range: bytes {start}-{end}/{total}` and be
sequential and contiguous — wrong offset, gaps, inconsistent totals, or
range/body mismatches return **416**; mid-session chunks return 202 with
`nextExpectedRanges`; the final range assembles the file and returns 201
with the driveItem; the session is invalidated afterwards (further chunks
404). Strictness is deliberate: a lenient mock would mask client protocol
bugs.
- **SharePoint:** `GET /v1.0/groups/{id}/sites`.
- **Teams chats:** `GET /v1.0/me/chats`, `POST /v1.0/me/chats`,
`GET /v1.0/chats/{id}/messages`, `POST /v1.0/chats/{id}/messages` (STATEFUL).
Expand All @@ -45,8 +64,18 @@ endpoints, with `@odata.nextLink` pagination.
| POST | `/v1.0/me/sendMail` | `mail.star#on_send_mail` | Send mail → 202 |
| GET | `/v1.0/me/events` | `calendar.star#on_list_events` | List events (OData) |
| POST | `/v1.0/me/events` | `calendar.star#on_create_event` | Create event |
| GET | `/v1.0/me/drive` | `drive.star#on_get_drive` | Drive info |
| GET | `/v1.0/me/drive` | `drive.star#on_get_drive` | Drive info (incl. quota) |
| GET | `/v1.0/me/drive/root/children` | `drive.star#on_list_children` | Root children |
| POST | `/v1.0/me/drive/root/children` | `drive.star#on_create_child_root` | createFolder (root) |
| GET | `/v1.0/me/drive/items/{id}/children` | `drive.star#on_list_item_children` | Folder children |
| POST | `/v1.0/me/drive/items/{id}/children` | `drive.star#on_create_child_item` | createFolder (nested) |
| GET | `/v1.0/me/drive/items/{id}/content` | `drive_upload.star#on_get_content` | Download stored bytes |
| PUT | `/v1.0/me/drive/root:/{name}:/content` | `drive_upload.star#on_simple_upload_root` | Simple upload (root) |
| PUT | `/v1.0/me/drive/items/{parentId}:/{name}:/content` | `drive_upload.star#on_simple_upload_item` | Simple upload (folder) |
| POST | `/v1.0/me/drive/root:/{name}:/createUploadSession` | `drive_upload.star#on_create_session_root` | Resumable session (root) |
| POST | `/v1.0/me/drive/items/{parentId}:/{name}:/createUploadSession` | `drive_upload.star#on_create_session_item` | Resumable session (folder) |
| PUT | `/v1.0/_upload/{session}` | `drive_upload.star#on_upload_chunk` | Strict chunk PUT (416 on violations) |
| GET | `/v1.0/me/drive/root:/{path}:/` | `drive_upload.star#on_resolve_path` | Path resolution (`?select=id`) |
| GET | `/v1.0/groups/{id}/sites` | `sharepoint.star#on_list_sites` | SharePoint sites |
| GET | `/v1.0/me/chats` | `teams.star#on_list_chats` | List chats (OData) |
| POST | `/v1.0/me/chats` | `teams.star#on_create_chat` | Create chat |
Expand All @@ -63,20 +92,38 @@ endpoints, with `@odata.nextLink` pagination.
| `events` | Calendar events |
| `chats` | Teams chats |
| `chat_messages` | Teams chat messages (per chat) |
| `files` | OneDrive files/folders |
| `files` | OneDrive files/folders (with `parentId` for per-parent listing) |
| `sessions` | OneDrive resumable upload sessions (next offset, total, conflict behavior) |

File content lives in the blob store, keyed by driveItem id (in-flight
session chunks accumulate under `up-{session}` until the final range).

## Auth

All endpoints require `Authorization: Bearer <token>`. The token value is not validated —
only presence is checked. A missing header returns `401` with a Graph error envelope
(`{error:{code, message}}`).
All endpoints require `Authorization: Bearer <token>`, except
`PUT /v1.0/_upload/{session}` — real upload session URLs are
pre-authenticated, so the sim matches. The token value is not validated —
only presence is checked. A missing header returns `401` with a Graph error
envelope (`{error:{code, message}}`).

## Usage

```yaml
services:
graph:
adapter: ./adapters/microsoft-graph-style
max_body_bytes: 33554432 # uploads over 1 MiB need a raised body limit
```

Then `stunt up` and make requests to the served address.

Note: the engine's default request-body limit is 1 MiB and oversize bodies
get a `413`; set `max_body_bytes` (as above) when testing uploads or chunk
PUTs over 1 MiB.

Upload session URLs (`/v1.0/_upload/sess-NNNNNN`) carry no bearer check,
matching real Graph where the upload URL is pre-authenticated. Unlike real
Graph the session ids here are deterministic monotonic counters, not
unguessable tokens: stunt ids are deterministic by design (`rng_seed`
reproducibility) and the sim binds to localhost. Do not expose a stunt
server to a shared network.
42 changes: 42 additions & 0 deletions adapters/microsoft-graph-style/adapter.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -57,12 +57,52 @@ endpoints:
handler: scripts/calendar.star#on_create_event

# --- OneDrive ---
# NOTE on colon addressing: the router does not recognize "{param}:" as a
# parameter, but a literal ':' inside a captured VALUE is fine. Routes below
# use fixed-depth segments where the colon lands inside the captured
# segment: /root:/{item}/content matches /root:/photo.jpg:/content with
# item="photo.jpg:" and the handler strips the trailing colon.
- route: /v1.0/me/drive
method: GET
handler: scripts/drive.star#on_get_drive
- route: /v1.0/me/drive/root/children
method: GET
handler: scripts/drive.star#on_list_children
- route: /v1.0/me/drive/root/children
method: POST
handler: scripts/drive.star#on_create_child_root
- route: /v1.0/me/drive/items/{id}/children
method: GET
handler: scripts/drive.star#on_list_item_children
- route: /v1.0/me/drive/items/{id}/children
method: POST
handler: scripts/drive.star#on_create_child_item
- route: /v1.0/me/drive/items/{id}/content
method: GET
handler: scripts/drive_upload.star#on_get_content
# Simple upload (< 4 MB path): PUT root:/{name}:/content and the
# items/{parentId}:/{name}:/content variant.
- route: /v1.0/me/drive/root:/{item}/content
method: PUT
handler: scripts/drive_upload.star#on_simple_upload_root
- route: /v1.0/me/drive/items/{parent}/{item}/content
method: PUT
handler: scripts/drive_upload.star#on_simple_upload_item
# Resumable upload sessions.
- route: /v1.0/me/drive/root:/{item}/createUploadSession
method: POST
handler: scripts/drive_upload.star#on_create_session_root
- route: /v1.0/me/drive/items/{parent}/{item}/createUploadSession
method: POST
handler: scripts/drive_upload.star#on_create_session_item
- route: /v1.0/_upload/{session}
method: PUT
handler: scripts/drive_upload.star#on_upload_chunk
# Path resolution (GET root:/{path}:/ with ?select=id) — parameterized,
# declared after the literal root:/... routes above.
- route: /v1.0/me/drive/root:/{item}
method: GET
handler: scripts/drive_upload.star#on_resolve_path

# --- SharePoint ---
- route: /v1.0/groups/{id}/sites
Expand Down Expand Up @@ -103,6 +143,8 @@ resources:
kind: collection
- name: files
kind: collection
- name: sessions
kind: collection

# Auth scheme metadata (mock: any Bearer token accepted; presence checked).
identity:
Expand Down
116 changes: 98 additions & 18 deletions adapters/microsoft-graph-style/scripts/drive.star
Original file line number Diff line number Diff line change
@@ -1,9 +1,18 @@
# Microsoft Graph v1.0 — OneDrive handlers.
# Microsoft Graph v1.0 — OneDrive metadata handlers.
#
# GET /v1.0/me/drive → default drive info
# GET /v1.0/me/drive/root/children → root folder children (files/folders)
# GET /v1.0/me/drive → default drive info (incl. quota)
# GET /v1.0/me/drive/root/children → root folder children
# POST /v1.0/me/drive/root/children → createFolder under root
# GET /v1.0/me/drive/items/{id}/children → children of a folder
# POST /v1.0/me/drive/items/{id}/children → createFolder inside a folder
#
# Listing is PER-PARENT: every files doc carries a parentId ("root" for the
# drive root) and children endpoints filter by it. The upload plane lives in
# drive_upload.star; shared driveItem helpers live in lib.star.

# on_get_drive returns the default drive for the current user.
# on_get_drive returns the default drive for the current user. The response
# always carries the quota object, so a ?select=quota (or $select=quota)
# query is satisfied by the same shape.
# GET /v1.0/me/drive (Bearer)
def on_get_drive(req):
err = _require_bearer(req)
Expand Down Expand Up @@ -36,27 +45,95 @@ def on_list_children(req):
return err

_seed_files()
entities = _children_entities("root")
base_url = "https://graph.microsoft.com/v1.0/me/drive/root/children"
return _apply_odata(entities, req["query"], base_url)

# on_list_item_children returns the children of a folder by id.
# GET /v1.0/me/drive/items/{id}/children (Bearer)
def on_list_item_children(req):
err = _require_bearer(req)
if err != None:
return err

_seed_files()
parent_id = req["params"]["id"]
fc = store_collection("files")
docs = fc.list()
entities = []
for d in docs:
entities.append(_file_entity(d))
if fc.get(parent_id) == None:
return _err("itemNotFound", 404, "The resource could not be found.")

base_url = "https://graph.microsoft.com/v1.0/me/drive/root/children"
entities = _children_entities(parent_id)
base_url = "https://graph.microsoft.com/v1.0/me/drive/items/" + parent_id + "/children"
return _apply_odata(entities, req["query"], base_url)

# on_create_child_root creates a folder under the drive root.
# POST /v1.0/me/drive/root/children (Bearer; {name, folder: {}})
def on_create_child_root(req):
err = _require_bearer(req)
if err != None:
return err
return _create_folder(req, "root")

# on_create_child_item creates a folder inside an existing folder.
# POST /v1.0/me/drive/items/{id}/children (Bearer; {name, folder: {}})
def on_create_child_item(req):
err = _require_bearer(req)
if err != None:
return err

_seed_files()
parent_id = req["params"]["id"]
fc = store_collection("files")
parent = fc.get(parent_id)
if parent == None or parent.get("folder") == None:
return _err("itemNotFound", 404, "The parent folder could not be found.")
return _create_folder(req, parent_id)

# --- helpers ---

def _file_entity(doc):
return {
"id": doc["id"],
"name": doc["name"],
"file": doc.get("file", None),
"folder": doc.get("folder", None),
"size": doc.get("size", 0),
"createdDateTime": doc.get("createdDateTime", "2024-01-01T00:00:00Z"),
"lastModifiedDateTime": doc.get("lastModifiedDateTime", "2024-01-01T00:00:00Z"),
# _create_folder handles the createFolder body against a parent. Graph's
# default conflict behavior for createFolder is "fail" (409
# nameAlreadyExists); "rename" appends " (1)"-style suffixes.
def _create_folder(req, parent_id):
body = req["body"]
if body == None:
body = {}
name = body.get("name", "")
if name == "" or body.get("folder") == None:
return _err("invalidRequest", 400, "A folder item requires 'name' and a 'folder' facet.")

conflict = body.get("@microsoft.graph.conflictBehavior", "fail")
fc = store_collection("files")
existing = _find_child_by_name(fc, parent_id, name)
if existing != None:
if conflict == "rename":
name = _conflict_rename(fc, parent_id, name)
elif conflict == "replace":
return respond(200, _drive_item(existing))
else:
return _err("nameAlreadyExists", 409, "An item with the same name already exists under the parent.")

doc = {
"id": _next_item_id(),
"name": name,
"file": None,
"folder": {"childCount": 0},
"size": 0,
"parentId": parent_id,
"createdDateTime": "2024-06-15T12:00:00Z",
"lastModifiedDateTime": "2024-06-15T12:00:00Z",
}
fc.insert(doc)
return respond(201, _drive_item(doc))

# _children_entities lists the driveItems whose parentId matches.
def _children_entities(parent_id):
fc = store_collection("files")
entities = []
for d in fc.list():
if d.get("parentId", "root") == parent_id:
entities.append(_drive_item(d))
return entities

def _seed_files():
fc = store_collection("files")
Expand All @@ -70,6 +147,7 @@ def _seed_files():
"file": {"mimeType": "application/vnd.openxmlformats-officedocument.wordprocessingml.document"},
"folder": None,
"size": 24576,
"parentId": "root",
"createdDateTime": "2024-03-01T10:00:00Z",
"lastModifiedDateTime": "2024-06-10T15:30:00Z",
},
Expand All @@ -79,6 +157,7 @@ def _seed_files():
"file": {"mimeType": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"},
"folder": None,
"size": 53248,
"parentId": "root",
"createdDateTime": "2024-02-15T09:00:00Z",
"lastModifiedDateTime": "2024-06-12T11:00:00Z",
},
Expand All @@ -88,6 +167,7 @@ def _seed_files():
"file": None,
"folder": {"childCount": 5},
"size": 0,
"parentId": "root",
"createdDateTime": "2024-01-20T08:00:00Z",
"lastModifiedDateTime": "2024-06-14T16:00:00Z",
},
Expand Down
Loading
Loading