19 KiB
OpenAPI Client Pattern for Remote Operations
This document describes the OpenAPI client generation pattern used in the OpenFeature CLI for implementing remote API operations. This pattern provides type-safe, well-documented client code for interacting with remote services.
Overview
The OpenFeature CLI uses a code generation approach for remote API operations:
- Define: Remote API functionality is defined in an OpenAPI specification (YAML)
- Generate: A type-safe Go client is generated from the specification
- Wrap: A thin wrapper provides convenience methods and integrates with CLI infrastructure
- Use: Commands consume the wrapped client for all remote operations
This pattern ensures consistency, type safety, and maintainability across all remote operations in the CLI.
Current Implementation
The current implementation consists of:
- OpenAPI Specification:
api/v0/sync.yaml- Defines the Manifest Management API v0 - Generated Client:
internal/api/client/sync_client.gen.go- Auto-generated from the spec - Wrapper Client:
internal/api/sync/client.go- Provides convenience methods and retry logic - Test Suite:
internal/api/sync/retry_test.go- Tests retry logic and error handling - CLI Commands:
internal/cmd/pull.goandinternal/cmd/push.go- Use the wrapped client
Dependencies
The implementation relies on these key libraries:
- oapi-codegen: Generates type-safe Go clients from OpenAPI specs
- GoRetry: Provides retry logic with exponential backoff for transient failures
- gock: HTTP mocking for comprehensive testing without real servers
Architecture
┌─────────────────────┐
│ OpenAPI Spec │
│ (api/v0/sync.yaml) │
└──────────┬──────────┘
│
│ oapi-codegen
▼
┌─────────────────────┐
│ Generated Client │
│ (internal/api/ │
│ client/*.gen.go) │
└──────────┬──────────┘
│
│ wrapped by
▼
┌─────────────────────┐
│ Sync Client │
│ (internal/api/ │
│ sync/client.go) │
└──────────┬──────────┘
│
│ used by
▼
┌─────────────────────┐
│ CLI Commands │
│ (internal/cmd/ │
│ pull.go,push.go) │
└─────────────────────┘
Benefits
1. Type Safety
- Compile-time checking of API requests and responses
- Eliminates string-based URL construction errors
- Strongly typed request/response models
2. Documentation as Code
- API contract is clearly defined in OpenAPI spec
- Self-documenting code through generated types
- Examples and descriptions in the spec translate to code comments
3. Maintainability
- Changes to API are made in one place (the spec)
- Regenerate client to update all consumers
- Clear separation between generated and custom code
4. Consistency
- All remote operations follow the same pattern
- Standardized error handling across endpoints
- Uniform logging and debugging capabilities
5. Extensibility
- Easy to add new endpoints (just update the spec)
- Supports multiple API versions side-by-side
- Foundation for plugin systems and provider implementations
Implementation Guide
Step 1: Define the OpenAPI Specification
Create or update the OpenAPI specification in api/v0/ directory:
# api/v0/sync.yaml
openapi: 3.0.3
info:
title: Manifest Management API
version: 1.0.0
description: |
CRUD endpoints for managing feature flags through the OpenFeature CLI.
paths:
/openfeature/v0/manifest:
get:
summary: Get Project Manifest
description: Returns the project manifest containing active flags
security:
- bearerAuth: []
responses:
'200':
description: Manifest exported successfully
content:
application/json:
schema:
$ref: '#/components/schemas/ManifestEnvelope'
Best Practices:
- Use semantic versioning for the API version
- Include detailed descriptions for all operations
- Define reusable schemas in
components/schemas - Specify all possible response codes
- Document authentication requirements
Step 2: Generate the Client
Add a Makefile target to generate the client:
# Generate OpenAPI clients
generate-sync-client:
oapi-codegen -package syncclient \
-generate types,client \
-o internal/api/client/sync_client.gen.go \
api/v0/sync.yaml
Run the generator:
make generate-sync-client
This produces type-safe Go code in internal/api/client/sync_client.gen.go including:
- Request/response types
- Client interface
- HTTP request builders
- Response parsers
Important: Never manually edit generated files. Always regenerate from the spec.
Step 3: Create a Wrapper Client
Create a wrapper in internal/api/sync/client.go that:
- Provides convenience methods
- Handles common concerns (logging, retries, error mapping)
- Converts between API and internal models
// internal/api/sync/client.go
package sync
import (
"context"
"encoding/json"
"fmt"
"net/http"
syncclient "github.com/open-feature/cli/internal/api/client"
"github.com/open-feature/cli/internal/flagset"
"github.com/open-feature/cli/internal/logger"
)
// Client wraps the generated OpenAPI client with convenience methods
type Client struct {
apiClient *syncclient.ClientWithResponses
authToken string
}
// NewClient creates a new sync client
func NewClient(baseURL string, authToken string) (*Client, error) {
// Configure HTTP client options
var opts []syncclient.ClientOption
if authToken != "" {
opts = append(opts, syncclient.WithRequestEditorFn(
func(ctx context.Context, req *http.Request) error {
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", authToken))
return nil
}))
}
apiClient, err := syncclient.NewClientWithResponses(baseURL, opts...)
if err != nil {
return nil, fmt.Errorf("failed to create API client: %w", err)
}
return &Client{
apiClient: apiClient,
authToken: authToken,
}, nil
}
// PullFlags fetches flags from the remote API (without retry logic)
func (c *Client) PullFlags(ctx context.Context) (*flagset.Flagset, error) {
logger.Default.Debug("Fetching flags using sync API client")
resp, err := c.apiClient.GetOpenfeatureV0ManifestWithResponse(ctx)
if err != nil {
return nil, fmt.Errorf("failed to fetch manifest: %w", err)
}
// Debug logging
if resp.HTTPResponse != nil {
logger.Default.Debug(fmt.Sprintf("Pull response: HTTP %d",
resp.HTTPResponse.StatusCode))
}
// Handle error responses
if resp.HTTPResponse.StatusCode < 200 || resp.HTTPResponse.StatusCode >= 300 {
if resp.JSON401 != nil {
return nil, fmt.Errorf("authentication failed: %s",
resp.JSON401.Error.Message)
}
// ... handle other error cases
}
// Convert API model to internal model
return convertToFlagset(resp.JSON200)
}
Key responsibilities of the wrapper:
- Authentication/authorization setup
- Request/response logging for debugging
- Error handling and user-friendly error messages
- Model conversion (API types ↔ internal types)
- Retry logic for write operations (POST/PUT/DELETE) using GoRetry library
- Context propagation
- Smart push logic (comparing local vs remote flags before making changes)
The wrapper also defines result types for operations:
// PushResult tracks changes made during a push operation
type PushResult struct {
Created []flagset.Flag // Flags created on the remote
Updated []flagset.Flag // Flags updated on the remote
Unchanged []flagset.Flag // Flags that didn't need changes
}
Step 4: Use in Commands
Update commands to use the wrapped client:
// internal/cmd/pull.go
func GetPullCmd() *cobra.Command {
return &cobra.Command{
Use: "pull",
Short: "Pull flag configurations from a remote source",
RunE: func(cmd *cobra.Command, args []string) error {
flagSourceUrl := config.GetFlagSourceUrl(cmd)
authToken := config.GetAuthToken(cmd)
// Use the sync API client
flags, err := manifest.LoadFromSyncAPI(flagSourceUrl, authToken)
if err != nil {
return fmt.Errorf("error fetching flags: %w", err)
}
// Process flags...
return nil
},
}
}
URL Handling
The generated client expects the base URL only (domain and optionally port). The client automatically constructs full paths from the OpenAPI spec.
Correct:
# Base URL only
openfeature pull --flag-source-url https://api.example.com
openfeature pull --flag-source-url https://api.example.com:8080
Incorrect:
# Don't include API paths in the URL
openfeature pull --flag-source-url https://api.example.com/openfeature/v0/manifest
The generated client automatically:
- Adds a trailing slash to the base URL
- Constructs operation paths from the spec (e.g.,
/openfeature/v0/manifest) - Handles path parameters and query strings
Error Handling
The pattern provides multiple levels of error handling:
1. Network Errors
resp, err := c.apiClient.GetOpenfeatureV0ManifestWithResponse(ctx)
if err != nil {
return nil, fmt.Errorf("failed to fetch manifest: %w", err)
}
2. HTTP Status Errors
if resp.HTTPResponse.StatusCode >= 400 {
// Access typed error responses
if resp.JSON401 != nil {
return nil, fmt.Errorf("authentication failed: %s",
resp.JSON401.Error.Message)
}
}
3. Schema Validation
The generated client automatically validates responses against the OpenAPI schema.
Debugging
Enable debug logging to see full HTTP request/response details:
openfeature pull --flag-source-url https://api.example.com --debug
The wrapper client should log:
- Request URLs and headers
- Request bodies (sanitized)
- Response status codes
- Response bodies (truncated if large)
Example debug output:
[DEBUG] Loading flags from sync API at https://api.example.com
[DEBUG] Pull response: HTTP 200 - OK
[DEBUG] Response body: {"flags":[{"key":"feature-x",...}]}
[DEBUG] Successfully pulled 5 flags
Testing
HTTP Mocking with Gock
The CLI uses gock for mocking HTTP requests in tests. This provides a clean way to test retry logic, error handling, and different response scenarios without needing a real server.
Why Gock?
- Simple API: Fluent interface for setting up mock responses
- Request Matching: Can match on URL, headers, body, query params
- Response Stacking: Queue multiple responses for testing retry logic
- Network Interception: Works at the HTTP transport level, no code changes needed
- Clean Test Isolation: Each test can set up its own mock expectations
// internal/api/sync/retry_test.go
import "github.com/h2non/gock"
func TestRetryLogic(t *testing.T) {
defer gock.Off() // Clean up after test
// First attempt: 500 error (will trigger retry)
gock.New("https://api.example.com").
Post("/openfeature/v0/manifest/flags").
Reply(500).
JSON(map[string]interface{}{
"error": map[string]interface{}{
"message": "Internal Server Error",
"status": 500,
},
})
// Second attempt: Success
gock.New("https://api.example.com").
Post("/openfeature/v0/manifest/flags").
Reply(201).
JSON(map[string]interface{}{
"flag": map[string]interface{}{
"key": "test-flag",
"type": "boolean",
"defaultValue": true,
},
"updatedAt": "2024-03-02T09:45:03.000Z",
})
// Create client and test retry behavior
client, _ := NewClient("https://api.example.com", "test-token")
// This will fail once, then succeed on retry
result, err := client.PushFlags(ctx, localFlags, remoteFlags, false)
assert.NoError(t, err)
assert.Len(t, result.Created, 1)
}
Key Testing Patterns with Gock:
- Testing Retry Logic: Stack multiple responses for the same endpoint to simulate failures followed by success
- Testing Error Handling: Mock specific HTTP status codes and error responses
- Request Validation: Use
MatchBody()to verify request payloads - Header Validation: Use
MatchHeader()to ensure authentication headers are sent
// Testing authentication header
gock.New("https://api.example.com").
Post("/openfeature/v0/manifest/flags").
MatchHeader("Authorization", "Bearer test-token").
Reply(201).
JSON(successResponse)
// Testing request body
gock.New("https://api.example.com").
Post("/openfeature/v0/manifest/flags").
MatchType("json").
BodyString(`{"key":"test-flag","type":"boolean","defaultValue":true}`).
Reply(201).
JSON(successResponse)
When to Use Gock vs Interface Mocking
Use Gock when:
- Testing retry logic and transient failures
- Testing HTTP-specific behaviors (status codes, headers, timeouts)
- Testing the full request/response cycle
- Verifying exact request payloads and headers
Use Interface Mocking when:
- Testing business logic independent of HTTP
- Need fast unit tests without network overhead
- Testing complex data transformations
- Want to avoid HTTP transport complexity
Unit Tests
For testing business logic without HTTP calls, you can also mock the generated client interface:
// client_test.go
type mockSyncClient struct {
getManifestFunc func(ctx context.Context) (*GetOpenfeatureV0ManifestResponse, error)
}
func (m *mockSyncClient) GetOpenfeatureV0ManifestWithResponse(ctx context.Context,
reqEditors ...RequestEditorFn) (*GetOpenfeatureV0ManifestResponse, error) {
return m.getManifestFunc(ctx)
}
func TestPullFlags(t *testing.T) {
mock := &mockSyncClient{
getManifestFunc: func(ctx context.Context) (*GetOpenfeatureV0ManifestResponse, error) {
return &GetOpenfeatureV0ManifestResponse{
JSON200: &ManifestEnvelope{
Flags: []ManifestFlag{{Key: "test-flag"}},
},
}, nil
},
}
// Test with mock client...
}
Integration Tests
Test against a real implementation of the API:
# Run integration tests
make test-integration
# Run specific language integration tests
make test-integration-go
make test-integration-csharp
make test-integration-nodejs
Future Extensions
Plugin System
This pattern enables a plugin architecture for supporting multiple flag providers:
// Provider interface using the pattern
type Provider interface {
PullFlags(ctx context.Context) (*flagset.Flagset, error)
PushFlags(ctx context.Context, flags *flagset.Flagset) error
}
// Each provider implements the interface
type FlagdStudioProvider struct {
client *sync.Client
}
type LaunchDarklyProvider struct {
client *launchdarkly.Client
}
// Register providers
providers := map[string]Provider{
"flagd-studio": NewFlagdStudioProvider(config),
"launchdarkly": NewLaunchDarklyProvider(config),
}
Multiple API Versions
Support multiple API versions simultaneously:
api/
├── v0/
│ └── sync.yaml
└── v1/
└── sync.yaml
internal/api/client/
├── v0/
│ └── sync_client.gen.go
└── v1/
└── sync_client.gen.go
Custom Operations
Add provider-specific operations while maintaining the core pattern:
// Extended client for provider-specific features
type FlagdStudioClient struct {
*sync.Client
}
func (c *FlagdStudioClient) ListEnvironments(ctx context.Context) ([]Environment, error) {
// Provider-specific operation
}
Common Patterns
Retry Logic
The PushFlags method uses the GoRetry library to handle transient failures when creating or updating flags:
import goretry "github.com/kriscoleman/GoRetry"
func (c *Client) PushFlags(ctx context.Context, localFlags *flagset.Flagset, remoteFlags *flagset.Flagset, dryRun bool) (*PushResult, error) {
// ... flag comparison logic ...
for _, flag := range toCreate {
err := goretry.IfNeededWithContext(ctx, func(ctx context.Context) error {
body, err := c.convertFlagToAPIBody(flag)
if err != nil {
return fmt.Errorf("failed to convert flag %s: %w", flag.Key, err)
}
resp, err := c.apiClient.PostOpenfeatureV0ManifestFlagsWithResponse(ctx, body)
if err != nil {
return fmt.Errorf("failed to create flag %s: %w", flag.Key, err)
}
if resp.HTTPResponse.StatusCode >= 500 {
return &httpError{statusCode: resp.HTTPResponse.StatusCode} // Retryable
}
return nil
}, goretry.WithTransientErrorFunc(isTransientHTTPError))
// ... handle result ...
}
}
Note: Retry logic is only used for write operations (POST, PUT, DELETE) where transient failures are more impactful. Read operations like PullFlags don't implement retry logic by default.
Pagination
func (c *Client) ListAllFlags(ctx context.Context) ([]*flagset.Flag, error) {
var allFlags []*flagset.Flag
cursor := ""
for {
// Note: Pagination is not yet implemented in the current API spec
// This is an example of how it could work
resp, err := c.apiClient.GetOpenfeatureV0ManifestWithResponse(ctx,
&GetOpenfeatureV0ManifestParams{Cursor: &cursor})
if err != nil {
return nil, err
}
allFlags = append(allFlags, convertFlags(resp.JSON200.Flags)...)
// Check for pagination cursor in response
if resp.JSON200.NextCursor == nil {
break
}
cursor = *resp.JSON200.NextCursor
}
return allFlags, nil
}
Streaming
func (c *Client) StreamFlags(ctx context.Context) (<-chan *flagset.Flag, error) {
ch := make(chan *flagset.Flag)
go func() {
defer close(ch)
// Server-sent events or WebSocket connection
stream, err := c.apiClient.StreamManifest(ctx)
if err != nil {
return
}
for event := range stream {
ch <- convertFlag(event)
}
}()
return ch, nil
}
Checklist for Adding a New Remote Operation
- Update OpenAPI specification in
api/v0/ - Regenerate client:
make generate-api - Add wrapper method in
internal/api/sync/client.go - Add debug logging for requests/responses
- Handle all error cases (4xx, 5xx)
- Convert API models to internal models
- Add unit tests with mocked client
- Add integration tests
- Update command to use new operation
- Update documentation
- Test with
--debugflag
References
Related Patterns
- Code Generation Pattern: Used for generating flag accessors (see DESIGN.md)
- Provider Pattern: Abstraction for different flag provider implementations
- Repository Pattern: Separates data access logic from business logic
For questions or suggestions about this pattern, please open an issue or discussion in the GitHub repository.