Gopher says
Welcome to Part 2! We’re building a complete connector from scratch. This level lays the entire foundation: project structure, API models, HTTP client, the connector struct, and the entry point. By the end, you’ll have a skeleton that compiles and validates credentials.
In baton-junction, we use typed API models with JSON tags, an authenticated HTTP client shared by every builder, and a connector struct that ties it all together. Levels 14 through 19 build on what you create here: config, sync, provisioning, and testing.
Project Structure
Conventions make connectors predictable - when you open any Baton project, you know where to find the client, config, and connector logic. Every Baton connector follows the same layout:
baton-junction/
cmd/baton-junction/ produces the binary
pkg/
client/ HTTP client and API models
config/ CLI configuration fields
connector/ resource builders and connector logic Initialize a Go module with the path 'example/baton-junction':
.gitignore
Before writing any code, set up your .gitignore. Baton connectors produce several artifacts that should never be committed:
dist/- common build output directory used by Makefiles and CI scripts (e.g.,go build -o dist/baton-junction)*.c1z- sync output filesvendor/- Go module vendor directory (usego mod downloadinstead)
Standard Go project ignores also apply:
*.exe,*.dll,*.so,*.dylib- compiled binaries for different platforms*.test- compiled test binaries fromgo test -c*.out- coverage output fromgo test -coverprofile.env- environment files with secrets (client IDs, tokens)
# Compiled binaries
dist/
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binaries and coverage
*.test
*.out
# Go workspace
vendor/
# Baton sync output (may contain PII)
*.c1z
# Environment and secrets
.env
.env.*
# OS
.DS_Store
Thumbs.dbAPI Models
Models mirror the target app’s JSON responses. Two fields deserve attention:
type User struct {
ID string `json:"id"`
Email string `json:"email"`
Department string `json:"department,omitempty"`
LastLogin *time.Time `json:"last_login,omitempty"`
}
Department uses omitempty to skip empty values in JSON. LastLogin is a pointer - nil means “never logged in,” which is different from a zero time.
Generic response wrappers avoid repeating yourself:
type ListResponse[T any] struct {
Data []T `json:"data"`
NextCursor string `json:"next_cursor"`
}
ListResponse[User] gives you Data: []User. Same struct works for roles, groups, and members.
The HTTP Client
Your connector needs to talk to the target app’s API. The HTTP client is that ambassador - it handles OAuth2 authentication, request building, and pagination so your resource builders can focus on data conversion. Here’s the constructor:
func New(ctx context.Context, clientID, clientSecret, baseURL string) (*Client, error) {
tokenURL, err := url.Parse(baseURL + "/oauth/token")
if err != nil {
return nil, fmt.Errorf("baton-junction: invalid base URL: %w", err)
}
creds := uhttp.NewOAuth2ClientCredentials(clientID, clientSecret, tokenURL, nil)
httpClient, err := creds.GetClient(ctx)
if err != nil {
return nil, fmt.Errorf("baton-junction: failed to create OAuth client: %w", err)
}
baseClient, err := uhttp.NewBaseHttpClientWithContext(ctx, httpClient)
if err != nil {
return nil, fmt.Errorf("baton-junction: failed to create HTTP client: %w", err)
}
return &Client{httpClient: baseClient, baseURL: baseURL}, nil
}
The SDK’s uhttp package handles token exchange, caching, refresh, response body closing, and HTTP-to-gRPC error mapping. You focus on endpoints.
All paginated GET requests share the same pattern: parse URL, set limit and
cursor query params, build request, execute, deserialize. The private
doGet helper captures this once. Public methods like GetUsers, GetRoles,
GetGroups are one-liners that call doGet with the right path and response
type.
Public API Methods
The client exposes a small, predictable surface: GetUsers, GetGroups, AddGroupMember. The naming follows a pattern so you always know what to expect. Each resource type gets a consistent set of methods:
// List with pagination
func (c *Client) GetUsers(ctx context.Context, cursor string) ([]User, string, error)
func (c *Client) GetGroups(ctx context.Context, cursor string) ([]Group, string, error)
// Get by ID
func (c *Client) GetUser(ctx context.Context, userID string) (*User, error)
// Membership operations
func (c *Client) AddGroupMember(ctx context.Context, groupID, userID string) error
func (c *Client) RemoveGroupMember(ctx context.Context, groupID, userID string) error
List methods return ([]T, string, error) - items, next cursor, error. The cursor contract: pass the returned cursor back to get the next page.
The Connector Struct
The Connector struct is the central coordination point. It holds the client and implements the top-level SDK interfaces:
type Connector struct {
client *client.Client
}
func New(ctx context.Context, cc *cfg.App, opts *cli.ConnectorOpts) (connectorbuilder.ConnectorBuilderV2, []connectorbuilder.Opt, error) {
c, err := client.New(ctx, cc.AppClientId, cc.AppClientSecret, cc.BaseUrl)
if err != nil {
return nil, nil, fmt.Errorf("baton-junction: failed to create client: %w", err)
}
return &Connector{client: c}, nil, nil
}
Three key methods make the connector work:
func (m *Connector) Metadata(_ context.Context) (*v2.ConnectorMetadata, error)
func (m *Connector) Validate(ctx context.Context) (annotations.Annotations, error)
func (m *Connector) ResourceSyncers(_ context.Context) []connectorbuilder.ResourceSyncerV2
Validate makes a lightweight API call (GetCurrentUser) to confirm credentials work before syncing. ResourceSyncers returns the three builders - order matters: users first, then groups, then roles.
The Entry Point: main.go
main.go is where everything meets: one function call hands the connector, config, and constructor to the SDK, and the framework takes it from there:
func main() {
ctx := context.Background()
config.RunConnector(ctx,
"baton-junction",
version,
cfg.Config,
connector.New,
)
}
config.RunConnector takes the connector name, version, config object, and the New constructor function. The SDK handles CLI parsing, builds a *cfg.App from the flags, and calls your connector.New with it. The import alias cfg prevents collision with the SDK’s config package.
Build & Run
Build the connector binary from the project root:
Run all tests including integration tests:
Define the User struct with all 9 fields listed in the starter code comments. Each field needs a JSON tag using snake_case. Use omitempty for optional fields, and a pointer type for LastLogin since a user may have never logged in.
Gopher says
The skeleton compiles, credentials validate, and the entry point hands everything to the SDK. You’ve built the full structural foundation: API models, HTTP client, connector struct, and main.go.
Next up: configuring the connector with CLI flags, environment variables, and the go:generate pattern.
Next Lesson
Connector Config

