Skip to content

Commit 2c1e1a0

Browse files
authored
Merge pull request #117 from speakeasy-api/walker/gram-redirect-url-go-sdk
Add Guide.Render to substitute the OAuth callback URL [minor]
2 parents 6b3bb3b + 0ac5a5a commit 2c1e1a0

6 files changed

Lines changed: 269 additions & 4 deletions

File tree

go/README.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,32 @@ func main() {
3434
}
3535
```
3636

37+
## Rendering the callback URL
38+
39+
Guide content ships with the template key `{{ gram.oauth.callback_url }}`
40+
in place. Pass your own value on every lookup to substitute it:
41+
42+
```go
43+
g, _ := guides.Lookup("intercom")
44+
45+
g = g.Render(guides.Vars{
46+
OAuthCallbackURL: "https://app.example.com/oauth/callback",
47+
})
48+
os.Stdout.Write(g.External)
49+
```
50+
51+
- `Render` returns a **copy**. It never changes the embedded content, so a
52+
server may render per request with a different value each time.
53+
- The callback URL is a property of your deployment, not of the guide, so
54+
there is nothing to look up first. A guide that never references the key
55+
comes back unchanged.
56+
- An empty `Vars` field leaves its key in place, so a missing value degrades
57+
to the unrendered guide instead of a blank.
58+
- `Render` substitutes `External` and `Speakeasy` only. No template key
59+
reaches `Meta` or an asset — the generator fails if one ever does.
60+
- `{{ gram.oauth.callback_url }}` is the only supported key. Add a field to
61+
`Vars` to support another; existing callers keep compiling.
62+
3763
## Identifiers
3864

3965
- **Canonical:** `ServerRef{Guide, Remote}` text form `slug/remote-id`

go/doc.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@
66
// secondary indexes that may return zero, one, or many matches; they never
77
// invent a default remote.
88
//
9+
// Guide content ships with the template key {{ gram.oauth.callback_url }}
10+
// in place. Guide.Render(Vars) returns a copy with a caller-supplied value
11+
// substituted. Supply the value on every call: it is a property of the
12+
// deployment, not of the guide, and a guide that never references the key
13+
// comes back unchanged.
14+
//
915
// Module path: github.com/speakeasy-api/mcp-setup-docs/go
1016
// Version tags: go/vX.Y.Z
1117
package guides

go/guides_test.go

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package guides_test
22

33
import (
4+
"bytes"
45
"io/fs"
56
"os"
67
"path/filepath"
@@ -10,6 +11,9 @@ import (
1011
guides "github.com/speakeasy-api/mcp-setup-docs/go"
1112
)
1213

14+
// canonicalCallbackKey is the one template key the generator admits.
15+
const canonicalCallbackKey = "{{ gram.oauth.callback_url }}"
16+
1317
func TestSlugsMatchDisk(t *testing.T) {
1418
disk, err := onDiskGuideSlugs()
1519
if err != nil {
@@ -429,6 +433,75 @@ func TestQueryMechanisms(t *testing.T) {
429433
})
430434
}
431435

436+
// A caller supplies the callback URL on every Render, so run the whole
437+
// corpus through one call: no guide may keep the key, and a guide that
438+
// never carried it must come back byte-identical.
439+
func TestRenderSubstitutesEveryKey(t *testing.T) {
440+
const callback = "https://app.example.com/oauth/callback"
441+
key := []byte(canonicalCallbackKey)
442+
substituted := 0
443+
for _, g := range guides.Guides() {
444+
hadKey := bytes.Contains(g.External, key) || bytes.Contains(g.Speakeasy, key)
445+
out := g.Render(guides.Vars{OAuthCallbackURL: callback})
446+
447+
if bytes.Contains(out.External, key) || bytes.Contains(out.Speakeasy, key) {
448+
t.Errorf("%s: content still carries the key after Render", g.Slug)
449+
}
450+
if !bytes.Equal(out.Meta, g.Meta) {
451+
t.Errorf("%s: Render must not touch Meta", g.Slug)
452+
}
453+
454+
if !hadKey {
455+
if !bytes.Equal(out.External, g.External) || !bytes.Equal(out.Speakeasy, g.Speakeasy) {
456+
t.Errorf("%s: Render changed a guide that carries no key", g.Slug)
457+
}
458+
continue
459+
}
460+
substituted++
461+
if !bytes.Contains(out.External, []byte(callback)) &&
462+
!bytes.Contains(out.Speakeasy, []byte(callback)) {
463+
t.Errorf("%s: Render substituted nothing", g.Slug)
464+
}
465+
}
466+
if substituted == 0 {
467+
t.Fatal("no guide carries the key; the matrix would be vacuous")
468+
}
469+
}
470+
471+
// A missing value must degrade to the unrendered guide, never to a blank
472+
// where the URL belongs.
473+
func TestRenderLeavesKeyWhenValueEmpty(t *testing.T) {
474+
g, ok := guides.Lookup("intercom")
475+
if !ok {
476+
t.Fatal("intercom missing")
477+
}
478+
out := g.Render(guides.Vars{})
479+
if !bytes.Contains(out.External, []byte(canonicalCallbackKey)) {
480+
t.Error("empty Vars must leave the template key in place")
481+
}
482+
}
483+
484+
// The embedded bytes back every future Lookup, so rendering one copy must
485+
// not reach them. A caller that renders per request depends on this.
486+
func TestRenderDoesNotDisturbEmbeddedContent(t *testing.T) {
487+
g, ok := guides.Lookup("intercom")
488+
if !ok {
489+
t.Fatal("intercom missing")
490+
}
491+
before := string(g.External)
492+
_ = g.Render(guides.Vars{OAuthCallbackURL: "https://app.example.com/cb"})
493+
if string(g.External) != before {
494+
t.Error("Render mutated the Guide it was called on")
495+
}
496+
again, ok := guides.Lookup("intercom")
497+
if !ok {
498+
t.Fatal("intercom missing on second lookup")
499+
}
500+
if !bytes.Contains(again.External, []byte(canonicalCallbackKey)) {
501+
t.Error("a later Lookup returned already-rendered content")
502+
}
503+
}
504+
432505
func hasKind(ms []guides.Match, kind guides.MatchKind) bool {
433506
for _, m := range ms {
434507
if m.Kind == kind {

go/internal/gen/main.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,17 @@ import (
2121

2222
var kebab = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`)
2323

