Skip to content

Revamp Retool connector to use API v2 instead of direct DB queries#32

Open
c1-dev-bot[bot] wants to merge 2 commits into
mainfrom
revamp-api-client
Open

Revamp Retool connector to use API v2 instead of direct DB queries#32
c1-dev-bot[bot] wants to merge 2 commits into
mainfrom
revamp-api-client

Conversation

@c1-dev-bot

@c1-dev-bot c1-dev-bot Bot commented Mar 27, 2026

Copy link
Copy Markdown

Summary

Replaces the entire Postgres database client with an HTTP REST client that uses the Retool API v2, eliminating the need for direct database access. Also adds Account provisioning capability.

Changes

  • API Client: Replace pgx/pgxscan database driver with net/http REST client targeting Retool API v2 endpoints
  • Configuration: Replace --connection-string (DB DSN) with --api-token + --api-url (API authentication)
  • Account Provisioning: Implement AccountManager interface to create new users via POST /api/v2/users
  • Permissions: Use Retool Permissions API (/api/v2/permissions/grant|revoke) for app/resource access instead of direct SQL on group_pages/group_resources tables
  • Groups: Use Groups API which returns members inline, simplifying member listing
  • Organization: API token is org-scoped, so GET /api/v2/organization replaces the organizations table query
  • Dependencies: Remove pgx, pgxscan, pgconn, pgtype, puddle and related PostgreSQL dependencies

API Endpoints Used

Endpoint Purpose
GET /api/v2/organization Get organization info, validate connection
GET /api/v2/users List users
POST /api/v2/users Create user (account provisioning)
DELETE /api/v2/users/:id Delete user
GET /api/v2/groups List groups
GET /api/v2/groups/:id Get group with members
POST /api/v2/groups/:id/members Add member to group
DELETE /api/v2/groups/:id/members/:userId Remove member from group
POST /api/v2/permissions/grant Grant app/resource permissions
POST /api/v2/permissions/revoke Revoke app/resource permissions
GET /api/v2/permissions/apps/:id List access for an app
GET /api/v2/permissions/resources/:id List access for a resource
GET /api/v2/apps List apps (formerly "pages")
GET /api/v2/resources List resources

Required API Token Scopes

  • Users: Read, Write
  • Groups: Read, Write
  • Permissions: Read, Write

Fixes: CXH-1263


Automated PR Notice

This PR was automatically created by c1-dev-bot as a potential implementation.

This code requires:

  • Human review of the implementation approach
  • Manual testing to verify correctness against a live Retool instance
  • Validation of exact API response shapes (field names, pagination format)
  • Approval from the appropriate team before merging

Replace the entire Postgres database client (pgx/scany) with an HTTP REST
client that uses the Retool API v2. This eliminates the need for direct
database access and uses the officially supported API endpoints.

Key changes:
- Replace database connection with API token + base URL configuration
- Rewrite pkg/client/ to use net/http REST calls to Retool API v2
- Add Account provisioning capability (create users via POST /api/v2/users)
- Use Permissions API for app/resource access grants instead of direct SQL
- Groups API returns members inline (no separate member queries needed)
- Remove pgx, pgxscan, and related database dependencies
- Update README with new setup instructions

API endpoints used:
- GET/POST /api/v2/users - List and create users
- GET /api/v2/groups, POST/DELETE members - Group management
- POST /api/v2/permissions/grant|revoke - Permission management
- GET /api/v2/organization - Organization info
- GET /api/v2/apps, /api/v2/resources - App and resource listing

Fixes: CXH-1263
@c1-dev-bot c1-dev-bot Bot requested a review from a team March 27, 2026 21:24
@linear

linear Bot commented Mar 27, 2026

Copy link
Copy Markdown

@github-actions

github-actions Bot commented Mar 27, 2026

Copy link
Copy Markdown

Claude finished @c1-dev-bot[bot]'s task in 2m 57s —— View job


PR Review: Revamp Retool connector to use API v2

  • Gather context and read changed files
  • Review API client implementation
  • Review connector/builder implementations
  • Review configuration changes
  • Post review findings

Overall Assessment

This is a well-structured rewrite that cleanly replaces the Postgres database driver with a REST API client. The generic ListResponse[T]/SingleResponse[T] types, consistent pagination via Pager, and proper use of json.Number for IDs are all good patterns. The removal of ~81k lines of vendored PG dependencies is a big win.

However, there are several bugs and issues that should be addressed before merging:

Bugs (must fix)

  1. IsNotFound / IsConflict are broken due to error wrapping (client.go:141-153)

    • These use direct type assertion (err.(*APIError)) but every call site wraps the error with fmt.Errorf("...: %w", err). The *APIError is always nested, so these checks always return false. This means conflict detection in Grant and not-found detection in Revoke are silently broken.
    • Fix: Use errors.As() instead. Fix this →
  2. Group Grant doesn't return GrantAlreadyExists annotation (groups.go:150-152)

    • On conflict, returns nil, nil instead of annotations.New(&v2.GrantAlreadyExists{}), nil. Per CLAUDE.md: "Already exists" is NOT an error — return nil error with the annotation.
  3. Missing Close() on ConnectorImpl — The connector creates an HTTP client but never closes it. Per CLAUDE.md: "Connectors that create clients MUST close them." Add a Close() method that delegates to c.client.Close(). (Could not post inline — line not in diff context.)

  4. parseObjectID / parseObjectIDString can panic — No bounds check on input. Empty string causes index-out-of-range panic on id[1:]. (Could not post inline — line not in diff context.)

Issues (should fix)

  1. Missing ExternalId on groups (groups.go:64)

    • Groups have Grant/Revoke but don't set WithExternalID() during sync. Users correctly set it. Per CLAUDE.md this is required for provisioning to work reliably.
  2. pageSyncer.Grant parameter naming (pages.go:160)

    • First param named resource is actually the principal. Confusing vs SDK convention and vs groupSyncer.Grant which correctly names it principal.
  3. Fragile entitlement ID parsing (pages.go:166-170)

    • Splits by : and asserts exactly 4 parts. Consider using entitlement.Slug or extracting the last element without asserting count.
  4. Manually overridden grant IDs (resources.go:130)

    • Overriding SDK-generated grant IDs is fragile. If the format doesn't match what the SDK expects during revocation, grants become unrevocable.

Positive Notes

  • Clean use of Go generics for ListResponse[T] / SingleResponse[T]
  • Proper json.Number usage throughout for API IDs
  • ExternalId correctly set on users for provisioning
  • Good pagination pattern with offset-based tokens
  • Idempotent revoke handling (not-found = already revoked) in pages — once IsNotFound is fixed
  • CreateAccount implementation is solid with good email fallback logic

Summary

The architecture is sound and the API client is well-designed. The critical path issue is #1 (broken errors.As) — it silently disables all conflict/not-found detection, making Grant idempotency and Revoke safety non-functional. Fix that plus the missing Close() method, and this is in good shape.

