Level 13 /

Project Foundation

Time to break ground on your own connector. The permits are signed, the survey stakes are driven into the earth, and the foundation is being poured. Gopher reviews the architectural blueprints one last time before the concrete sets. Everything that follows depends on getting this right.

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:

entry pointlibrary packages
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 files
  • vendor/ - Go module vendor directory (use go mod download instead)

Standard Go project ignores also apply:

  • *.exe, *.dll, *.so, *.dylib - compiled binaries for different platforms
  • *.test - compiled test binaries from go test -c
  • *.out - coverage output from go test -coverprofile
  • .env - environment files with secrets (client IDs, tokens)

API 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.

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.

Explore the related connector code files

Next Lesson

Connector Config

Continue