24+
// canonicalCallbackKey is the only template key a guide may carry, in the
25+
// only spelling it may use. doctrine/constitution.md pins the rule; this
26+
// generator enforces it so package guides can substitute the key with a
27+
// literal byte replacement.
28+
const canonicalCallbackKey = "{{ gram.oauth.callback_url }}"
29+
30+
// templateKeyPattern finds every "{{ … }}" span, including malformed
31+
// spacing, so a non-canonical key fails generation instead of shipping
32+
// unrendered. This check is what lets Render match one exact byte string.
33+
var templateKeyPattern = regexp.MustCompile(`\{\{[^{}]*\}\}`)
34+
2435
type assetMeta struct {
2536
Path string `yaml:"path"`
2637
ContentHash string `yaml:"content_hash"`
@@ -200,6 +211,10 @@ func run() error {
200211
}
201212
}
202213

214+
if err := scanTemplateKeys(slug, srcDir, raw); err != nil {
215+
return err
216+
}
217+
203218
// Rebuild each guide dir from scratch so deleted assets/files cannot linger.
204219
dstDir := filepath.Join(outGuides, slug)
205220
if err := os.RemoveAll(dstDir); err != nil {
@@ -401,6 +416,33 @@ func run() error {
401416
return nil
402417
}
403418

419+
// scanTemplateKeys enforces the single-template-key rule. One canonical
420+
// spelling is what lets Guide.Render substitute with a plain byte replace
421+
// instead of a regexp. metaRaw must carry no key at all: Render substitutes
422+
// only External and Speakeasy, so a key in meta.yaml would ship to a reader
423+
// unrendered.
424+
func scanTemplateKeys(slug, srcDir string, metaRaw []byte) error {
425+
if key := templateKeyPattern.Find(metaRaw); key != nil {
426+
return fmt.Errorf(
427+
"%s: meta.yaml carries template key %s; keys belong in external.md or speakeasy.md only",
428+
slug, key)
429+
}
430+
for _, name := range []string{"external.md", "speakeasy.md"} {
431+
data, err := os.ReadFile(filepath.Join(srcDir, name))
432+
if err != nil {
433+
return fmt.Errorf("%s: read %s: %w", slug, name, err)
434+
}
435+
for _, key := range templateKeyPattern.FindAllString(string(data), -1) {
436+
if key != canonicalCallbackKey {
437+
return fmt.Errorf(
438+
"%s: %s carries template key %s; %s is the only supported key",
439+
slug, name, key, canonicalCallbackKey)
440+
}
441+
}
442+
}
443+
return nil
444+
}
445+
404446
func pruneStaleGuideDirs(outGuides string, slugs []string) error {
405447
keep := map[string]struct{}{}
406448
for _, s := range slugs {

go/internal/gen/main_test.go

Lines changed: 83 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,18 @@ package main
33
import (
44
"os"
55
"path/filepath"
6+
"regexp"
67
"strings"
78
"testing"
89
)
910

11+
// fieldSet reports whether the generated index sets field to value. It
12+
// ignores the column alignment gofmt applies, which shifts whenever a new
13+
// field lengthens the struct literal.
14+
func fieldSet(s, field, value string) bool {
15+
return regexp.MustCompile(regexp.QuoteMeta(field) + `:\s+` + regexp.QuoteMeta(value) + `,`).MatchString(s)
16+
}
17+
1018
func TestNormalizeURLMatchesRuntimeRules(t *testing.T) {
1119
cases := map[string]string{
1220
"https://mcp.box.com": "https://mcp.box.com",
@@ -125,8 +133,10 @@ remotes:
125133
transport: streamable-http
126134
`
127135
for name, body := range map[string]string{
128-
"meta.yaml": meta,
129-
"external.md": "# external\n",
136+
"meta.yaml": meta,
137+
// The canonical callback key here takes run() through the accept
138+
// path of the template scan, not only the rejections below.
139+
"external.md": "# external\n\nEnter {{ gram.oauth.callback_url }} in the field.\n",
130140
"speakeasy.md": "# speakeasy\n",
131141
} {
132142
if err := os.WriteFile(filepath.Join(guidesDir, name), []byte(body), 0o644); err != nil {
@@ -215,17 +225,22 @@ remotes:
215225
}
216226
for _, want := range []string{
217227
"Summary:", "SpeakeasyAddServer:", "https://mcp.example.com/demo", "com.example/demo",
218-
"SetupRequired: true,",
219228
`{ID: "oauth-app", Kind: "oauth", ClientRegistration: "manual", UpstreamSetup: "provider-steps", SpeakeasySetup: "manual-oauth"},`,
220229
} {
221230
if !strings.Contains(string(idx), want) {
222231
t.Errorf("index_gen.go missing %q", want)
223232
}
224233
}
225234

235+
_, afterDemo, _ := strings.Cut(string(idx), `"demo": {`)
236+
demoEntry, _, _ := strings.Cut(afterDemo, "\n\t},")
237+
if !fieldSet(demoEntry, "SetupRequired", "true") {
238+
t.Errorf("demo should need setup, got:\n%s", demoEntry)
239+
}
240+
226241
_, afterTenant, _ := strings.Cut(string(idx), `"tenant": {`)
227242
tenantEntry, _, _ := strings.Cut(afterTenant, "\n\t},")
228-
if !strings.Contains(tenantEntry, "SetupRequired: true,") {
243+
if !fieldSet(tenantEntry, "SetupRequired", "true") {
229244
t.Errorf("tenanted remote must force SetupRequired=true, got:\n%s", tenantEntry)
230245
}
231246
// Guards the fixture itself: if its option ever needs setup, the
@@ -235,6 +250,70 @@ remotes:
235250
}
236251
}
237252

253+
// The generator is the only place that enforces the one-key rule, so the
254+
// rejections matter as much as the happy path: Render substitutes with a
255+
// literal byte replacement on the strength of this check.
256+
func TestScanTemplateKeys(t *testing.T) {
257+
for _, tc := range []struct {
258+
name string
259+
external string
260+
speakeasy string
261+
meta string
262+
errText string
263+
}{
264+
{name: "no keys", external: "# external\n", speakeasy: "# speakeasy\n"},
265+
{
266+
name: "canonical key in external",
267+
external: "Enter " + canonicalCallbackKey + " here.\n",
268+
},
269+
{
270+
name: "canonical key in speakeasy",
271+
speakeasy: "Confirm " + canonicalCallbackKey + " matches.\n",
272+
},
273+
{
274+
name: "non-canonical spacing",
275+
external: "Enter {{gram.oauth.callback_url}} here.\n",
276+
errText: "only supported key",
277+
},
278+
{
279+
name: "unknown key",
280+
external: "Enter {{ gram.server.redirect_uri }} here.\n",
281+
errText: "only supported key",
282+
},
283+
{
284+
name: "key in meta",
285+
meta: "callback: \"" + canonicalCallbackKey + "\"\n",
286+
errText: "meta.yaml carries template key",
287+
},
288+
} {
289+
t.Run(tc.name, func(t *testing.T) {
290+
dir := t.TempDir()
291+
files := map[string]string{
292+
"external.md": "# external\n" + tc.external,
293+
"speakeasy.md": "# speakeasy\n" + tc.speakeasy,
294+
}
295+
for name, body := range files {
296+
if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil {
297+
t.Fatal(err)
298+
}
299+
}
300+
err := scanTemplateKeys("demo", dir, []byte("slug: demo\n"+tc.meta))
301+
if tc.errText != "" {
302+
if err == nil {
303+
t.Fatalf("expected an error mentioning %q, got nil", tc.errText)
304+
}
305+
if !strings.Contains(err.Error(), tc.errText) {
306+
t.Fatalf("error %v should mention %q", err, tc.errText)
307+
}
308+
return
309+
}
310+
if err != nil {
311+
t.Fatalf("unexpected error: %v", err)
312+
}
313+
})
314+
}
315+
}
316+
238317
func TestDeriveSpeakeasySetup(t *testing.T) {
239318
for _, tc := range []struct {
240319
name string

go/render.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package guides
2+
3+
import "bytes"
4+
5+
// callbackURLKey is the one template key the guide corpus uses. The
6+
// generator rejects every other key, and every other spelling of this one,
7+
// so a plain byte substitution is enough.
8+
var callbackURLKey = []byte("{{ gram.oauth.callback_url }}")
9+
10+
// Vars carries the values a consumer substitutes into guide content.
11+
type Vars struct {
12+
// OAuthCallbackURL replaces {{ gram.oauth.callback_url }} — the
13+
// Speakeasy AI Control Plane callback URL that the reader registers
14+
// in the provider's redirect field.
15+
//
16+
// Supply it on every Render call. It is a property of the deployment,
17+
// not of the guide, so there is nothing to look up first: guides that
18+
// never reference it come back unchanged.
19+
//
20+
// An empty value leaves the key in the content rather than blanking
21+
// it, so a missing value degrades to the unrendered guide.
22+
OAuthCallbackURL string
23+
}
24+
25+
// Render returns a copy of g whose External and Speakeasy content has the
26+
// template keys replaced by the values in v. Meta, Assets, and every
27+
// identity field are unchanged — no template key appears in meta.yaml or
28+
// in an asset, and the generator fails if one ever does.
29+
//
30+
// Render never changes the embedded content. Each Lookup starts from the
31+
// unrendered bytes, and rendering one copy does not affect another.
32+
func (g Guide) Render(v Vars) Guide {
33+
if v.OAuthCallbackURL != "" {
34+
value := []byte(v.OAuthCallbackURL)
35+
g.External = bytes.ReplaceAll(g.External, callbackURLKey, value)
36+
g.Speakeasy = bytes.ReplaceAll(g.Speakeasy, callbackURLKey, value)
37+
}
38+
return g
39+
}

0 commit comments

Comments
 (0)