Create a Tutorial
In this repo a tutorial is usually a runnable, self-teaching YAML config. The leading comment block of the file is the lesson; the config below it is the working example the reader runs. A complete tutorial therefore has three parts, and they must stay in sync:
- The numbered YAML in
distribution/tutorials/<category>/— the lesson plus the config. - Category support files —
membrane.sh,membrane.cmd, aREADME.md(andrun-docker.*where the category offers Docker), so the reader can actually start it. - An integration test under
distribution/src/test/java/com/predic8/membrane/tutorials/…so the tutorial can't silently rot. These tests unzip the built distribution and start Membrane against the tutorial's YAML, so a broken example fails CI.
A tutorial step can also be a plain .md file — use this when the step needs no local
Membrane config (e.g. a curl-only walkthrough against a hosted API). A .md step gets no
integration test; it just lives alongside the numbered YAMLs and is linked from the README.
Work through the steps below in order. The reader of a tutorial follows the file top-to-bottom with no other docs open, so the single most important thing is that the YAML teaches itself.
Step 1 — Choose the category and filename
Categories live under distribution/tutorials/: getting-started, json, xml, advanced,
transformation, security, jwt, api-keys, oauth2, soap, soap-rest-converter,
wsdl-to-openapi, orchestration, sse, mcp, llm-gateway. Pick the one that fits; only
create a new category if nothing fits (see "New category" at the end).
Files are numbered NN-Title.yaml (e.g. 10-PathParameters.yaml). The number sets reading
order. List the directory and pick the next gap — convention leaves room between numbers
(10, 20, 30…) so steps can be inserted later. Match the existing PascalCase-with-hyphens
title style.
Step 2 — Write the YAML (the lesson lives here)
Open a sibling file in the same category and mirror its shape. Every tutorial YAML has the same skeleton:
# yaml-language-server: $schema=https://www.membrane-api.io/v7.2.4.json
#
# Tutorial: <Human Readable Name>
#
# <One sentence on what this teaches.>
#
# 1.) Start Membrane:
# ./membrane.sh -c NN-Title.yaml
#
# 2.) Call the API:
# curl localhost:2000/...
#
# <Expected output — paste it verbatim.>
#
# 3.) Continue with file NN-Next.yaml # the next step in the chain, if any
api:
port: 2000
...
Hold to these conventions — they're what makes the set feel like one coherent course:
- Pin the schema line to whatever sibling files use (grep one:
grep -m1 schema= distribution/tutorials/<category>/*.yaml). It tracks the current release version, so don't invent a number. - Keep the comment block short. Steps are a command and its expected output — nothing more.
Don't explain what the config does; the reader can see the config. Don't repeat information
the
name:field already provides. - No section-header comments above each
api:block. Thename:field describes the API. - Inline comments only for the non-obvious WHY — a surprising built-in, a hidden constraint. Never comment what the interceptor name already says.
- Keep the config minimal and focused on the one concept the step teaches. Tutorials favor
clarity over completeness; richer setups belong under
distribution/examples/. - Port 2000 is the house convention for tutorial listeners unless the lesson needs more.
- Use
return: status: 200notreturn: {}. - If this file continues a chain, end the previous file's comment block with a "Continue with file NN-Title.yaml" pointer to the new one.
Step 3 — Make the category runnable
The reader runs ./membrane.sh -c <file> from the category directory, so that directory needs
the launcher scripts and a README.
- If
membrane.sh/membrane.cmdare missing from the category dir, copy them verbatim from a sibling category — they are identical generic launchers that walk up to findMEMBRANE_HOME. - Copy
run-docker.sh/run-docker.cmdonly if the category documents a Docker workflow. README.md: the category README points at the first file in the chain ("open NN-First.yaml and follow the instructions there"). If you added the first file of a new category, write this README modeled on a sibling's.
Step 4 — Write the integration test (don't skip this)
Without a test the example will drift out of sync with the code and break unnoticed — that's the whole reason these tests exist. The harness starts Membrane from the built distribution with your YAML and lets you assert against the live gateway.
Test package mirrors the category but with underscores: dir getting-started →
package com.predic8.membrane.tutorials.getting_started. The ExampleTests @Suite selects the
whole com.predic8.membrane.tutorials package, so a test placed correctly is auto-discovered
— no registration needed.
Each category has an Abstract<Category>TutorialTest that fixes getTutorialDir() (this is where
the hyphen dir name is restored). Your concrete test extends it, names the YAML, and asserts:
/* <copy the Apache 2.0 license header verbatim from a sibling test — keep its year> */
package com.predic8.membrane.tutorials.<category_with_underscores>;
import org.junit.jupiter.api.Test;
import static io.restassured.RestAssured.given;
import static org.hamcrest.CoreMatchers.containsString;
public class <Name>TutorialTest extends Abstract<Category>TutorialTest {
@Override
protected String getTutorialYaml() {
return "NN-Title.yaml";
}
@Test
void <whatItVerifies>() {
// @formatter:off
given()
.when()
.get("http://localhost:2000/...")
.then()
.statusCode(200)
.body(containsString("..."));
// @formatter:on
}
}
Notes:
- Copy the license header from an existing sibling test rather than retyping it, so the year and wording match the repo.
- Assert the observable behavior the YAML comments promise in Step 2 — if the lesson says "you should see Customer: 7", assert exactly that. The test is the executable form of the lesson.
- If you created a brand-new category, also create its
Abstract<Category>TutorialTestmodeled on an existing one — it just returns the hyphenated dir name fromgetTutorialDir(). - Sub-categories (e.g.
llm-gateway/google) need their own abstract base returning the full relative path (llm-gateway/google), not just the top-level category abstract. Model onAbstractAiTutorialTest/AbstractGoogleTutorialTestindistribution/src/test/java/com/predic8/membrane/tutorials/. .mdsteps have no integration test — skip this step for them.
Step 5 — Verify
Run the test through the existing run-example-test skill. Pass -b so the distribution is
rebuilt first: the tutorial YAML ships inside distribution/target/*.zip, and the tests run
from the unzipped distribution, so an un-rebuilt run would test the old YAML (or none).
.claude/skills/run-example-test/run-example-test.sh -b <Name>TutorialTest
A green run means the example actually starts and behaves as the lesson claims.
Common pitfalls
Template JSON auto-quoting
When a template interceptor has contentType: application/json, the engine automatically
adds quotes around every ${...} interpolation. Writing "${expr}" produces ""value"" and
breaks JSON. Always write interpolations unquoted:
- template:
contentType: application/json
src: |
{
"sub": ${user()},
"aud": "demo-resource"
}
String literals that are not interpolated keep their quotes as normal.
Built-in functions in Groovy templates
Prefer the built-ins over raw property access:
user()— authenticated username (frombasicAuthenticationsecurity scheme)scopes()— list of granted scopeshasScope("read")— boolean scope check
Avoid the verbose ${property.'membrane.security.schemes'[0].username} — use ${user()} instead.
New category (only when nothing fits)
distribution/tutorials/<new-category>/with the firstNN-*.yaml,membrane.sh,membrane.cmd, and aREADME.md.- Add a section linking the new category in
distribution/tutorials/README.md. - Create
com.predic8.membrane.tutorials.<new_category>.Abstract<NewCategory>TutorialTestextendingAbstractMembraneTutorialTest, returning the hyphenated dir fromgetTutorialDir(). - Add concrete tests as in Step 4.
Checklist
-
NN-Title.yaml(or.mdfor curl-only steps) created with schema line, short numbered comment block, minimal config - Comment block: steps = command + expected output only; no prose repeating the config
- No section-header comments above
api:blocks;name:describes them -
return: status: 200used (notreturn: {}) - Template interpolations unquoted when
contentType: application/json - Previous file in the chain points to it ("Continue with…"), if applicable
- Category has
membrane.sh,membrane.cmd,README.md(andrun-docker.*if used) -
<Name>TutorialTestin the underscore package, extends the category abstract base, has the license header - Sub-category has its own abstract base returning the full relative path
-
.mdsteps have no integration test - Assertions match the behavior the YAML comments promise
-
run-example-test.sh -b <Name>TutorialTestis green