Skip to content

Repository files navigation

Godoc: https://pkg.go.dev/github.com/pbedat/expose

exposeRPC

exposeRPC allows you to create RPC interfaces, without the usual boilerplate. Methods can be exposed directly in Go code, without any further generation or definition steps. The resulting http interface provides an OpenAPI specification, that can be used to create type safe clients, to call the functions you exposed.

Example

Expose the functions Inc and Get as RPC endpoints:

package main

import (
	"context"
	"log"
	"net/http"
	"sync/atomic"

	"github.com/pbedat/expose"
)

var i = &atomic.Int32{}


func Inc(_ context.Context, delta int) (int, error) {
	return int(i.Add(int32(delta))), nil
}

func Get(context.Context, expose.Void) (int, error) {
	return int(i.Load()), nil
}

func main() {
	h, err := expose.NewHandler(
		[]expose.Function{
			expose.Func("/counter/inc", Inc),
			expose.Func("/counter/get", Get),
		},
	)
	if err != nil {
		panic(err)
	}

	http.Handle("/", h)

	http.ListenAndServe(":8000", nil)
}

Perform the RPC calls:

curl -H "content-type: application/json" --data 1 localhost:8000/rpc/counter/inc
curl -X POST localhost:8000/rpc/counter/get
> 1

Get the OpenAPI Spec:

curl localhost:8000/rpc/swagger.json
{
  "components": {
    "schemas": {
      "int": {
        "type": "integer"
      }
    }
  },
  "info": {
    "title": "Starter Example",
    "version": ""
  },
  "openapi": "3.0.2",
  "paths": {
    "/counter/get": {
      "post": {
        "operationId": "counter#get",
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/int"
                }
              }
            }
          },
          "default": {
            "description": ""
          }
        },
        "tags": ["counter"]
      }
    },
    "/counter/inc": {
      "post": {
        "operationId": "counter#inc",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/int"
              }
            }
          }
        },
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/int"
                }
              }
            }
          },
          "default": {
            "description": ""
          }
        },
        "tags": ["counter"]
      }
    }
  },
  "servers": [
    {
      "url": "http://localhost:8000/rpc"
    }
  ]
}

Operation Metadata

Exposed functions can be annotated with the standard openapi operation fields (summary, description, tags, deprecated). Well written summaries and descriptions make the spec self describing, so consumers (e.g. AI agents) can figure out on their own which endpoints they need - and in which order - to achieve a given goal:

expose.Func("/orders/submit", submitOrder,
    expose.Summary("Submit an order"),
    expose.Description("Creates the order and reserves the stock. Requires a prior call to /cart/checkout."),
)

Functions registered via expose.Struct can be annotated afterwards with expose.Describe:

fns := expose.Struct("/api", &myService{})
for i, fn := range fns {
    if fn.Path() == "/api/orders/submit" {
        fns[i] = expose.Describe(fn, expose.Summary("Submit an order"))
    }
}

For anything beyond the standard fields, expose.Extension adds custom specification extensions and expose.OperationCustomizer gives full control over the reflected operation:

expose.Func("/orders/submit", submitOrder,
    expose.Extension("x-requires-auth", true),
    expose.OperationCustomizer(func(op *openapi3.Operation) {
        op.ExternalDocs = &openapi3.ExternalDocs{URL: "https://example.com/docs/orders"}
    }))

Agent discovery

A full spec of a large service can be too much context for an agent to take in at once. The spec endpoint (default /swagger.json) therefore supports progressive discovery via the query parameters paths.prefix and paths.depth:

# compact overview: only the top level, everything below is collapsed
curl "localhost:8000/rpc/swagger.json?paths.depth=1"

Collapsed path groups are returned as stubs without operations. They carry a description and an x-expose-expand hint with the query string, that expands them:

"/guestlist": {
  "description": "Manage the guestlist of an event.\n\n7 operations",
  "x-expose-expand": "?paths.prefix=/guestlist&paths.depth=1"
}
# drill down into a section
curl "localhost:8000/rpc/swagger.json?paths.prefix=/guestlist&paths.depth=1"

Every response is a valid OpenAPI document and contains only the schemas, that are referenced by the paths it actually includes. Without the parameters the full spec is served as before.

Use expose.Module to document a whole path section. The description shows up as the group header in Swagger UI and as the description of the discovery stubs, so an agent can tell from the overview alone, which section it needs to expand:

fns := expose.Module("/guestlist", "Manage the guestlist of an event.",
    expose.Func("/guestlist/add", Add),
    expose.Func("/guestlist/vip/upgrade", Upgrade),
)

Module wraps the functions, so the documentation travels with the route definitions. Nested modules are supported - just wrap an inner expose.Module(...) again.

Services registered with expose.Struct document their section by implementing ModuleDoc() string. Nested struct fields can do the same and get their own section:

type Guestlist struct {
    Vip VipService
}

func (Guestlist) ModuleDoc() string { return "Manage the guestlist of an event." }

More examples: https://github.com/pbedat/expose/tree/main/examples

About

No description, website, or topics provided.

Resources

Stars

5 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages