chore: import upstream snapshot with attribution
govulncheck / govulncheck (push) Has been cancelled
Lint / golangci-lint (push) Has been cancelled
Run Tests / Unit Tests (push) Has been cancelled
Run Tests / Etcd Integration Tests (push) Has been cancelled
Harness (E2E) / Harnesses (mock LLM) (push) Has been cancelled
Harness (E2E) / Provider harnesses (live LLM conformance) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:40:33 +08:00
commit e071084ebe
982 changed files with 160368 additions and 0 deletions
+560
View File
@@ -0,0 +1,560 @@
# Auth Wrapper
The auth wrapper package provides server and client wrappers for adding authentication and authorization to your go-micro services.
## Installation
```go
import "go-micro.dev/v5/wrapper/auth"
```
## Overview
The auth wrapper consists of three main components:
1. **Server Wrapper** (`AuthHandler`) - Protects service endpoints
2. **Client Wrapper** (`AuthClient`) - Adds auth tokens to requests
3. **Metadata Helpers** - Extract/inject tokens from/to metadata
## Server Wrapper
The server wrapper enforces authentication and authorization on incoming requests.
### Basic Usage
```go
import (
"go-micro.dev/v5"
"go-micro.dev/v5/auth/jwt"
authWrapper "go-micro.dev/v5/wrapper/auth"
)
func main() {
// Create auth provider
authProvider, _ := jwt.NewAuth()
// Create authorization rules
rules := auth.NewRules()
// Wrap service with auth
service := micro.NewService(
micro.Name("myservice"),
micro.WrapHandler(
authWrapper.AuthHandler(authWrapper.HandlerOptions{
Auth: authProvider,
Rules: rules,
}),
),
)
service.Run()
}
```
### Configuration Options
```go
type HandlerOptions struct {
// Auth provider for token verification (required)
Auth auth.Auth
// Rules for authorization checks (optional)
Rules auth.Rules
// SkipEndpoints is a list of endpoints that don't require auth
// Format: "Service.Method" e.g., "Greeter.Hello"
SkipEndpoints []string
}
```
### Auth Flow
For each incoming request:
1. **Check Skip List**: If endpoint in `SkipEndpoints`, skip auth
2. **Extract Token**: Get `Authorization: Bearer <token>` from metadata
3. **Verify Token**: Call `auth.Inspect(token)` to get account
4. **Check Authorization**: Call `rules.Verify(account, resource)`
5. **Inject Context**: Add account to context with `auth.ContextWithAccount()`
6. **Call Handler**: Proceed to actual handler
**Errors:**
- `401 Unauthorized` - Missing or invalid token
- `403 Forbidden` - Token valid but insufficient permissions
### Helper Functions
#### AuthRequired
Enforce auth on all endpoints (no public endpoints):
```go
micro.WrapHandler(
authWrapper.AuthRequired(authProvider, rules),
)
```
#### PublicEndpoints
Allow specific endpoints to be public:
```go
micro.WrapHandler(
authWrapper.PublicEndpoints(authProvider, rules, []string{
"Health.Check",
"Status.Version",
}),
)
```
#### AuthOptional
Extract auth if present but don't enforce (useful for endpoints that behave differently for authenticated users):
```go
micro.WrapHandler(
authWrapper.AuthOptional(authProvider),
)
```
With `AuthOptional`, the handler can check:
```go
func (s *Service) Hello(ctx context.Context, req *Request, rsp *Response) error {
if acc, ok := auth.AccountFromContext(ctx); ok {
rsp.Msg = "Hello, " + acc.ID
} else {
rsp.Msg = "Hello, anonymous"
}
return nil
}
```
## Client Wrapper
The client wrapper adds authentication tokens to outgoing requests.
### Basic Usage
```go
import (
"go-micro.dev/v5"
"go-micro.dev/v5/client"
authWrapper "go-micro.dev/v5/wrapper/auth"
)
func main() {
token := "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..."
service := micro.NewService(
micro.Name("myclient"),
micro.WrapClient(
authWrapper.FromToken(token),
),
)
service.Init()
// All calls now include the token
client := pb.NewMyServiceClient("myservice", service.Client())
rsp, err := client.SomeMethod(ctx, &pb.Request{})
}
```
### Configuration Options
```go
type ClientOptions struct {
// Auth provider for token generation (optional)
Auth auth.Auth
// Static token to use (optional)
// If not provided, will try to extract from context
Token string
}
```
### Helper Functions
#### FromToken
Use a static token for all requests:
```go
client.Wrap(
authWrapper.FromToken("eyJhbGciOi..."),
)
```
Best for:
- Pre-generated tokens
- Service accounts
- Long-lived tokens
#### FromContext
Extract account from context and generate token per-request:
```go
client.Wrap(
authWrapper.FromContext(authProvider),
)
```
Best for:
- Service-to-service auth
- Dynamic token generation
- Request context propagation
Example:
```go
func (s *Service) HandleRequest(ctx context.Context, req *Request, rsp *Response) error {
// Account already in context from incoming request
// Client wrapper extracts account and generates token
client := pb.NewOtherService("other", s.Client())
// Token automatically added
otherRsp, err := client.SomeMethod(ctx, &pb.OtherRequest{})
return nil
}
```
## Metadata Helpers
Low-level helpers for working with auth tokens in metadata.
### TokenFromMetadata
Extract Bearer token from request metadata:
```go
import (
"go-micro.dev/v5/metadata"
authWrapper "go-micro.dev/v5/wrapper/auth"
)
func handler(ctx context.Context, req *Request, rsp *Response) error {
md, _ := metadata.FromContext(ctx)
token, err := authWrapper.TokenFromMetadata(md)
if err != nil {
return err // ErrMissingToken or ErrInvalidToken
}
// Use token...
}
```
**Returns:**
- Token string (without "Bearer " prefix)
- `ErrMissingToken` - No Authorization header found
- `ErrInvalidToken` - Not in "Bearer <token>" format
### TokenToMetadata
Add Bearer token to outgoing request metadata:
```go
md := metadata.Metadata{}
md = authWrapper.TokenToMetadata(md, "eyJhbGciOi...")
ctx := metadata.NewContext(context.Background(), md)
// Make RPC call with metadata
client.Call(ctx, req, rsp)
```
### AccountFromMetadata
Extract token and verify in one step:
```go
func handler(ctx context.Context, req *Request, rsp *Response) error {
md, _ := metadata.FromContext(ctx)
account, err := authWrapper.AccountFromMetadata(md, authProvider)
if err != nil {
return errors.Unauthorized("myservice", "invalid auth")
}
// Use account...
log.Printf("Request from: %s", account.ID)
}
```
This combines:
1. `TokenFromMetadata(md)`
2. `authProvider.Inspect(token)`
## Complete Example
### Server
```go
package main
import (
"context"
"go-micro.dev/v5"
"go-micro.dev/v5/auth"
"go-micro.dev/v5/auth/jwt"
authWrapper "go-micro.dev/v5/wrapper/auth"
)
type Greeter struct{}
func (g *Greeter) Hello(ctx context.Context, req *Request, rsp *Response) error {
// Get authenticated account
acc, ok := auth.AccountFromContext(ctx)
if !ok {
return errors.Unauthorized("greeter", "auth required")
}
rsp.Msg = "Hello, " + acc.ID
return nil
}
func main() {
authProvider, _ := jwt.NewAuth()
rules := auth.NewRules()
service := micro.NewService(
micro.Name("greeter"),
micro.WrapHandler(
authWrapper.PublicEndpoints(authProvider, rules, []string{
"Greeter.Health",
}),
),
)
pb.RegisterGreeterHandler(service.Server(), &Greeter{})
service.Run()
}
```
### Client
```go
package main
import (
"context"
"go-micro.dev/v5"
authWrapper "go-micro.dev/v5/wrapper/auth"
)
func main() {
token := "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..."
service := micro.NewService(
micro.WrapClient(
authWrapper.FromToken(token),
),
)
client := pb.NewGreeterService("greeter", service.Client())
rsp, _ := client.Hello(context.Background(), &pb.Request{})
}
```
## Testing
### Mock Auth for Tests
```go
import "go-micro.dev/v5/auth/noop"
func TestService(t *testing.T) {
// Use noop auth for testing (always grants access)
authProvider := noop.NewAuth()
service := micro.NewService(
micro.WrapHandler(
authWrapper.AuthHandler(authWrapper.HandlerOptions{
Auth: authProvider,
}),
),
)
// Test your service...
}
```
### Generate Test Tokens
```go
func TestWithAuth(t *testing.T) {
authProvider := noop.NewAuth()
// Generate test account
acc, _ := authProvider.Generate("test-user")
// Generate token
token, _ := authProvider.Token(
auth.WithCredentials(acc.ID, acc.Secret),
)
// Use token in tests
client := micro.NewService(
micro.WrapClient(
authWrapper.FromToken(token.AccessToken),
),
)
}
```
## Integration with Gateway
If you're using the HTTP gateway (`micro server`), auth is automatically integrated:
```bash
# Gateway enforces auth on HTTP requests
micro server --auth jwt
```
The gateway:
1. Extracts Bearer token from HTTP `Authorization` header
2. Verifies token
3. Adds account to metadata
4. Forwards to service (service still checks with wrapper)
## Best Practices
### 1. Always Use Server Wrapper
Even if using gateway auth, still wrap your services:
```go
// ✅ Good: Defense in depth
micro.WrapHandler(authWrapper.AuthHandler(...))
// ❌ Bad: Only rely on gateway
// (services can be called directly, bypassing gateway)
```
### 2. Use Strong Auth in Production
```go
// ✅ Production
authProvider, _ := jwt.NewAuth(
auth.Issuer("your-company"),
auth.PrivateKey(privateKey),
auth.PublicKey(publicKey),
)
// ❌ Development only
authProvider := noop.NewAuth()
```
### 3. Scope Your Rules
```go
// ✅ Good: Specific scopes
rules.Grant(&auth.Rule{
Scope: "admin",
Resource: &auth.Resource{Endpoint: "Admin.*"},
})
// ⚠️ Risky: Too broad
rules.Grant(&auth.Rule{
Scope: "*",
Resource: &auth.Resource{Endpoint: "*"},
})
```
### 4. Check Account in Handlers
```go
// ✅ Good: Verify account exists
func (s *Service) Delete(ctx context.Context, req *Request, rsp *Response) error {
acc, ok := auth.AccountFromContext(ctx)
if !ok || acc.ID != req.UserID {
return errors.Forbidden("service", "can only delete own data")
}
// ...
}
```
### 5. Use AuthOptional for Mixed Endpoints
```go
// ✅ Good: Works for both auth and no-auth
func (s *Service) GetProfile(ctx context.Context, req *Request, rsp *Response) error {
if acc, ok := auth.AccountFromContext(ctx); ok {
// Authenticated: return private profile
rsp.Profile = s.getPrivateProfile(acc.ID)
} else {
// Public: return limited profile
rsp.Profile = s.getPublicProfile(req.UserID)
}
return nil
}
```
## Troubleshooting
### Issue: Handler receives requests without auth
**Check:**
1. Is wrapper applied? `micro.WrapHandler(authWrapper.AuthHandler(...))`
2. Is endpoint in skip list? Check `SkipEndpoints`
3. Is service registered correctly?
### Issue: Client gets 401 errors
**Check:**
1. Is token valid? Verify with `authProvider.Inspect(token)`
2. Is client wrapper applied? `micro.WrapClient(authWrapper.FromToken(...))`
3. Is token expired? Check `token.Expiry`
### Issue: Token extraction fails
**Check:**
1. Is Authorization header present? `md.Get("Authorization")`
2. Is format correct? Must be `Bearer <token>`
3. Is metadata propagated? Check context
## API Reference
### Server Wrapper
- `AuthHandler(opts HandlerOptions) server.HandlerWrapper`
- `PublicEndpoints(auth, rules, endpoints) HandlerOptions`
- `AuthRequired(auth, rules) HandlerOptions`
- `AuthOptional(auth) server.HandlerWrapper`
### Client Wrapper
- `AuthClient(opts ClientOptions) client.Wrapper`
- `FromToken(token) client.Wrapper`
- `FromContext(auth) client.Wrapper`
### Metadata Helpers
- `TokenFromMetadata(md) (string, error)`
- `TokenToMetadata(md, token) Metadata`
- `AccountFromMetadata(md, auth) (*Account, error)`
### Constants
- `MetadataKeyAuthorization` = `"Authorization"`
- `BearerPrefix` = `"Bearer "`
### Errors
- `ErrMissingToken` - No authorization token in metadata
- `ErrInvalidToken` - Token format invalid (not "Bearer <token>")
## See Also
- [Auth Package Documentation](/auth)
- [JWT Auth Provider](/auth/jwt)
- [Authorization Rules](/auth#rules)
- [Example Usage](/examples/auth)
## License
Apache 2.0
+144
View File
@@ -0,0 +1,144 @@
package auth
import (
"context"
"go-micro.dev/v6/auth"
"go-micro.dev/v6/client"
"go-micro.dev/v6/metadata"
)
// ClientOptions for configuring the auth client wrapper
type ClientOptions struct {
// Auth provider for token generation
Auth auth.Auth
// Token to use (optional - if not provided, will be extracted from context)
Token string
}
// AuthClient returns a client Wrapper that adds authentication tokens to outgoing requests.
//
// For each outgoing request:
// 1. Extracts or uses provided token
// 2. Adds Bearer token to request metadata
// 3. Makes the RPC call
//
// Example usage:
//
// client := client.NewClient(
// client.Wrap(auth.AuthClient(auth.ClientOptions{
// Auth: myAuthProvider,
// Token: myToken,
// })),
// )
func AuthClient(opts ClientOptions) client.Wrapper {
return func(c client.Client) client.Client {
return &authClient{
Client: c,
opts: opts,
}
}
}
// authClient wraps a client to add authentication
type authClient struct {
client.Client
opts ClientOptions
}
// Call adds authentication token to the request
func (a *authClient) Call(ctx context.Context, req client.Request, rsp interface{}, opts ...client.CallOption) error {
// Get token from options or context
token := a.opts.Token
if token == "" && a.opts.Auth != nil {
// Try to get token from context account
if acc, ok := auth.AccountFromContext(ctx); ok {
// Generate token for this account
if t, err := a.opts.Auth.Token(auth.WithCredentials(acc.ID, acc.Secret)); err == nil {
token = t.AccessToken
}
}
}
// Add token to metadata if available
if token != "" {
md, ok := metadata.FromContext(ctx)
if !ok {
md = metadata.Metadata{}
}
md = TokenToMetadata(md, token)
ctx = metadata.NewContext(ctx, md)
}
return a.Client.Call(ctx, req, rsp, opts...)
}
// Stream adds authentication token to the stream request
func (a *authClient) Stream(ctx context.Context, req client.Request, opts ...client.CallOption) (client.Stream, error) {
// Get token from options or context
token := a.opts.Token
if token == "" && a.opts.Auth != nil {
// Try to get token from context account
if acc, ok := auth.AccountFromContext(ctx); ok {
// Generate token for this account
if t, err := a.opts.Auth.Token(auth.WithCredentials(acc.ID, acc.Secret)); err == nil {
token = t.AccessToken
}
}
}
// Add token to metadata if available
if token != "" {
md, ok := metadata.FromContext(ctx)
if !ok {
md = metadata.Metadata{}
}
md = TokenToMetadata(md, token)
ctx = metadata.NewContext(ctx, md)
}
return a.Client.Stream(ctx, req, opts...)
}
// Publish adds authentication token to the publish request
func (a *authClient) Publish(ctx context.Context, msg client.Message, opts ...client.PublishOption) error {
// Get token from options or context
token := a.opts.Token
if token == "" && a.opts.Auth != nil {
// Try to get token from context account
if acc, ok := auth.AccountFromContext(ctx); ok {
// Generate token for this account
if t, err := a.opts.Auth.Token(auth.WithCredentials(acc.ID, acc.Secret)); err == nil {
token = t.AccessToken
}
}
}
// Add token to metadata if available
if token != "" {
md, ok := metadata.FromContext(ctx)
if !ok {
md = metadata.Metadata{}
}
md = TokenToMetadata(md, token)
ctx = metadata.NewContext(ctx, md)
}
return a.Client.Publish(ctx, msg, opts...)
}
// FromToken creates a client wrapper with a static token.
// This is useful when you have a pre-generated token and don't need the auth provider.
func FromToken(token string) client.Wrapper {
return AuthClient(ClientOptions{
Token: token,
})
}
// FromContext creates a client wrapper that extracts the account from context
// and generates a token for each request. Useful for service-to-service auth.
func FromContext(authProvider auth.Auth) client.Wrapper {
return AuthClient(ClientOptions{
Auth: authProvider,
})
}
+74
View File
@@ -0,0 +1,74 @@
package auth
import (
"errors"
"strings"
"go-micro.dev/v6/auth"
"go-micro.dev/v6/metadata"
)
const (
// MetadataKeyAuthorization is the key for the Authorization header in metadata
MetadataKeyAuthorization = "Authorization"
// BearerPrefix is the prefix for Bearer tokens
BearerPrefix = "Bearer "
)
var (
// ErrMissingToken is returned when no authorization token is found in metadata
ErrMissingToken = errors.New("missing authorization token in metadata")
// ErrInvalidToken is returned when the token format is invalid
ErrInvalidToken = errors.New("invalid token format, expected 'Bearer <token>'")
)
// TokenFromMetadata extracts the Bearer token from request metadata.
// Returns the token string without the "Bearer " prefix, or an error if not found.
func TokenFromMetadata(md metadata.Metadata) (string, error) {
// Check for Authorization header
authHeader, ok := md.Get(MetadataKeyAuthorization)
if !ok {
// Also check lowercase version
authHeader, ok = md.Get(strings.ToLower(MetadataKeyAuthorization))
if !ok {
return "", ErrMissingToken
}
}
// Verify Bearer prefix
if !strings.HasPrefix(authHeader, BearerPrefix) {
return "", ErrInvalidToken
}
// Extract token (remove "Bearer " prefix)
token := strings.TrimPrefix(authHeader, BearerPrefix)
if token == "" {
return "", ErrInvalidToken
}
return token, nil
}
// TokenToMetadata adds a Bearer token to metadata for outgoing requests.
// The token should be provided without the "Bearer " prefix.
func TokenToMetadata(md metadata.Metadata, token string) metadata.Metadata {
if md == nil {
md = metadata.Metadata{}
}
// Add Bearer prefix and set in metadata
md.Set(MetadataKeyAuthorization, BearerPrefix+token)
return md
}
// AccountFromMetadata extracts and verifies the token from metadata,
// returning the associated account. This is a convenience function that
// combines TokenFromMetadata and auth.Inspect.
func AccountFromMetadata(md metadata.Metadata, a auth.Auth) (*auth.Account, error) {
token, err := TokenFromMetadata(md)
if err != nil {
return nil, err
}
return a.Inspect(token)
}
+149
View File
@@ -0,0 +1,149 @@
package auth
import (
"context"
"go-micro.dev/v6/auth"
"go-micro.dev/v6/errors"
"go-micro.dev/v6/metadata"
"go-micro.dev/v6/server"
)
// HandlerOptions for configuring the auth handler wrapper
type HandlerOptions struct {
// Auth provider for token verification
Auth auth.Auth
// Rules for authorization checks
Rules auth.Rules
// SkipEndpoints is a list of endpoints that don't require auth
// Format: "Service.Method" e.g., "Greeter.Hello"
SkipEndpoints []string
}
// AuthHandler returns a server HandlerWrapper that enforces authentication and authorization.
//
// For each incoming request:
// 1. Extracts Bearer token from metadata
// 2. Verifies token using auth.Inspect()
// 3. Checks authorization using rules.Verify()
// 4. Adds account to context
// 5. Calls the handler if authorized
//
// Returns 401 Unauthorized if token is missing/invalid.
// Returns 403 Forbidden if account lacks necessary permissions.
//
// Example usage:
//
// service := micro.NewService(
// micro.WrapHandler(auth.AuthHandler(auth.HandlerOptions{
// Auth: myAuthProvider,
// Rules: myRules,
// SkipEndpoints: []string{"Health.Check"},
// })),
// )
func AuthHandler(opts HandlerOptions) server.HandlerWrapper {
return func(h server.HandlerFunc) server.HandlerFunc {
return func(ctx context.Context, req server.Request, rsp interface{}) error {
// Get endpoint name
endpoint := req.Endpoint()
// Check if this endpoint should skip auth
for _, skip := range opts.SkipEndpoints {
if skip == endpoint {
// Skip auth, proceed to handler
return h(ctx, req, rsp)
}
}
// Extract metadata from context
md, ok := metadata.FromContext(ctx)
if !ok {
return errors.Unauthorized(req.Service(), "missing metadata")
}
// Extract and verify token
token, err := TokenFromMetadata(md)
if err != nil {
if err == ErrMissingToken {
return errors.Unauthorized(req.Service(), "missing authorization token")
}
return errors.Unauthorized(req.Service(), "invalid authorization token: %v", err)
}
// Verify token and get account
var account *auth.Account
if opts.Auth != nil {
account, err = opts.Auth.Inspect(token)
if err != nil {
if err == auth.ErrInvalidToken {
return errors.Unauthorized(req.Service(), "invalid token")
}
return errors.Unauthorized(req.Service(), "token verification failed: %v", err)
}
}
// Check authorization if rules are provided
if opts.Rules != nil && account != nil {
resource := &auth.Resource{
Name: req.Service(),
Type: "service",
Endpoint: endpoint,
}
if err := opts.Rules.Verify(account, resource); err != nil {
if err == auth.ErrForbidden {
return errors.Forbidden(req.Service(), "access denied to %s", endpoint)
}
return errors.Forbidden(req.Service(), "authorization failed: %v", err)
}
}
// Add account to context for handler to use
if account != nil {
ctx = auth.ContextWithAccount(ctx, account)
}
// Call the handler
return h(ctx, req, rsp)
}
}
}
// PublicEndpoints is a helper to create auth options that allow public access to specific endpoints.
func PublicEndpoints(authProvider auth.Auth, rules auth.Rules, publicEndpoints []string) HandlerOptions {
return HandlerOptions{
Auth: authProvider,
Rules: rules,
SkipEndpoints: publicEndpoints,
}
}
// AuthRequired creates auth options that require authentication for all endpoints.
func AuthRequired(authProvider auth.Auth, rules auth.Rules) HandlerOptions {
return HandlerOptions{
Auth: authProvider,
Rules: rules,
SkipEndpoints: []string{},
}
}
// AuthOptional creates auth options that extract auth if present but don't enforce it.
// Useful for endpoints that behave differently for authenticated users but also work without auth.
func AuthOptional(authProvider auth.Auth) server.HandlerWrapper {
return func(h server.HandlerFunc) server.HandlerFunc {
return func(ctx context.Context, req server.Request, rsp interface{}) error {
// Try to extract account, but don't fail if missing
md, ok := metadata.FromContext(ctx)
if ok {
if token, err := TokenFromMetadata(md); err == nil {
if account, err := authProvider.Inspect(token); err == nil {
ctx = auth.ContextWithAccount(ctx, account)
}
}
}
// Always call handler, with or without account in context
return h(ctx, req, rsp)
}
}
}
+129
View File
@@ -0,0 +1,129 @@
# Prometheus Wrapper
The `prometheus` wrapper package exposes standard request metrics (request
count, latency, errors) for go-micro services and clients, so they can be
scraped by a Prometheus server with zero extra boilerplate.
Resolves [micro/go-micro#2893](https://github.com/micro/go-micro/issues/2893).
## Installation
```go
import prom "go-micro.dev/v5/wrapper/monitoring/prometheus"
```
## Exported Metrics
All metrics are labelled with `service`, `endpoint` and `status`
(`"success"` or `"fail"`). Labels are kept small on purpose to avoid
blowing up Prometheus memory.
| Metric | Type | Description |
|---------------------------------|-----------|---------------------------------------------|
| `micro_request_total` | Counter | Total number of requests handled. |
| `micro_request_duration_seconds`| Histogram | Request latency distribution (seconds). |
The `micro` prefix can be overridden with `prom.ServiceName("myapp")`.
## Basic Usage
```go
import (
"go-micro.dev/v5"
prom "go-micro.dev/v5/wrapper/monitoring/prometheus"
)
func main() {
service := micro.NewService(
micro.Name("example.service"),
micro.WrapHandler(prom.NewHandlerWrapper()),
micro.WrapClient(prom.NewClientWrapper()),
micro.WrapSubscriber(prom.NewSubscriberWrapper()),
)
service.Init()
if err := service.Run(); err != nil {
panic(err)
}
}
```
To expose the metrics to Prometheus, serve the default `promhttp` handler
on a side HTTP endpoint:
```go
import (
"net/http"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
go func() {
http.Handle("/metrics", promhttp.Handler())
_ = http.ListenAndServe(":9100", nil)
}()
```
Then point Prometheus at it:
```yaml
scrape_configs:
- job_name: 'example.service'
static_configs:
- targets: ['localhost:9100']
```
## Wrappers
| Constructor | Wraps | Notes |
|---------------------------|-------------------------|--------------------------------------------|
| `NewHandlerWrapper` | `server.HandlerWrapper` | Incoming RPC handlers. |
| `NewSubscriberWrapper` | `server.SubscriberWrapper` | Event subscribers (uses topic as endpoint). |
| `NewCallWrapper` | `client.CallWrapper` | Outgoing unary RPC calls only. |
| `NewClientWrapper` | `client.Wrapper` | Outgoing `Call` **and** `Publish`. |
`NewClientWrapper` is the right choice when you want metrics for both
`Call` and `Publish`; use `NewCallWrapper` if you only care about unary
calls and want lower overhead.
## Configuration
All constructors accept functional options:
```go
prom.NewHandlerWrapper(
prom.ServiceName("myapp"), // metric name prefix
prom.Namespace("prod"), // Prometheus namespace
prom.Subsystem("api"), // Prometheus subsystem
prom.ConstLabels(prometheus.Labels{"dc": "eu-1"}), // labels on every metric
prom.Buckets([]float64{0.005, 0.05, 0.5, 1, 5}), // latency buckets
prom.Registerer(myRegistry), // custom registerer
)
```
Defaults:
- `ServiceName`: `"micro"`
- `Buckets`: `prometheus.DefBuckets`
- `Registerer`: `prometheus.DefaultRegisterer`
## Reusing Collectors
Creating multiple wrappers with the same options (e.g. `NewHandlerWrapper`
and `NewClientWrapper` together) is safe: the collectors are cached per
`(name, namespace, subsystem)` triple and `AlreadyRegisteredError` from
Prometheus is handled transparently, so the existing collector is reused.
## Testing
The package ships with unit tests that use a fresh `prometheus.Registry`
per test to keep assertions isolated:
```bash
go test ./wrapper/monitoring/prometheus/...
```
## License
Apache 2.0
+95
View File
@@ -0,0 +1,95 @@
package prometheus
import (
"sync"
"github.com/prometheus/client_golang/prometheus"
)
// metrics bundles the counters/histograms used by the wrappers.
// A metrics value is keyed by (name + namespace + subsystem) so that
// multiple wrappers created with the same options share the same
// collectors instead of failing with AlreadyRegisteredError.
type metrics struct {
requestTotal *prometheus.CounterVec
requestDuration *prometheus.HistogramVec
}
// metricLabels are the labels we use on every metric. They intentionally
// stay on a small, low-cardinality set: high-cardinality labels (e.g. the
// full request body) must not end up in Prometheus.
var metricLabels = []string{"service", "endpoint", "status"}
var (
metricsMu sync.Mutex
metricsCache = map[string]*metrics{}
)
// getMetrics returns a cached metrics bundle for the given options, creating
// and registering it on first use. Collectors that were already registered
// on the underlying Registerer (e.g. because a user constructed two wrappers
// with identical options) are reused transparently.
func getMetrics(opts Options) *metrics {
key := opts.Name + "\x00" + opts.Namespace + "\x00" + opts.Subsystem
metricsMu.Lock()
defer metricsMu.Unlock()
if m, ok := metricsCache[key]; ok {
return m
}
counter := prometheus.NewCounterVec(
prometheus.CounterOpts{
Namespace: opts.Namespace,
Subsystem: opts.Subsystem,
Name: opts.Name + "_request_total",
Help: "How many go-micro requests processed, partitioned by service, endpoint and status.",
ConstLabels: opts.ConstLabels,
},
metricLabels,
)
histogram := prometheus.NewHistogramVec(
prometheus.HistogramOpts{
Namespace: opts.Namespace,
Subsystem: opts.Subsystem,
Name: opts.Name + "_request_duration_seconds",
Help: "Histogram of go-micro request latencies in seconds, partitioned by service, endpoint and status.",
ConstLabels: opts.ConstLabels,
Buckets: opts.Buckets,
},
metricLabels,
)
m := &metrics{
requestTotal: register(opts.Registerer, counter).(*prometheus.CounterVec),
requestDuration: register(opts.Registerer, histogram).(*prometheus.HistogramVec),
}
metricsCache[key] = m
return m
}
// register registers c on r. If an identical collector is already registered
// (AlreadyRegisteredError), the existing collector is returned so that the
// wrapper can be constructed more than once without panicking.
func register(r prometheus.Registerer, c prometheus.Collector) prometheus.Collector {
if err := r.Register(c); err != nil {
if are, ok := err.(prometheus.AlreadyRegisteredError); ok {
return are.ExistingCollector
}
// Any other registration error is a programming mistake (e.g.
// inconsistent label dimensions) and should surface loudly.
panic(err)
}
return c
}
// status returns "success" or "fail" depending on whether err is nil.
// Using a fixed, low-cardinality set keeps Prometheus memory bounded.
func status(err error) string {
if err != nil {
return "fail"
}
return "success"
}
+99
View File
@@ -0,0 +1,99 @@
// Package prometheus provides a go-micro wrapper that exposes standard
// request/response metrics (request count, latency, errors) to Prometheus.
package prometheus
import (
"github.com/prometheus/client_golang/prometheus"
)
// Options holds configuration for the Prometheus wrapper.
type Options struct {
// Name is the metric name prefix (Prometheus "name").
// Default: "micro".
Name string
// Namespace for the Prometheus metrics.
// Default: "" (empty).
Namespace string
// Subsystem for the Prometheus metrics.
// Default: "" (empty).
Subsystem string
// ConstLabels are labels applied to every metric.
ConstLabels prometheus.Labels
// Buckets defines the histogram buckets (seconds) for latency.
// When nil, prometheus.DefBuckets is used.
Buckets []float64
// Registerer is used to register the metrics.
// When nil, prometheus.DefaultRegisterer is used.
Registerer prometheus.Registerer
}
// Option applies a single configuration value.
type Option func(*Options)
// ServiceName sets the metric name prefix (Prometheus "name").
func ServiceName(name string) Option {
return func(o *Options) {
o.Name = name
}
}
// Namespace sets the Prometheus namespace for metrics.
func Namespace(namespace string) Option {
return func(o *Options) {
o.Namespace = namespace
}
}
// Subsystem sets the Prometheus subsystem for metrics.
func Subsystem(subsystem string) Option {
return func(o *Options) {
o.Subsystem = subsystem
}
}
// ConstLabels sets labels applied to every metric.
func ConstLabels(labels prometheus.Labels) Option {
return func(o *Options) {
o.ConstLabels = labels
}
}
// Buckets sets the histogram buckets (in seconds) for latency metrics.
func Buckets(buckets []float64) Option {
return func(o *Options) {
o.Buckets = buckets
}
}
// Registerer sets the Prometheus registerer used to register metrics.
// When unset, prometheus.DefaultRegisterer is used.
func Registerer(r prometheus.Registerer) Option {
return func(o *Options) {
o.Registerer = r
}
}
// newOptions builds Options from the provided Option functions, applying
// sensible defaults.
func newOptions(opts ...Option) Options {
options := Options{
Name: "micro",
Buckets: prometheus.DefBuckets,
Registerer: prometheus.DefaultRegisterer,
}
for _, o := range opts {
o(&options)
}
if options.Registerer == nil {
options.Registerer = prometheus.DefaultRegisterer
}
if len(options.Buckets) == 0 {
options.Buckets = prometheus.DefBuckets
}
return options
}
+89
View File
@@ -0,0 +1,89 @@
package prometheus
import (
"context"
"time"
"go-micro.dev/v6/client"
"go-micro.dev/v6/registry"
"go-micro.dev/v6/server"
)
// NewHandlerWrapper returns a server.HandlerWrapper that records Prometheus
// metrics (request count and latency) for every incoming RPC handled by the
// server.
func NewHandlerWrapper(opts ...Option) server.HandlerWrapper {
m := getMetrics(newOptions(opts...))
return func(fn server.HandlerFunc) server.HandlerFunc {
return func(ctx context.Context, req server.Request, rsp interface{}) error {
start := time.Now()
err := fn(ctx, req, rsp)
observe(m, req.Service(), req.Endpoint(), err, start)
return err
}
}
}
// NewSubscriberWrapper returns a server.SubscriberWrapper that records
// Prometheus metrics for every message delivered to a subscriber.
func NewSubscriberWrapper(opts ...Option) server.SubscriberWrapper {
m := getMetrics(newOptions(opts...))
return func(fn server.SubscriberFunc) server.SubscriberFunc {
return func(ctx context.Context, msg server.Message) error {
start := time.Now()
err := fn(ctx, msg)
observe(m, "subscriber", msg.Topic(), err, start)
return err
}
}
}
// NewCallWrapper returns a client.CallWrapper that records Prometheus
// metrics for every outgoing RPC issued by the client.
func NewCallWrapper(opts ...Option) client.CallWrapper {
m := getMetrics(newOptions(opts...))
return func(fn client.CallFunc) client.CallFunc {
return func(ctx context.Context, node *registry.Node, req client.Request, rsp interface{}, opts client.CallOptions) error {
start := time.Now()
err := fn(ctx, node, req, rsp, opts)
observe(m, req.Service(), req.Endpoint(), err, start)
return err
}
}
}
// NewClientWrapper returns a client.Wrapper that records Prometheus metrics
// for every Call and Publish issued on the wrapped client. Use it when you
// need metrics for Publish as well as Call; NewCallWrapper only covers Call.
func NewClientWrapper(opts ...Option) client.Wrapper {
m := getMetrics(newOptions(opts...))
return func(c client.Client) client.Client {
return &clientWrapper{Client: c, metrics: m}
}
}
type clientWrapper struct {
client.Client
metrics *metrics
}
func (w *clientWrapper) Call(ctx context.Context, req client.Request, rsp interface{}, opts ...client.CallOption) error {
start := time.Now()
err := w.Client.Call(ctx, req, rsp, opts...)
observe(w.metrics, req.Service(), req.Endpoint(), err, start)
return err
}
func (w *clientWrapper) Publish(ctx context.Context, p client.Message, opts ...client.PublishOption) error {
start := time.Now()
err := w.Client.Publish(ctx, p, opts...)
observe(w.metrics, "publisher", p.Topic(), err, start)
return err
}
// observe records a single request into the counter and histogram.
func observe(m *metrics, service, endpoint string, err error, start time.Time) {
st := status(err)
m.requestTotal.WithLabelValues(service, endpoint, st).Inc()
m.requestDuration.WithLabelValues(service, endpoint, st).Observe(time.Since(start).Seconds())
}
@@ -0,0 +1,206 @@
package prometheus
import (
"context"
"errors"
"testing"
"github.com/prometheus/client_golang/prometheus"
dto "github.com/prometheus/client_model/go"
"go-micro.dev/v6/client"
"go-micro.dev/v6/codec"
"go-micro.dev/v6/registry"
"go-micro.dev/v6/server"
)
// mockServerRequest is a minimal implementation of server.Request.
type mockServerRequest struct {
service string
endpoint string
}
func (r *mockServerRequest) Service() string { return r.service }
func (r *mockServerRequest) Method() string { return r.endpoint }
func (r *mockServerRequest) Endpoint() string { return r.endpoint }
func (r *mockServerRequest) ContentType() string { return "" }
func (r *mockServerRequest) Header() map[string]string { return nil }
func (r *mockServerRequest) Body() interface{} { return nil }
func (r *mockServerRequest) Read() ([]byte, error) { return nil, nil }
func (r *mockServerRequest) Codec() codec.Reader { return nil }
func (r *mockServerRequest) Stream() bool { return false }
// mockMessage implements server.Message for subscriber tests.
type mockMessage struct {
topic string
}
func (m *mockMessage) Topic() string { return m.topic }
func (m *mockMessage) Payload() interface{} { return nil }
func (m *mockMessage) ContentType() string { return "" }
func (m *mockMessage) Header() map[string]string { return nil }
func (m *mockMessage) Body() []byte { return nil }
func (m *mockMessage) Codec() codec.Reader { return nil }
// mockClientRequest is a minimal implementation of client.Request.
type mockClientRequest struct {
service string
endpoint string
}
func (r *mockClientRequest) Service() string { return r.service }
func (r *mockClientRequest) Method() string { return r.endpoint }
func (r *mockClientRequest) Endpoint() string { return r.endpoint }
func (r *mockClientRequest) ContentType() string { return "" }
func (r *mockClientRequest) Body() interface{} { return nil }
func (r *mockClientRequest) Codec() codec.Writer { return nil }
func (r *mockClientRequest) Stream() bool { return false }
// isolatedOpts returns wrapper options pinned to a fresh registry so each
// test starts with its own counters. We also vary Name so cached metrics
// bundles don't bleed between tests.
func isolatedOpts(name string) []Option {
reg := prometheus.NewRegistry()
return []Option{ServiceName(name), Registerer(reg)}
}
func counterValue(t *testing.T, vec *prometheus.CounterVec, labels ...string) float64 {
t.Helper()
c, err := vec.GetMetricWithLabelValues(labels...)
if err != nil {
t.Fatalf("GetMetricWithLabelValues: %v", err)
}
var m dto.Metric
if err := c.Write(&m); err != nil {
t.Fatalf("Write: %v", err)
}
return m.GetCounter().GetValue()
}
func histogramCount(t *testing.T, vec *prometheus.HistogramVec, labels ...string) uint64 {
t.Helper()
obs, err := vec.GetMetricWithLabelValues(labels...)
if err != nil {
t.Fatalf("GetMetricWithLabelValues: %v", err)
}
h, ok := obs.(prometheus.Histogram)
if !ok {
t.Fatalf("expected Histogram, got %T", obs)
}
var m dto.Metric
if err := h.Write(&m); err != nil {
t.Fatalf("Write: %v", err)
}
return m.GetHistogram().GetSampleCount()
}
func TestHandlerWrapperSuccess(t *testing.T) {
opts := isolatedOpts("test_handler_success")
wrap := NewHandlerWrapper(opts...)
called := false
handler := wrap(func(ctx context.Context, req server.Request, rsp interface{}) error {
called = true
return nil
})
err := handler(context.Background(), &mockServerRequest{service: "svc", endpoint: "Foo.Bar"}, nil)
if err != nil {
t.Fatalf("handler returned error: %v", err)
}
if !called {
t.Fatal("inner handler was not called")
}
m := getMetrics(newOptions(opts...))
if got := counterValue(t, m.requestTotal, "svc", "Foo.Bar", "success"); got != 1 {
t.Errorf("success counter = %v, want 1", got)
}
if got := histogramCount(t, m.requestDuration, "svc", "Foo.Bar", "success"); got != 1 {
t.Errorf("histogram count = %v, want 1", got)
}
}
func TestHandlerWrapperFailure(t *testing.T) {
opts := isolatedOpts("test_handler_failure")
wrap := NewHandlerWrapper(opts...)
boom := errors.New("boom")
handler := wrap(func(ctx context.Context, req server.Request, rsp interface{}) error {
return boom
})
err := handler(context.Background(), &mockServerRequest{service: "svc", endpoint: "Foo.Bar"}, nil)
if !errors.Is(err, boom) {
t.Fatalf("error not propagated, got %v", err)
}
m := getMetrics(newOptions(opts...))
if got := counterValue(t, m.requestTotal, "svc", "Foo.Bar", "fail"); got != 1 {
t.Errorf("fail counter = %v, want 1", got)
}
}
func TestSubscriberWrapper(t *testing.T) {
opts := isolatedOpts("test_subscriber")
wrap := NewSubscriberWrapper(opts...)
sub := wrap(func(ctx context.Context, msg server.Message) error {
return nil
})
if err := sub(context.Background(), &mockMessage{topic: "events"}); err != nil {
t.Fatalf("subscriber returned error: %v", err)
}
m := getMetrics(newOptions(opts...))
if got := counterValue(t, m.requestTotal, "subscriber", "events", "success"); got != 1 {
t.Errorf("subscriber counter = %v, want 1", got)
}
}
func TestCallWrapperSuccess(t *testing.T) {
opts := isolatedOpts("test_call")
wrap := NewCallWrapper(opts...)
cf := wrap(func(ctx context.Context, node *registry.Node, req client.Request, rsp interface{}, opts client.CallOptions) error {
return nil
})
err := cf(context.Background(), &registry.Node{}, &mockClientRequest{service: "svc", endpoint: "Foo.Bar"}, nil, client.CallOptions{})
if err != nil {
t.Fatalf("call returned error: %v", err)
}
m := getMetrics(newOptions(opts...))
if got := counterValue(t, m.requestTotal, "svc", "Foo.Bar", "success"); got != 1 {
t.Errorf("call counter = %v, want 1", got)
}
}
func TestRegisterAlreadyRegistered(t *testing.T) {
// Creating two wrappers against the same Registerer with the same
// options must not panic, and the second one must reuse the already
// registered collector.
reg := prometheus.NewRegistry()
opts := []Option{ServiceName("test_dup"), Registerer(reg)}
_ = NewHandlerWrapper(opts...)
_ = NewHandlerWrapper(opts...)
// After resetting the cache we must still not panic, which proves the
// AlreadyRegisteredError branch in register() is exercised.
metricsMu.Lock()
delete(metricsCache, "test_dup\x00\x00")
metricsMu.Unlock()
_ = NewHandlerWrapper(opts...)
}
func TestStatusHelper(t *testing.T) {
if status(nil) != "success" {
t.Error("nil error should yield success")
}
if status(errors.New("x")) != "fail" {
t.Error("non-nil error should yield fail")
}
}
+14
View File
@@ -0,0 +1,14 @@
# OpenTelemetry wrappers
OpenTelemetry wrappers propagate traces (spans) accross services.
## Usage
```go
service := micro.NewService(
micro.Name("go.micro.srv.greeter"),
micro.WrapClient(opentelemetry.NewClientWrapper()),
micro.WrapHandler(opentelemetry.NewHandlerWrapper()),
micro.WrapSubscriber(opentelemetry.NewSubscriberWrapper()),
)
```
@@ -0,0 +1,55 @@
package opentelemetry
import (
"context"
"strings"
"go-micro.dev/v6/metadata"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/baggage"
"go.opentelemetry.io/otel/propagation"
"go.opentelemetry.io/otel/trace"
)
const (
instrumentationName = "github.com/micro/plugins/v5/wrapper/trace/opentelemetry"
)
// StartSpanFromContext returns a new span with the given operation name and options. If a span
// is found in the context, it will be used as the parent of the resulting span.
func StartSpanFromContext(ctx context.Context, tp trace.TracerProvider, name string, opts ...trace.SpanStartOption) (context.Context, trace.Span) {
md, ok := metadata.FromContext(ctx)
if !ok {
md = make(metadata.Metadata)
}
propagator, carrier := otel.GetTextMapPropagator(), make(propagation.MapCarrier)
for k, v := range md {
for _, f := range propagator.Fields() {
if strings.EqualFold(k, f) {
carrier[f] = v
}
}
}
ctx = propagator.Extract(ctx, carrier)
spanCtx := trace.SpanContextFromContext(ctx)
ctx = baggage.ContextWithBaggage(ctx, baggage.FromContext(ctx))
var tracer trace.Tracer
var span trace.Span
if tp != nil {
tracer = tp.Tracer(instrumentationName)
} else {
tracer = otel.Tracer(instrumentationName)
}
ctx, span = tracer.Start(trace.ContextWithRemoteSpanContext(ctx, spanCtx), name, opts...)
carrier = make(propagation.MapCarrier)
propagator.Inject(ctx, carrier)
for k, v := range carrier {
//lint:ignore SA1019 no unicode punctution handle needed
md.Set(strings.Title(k), v)
}
ctx = metadata.NewContext(ctx, md)
return ctx, span
}
+72
View File
@@ -0,0 +1,72 @@
package opentelemetry
import (
"context"
"go-micro.dev/v6/client"
"go-micro.dev/v6/server"
"go.opentelemetry.io/otel/trace"
)
type Options struct {
TraceProvider trace.TracerProvider
CallFilter CallFilter
StreamFilter StreamFilter
PublishFilter PublishFilter
SubscriberFilter SubscriberFilter
HandlerFilter HandlerFilter
}
// CallFilter used to filter client.Call, return true to skip call trace.
type CallFilter func(context.Context, client.Request) bool
// StreamFilter used to filter client.Stream, return true to skip stream trace.
type StreamFilter func(context.Context, client.Request) bool
// PublishFilter used to filter client.Publish, return true to skip publish trace.
type PublishFilter func(context.Context, client.Message) bool
// SubscriberFilter used to filter server.Subscribe, return true to skip subcribe trace.
type SubscriberFilter func(context.Context, server.Message) bool
// HandlerFilter used to filter server.Handle, return true to skip handle trace.
type HandlerFilter func(context.Context, server.Request) bool
type Option func(*Options)
func WithTraceProvider(tp trace.TracerProvider) Option {
return func(o *Options) {
o.TraceProvider = tp
}
}
func WithCallFilter(filter CallFilter) Option {
return func(o *Options) {
o.CallFilter = filter
}
}
func WithStreamFilter(filter StreamFilter) Option {
return func(o *Options) {
o.StreamFilter = filter
}
}
func WithPublishFilter(filter PublishFilter) Option {
return func(o *Options) {
o.PublishFilter = filter
}
}
func WithSubscribeFilter(filter SubscriberFilter) Option {
return func(o *Options) {
o.SubscriberFilter = filter
}
}
func WithHandleFilter(filter HandlerFilter) Option {
return func(o *Options) {
o.HandlerFilter = filter
}
}
+175
View File
@@ -0,0 +1,175 @@
package opentelemetry
import (
"context"
"fmt"
"go-micro.dev/v6/client"
"go-micro.dev/v6/registry"
"go-micro.dev/v6/server"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
)
// NewCallWrapper accepts an opentracing Tracer and returns a Call Wrapper.
func NewCallWrapper(opts ...Option) client.CallWrapper {
options := Options{}
for _, o := range opts {
o(&options)
}
return func(cf client.CallFunc) client.CallFunc {
return func(ctx context.Context, node *registry.Node, req client.Request, rsp interface{}, opts client.CallOptions) error {
if options.CallFilter != nil && options.CallFilter(ctx, req) {
return cf(ctx, node, req, rsp, opts)
}
name := fmt.Sprintf("%s.%s", req.Service(), req.Endpoint())
spanOpts := []trace.SpanStartOption{
trace.WithSpanKind(trace.SpanKindClient),
}
ctx, span := StartSpanFromContext(ctx, options.TraceProvider, name, spanOpts...)
defer span.End()
if err := cf(ctx, node, req, rsp, opts); err != nil {
span.SetStatus(codes.Error, err.Error())
span.RecordError(err)
return err
}
return nil
}
}
}
// NewHandlerWrapper accepts an opentracing Tracer and returns a Handler Wrapper.
func NewHandlerWrapper(opts ...Option) server.HandlerWrapper {
options := Options{}
for _, o := range opts {
o(&options)
}
return func(h server.HandlerFunc) server.HandlerFunc {
return func(ctx context.Context, req server.Request, rsp interface{}) error {
if options.HandlerFilter != nil && options.HandlerFilter(ctx, req) {
return h(ctx, req, rsp)
}
name := fmt.Sprintf("%s.%s", req.Service(), req.Endpoint())
spanOpts := []trace.SpanStartOption{
trace.WithSpanKind(trace.SpanKindServer),
}
ctx, span := StartSpanFromContext(ctx, options.TraceProvider, name, spanOpts...)
defer span.End()
if err := h(ctx, req, rsp); err != nil {
span.SetStatus(codes.Error, err.Error())
span.RecordError(err)
return err
}
return nil
}
}
}
// NewSubscriberWrapper accepts an opentracing Tracer and returns a Subscriber Wrapper.
func NewSubscriberWrapper(opts ...Option) server.SubscriberWrapper {
options := Options{}
for _, o := range opts {
o(&options)
}
return func(next server.SubscriberFunc) server.SubscriberFunc {
return func(ctx context.Context, msg server.Message) error {
if options.SubscriberFilter != nil && options.SubscriberFilter(ctx, msg) {
return next(ctx, msg)
}
name := "Sub from " + msg.Topic()
spanOpts := []trace.SpanStartOption{
trace.WithSpanKind(trace.SpanKindServer),
}
ctx, span := StartSpanFromContext(ctx, options.TraceProvider, name, spanOpts...)
defer span.End()
if err := next(ctx, msg); err != nil {
span.SetStatus(codes.Error, err.Error())
span.RecordError(err)
return err
}
return nil
}
}
}
// NewClientWrapper returns a client.Wrapper
// that adds monitoring to outgoing requests.
func NewClientWrapper(opts ...Option) client.Wrapper {
options := Options{}
for _, o := range opts {
o(&options)
}
return func(c client.Client) client.Client {
w := &clientWrapper{
Client: c,
tp: options.TraceProvider,
callFilter: options.CallFilter,
streamFilter: options.StreamFilter,
publishFilter: options.PublishFilter,
}
return w
}
}
type clientWrapper struct {
client.Client
tp trace.TracerProvider
callFilter CallFilter
streamFilter StreamFilter
publishFilter PublishFilter
}
func (w *clientWrapper) Call(ctx context.Context, req client.Request, rsp interface{}, opts ...client.CallOption) error {
if w.callFilter != nil && w.callFilter(ctx, req) {
return w.Client.Call(ctx, req, rsp, opts...)
}
name := fmt.Sprintf("%s.%s", req.Service(), req.Endpoint())
spanOpts := []trace.SpanStartOption{
trace.WithSpanKind(trace.SpanKindClient),
}
ctx, span := StartSpanFromContext(ctx, w.tp, name, spanOpts...)
defer span.End()
if err := w.Client.Call(ctx, req, rsp, opts...); err != nil {
span.SetStatus(codes.Error, err.Error())
span.RecordError(err)
return err
}
return nil
}
func (w *clientWrapper) Stream(ctx context.Context, req client.Request, opts ...client.CallOption) (client.Stream, error) {
if w.streamFilter != nil && w.streamFilter(ctx, req) {
return w.Client.Stream(ctx, req, opts...)
}
name := fmt.Sprintf("%s.%s", req.Service(), req.Endpoint())
spanOpts := []trace.SpanStartOption{
trace.WithSpanKind(trace.SpanKindClient),
}
ctx, span := StartSpanFromContext(ctx, w.tp, name, spanOpts...)
defer span.End()
stream, err := w.Client.Stream(ctx, req, opts...)
if err != nil {
span.SetStatus(codes.Error, err.Error())
span.RecordError(err)
}
return stream, err
}
func (w *clientWrapper) Publish(ctx context.Context, p client.Message, opts ...client.PublishOption) error {
if w.publishFilter != nil && w.publishFilter(ctx, p) {
return w.Client.Publish(ctx, p, opts...)
}
name := fmt.Sprintf("Pub to %s", p.Topic())
spanOpts := []trace.SpanStartOption{
trace.WithSpanKind(trace.SpanKindClient),
}
ctx, span := StartSpanFromContext(ctx, w.tp, name, spanOpts...)
defer span.End()
if err := w.Client.Publish(ctx, p, opts...); err != nil {
span.SetStatus(codes.Error, err.Error())
span.RecordError(err)
return err
}
return nil
}
+109
View File
@@ -0,0 +1,109 @@
package x402
import (
"crypto/ed25519"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
)
// CDPFacilitatorURL is Coinbase Developer Platform's hosted x402 facilitator,
// which can settle real payments on Base mainnet (the open x402.org facilitator
// is testnet-only).
const CDPFacilitatorURL = "https://api.cdp.coinbase.com/platform/v2/x402"
// CDP returns a Facilitator (and Settler) backed by Coinbase's hosted
// facilitator, authenticating each verify/settle call with a short-lived
// Ed25519 Bearer JWT minted from a CDP Secret API Key. keyID and keySecret are
// the CDP API Key ID and base64 Ed25519 secret; the secret is used only to sign
// the JWT (stdlib crypto — no chain code, no external dependency).
//
// cfg.Facilitator = x402.CDP(os.Getenv("CDP_API_KEY_ID"), os.Getenv("CDP_API_KEY_SECRET"))
func CDP(keyID, keySecret string) *HTTPFacilitator {
return &HTTPFacilitator{
URL: CDPFacilitatorURL,
Authorize: cdpAuthorizer(keyID, keySecret),
}
}
// cdpAuthorizer returns an Authorize hook that attaches a CDP Bearer JWT bound
// to the request's method and URL.
func cdpAuthorizer(keyID, keySecret string) func(*http.Request) error {
return func(r *http.Request) error {
tok, err := cdpBearer(keyID, keySecret, r.Method, r.URL.Host, r.URL.Path)
if err != nil {
return err
}
r.Header.Set("Authorization", "Bearer "+tok)
return nil
}
}
// cdpBearer builds a CDP Bearer JWT (EdDSA / Ed25519) authorizing a single REST
// call, per CDP's authentication spec: the token binds to "METHOD host/path"
// and is valid for two minutes.
func cdpBearer(keyID, keySecret, method, host, path string) (string, error) {
if keyID == "" || keySecret == "" {
return "", fmt.Errorf("CDP API key id and secret are required")
}
key, err := ed25519KeyFromSecret(keySecret)
if err != nil {
return "", err
}
nonce := make([]byte, 16)
if _, err := rand.Read(nonce); err != nil {
return "", err
}
header := map[string]any{
"alg": "EdDSA",
"typ": "JWT",
"kid": keyID,
"nonce": hex.EncodeToString(nonce),
}
now := time.Now().Unix()
claims := map[string]any{
"sub": keyID,
"iss": "cdp",
"aud": []string{"cdp_service"},
"nbf": now,
"exp": now + 120,
"uri": method + " " + host + path,
}
hb, _ := json.Marshal(header)
cb, _ := json.Marshal(claims)
signing := b64url(hb) + "." + b64url(cb)
sig := ed25519.Sign(key, []byte(signing))
return signing + "." + b64url(sig), nil
}
// ed25519KeyFromSecret decodes a CDP Ed25519 secret. CDP secrets are base64 and
// decode to 64 bytes (32-byte seed + 32-byte public key) — Go's PrivateKey
// layout; a bare 32-byte seed is also accepted.
func ed25519KeyFromSecret(secret string) (ed25519.PrivateKey, error) {
secret = strings.TrimSpace(secret)
if strings.Contains(secret, "BEGIN") {
return nil, fmt.Errorf("CDP secret looks like a PEM/EC key; x402 bearer auth needs an Ed25519 Secret API Key")
}
raw, err := base64.StdEncoding.DecodeString(secret)
if err != nil {
if raw, err = base64.RawURLEncoding.DecodeString(secret); err != nil {
return nil, fmt.Errorf("CDP secret is not valid base64: %w", err)
}
}
switch len(raw) {
case ed25519.PrivateKeySize: // 64: seed + public key
return ed25519.PrivateKey(raw), nil
case ed25519.SeedSize: // 32: seed only
return ed25519.NewKeyFromSeed(raw), nil
default:
return nil, fmt.Errorf("CDP secret decoded to %d bytes; expected 32 or 64 (Ed25519)", len(raw))
}
}
func b64url(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) }
+156
View File
@@ -0,0 +1,156 @@
package x402
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"sync"
)
// Payer produces a payment payload for the given requirements. A real
// implementation signs a stablecoin authorization with a wallet; tests
// and local development can return a fixed token. It is the consumer
// counterpart to the Facilitator (which verifies on the server side).
type Payer interface {
Pay(ctx context.Context, req Requirements) (payment string, err error)
}
// Client is an HTTP client that automatically settles x402 payment
// challenges, up to an optional spend Budget. It is the consumer
// counterpart to Middleware: point it at a paid tool, and a 402 is paid
// and retried transparently — unless paying would exceed the budget, the
// spend cap that keeps an autonomous, paying caller in bounds.
type Client struct {
// HTTP is the underlying client (defaults to http.DefaultClient).
HTTP *http.Client
// Payer constructs payment payloads. Required to pay.
Payer Payer
// Budget caps total spend across all calls, in the asset's smallest
// unit (0 = unlimited). A call that would exceed it is refused before
// any payment is made.
Budget int64
mu sync.Mutex
spent int64
}
// Spent returns the total amount paid so far, in the asset's smallest unit.
func (c *Client) Spent() int64 {
c.mu.Lock()
defer c.mu.Unlock()
return c.spent
}
func (c *Client) httpClient() *http.Client {
if c.HTTP != nil {
return c.HTTP
}
return http.DefaultClient
}
// Do performs req. If the server answers 402, Do reads the requirements,
// checks the budget, pays via Payer, and retries once with the payment
// attached. It returns an error (without paying) if the payment would
// exceed the budget.
func (c *Client) Do(req *http.Request) (*http.Response, error) {
// Buffer the body so the request can be replayed after paying.
var body []byte
if req.Body != nil {
b, err := io.ReadAll(req.Body)
req.Body.Close()
if err != nil {
return nil, err
}
body = b
req.Body = io.NopCloser(bytes.NewReader(body))
}
resp, err := c.httpClient().Do(req)
if err != nil || resp.StatusCode != http.StatusPaymentRequired {
return resp, err
}
var ch challenge
_ = json.NewDecoder(resp.Body).Decode(&ch)
resp.Body.Close()
if len(ch.Accepts) == 0 {
return resp, fmt.Errorf("x402: 402 response carried no requirements")
}
reqd := ch.Accepts[0]
// The amount governs the whole spend cap, so it must be a real positive
// integer. A swallowed parse error (non-decimal, overflow, empty) would
// yield 0 and pass the budget check trivially, and a negative amount would
// inflate the remaining allowance — either way the cap is defeated. Refuse
// before signing anything.
amount, err := strconv.ParseInt(strings.TrimSpace(reqd.MaxAmountRequired), 10, 64)
if err != nil || amount <= 0 {
return resp, fmt.Errorf("x402: refusing to pay %s: invalid maxAmountRequired %q",
reqd.Resource, reqd.MaxAmountRequired)
}
// Spend cap: reserve before paying so concurrent calls cannot all pass
// the check and overspend the caller's allowance. Roll the reservation
// back if payment construction, replay, or verification fails.
c.mu.Lock()
if c.Budget > 0 && c.spent+amount > c.Budget {
spent := c.spent
c.mu.Unlock()
return nil, fmt.Errorf("x402: paying %d for %s would exceed budget (spent %d of %d)",
amount, reqd.Resource, spent, c.Budget)
}
c.spent += amount
c.mu.Unlock()
reserved := true
rollback := func() {
if !reserved {
return
}
c.mu.Lock()
c.spent -= amount
c.mu.Unlock()
reserved = false
}
if c.Payer == nil {
rollback()
return nil, fmt.Errorf("x402: payment required for %s but no Payer configured", reqd.Resource)
}
payment, err := c.Payer.Pay(req.Context(), reqd)
if err != nil {
rollback()
return nil, fmt.Errorf("x402: pay: %w", err)
}
// Replay the request with the payment attached.
var rbody io.Reader
if body != nil {
rbody = bytes.NewReader(body)
}
retry, err := http.NewRequestWithContext(req.Context(), req.Method, req.URL.String(), rbody)
if err != nil {
rollback()
return nil, err
}
for k, v := range req.Header {
retry.Header[k] = v
}
retry.Header.Set(PaymentHeader, payment)
resp2, err := c.httpClient().Do(retry)
if err != nil {
rollback()
return nil, err
}
if resp2.StatusCode == http.StatusPaymentRequired {
rollback()
return resp2, fmt.Errorf("x402: payment for %s was rejected", reqd.Resource)
}
reserved = false
return resp2, nil
}
+206
View File
@@ -0,0 +1,206 @@
package x402
import (
"context"
"io"
"net/http"
"net/http/httptest"
"testing"
)
// mockPayer returns a fixed payment payload that the (mock) facilitator
// on the server accepts.
type mockPayer struct{ calls int }
func (p *mockPayer) Pay(ctx context.Context, req Requirements) (string, error) {
p.calls++
return "payment-ok", nil
}
// paidServer is an httptest server whose endpoint requires `amount`,
// verified by an always-valid facilitator.
func paidServer(amount string) *httptest.Server {
cfg := Config{PayTo: "0xabc", Network: "base", Amount: amount, Facilitator: mockFacilitator{valid: true}}
h := Middleware(cfg)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("data"))
}))
return httptest.NewServer(h)
}
// The client pays a 402 within budget and gets the result; spend is tracked.
func TestClientPaysWithinBudget(t *testing.T) {
srv := paidServer("10000")
defer srv.Close()
payer := &mockPayer{}
c := &Client{Payer: payer, Budget: 50000}
req, _ := http.NewRequest(http.MethodGet, srv.URL, nil)
resp, err := c.Do(req)
if err != nil {
t.Fatalf("Do: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
if b, _ := io.ReadAll(resp.Body); string(b) != "data" {
t.Errorf("body = %q, want data", b)
}
if payer.calls != 1 {
t.Errorf("payer called %d times, want 1", payer.calls)
}
if c.Spent() != 10000 {
t.Errorf("spent = %d, want 10000", c.Spent())
}
}
// A call that would exceed the budget is refused before paying.
func TestClientRefusesOverBudget(t *testing.T) {
srv := paidServer("10000")
defer srv.Close()
payer := &mockPayer{}
c := &Client{Payer: payer, Budget: 5000} // less than the price
req, _ := http.NewRequest(http.MethodGet, srv.URL, nil)
_, err := c.Do(req)
if err == nil {
t.Fatal("expected an over-budget error")
}
if payer.calls != 0 {
t.Errorf("payer should not be called when over budget (calls=%d)", payer.calls)
}
if c.Spent() != 0 {
t.Errorf("nothing should be spent when refused, got %d", c.Spent())
}
}
// The budget accumulates across calls and stops further spend.
func TestClientBudgetAccumulates(t *testing.T) {
srv := paidServer("10000")
defer srv.Close()
c := &Client{Payer: &mockPayer{}, Budget: 15000}
// First call (10000) fits.
req1, _ := http.NewRequest(http.MethodGet, srv.URL, nil)
if _, err := c.Do(req1); err != nil {
t.Fatalf("first call: %v", err)
}
// Second call (another 10000) would total 20000 > 15000 — refused.
req2, _ := http.NewRequest(http.MethodGet, srv.URL, nil)
if _, err := c.Do(req2); err == nil {
t.Fatal("second call should be refused (would exceed budget)")
}
if c.Spent() != 10000 {
t.Errorf("spent = %d, want 10000", c.Spent())
}
}
// A free endpoint needs no payer and no spend.
func TestClientFreeEndpoint(t *testing.T) {
srv := paidServer("0") // free
defer srv.Close()
c := &Client{} // no payer
req, _ := http.NewRequest(http.MethodGet, srv.URL, nil)
resp, err := c.Do(req)
if err != nil {
t.Fatalf("Do: %v", err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("status = %d, want 200 (free)", resp.StatusCode)
}
if c.Spent() != 0 {
t.Errorf("free call should spend nothing, got %d", c.Spent())
}
}
// Concurrent callers reserve budget before paying, so a spend cap cannot be
// overshot by parallel tool calls that all observe the same pre-spend balance.
func TestClientBudgetReservedConcurrently(t *testing.T) {
srv := paidServer("10000")
defer srv.Close()
c := &Client{Payer: &mockPayer{}, Budget: 10000}
errCh := make(chan error, 2)
for i := 0; i < 2; i++ {
go func() {
req, _ := http.NewRequest(http.MethodGet, srv.URL, nil)
resp, err := c.Do(req)
if resp != nil {
resp.Body.Close()
}
errCh <- err
}()
}
var paid, refused int
for i := 0; i < 2; i++ {
if err := <-errCh; err != nil {
refused++
} else {
paid++
}
}
if paid != 1 || refused != 1 {
t.Fatalf("paid=%d refused=%d, want one paid and one refused", paid, refused)
}
if c.Spent() != 10000 {
t.Errorf("spent = %d, want 10000", c.Spent())
}
}
// A reserved budget slot is released when payment cannot be produced, so a
// transient payer failure does not permanently consume an agent's allowance.
func TestClientBudgetReservationRollsBackOnPayError(t *testing.T) {
srv := paidServer("10000")
defer srv.Close()
c := &Client{Payer: payerFunc(func(context.Context, Requirements) (string, error) {
return "", context.Canceled
}), Budget: 10000}
req, _ := http.NewRequest(http.MethodGet, srv.URL, nil)
if _, err := c.Do(req); err == nil {
t.Fatal("expected pay error")
}
if c.Spent() != 0 {
t.Errorf("failed payment should roll back spend, got %d", c.Spent())
}
}
// A 402 whose maxAmountRequired is not a positive integer must be refused
// before any payment — otherwise a swallowed parse error (0) or a negative
// amount defeats the spend cap. The payer is never called and nothing is spent.
func TestClientRefusesInvalidAmount(t *testing.T) {
for _, amount := range []string{"abc", "-100", "99999999999999999999999999", "0x10", "1.5"} {
srv := paidServer(amount)
payer := &mockPayer{}
c := &Client{Payer: payer, Budget: 1_000_000}
req, _ := http.NewRequest(http.MethodGet, srv.URL, nil)
resp, err := c.Do(req)
if resp != nil {
resp.Body.Close()
}
if err == nil {
t.Errorf("amount %q: expected refusal, got nil error", amount)
}
if payer.calls != 0 {
t.Errorf("amount %q: payer called %d times, want 0", amount, payer.calls)
}
if c.Spent() != 0 {
t.Errorf("amount %q: spent %d, want 0", amount, c.Spent())
}
srv.Close()
}
}
type payerFunc func(context.Context, Requirements) (string, error)
func (f payerFunc) Pay(ctx context.Context, req Requirements) (string, error) {
return f(ctx, req)
}
+52
View File
@@ -0,0 +1,52 @@
package x402
import (
"context"
"os"
"testing"
"time"
)
// TestLiveFacilitatorConformance is an opt-in smoke test for hosted x402
// facilitators. It is skipped by default so local and CI runs never need live
// credentials, but operators can run it against Coinbase, Alchemy, or a
// self-hosted facilitator by providing a real payment payload and matching
// requirements.
func TestLiveFacilitatorConformance(t *testing.T) {
url := os.Getenv("GO_MICRO_X402_LIVE_FACILITATOR_URL")
payment := os.Getenv("GO_MICRO_X402_LIVE_PAYMENT")
payTo := os.Getenv("GO_MICRO_X402_LIVE_PAY_TO")
if url == "" || payment == "" || payTo == "" {
t.Skip("set GO_MICRO_X402_LIVE_FACILITATOR_URL, GO_MICRO_X402_LIVE_PAYMENT, and GO_MICRO_X402_LIVE_PAY_TO to run live x402 facilitator conformance")
}
network := getenv("GO_MICRO_X402_LIVE_NETWORK", "base")
amount := getenv("GO_MICRO_X402_LIVE_AMOUNT", "1")
asset := os.Getenv("GO_MICRO_X402_LIVE_ASSET")
resource := getenv("GO_MICRO_X402_LIVE_RESOURCE", "go-micro-x402-live-conformance")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
res, err := (&HTTPFacilitator{URL: url}).Verify(ctx, payment, Requirements{
Scheme: "exact",
Network: network,
MaxAmountRequired: amount,
Resource: resource,
PayTo: payTo,
Asset: asset,
MaxTimeoutSeconds: 60,
})
if err != nil {
t.Fatalf("live facilitator verify: %v", err)
}
if !res.Valid {
t.Fatalf("live facilitator rejected payment: %s", res.Reason)
}
}
func getenv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
+38
View File
@@ -0,0 +1,38 @@
package x402
import "strings"
// assetInfo describes a known stablecoin: its contract address and EIP-712
// domain (name, version) used to build a transfer-authorization signature.
type assetInfo struct {
Address string
Name string
Version string
}
// knownAssets maps a CAIP-2 network to its default stablecoin (USDC). Used to
// fill in the asset and its EIP-712 domain when the operator does not specify
// them, so a client can sign without hand-configured token metadata.
var knownAssets = map[string]assetInfo{
"eip155:8453": {"0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", "USD Coin", "2"}, // Base mainnet
"eip155:84532": {"0x036CbD53842c5426634e7929541eC2318f3dCF7e", "USDC", "2"}, // Base Sepolia
}
// NormalizeNetwork maps common short chain names to their CAIP-2 identifiers,
// which hosted facilitators (Coinbase CDP) use, and passes anything else
// through unchanged. Empty defaults to Base mainnet.
func NormalizeNetwork(n string) string {
switch strings.ToLower(strings.TrimSpace(n)) {
case "", "base", "eip155:8453":
return "eip155:8453"
case "base-sepolia", "eip155:84532":
return "eip155:84532"
default:
return n
}
}
func defaultAsset(network string) (assetInfo, bool) {
a, ok := knownAssets[network]
return a, ok
}
+413
View File
@@ -0,0 +1,413 @@
// Package x402 implements the server side of the x402 payment protocol
// (the HTTP 402 "Payment Required" standard) as pluggable middleware.
//
// It lets a service or gateway require a stablecoin payment per request
// and verify it through a pluggable Facilitator (Coinbase CDP, Alchemy,
// or self-hosted), so AI agents can pay for tools and APIs autonomously.
// Go Micro stays chain-agnostic and free of crypto dependencies: it
// speaks the HTTP protocol and delegates verification and settlement to
// the facilitator, which does the on-chain work.
//
// pay := x402.Middleware(x402.Config{
// PayTo: "0xYourAddress", // where payments go
// Network: "base", // or "solana", ...
// Amount: "10000", // smallest units (e.g. 0.01 USDC)
// })
// mux.Handle("/paid", pay(handler))
//
// For real settlement on Base mainnet, point it at the Coinbase CDP
// facilitator, which requires an authenticated request:
//
// cfg.Facilitator = x402.CDP(os.Getenv("CDP_API_KEY_ID"), os.Getenv("CDP_API_KEY_SECRET"))
//
// x402 is governed by the x402 Foundation (Linux Foundation). See
// https://x402.org and https://docs.cdp.coinbase.com/x402.
package x402
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"os"
"strings"
)
// Version is the x402 protocol version this package speaks.
const Version = 1
// Header names defined by the protocol. Version 1 uses X-PAYMENT /
// X-PAYMENT-RESPONSE; version 2 renamed them to PAYMENT-SIGNATURE /
// PAYMENT-RESPONSE. We accept either request header and emit both response
// headers so any conformant client interoperates.
const (
PaymentHeader = "X-PAYMENT" // request: the client's payment payload
PaymentHeaderV2 = "PAYMENT-SIGNATURE" // request: v2 alias
PaymentResponseHeader = "X-PAYMENT-RESPONSE" // response: settlement details
PaymentResponseHeaderV2 = "PAYMENT-RESPONSE" // response: v2 alias
)
// Requirements describes what a client must pay to access a resource —
// the body of a 402 response (one entry of "accepts").
type Requirements struct {
Scheme string `json:"scheme"` // payment scheme, e.g. "exact"
Network string `json:"network"` // chain, e.g. "base", "eip155:8453"
MaxAmountRequired string `json:"maxAmountRequired"` // amount in the asset's smallest unit
Resource string `json:"resource"` // the resource being paid for
Description string `json:"description,omitempty"` // human/agent-readable description
MimeType string `json:"mimeType,omitempty"` // response mime type
PayTo string `json:"payTo"` // receiving address
Asset string `json:"asset,omitempty"` // token contract/mint (default: network USDC)
MaxTimeoutSeconds int `json:"maxTimeoutSeconds,omitempty"` // how long the client has to pay
Extra map[string]string `json:"extra,omitempty"` // scheme extras, e.g. EIP-712 domain {name, version}
}
// challenge is the JSON body returned with a 402 response.
type challenge struct {
X402Version int `json:"x402Version"`
Accepts []Requirements `json:"accepts"`
Error string `json:"error,omitempty"`
}
// Result is the outcome of verifying (or settling) a payment.
type Result struct {
Valid bool // whether the payment satisfies the requirements
Payer string // the paying address, if known
Reason string // why the payment was rejected, if not valid
Settlement string // settlement reference (e.g. tx hash), set into X-PAYMENT-RESPONSE
}
// Facilitator verifies a payment a client presented against the stated
// requirements. Implementations talk to a chain or a hosted facilitator; the
// gateway stays chain-agnostic, so a Base facilitator and a Solana facilitator
// are just different implementations behind this interface.
type Facilitator interface {
Verify(ctx context.Context, payment string, req Requirements) (Result, error)
}
// Settler is an optional capability: a Facilitator that also settles a verified
// payment on-chain (captures the funds) and returns a settlement reference.
// Require calls Settle after a successful Verify when the facilitator
// implements it — for the "exact" scheme, verify authorizes and settle
// captures, so without settlement no funds actually move.
type Settler interface {
Settle(ctx context.Context, payment string, req Requirements) (Result, error)
}
// Config configures payment enforcement for a set of routes or tools.
type Config struct {
// PayTo is the address payments are sent to. Required.
PayTo string `json:"payTo"`
// Network is the chain to settle on (default "base"). Accepts short
// names ("base", "base-sepolia") or CAIP-2 ids ("eip155:8453").
Network string `json:"network,omitempty"`
// Asset is the token contract/mint (default: the network's USDC).
Asset string `json:"asset,omitempty"`
// Amount is the default amount required per request, in the asset's
// smallest unit (e.g. "10000" for 0.01 USDC at 6 decimals). "0" or
// empty means free.
Amount string `json:"amount,omitempty"`
// Amounts overrides Amount per tool/resource name, so an operator can
// charge for tools individually — the way Scopes and RateLimit are
// configured per tool at the gateway.
Amounts map[string]string `json:"amounts,omitempty"`
// Description is shown to the paying client/agent.
Description string `json:"description,omitempty"`
// Extra carries scheme-specific data echoed in the requirement's "extra".
// For the EVM "exact" scheme this is the asset's EIP-712 domain, e.g.
// {"name":"USD Coin","version":"2"}; when empty it is filled in for known
// assets so clients can build a valid transfer signature.
Extra map[string]string `json:"extra,omitempty"`
// Facilitator verifies (and, if it implements Settler, settles) payments.
// Defaults to an HTTPFacilitator pointed at FacilitatorURL.
Facilitator Facilitator `json:"-"`
// FacilitatorURL is the verify/settle endpoint used when Facilitator
// is nil (e.g. Coinbase CDP or Alchemy).
FacilitatorURL string `json:"facilitator,omitempty"`
// RequireSettlement fails closed when a paid request cannot be settled:
// if the facilitator only verifies (does not implement Settler), Require
// refuses to serve rather than releasing the resource while no funds move.
// Leave false only for verify-only flows where authorization is enough.
RequireSettlement bool `json:"requireSettlement,omitempty"`
}
func (c Config) network() string {
if c.Network == "" {
return "base"
}
return c.Network
}
// AmountFor returns the amount required for a named tool/resource: the
// per-tool override if present, otherwise the default Amount.
func (c Config) AmountFor(name string) string {
if a, ok := c.Amounts[name]; ok {
return a
}
return c.Amount
}
func (c Config) facilitator() Facilitator {
if c.Facilitator != nil {
return c.Facilitator
}
return &HTTPFacilitator{URL: c.FacilitatorURL}
}
func (c Config) requirements(amount, resource string) Requirements {
net := c.network()
asset := c.Asset
extra := c.Extra
// Fill the asset and its EIP-712 domain for known networks so a client
// can sign without the operator hand-configuring token metadata. Keyed on
// the CAIP-2 form so both "base" and "eip155:8453" resolve.
if def, ok := defaultAsset(NormalizeNetwork(net)); ok {
if asset == "" {
asset = def.Address
}
if extra == nil && strings.EqualFold(asset, def.Address) {
extra = map[string]string{"name": def.Name, "version": def.Version}
}
}
return Requirements{
Scheme: "exact",
Network: net,
MaxAmountRequired: amount,
Resource: resource,
Description: c.Description,
MimeType: "application/json",
PayTo: c.PayTo,
Asset: asset,
MaxTimeoutSeconds: 60,
Extra: extra,
}
}
// Payment returns the client's payment payload from either the v1 or v2
// request header, or "" if none is present.
func Payment(r *http.Request) string {
if p := r.Header.Get(PaymentHeader); p != "" {
return p
}
return r.Header.Get(PaymentHeaderV2)
}
// Require enforces payment of amount for a single request. It returns
// true if the request may proceed — the amount is free ("" or "0"), or a
// valid payment was presented (and settled, when the facilitator supports
// it) — and false once it has written a 402 challenge, in which case the
// caller must stop. resource names what is being paid for (a tool name or
// URL path).
func (c Config) Require(w http.ResponseWriter, r *http.Request, amount, resource string) bool {
if amount == "" || amount == "0" {
return true // free
}
req := c.requirements(amount, resource)
payment := Payment(r)
if payment == "" {
writeChallenge(w, req, "payment required")
return false
}
fac := c.facilitator()
res, err := fac.Verify(r.Context(), payment, req)
if err != nil {
writeChallenge(w, req, "payment verification failed: "+err.Error())
return false
}
if !res.Valid {
reason := res.Reason
if reason == "" {
reason = "payment invalid"
}
writeChallenge(w, req, reason)
return false
}
// Capture the funds when the facilitator can settle. Verify alone only
// authorizes the "exact" transfer; settlement broadcasts it.
s, canSettle := fac.(Settler)
if c.RequireSettlement && !canSettle {
// Fail closed: a paid config must not serve the resource on a
// verify-only facilitator, or it gives the tool away for free.
writeChallenge(w, req, "payment settlement unavailable")
return false
}
if canSettle {
sres, err := s.Settle(r.Context(), payment, req)
if err != nil {
writeChallenge(w, req, "payment settlement failed: "+err.Error())
return false
}
if !sres.Valid {
reason := sres.Reason
if reason == "" {
reason = "settlement rejected"
}
writeChallenge(w, req, reason)
return false
}
if sres.Settlement != "" {
res.Settlement = sres.Settlement
}
}
if res.Settlement != "" {
setSettlementHeaders(w, res.Settlement)
}
return true
}
// Middleware returns HTTP middleware that requires the default Amount for
// any wrapped route. For per-tool amounts, resolve the amount with
// AmountFor and call Require directly (the MCP gateway does this).
func Middleware(cfg Config) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if cfg.Require(w, r, cfg.Amount, r.URL.Path) {
next.ServeHTTP(w, r)
}
})
}
}
func setSettlementHeaders(w http.ResponseWriter, settlement string) {
w.Header().Set(PaymentResponseHeader, settlement)
w.Header().Set(PaymentResponseHeaderV2, settlement)
}
func writeChallenge(w http.ResponseWriter, req Requirements, reason string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusPaymentRequired) // 402
_ = json.NewEncoder(w).Encode(challenge{
X402Version: Version,
Accepts: []Requirements{req},
Error: reason,
})
}
// LoadConfig reads an x402 config file (JSON) describing the operator's
// payTo address, network, asset, default amount, and per-tool amounts:
//
// { "payTo": "0x…", "network": "solana", "asset": "USDC",
// "amount": "0", "amounts": { "weather.Weather.Forecast": "10000" } }
func LoadConfig(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var c Config
if err := json.Unmarshal(data, &c); err != nil {
return nil, fmt.Errorf("parse x402 config %s: %w", path, err)
}
return &c, nil
}
// HTTPFacilitator verifies and settles payments by POSTing to an x402
// facilitator's /verify and /settle endpoints (Coinbase CDP, Alchemy, or
// self-hosted). It carries no chain or crypto code itself; when the endpoint
// requires authentication (e.g. CDP), set Authorize to attach credentials —
// see CDP.
type HTTPFacilitator struct {
URL string
Client *http.Client
// Authorize, when set, is called on each facilitator request to attach
// authentication (e.g. a Bearer token). Nil for open facilitators.
Authorize func(*http.Request) error
}
// Verify checks the payment is valid against the requirements.
func (f *HTTPFacilitator) Verify(ctx context.Context, payment string, req Requirements) (Result, error) {
out, err := f.post(ctx, "/verify", payment, req)
if err != nil {
return Result{}, err
}
return Result{Valid: out.IsValid, Reason: firstNonEmpty(out.InvalidReason, out.Error), Payer: out.Payer}, nil
}
// Settle captures a verified payment on-chain and returns the transaction
// reference, satisfying Settler.
func (f *HTTPFacilitator) Settle(ctx context.Context, payment string, req Requirements) (Result, error) {
out, err := f.post(ctx, "/settle", payment, req)
if err != nil {
return Result{}, err
}
// Facilitators report settlement as "success" with a "transaction" ref.
ok := out.Success || out.IsValid
return Result{Valid: ok, Reason: firstNonEmpty(out.ErrorReason, out.InvalidReason, out.Error), Payer: out.Payer, Settlement: out.Transaction}, nil
}
type facilitatorResponse struct {
IsValid bool `json:"isValid"`
Success bool `json:"success"`
InvalidReason string `json:"invalidReason"`
ErrorReason string `json:"errorReason"`
Error string `json:"error"`
Payer string `json:"payer"`
Transaction string `json:"transaction"`
}
func (f *HTTPFacilitator) post(ctx context.Context, path, payment string, req Requirements) (facilitatorResponse, error) {
var out facilitatorResponse
if f.URL == "" {
return out, fmt.Errorf("no facilitator configured")
}
body, _ := json.Marshal(map[string]any{
"x402Version": Version,
"paymentPayload": decodePayment(payment),
"paymentRequirements": req,
})
hreq, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(f.URL, "/")+path, bytes.NewReader(body))
if err != nil {
return out, err
}
hreq.Header.Set("Content-Type", "application/json")
if f.Authorize != nil {
if err := f.Authorize(hreq); err != nil {
return out, fmt.Errorf("authorize: %w", err)
}
}
cl := f.Client
if cl == nil {
cl = http.DefaultClient
}
resp, err := cl.Do(hreq)
if err != nil {
return out, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
var buf bytes.Buffer
_, _ = buf.ReadFrom(resp.Body)
return out, fmt.Errorf("facilitator %s returned %d: %s", path, resp.StatusCode, strings.TrimSpace(buf.String()))
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return out, err
}
return out, nil
}
// decodePayment turns the base64 X-PAYMENT header into the PaymentPayload
// object facilitators expect. If it is not base64 JSON, the raw value is passed
// through unchanged (some facilitators accept the encoded string).
func decodePayment(payment string) any {
payment = strings.TrimSpace(payment)
for _, dec := range []*base64.Encoding{base64.StdEncoding, base64.RawURLEncoding} {
if raw, err := dec.DecodeString(payment); err == nil {
var obj any
if json.Unmarshal(raw, &obj) == nil {
return obj
}
}
}
return payment
}
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if strings.TrimSpace(v) != "" {
return v
}
}
return ""
}
+216
View File
@@ -0,0 +1,216 @@
package x402
import (
"crypto/ed25519"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// TestVerifyAndSettle checks that Require verifies then settles against an
// HTTP facilitator and surfaces the settlement in both response headers.
func TestVerifyAndSettle(t *testing.T) {
var verifyHit, settleHit bool
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/verify":
verifyHit = true
_ = json.NewEncoder(w).Encode(map[string]any{"isValid": true, "payer": "0xabc"})
case "/settle":
settleHit = true
_ = json.NewEncoder(w).Encode(map[string]any{"success": true, "transaction": "0xdeadbeef"})
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
cfg := Config{PayTo: "0xpay", Network: "eip155:8453", FacilitatorURL: srv.URL}
r := httptest.NewRequest(http.MethodGet, "/tool", nil)
r.Header.Set(PaymentHeader, base64.StdEncoding.EncodeToString([]byte(`{"network":"eip155:8453"}`)))
rec := httptest.NewRecorder()
if !cfg.Require(rec, r, "10000", "chat") {
t.Fatalf("Require returned false; body=%s", rec.Body.String())
}
if !verifyHit || !settleHit {
t.Fatalf("expected both verify and settle to be hit: verify=%v settle=%v", verifyHit, settleHit)
}
if got := rec.Header().Get(PaymentResponseHeader); got != "0xdeadbeef" {
t.Errorf("X-PAYMENT-RESPONSE = %q, want 0xdeadbeef", got)
}
if got := rec.Header().Get(PaymentResponseHeaderV2); got != "0xdeadbeef" {
t.Errorf("PAYMENT-RESPONSE = %q, want 0xdeadbeef", got)
}
}
// TestSettlementFailureChallenges checks that a failed settlement blocks the
// request with a fresh 402 rather than letting it through.
func TestSettlementFailureChallenges(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/verify":
_ = json.NewEncoder(w).Encode(map[string]any{"isValid": true})
case "/settle":
_ = json.NewEncoder(w).Encode(map[string]any{"success": false, "errorReason": "insufficient_funds"})
}
}))
defer srv.Close()
cfg := Config{PayTo: "0xpay", FacilitatorURL: srv.URL}
r := httptest.NewRequest(http.MethodGet, "/tool", nil)
r.Header.Set(PaymentHeader, "eyJ4IjoxfQ==")
rec := httptest.NewRecorder()
if cfg.Require(rec, r, "10000", "chat") {
t.Fatal("Require should return false when settlement fails")
}
if rec.Code != http.StatusPaymentRequired {
t.Errorf("status = %d, want 402", rec.Code)
}
}
// TestPaymentSignatureHeaderAccepted checks the v2 request header is honored.
func TestPaymentSignatureHeaderAccepted(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{"isValid": true, "success": true, "transaction": "0x1"})
}))
defer srv.Close()
cfg := Config{PayTo: "0xpay", FacilitatorURL: srv.URL}
r := httptest.NewRequest(http.MethodGet, "/tool", nil)
r.Header.Set(PaymentHeaderV2, "eyJ4IjoxfQ==") // PAYMENT-SIGNATURE only
rec := httptest.NewRecorder()
if !cfg.Require(rec, r, "10000", "chat") {
t.Fatalf("Require should honor PAYMENT-SIGNATURE; body=%s", rec.Body.String())
}
}
// TestRequirementsExtraForKnownNetwork checks the EIP-712 domain is filled in.
func TestRequirementsExtraForKnownNetwork(t *testing.T) {
for _, net := range []string{"base", "eip155:8453"} {
req := Config{PayTo: "0xpay", Network: net}.requirements("10000", "chat")
if req.Extra["name"] == "" || req.Extra["version"] == "" {
t.Errorf("network %q: extra not filled: %v", net, req.Extra)
}
if req.Asset == "" {
t.Errorf("network %q: asset not defaulted", net)
}
}
}
// TestDecodePayment checks the base64 header is decoded to an object for the
// facilitator (which expects the payload object, not the raw string).
func TestDecodePayment(t *testing.T) {
enc := base64.StdEncoding.EncodeToString([]byte(`{"network":"eip155:8453","payload":{"x":1}}`))
obj := decodePayment(enc)
m, ok := obj.(map[string]any)
if !ok {
t.Fatalf("decodePayment did not return an object: %T", obj)
}
if m["network"] != "eip155:8453" {
t.Errorf("decoded network = %v", m["network"])
}
// Non-base64 passes through unchanged.
if got := decodePayment("not-base64!"); got != "not-base64!" {
t.Errorf("passthrough failed: %v", got)
}
}
// TestCDPBearer certifies the CDP JWT is well-formed and its signature verifies.
func TestCDPBearer(t *testing.T) {
pub, priv, _ := ed25519.GenerateKey(nil)
secret := base64.StdEncoding.EncodeToString(priv)
tok, err := cdpBearer("key-id", secret, "POST", "api.cdp.coinbase.com", "/platform/v2/x402/verify")
if err != nil {
t.Fatalf("cdpBearer: %v", err)
}
parts := strings.Split(tok, ".")
if len(parts) != 3 {
t.Fatalf("want 3 JWT segments, got %d", len(parts))
}
sig, _ := base64.RawURLEncoding.DecodeString(parts[2])
if !ed25519.Verify(pub, []byte(parts[0]+"."+parts[1]), sig) {
t.Fatal("JWT signature does not verify")
}
var claims map[string]any
cb, _ := base64.RawURLEncoding.DecodeString(parts[1])
_ = json.Unmarshal(cb, &claims)
if claims["iss"] != "cdp" || claims["uri"] != "POST api.cdp.coinbase.com/platform/v2/x402/verify" {
t.Errorf("bad claims: %v", claims)
}
}
// TestCDPAuthorizeAttachesBearer checks CDP() signs facilitator requests.
func TestCDPAuthorizeAttachesBearer(t *testing.T) {
_, priv, _ := ed25519.GenerateKey(nil)
fac := CDP("key-id", base64.StdEncoding.EncodeToString(priv))
req, _ := http.NewRequest(http.MethodPost, "https://api.cdp.coinbase.com/platform/v2/x402/verify", nil)
if err := fac.Authorize(req); err != nil {
t.Fatalf("authorize: %v", err)
}
if !strings.HasPrefix(req.Header.Get("Authorization"), "Bearer ey") {
t.Errorf("missing Bearer JWT: %q", req.Header.Get("Authorization"))
}
}
// TestRequireSettlementFailsClosed checks that a paid config with
// RequireSettlement refuses to serve when the facilitator only verifies (does
// not settle) — otherwise the resource is released while no funds move.
func TestRequireSettlementFailsClosed(t *testing.T) {
// mockFacilitator implements Verify but not Settler.
cfg := Config{PayTo: "0xpay", Facilitator: mockFacilitator{valid: true}, RequireSettlement: true}
r := httptest.NewRequest(http.MethodGet, "/tool", nil)
r.Header.Set(PaymentHeader, "eyJ4IjoxfQ==")
rec := httptest.NewRecorder()
if cfg.Require(rec, r, "10000", "chat") {
t.Fatal("Require should fail closed when settlement is required but unavailable")
}
if rec.Code != http.StatusPaymentRequired {
t.Errorf("status = %d, want 402", rec.Code)
}
// Without RequireSettlement the verify-only facilitator still serves.
cfg.RequireSettlement = false
rec = httptest.NewRecorder()
r = httptest.NewRequest(http.MethodGet, "/tool", nil)
r.Header.Set(PaymentHeader, "eyJ4IjoxfQ==")
if !cfg.Require(rec, r, "10000", "chat") {
t.Fatalf("verify-only should serve when settlement is not required; body=%s", rec.Body.String())
}
}
// TestRequireSettlementServesWithSettler checks that a paid config with
// RequireSettlement serves when the facilitator can settle.
func TestRequireSettlementServesWithSettler(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/verify":
_ = json.NewEncoder(w).Encode(map[string]any{"isValid": true})
case "/settle":
_ = json.NewEncoder(w).Encode(map[string]any{"success": true, "transaction": "0xabc"})
}
}))
defer srv.Close()
// HTTPFacilitator implements Settler.
cfg := Config{PayTo: "0xpay", FacilitatorURL: srv.URL, RequireSettlement: true}
r := httptest.NewRequest(http.MethodGet, "/tool", nil)
r.Header.Set(PaymentHeader, "eyJ4IjoxfQ==")
rec := httptest.NewRecorder()
if !cfg.Require(rec, r, "10000", "chat") {
t.Fatalf("Require should serve with a settling facilitator; body=%s", rec.Body.String())
}
if got := rec.Header().Get(PaymentResponseHeader); got != "0xabc" {
t.Errorf("settlement header = %q, want 0xabc", got)
}
}
var _ Settler = (*HTTPFacilitator)(nil)
+142
View File
@@ -0,0 +1,142 @@
package x402
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
)
type mockFacilitator struct {
valid bool
reason string
}
func (m mockFacilitator) Verify(ctx context.Context, payment string, req Requirements) (Result, error) {
return Result{Valid: m.valid, Reason: m.reason, Payer: "0xpayer", Settlement: "0xtx"}, nil
}
func paidHandler(cfg Config) http.Handler {
served := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
return Middleware(cfg)(served)
}
// No payment yields a 402 with the requirements describing where to pay.
func TestChallengeWhenNoPayment(t *testing.T) {
h := paidHandler(Config{PayTo: "0xabc", Network: "solana", Amount: "10000", Facilitator: mockFacilitator{valid: true}})
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/tool", nil))
if rec.Code != http.StatusPaymentRequired {
t.Fatalf("status = %d, want 402", rec.Code)
}
var ch challenge
if err := json.Unmarshal(rec.Body.Bytes(), &ch); err != nil {
t.Fatalf("challenge body: %v", err)
}
if ch.X402Version != Version || len(ch.Accepts) != 1 {
t.Fatalf("unexpected challenge: %+v", ch)
}
req := ch.Accepts[0]
if req.PayTo != "0xabc" || req.Network != "solana" || req.MaxAmountRequired != "10000" {
t.Errorf("requirements not advertised correctly: %+v", req)
}
}
// A payment the facilitator accepts lets the request through, and the
// settlement is surfaced on the response.
func TestServesWhenPaymentValid(t *testing.T) {
h := paidHandler(Config{PayTo: "0xabc", Amount: "10000", Facilitator: mockFacilitator{valid: true}})
r := httptest.NewRequest(http.MethodGet, "/tool", nil)
r.Header.Set(PaymentHeader, "base64-payment-payload")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, r)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if rec.Header().Get(PaymentResponseHeader) != "0xtx" {
t.Errorf("settlement not surfaced in %s", PaymentResponseHeader)
}
}
// A payment the facilitator rejects gets a 402 with the reason.
func TestChallengeWhenPaymentInvalid(t *testing.T) {
h := paidHandler(Config{PayTo: "0xabc", Amount: "10000", Facilitator: mockFacilitator{valid: false, reason: "insufficient amount"}})
r := httptest.NewRequest(http.MethodGet, "/tool", nil)
r.Header.Set(PaymentHeader, "bad-payment")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, r)
if rec.Code != http.StatusPaymentRequired {
t.Fatalf("status = %d, want 402", rec.Code)
}
var ch challenge
json.Unmarshal(rec.Body.Bytes(), &ch)
if ch.Error != "insufficient amount" {
t.Errorf("reason not surfaced: %q", ch.Error)
}
}
// A free amount ("" or "0") requires no payment.
func TestRequireFreeAmount(t *testing.T) {
cfg := Config{PayTo: "0xabc", Facilitator: mockFacilitator{valid: false}}
rec := httptest.NewRecorder()
if !cfg.Require(rec, httptest.NewRequest(http.MethodGet, "/free", nil), "0", "free.tool") {
t.Error("a zero amount should be free and proceed")
}
// Require doesn't write on success; recorder defaults to 200.
if rec.Code != http.StatusOK {
t.Errorf("expected status %d, got %d", http.StatusOK, rec.Code)
}
}
// Per-tool amounts override the default.
func TestAmountFor(t *testing.T) {
cfg := Config{Amount: "100", Amounts: map[string]string{"paid.Tool.Do": "5000"}}
if got := cfg.AmountFor("paid.Tool.Do"); got != "5000" {
t.Errorf("per-tool amount = %q, want 5000", got)
}
if got := cfg.AmountFor("other.Tool.Do"); got != "100" {
t.Errorf("default amount = %q, want 100", got)
}
}
// LoadConfig reads an operator x402 config file.
func TestLoadConfig(t *testing.T) {
path := filepath.Join(t.TempDir(), "x402.json")
os.WriteFile(path, []byte(`{
"payTo": "0xabc", "network": "solana", "asset": "USDC",
"amount": "0", "amounts": {"weather.Weather.Forecast": "10000"}
}`), 0o644)
cfg, err := LoadConfig(path)
if err != nil {
t.Fatalf("LoadConfig: %v", err)
}
if cfg.PayTo != "0xabc" || cfg.Network != "solana" {
t.Errorf("config not parsed: %+v", cfg)
}
if cfg.AmountFor("weather.Weather.Forecast") != "10000" {
t.Errorf("per-tool amount not loaded: %+v", cfg.Amounts)
}
if cfg.AmountFor("anything.else") != "0" {
t.Errorf("default amount = %q, want 0", cfg.AmountFor("anything.else"))
}
}
// Network defaults to base when unset.
func TestNetworkDefault(t *testing.T) {
if got := (Config{}).network(); got != "base" {
t.Errorf("default network = %q, want base", got)
}
}