Type the REST API's responses
Every tool now returns a Go struct (apitypes.go) instead of a map, and typeschema.go derives the OpenAPI response schemas from those types by reflection (json, doc and enum tags), so the document cannot drift from what is sent. Text results are documented as text/plain, settings in answers use the typed settings schema, and edits answer with EditResponse. TestResponsesMatchTheirSchemas checks real responses against the generated schemas. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
This commit is contained in:
+27
-15
@@ -6,6 +6,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -41,6 +42,14 @@ var apiRoutes = []apiRoute{
|
||||
// etagHeader documents the ETag header of the answers about the settings.
|
||||
var etagHeader = map[string]any{"ETag": map[string]any{"description": "The settings' ETag (in quotes): send it in `If-Match` to edit them.", "schema": map[string]any{"type": "string"}}}
|
||||
|
||||
// resultContent is the 200 response's content: the result's type as JSON, or text.
|
||||
func resultContent(b *schemaBuilder, tool *mcpTool) map[string]any {
|
||||
if tool.output == nil {
|
||||
return map[string]any{"text/plain": map[string]any{"schema": map[string]any{"type": "string"}}}
|
||||
}
|
||||
return map[string]any{"application/json": map[string]any{"schema": b.of(reflect.TypeOf(tool.output))}}
|
||||
}
|
||||
|
||||
func routeText(rt apiRoute, tool *mcpTool) string {
|
||||
if rt.description != "" {
|
||||
return rt.description
|
||||
@@ -107,15 +116,11 @@ func (a *app) apiHandler(rt apiRoute) http.HandlerFunc {
|
||||
_, _ = io.WriteString(w, text+"\n")
|
||||
return
|
||||
}
|
||||
if m, ok := res.(map[string]any); ok && rt.tool == "get_settings" {
|
||||
if e, ok := m["etag"].(string); ok {
|
||||
w.Header().Set("ETag", `"`+e+`"`)
|
||||
}
|
||||
}
|
||||
if m, ok := res.(map[string]any); ok && rt.tool == "update_settings" {
|
||||
if e, ok := m["etag"].(string); ok {
|
||||
w.Header().Set("ETag", `"`+e+`"`)
|
||||
}
|
||||
switch r := res.(type) {
|
||||
case SettingsResponse:
|
||||
w.Header().Set("ETag", `"`+r.ETag+`"`)
|
||||
case EditResponse:
|
||||
w.Header().Set("ETag", `"`+r.ETag+`"`)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
enc := json.NewEncoder(w)
|
||||
@@ -193,6 +198,7 @@ func openAPISpec(version string, schema map[string]any, host string) map[string]
|
||||
errResp := func(d string) map[string]any {
|
||||
return map[string]any{"description": d, "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"$ref": "#/components/schemas/Error"}}}}
|
||||
}
|
||||
b := newSchemaBuilder()
|
||||
paths := map[string]any{}
|
||||
for _, rt := range apiRoutes {
|
||||
tool := toolNamed(rt.tool)
|
||||
@@ -204,10 +210,7 @@ func openAPISpec(version string, schema map[string]any, host string) map[string]
|
||||
"security": []any{map[string]any{"bearer": []string{}}},
|
||||
"x-scope": tool.scope,
|
||||
"responses": map[string]any{
|
||||
"200": map[string]any{"description": "The result.", "content": map[string]any{
|
||||
"application/json": map[string]any{"schema": map[string]any{}},
|
||||
"text/plain": map[string]any{"schema": map[string]any{"type": "string"}},
|
||||
}},
|
||||
"200": map[string]any{"description": "The result.", "content": resultContent(b, tool)},
|
||||
"401": errResp("No or an unknown token."),
|
||||
"403": errResp("The token's scope does not allow this."),
|
||||
},
|
||||
@@ -253,11 +256,20 @@ func openAPISpec(version string, schema map[string]any, host string) map[string]
|
||||
"responses": map[string]any{"200": map[string]any{"description": "The options (JSON)", "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{}}}}, "404": errResp("Not available.")},
|
||||
}}
|
||||
if schema != nil {
|
||||
for p, ops := range areaPaths(schema) {
|
||||
for p, ops := range areaPaths(schema, b.of(reflect.TypeOf(EditResponse{}))) {
|
||||
paths[p] = ops
|
||||
}
|
||||
}
|
||||
addCurlSamples(paths, "https://"+host)
|
||||
if schema != nil { // the settings in the answers are the typed settings
|
||||
if sr, ok := b.defs["SettingsResponse"].(map[string]any); ok {
|
||||
sr["properties"].(map[string]any)["settings"] = schema
|
||||
}
|
||||
}
|
||||
schemas := map[string]any{"Error": map[string]any{"type": "object", "properties": map[string]any{"error": map[string]any{"type": "string"}}, "required": []string{"error"}}}
|
||||
for k, v := range b.defs {
|
||||
schemas[k] = v
|
||||
}
|
||||
return map[string]any{
|
||||
"openapi": "3.1.0",
|
||||
"info": map[string]any{
|
||||
@@ -274,7 +286,7 @@ func openAPISpec(version string, schema map[string]any, host string) map[string]
|
||||
"paths": paths,
|
||||
"components": map[string]any{
|
||||
"securitySchemes": map[string]any{"bearer": map[string]any{"type": "http", "scheme": "bearer", "description": "A token from the API access page (rtr_…)."}},
|
||||
"schemas": map[string]any{"Error": map[string]any{"type": "object", "properties": map[string]any{"error": map[string]any{"type": "string"}}, "required": []string{"error"}}},
|
||||
"schemas": schemas,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user