Comment thread pkg/connector/groups.go
Comment on lines +150 to +152
if client.IsConflict(err) {
return nil, nil
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Per CLAUDE.md conventions, "already exists" on Grant should return the GrantAlreadyExists annotation, not bare nil. The current code silently swallows the conflict.

Suggested change
if client.IsConflict(err) {
return nil, nil
}
if client.IsConflict(err) {
return annotations.New(&v2.GrantAlreadyExists{}), nil
}

Comment thread pkg/client/client.go
Comment on lines +141 to +146
func IsNotFound(err error) bool {
if apiErr, ok := err.(*APIError); ok {
return apiErr.StatusCode == http.StatusNotFound
}
return false
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Direct type assertion won't match wrapped errors. Since doRequest wraps API errors with fmt.Errorf("...: %w", err), callers like groups.go and pages.go further wrap them. By the time IsNotFound/IsConflict are called, the *APIError is nested and this assertion fails — meaning conflict/not-found detection is silently broken.

Use errors.As instead:

Suggested change
func IsNotFound(err error) bool {
if apiErr, ok := err.(*APIError); ok {
return apiErr.StatusCode == http.StatusNotFound
}
return false
}
func IsNotFound(err error) bool {
var apiErr *APIError
if errors.As(err, &apiErr) {
return apiErr.StatusCode == http.StatusNotFound
}
return false
}

Same fix needed for IsConflict below. You'll need to add "errors" to the imports.

return nil, nil, nil, fmt.Errorf("baton-retool: failed to create user account: %w", err)
}

_ = user

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: The created user result is discarded. Consider using the returned user to populate the CreateAccountResponse with relevant info (e.g., the new user's ID), or at minimum remove the assignment to avoid the dead-code smell.

Suggested change
_ = user
_, err := c.client.CreateUser(ctx, &client.CreateUserRequest{

Comment thread pkg/connector/pages.go
return ret, nextPageToken, nil, nil
}

func (s *pageSyncer) Grant(ctx context.Context, resource *v2.Resource, entitlement *v2.Entitlement) ([]*v2.Grant, annotations.Annotations, error) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: The first parameter is named resource but it's actually the principal (the entity being granted access). This is confusing — the SDK convention names this parameter principal. Consider renaming to principal to match the groupSyncer.Grant signature at groups.go:129.

Comment thread pkg/connector/pages.go
Comment on lines +166 to +170
splitV := strings.Split(entitlement.Id, ":")
if len(splitV) != 4 {
return nil, nil, fmt.Errorf("unexpected entitlement ID format while processing page grant: %s", entitlement.Id)
}
accessLevel := splitV[len(splitV)-1]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fragile: This splits the entitlement ID by : and assumes exactly 4 parts. If the ID format ever changes (e.g., resource IDs containing colons), this breaks silently by extracting the wrong access level. Consider extracting the slug from entitlement.Slug directly if available, or at minimum using the last element without asserting exact length:

parts := strings.Split(entitlement.Id, ":")
accessLevel := parts[len(parts)-1]

Id: fmt.Sprintf("grant:%s:%s", eID, principalID.Resource),
})
newGrant := grant.NewGrant(resource, level, principalID)
newGrant.Id = fmt.Sprintf("grant:%s:%s", eID, principalID.Resource)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fragile: Manually overriding the SDK-generated grant ID. If the format doesn't match what the SDK expects during revocation, grants may become unrevocable. Prefer using the SDK's grant.NewGrant() defaults or ensure the format is tested.

Comment thread pkg/connector/users.go
Comment on lines 78 to 89
ret = append(ret, &v2.Resource{
DisplayName: fmt.Sprintf("%s %s", o.GetFirstName(), o.GetLastName()),
DisplayName: displayName,
Id: &v2.ResourceId{
ResourceType: s.resourceType.Id,
Resource: resourceID,
},
ExternalId: &v2.ExternalId{
Id: o.ID.String(),
},
ParentResourceId: parentResourceID,
Annotations: annos,
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good: ExternalId is properly set using the native Retool user ID. This is critical for provisioning operations (Grant/Revoke) to work correctly. Well done.

Comment thread pkg/connector/groups.go
}

resource, err := rs.NewResource(o.GetName(), s.resourceType, formatObjectID(resourceTypeGroup.Id, o.ID), options...)
resource, err := rs.NewResource(o.GetName(), s.resourceType, formatObjectID(resourceTypeGroup.Id, o.GetID()), options...)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing ExternalId: Groups support Grant/Revoke operations but don't set WithExternalID() during sync. While the current code works by extracting the native ID from the formatted resource ID, this is fragile and inconsistent with the user syncer which properly sets ExternalId. Per CLAUDE.md: "WithExternalID() - REQUIRED for Grant/Revoke to work."

- Use errors.As instead of type assertion for wrapped error handling
- Handle resp.Body.Close() return value
- Fix struct field alignment in UserModel
- Fix import ordering in resources.go
@github-actions

github-actions Bot commented Mar 27, 2026

Copy link
Copy Markdown

Claude finished @c1-dev-bot[bot]'s task in 2m 2s —— View job


PR Review: Revamp Retool connector to use API v2

  • Gather context and understand the PR
  • Review API client implementation
  • Review connector/builder implementations
  • Review configuration and dependencies
  • Post final review

Overall Assessment

This is a clean, well-structured rewrite that replaces ~81k lines of vendored Postgres dependencies with a focused REST API client. The generic ListResponse[T]/SingleResponse[T] types, consistent offset-based pagination via Pager, and proper json.Number usage are all solid patterns. The errors.As fix in commit 56ab0e1 resolved the most critical bug from the prior review.

Remaining Bugs

1. Group Grant missing GrantAlreadyExists annotationpkg/connector/groups.go:150-152

On conflict, returns nil, nil instead of the required annotation. Per CLAUDE.md: "Already exists" is NOT an error — return nil error with the annotation.

// Current (line 151):
return nil, nil

// Should be:
return annotations.New(&v2.GrantAlreadyExists{}), nil

Fix this →

2. parseObjectID / parseObjectIDString panic on empty or single-char inputpkg/connector/helpers.go:16,20

Both functions index id[1:] without bounds checking. An empty string or single character causes an index-out-of-range panic.

// parseObjectID - add guard:
func parseObjectID(id string) (int64, error) {
    if len(id) < 2 {
        return 0, fmt.Errorf("baton-retool: invalid object ID: %q", id)
    }
    return strconv.ParseInt(id[1:], 10, 64)
}

Fix this →

3. Missing Close() delegation on ConnectorImplpkg/connector/connector.go

The ConnectorImpl creates a *client.Client (which has a Close() method) but the connector itself doesn't implement Close(). Per CLAUDE.md: "Connectors that create clients MUST close them." The connectorbuilder may call Close() on shutdown; without it, idle HTTP connections won't be cleaned up.

func (c *ConnectorImpl) Close() error {
    if c.client != nil {
        return c.client.Close()
    }
    return nil
}

Fix this →

Issues (should fix)

4. Missing ExternalId on groupspkg/connector/groups.go:59-64

Groups support Grant/Revoke but don't set WithExternalID() during sync. Users correctly set it at users.go:84-86. Per CLAUDE.md: WithExternalID() is REQUIRED for Grant/Revoke to work reliably. Currently the code works around this by parsing the native ID from the formatted resource ID, but that's fragile.

5. pageSyncer.Grant parameter namingpkg/connector/pages.go:160

First parameter is named resource but it's the principal (the entity being granted access). Compare with groupSyncer.Grant at groups.go:129 which correctly names it principal. This makes the code confusing to maintain.

6. Fragile entitlement ID parsingpkg/connector/pages.go:166-170

Splits entitlement ID by : and asserts exactly 4 parts. If the format changes, this breaks silently. Using parts[len(parts)-1] would be more resilient.

7. Manually overridden grant IDspkg/connector/resources.go:131

Overriding SDK-generated grant IDs is fragile. If the format doesn't match what the SDK expects during revocation, grants become unrevocable. Prefer using grant.NewGrant() defaults unless there's a specific reason to override.

8. Dead assignment in CreateAccountpkg/connector/connector.go:104,114

user is assigned from CreateUser then immediately discarded with _ = user. Either use the returned user to populate the CreateAccountResponse (e.g., with the new user's ID) or discard it at the call site: _, err := c.client.CreateUser(...).

Positive Notes

  • Clean use of Go generics for ListResponse[T] / SingleResponse[T]
  • Proper json.Number usage throughout for API IDs — avoids the JSON type mismatch pitfall
  • ExternalId correctly set on users for provisioning (users.go:84-86)
  • Good pagination pattern with offset-based tokens across all list operations
  • Idempotent revoke handling (not-found = already revoked) in both groups.go:182-183 and pages.go:265-266
  • CreateAccount implementation has solid email fallback logic (primary → any → login)
  • ValidateConnection delegates to GetOrganization — clean and effective
  • Massive dependency reduction: removing pgx/pgconn/pgtype/puddle/scany is a big win

Summary

The architecture is sound. Three bugs need fixing before merge: the missing GrantAlreadyExists annotation (#1), the panic-prone ID parsers (#2), and the missing Close() method (#3). The remaining issues are improvements worth addressing but not blockers.

Comment thread pkg/connector/groups.go
Comment on lines +150 to +152
if client.IsConflict(err) {
return nil, nil
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Per CLAUDE.md, "already exists" on Grant should return the GrantAlreadyExists annotation, not bare nil. The current code silently swallows the conflict signal.

Suggested change
if client.IsConflict(err) {
return nil, nil
}
if client.IsConflict(err) {
return annotations.New(&v2.GrantAlreadyExists{}), nil
}

Comment thread pkg/connector/helpers.go
Comment on lines +19 to +21
func parseObjectIDString(id string) string {
return id[1:]
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same bounds-check issue as parseObjectID — will panic on empty input.

Suggested change
func parseObjectIDString(id string) string {
return id[1:]
}
func parseObjectIDString(id string) string {
if len(id) < 2 {
return ""
}
return id[1:]

return nil, nil, nil, fmt.Errorf("baton-retool: failed to create user account: %w", err)
}

_ = user

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: user is assigned then immediately discarded. Either use the returned user to populate the response (e.g. with the new user's ID) or discard at the call site:

Suggested change
_ = user
_, err := c.client.CreateUser(ctx, &client.CreateUserRequest{

Comment thread pkg/connector/groups.go
}

resource, err := rs.NewResource(o.GetName(), s.resourceType, formatObjectID(resourceTypeGroup.Id, o.ID), options...)
resource, err := rs.NewResource(o.GetName(), s.resourceType, formatObjectID(resourceTypeGroup.Id, o.GetID()), options...)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing WithExternalID(): Groups support Grant/Revoke but don't set ExternalId during sync. Users correctly set it at users.go:84. Per CLAUDE.md this is required for provisioning to work reliably. Consider adding:

rs.WithExternalID(o.ID.String()),

to the options slice.

Comment thread pkg/connector/pages.go
return ret, nextPageToken, nil, nil
}

func (s *pageSyncer) Grant(ctx context.Context, resource *v2.Resource, entitlement *v2.Entitlement) ([]*v2.Grant, annotations.Annotations, error) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: First parameter named resource is actually the principal (the entity being granted access). This is inconsistent with groupSyncer.Grant at groups.go:129 which correctly names it principal, and with the SDK convention.

Comment thread pkg/connector/pages.go
Comment on lines +166 to +170
splitV := strings.Split(entitlement.Id, ":")
if len(splitV) != 4 {
return nil, nil, fmt.Errorf("unexpected entitlement ID format while processing page grant: %s", entitlement.Id)
}
accessLevel := splitV[len(splitV)-1]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fragile: Asserts exactly 4 parts after splitting by :. If the ID format ever changes (e.g. resource IDs containing colons), this breaks silently. Consider extracting the last element without asserting exact length:

Suggested change
splitV := strings.Split(entitlement.Id, ":")
if len(splitV) != 4 {
return nil, nil, fmt.Errorf("unexpected entitlement ID format while processing page grant: %s", entitlement.Id)
}
accessLevel := splitV[len(splitV)-1]
splitV := strings.Split(entitlement.Id, ":")
if len(splitV) < 2 {
return nil, nil, fmt.Errorf("unexpected entitlement ID format while processing page grant: %s", entitlement.Id)
}
accessLevel := splitV[len(splitV)-1]

Id: fmt.Sprintf("grant:%s:%s", eID, principalID.Resource),
})
newGrant := grant.NewGrant(resource, level, principalID)
newGrant.Id = fmt.Sprintf("grant:%s:%s", eID, principalID.Resource)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fragile: Manually overriding the SDK-generated grant ID. If this format doesn't match what the SDK expects during revocation, grants may become unrevocable. Prefer using grant.NewGrant() defaults unless there's a specific reason for the override.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants