Level 17 /

Integration Tests

This is the final full-system safety inspection. Not just checking individual switches, but running a train the entire length of the line: Appville to Baton Junction to Conductor City and back. Gopher and the rail inspectors verify that every piece works together before opening day.

Gopher says

Sync reads data. Provisioning changes memberships. Actions let the platform trigger any operation on your connector: enabling a user account, disabling it, updating a profile, or anything else your target system supports.

In baton-junction, global actions like enable/disable live on the Connector struct, while resource-scoped actions like profile updates live on the userBuilder. Both use the same ActionRegistry pattern.

Two Kinds of Actions

The SDK provides two interfaces for registering actions:

Global ActionsResource-Scoped Actions
InterfaceGlobalActionProviderResourceActionProvider
Registered onConnector structBuilder struct (e.g., userBuilder)
MethodGlobalActions(ctx, registry)ResourceActions(ctx, registry)

The ActionRegistry

Both GlobalActions and ResourceActions receive an actions.ActionRegistry. You register actions by providing a schema (what the action looks like) and a handler (what it does):

func (m *Connector) GlobalActions(ctx context.Context, registry actions.ActionRegistry) error {
    return registry.Register(ctx, schema, m.handler)
}

The SDK calls this method during connector startup and registers all actions with the platform. If a builder doesn’t implement ResourceActionProvider, the SDK skips it silently.

BatonActionSchema

Every action needs a schema that describes its name, arguments, return types, and action type:

machine identifierhuman-readable labeltyped input fieldsaction type enum
enableSchema := &v2.BatonActionSchema{
    Name:        "enable_user",
    DisplayName: "Enable User",
    Description: "Enable a user account in the target app",
    Arguments:   []*config.Field{
        {Name: "resource_id", DisplayName: "User Resource ID",
         Field: &config.Field_StringField{}, IsRequired: true},
    },
    ReturnTypes: []*config.Field{
        {Name: "success", DisplayName: "Success", Field: &config.Field_BoolField{}},
    },
    ActionType:  []v2.ActionType{
        v2.ActionType_ACTION_TYPE_ACCOUNT,
        v2.ActionType_ACTION_TYPE_ACCOUNT_ENABLE,
    },
}

The ActionType field tells the platform what kind of operation this is. Common values:

ActionTypeUse For
ACTION_TYPE_ACCOUNT_ENABLEActivating a disabled account
ACTION_TYPE_ACCOUNT_DISABLESuspending an account
ACTION_TYPE_ACCOUNT_UPDATE_PROFILEChanging profile attributes
ACTION_TYPE_RESOURCE_CREATECreating a new resource

Global Actions: Enable & Disable Accounts

In baton-junction, enable and disable are global actions on the Connector struct. Both delegate to a shared setUserStatus helper:

func (m *Connector) enableUser(ctx context.Context, args *structpb.Struct) (
    *structpb.Struct, annotations.Annotations, error) {
    return m.setUserStatus(ctx, args, "active", "enable-user")
}

func (m *Connector) disableUser(ctx context.Context, args *structpb.Struct) (
    *structpb.Struct, annotations.Annotations, error) {
    return m.setUserStatus(ctx, args, "inactive", "disable-user")
}

The shared helper extracts the user ID, calls the API, and returns a result:

func (m *Connector) setUserStatus(
    ctx context.Context, args *structpb.Struct, status, actionName string,
) (*structpb.Struct, annotations.Annotations, error) {
    l := ctxzap.Extract(ctx)

    userID, ok := actions.GetStringArg(args, "resource_id")
    if !ok || userID == "" {
        return nil, nil, fmt.Errorf("baton-junction: %s: missing resource_id", actionName)
    }

    updated, err := m.client.UpdateUser(ctx, userID, map[string]string{"status": status})
    if err != nil {
        l.Error(fmt.Sprintf("baton-junction: %s: failed to update user status", actionName),
            zap.Error(err), zap.String("user_id", userID))
        return nil, nil, fmt.Errorf("baton-junction: %s: %w", actionName, err)
    }

    l.Info(fmt.Sprintf("baton-junction: %s: success", actionName),
        zap.String("user_id", userID), zap.String("new_status", updated.Status))

    result := &structpb.Struct{
        Fields: map[string]*structpb.Value{
            "success": {Kind: &structpb.Value_BoolValue{BoolValue: true}},
        },
    }
    return result, nil, nil
}

The SDK’s actions.GetStringArg helper extracts typed values from the structpb.Struct arguments - no manual type assertions needed.

Action Handler Signature

Every action handler has the same signature:

func(ctx context.Context, args *structpb.Struct) (*structpb.Struct, annotations.Annotations, error)

Arguments come in as *structpb.Struct (a generic protobuf map), and results go out the same way. The SDK provides helpers to extract typed values:

HelperReturnsUse For
actions.GetStringArg(args, "name")(string, bool)Free-form string inputs
actions.GetResourceIDArg(args, "name")(*v2.ResourceId, bool)Resource reference inputs

Resource-Scoped Actions: Update Profile

Resource-scoped actions are registered on a builder. In baton-junction, the user builder registers “Update User Profile”:

func (o *userBuilder) ResourceActions(ctx context.Context, registry actions.ActionRegistry) error {
    updateProfileSchema := &v2.BatonActionSchema{
        Name:        "update_profile",
        DisplayName: "Update User Profile",
        Description: "Update a user's profile attributes",
        ActionType:  []v2.ActionType{v2.ActionType_ACTION_TYPE_ACCOUNT_UPDATE_PROFILE},
        Arguments: []*config.Field{
            {
                Name:       "user_id",
                IsRequired: true,
                Field: &config.Field_ResourceIdField{
                    ResourceIdField: &config.ResourceIdField{
                        Rules: &config.ResourceIDRules{
                            AllowedResourceTypeIds: []string{"user"},
                        },
                    },
                },
            },
            {Name: "first_name", Field: &config.Field_StringField{}},
            {Name: "last_name", Field: &config.Field_StringField{}},
            {Name: "email", Field: &config.Field_StringField{}},
            {Name: "department", Field: &config.Field_StringField{}},
        },
    }

    return registry.Register(ctx, updateProfileSchema, o.updateUserProfile)
}

Two important differences from global actions:

  1. The receiver is *userBuilder, not *Connector. The SDK detects that the user builder implements ResourceActionProvider via type assertion - just like it detects ResourceProvisioner for Grant/Revoke. If a builder doesn’t implement ResourceActions, the SDK skips it.

  2. The user_id argument uses Field_ResourceIdField instead of Field_StringField. This tells C1 the input is a resource reference, not a free-form string. AllowedResourceTypeIds constrains it to user resources, so the platform shows a user picker instead of a text box.

The Handler

The handler uses actions.GetResourceIDArg instead of actions.GetStringArg to extract the typed resource reference:

func (o *userBuilder) updateUserProfile(ctx context.Context, args *structpb.Struct) (
    *structpb.Struct, annotations.Annotations, error) {
    l := ctxzap.Extract(ctx)

    userResourceID, ok := actions.GetResourceIDArg(args, "user_id")
    if !ok {
        return nil, nil, fmt.Errorf("baton-junction: update-user-profile: missing required argument user_id")
    }
    userID := userResourceID.GetResource()

    attrs := make(map[string]string)
    for c1Name, apiName := range attrsLookup {
        val, found := actions.GetStringArg(args, c1Name)
        if found && val != "" {
            attrs[apiName] = val
        }
    }

    updated, err := o.client.UpdateUser(ctx, userID, attrs)
    if err != nil {
        l.Error("baton-junction: update-user-profile: failed to update user",
            zap.Error(err), zap.String("user_id", userID))
        return nil, nil, fmt.Errorf("baton-junction: update-user-profile: %w", err)
    }

    l.Info("baton-junction: update-user-profile: success",
        zap.String("user_id", userID), zap.Int("attrs_updated", len(attrs)))

    result := &structpb.Struct{
        Fields: map[string]*structpb.Value{
            "success": {Kind: &structpb.Value_BoolValue{BoolValue: true}},
            "user_id": {Kind: &structpb.Value_StringValue{StringValue: updated.ID}},
        },
    }
    return result, nil, nil
}

The attrsLookup map translates C1 attribute names to the target app’s API field names. Only attributes the user actually provided get sent in the update - this prevents accidentally blanking fields.

Gopher says

You’ve built a complete connector! Sync reads data, provisioning changes memberships, and actions handle everything else. From go mod init to enable/disable/update-profile - every file, every pattern, every interface.

In baton-junction, every technique you just used mirrors real baton connector code. The entire Baton ecosystem is now open to you.

Next: integration testing to verify everything works together!

Explore the related connector code files

Next Lesson

Standalone Test Server

Continue