Skip to content

Commit 91e02e4

Browse files
authored
feat(api): paginated GET /v2/templates (EN-603) (#3059)
Adds a cursor-paginated `GET /v2/templates` for the CLI — sandbox-style `limit` (1–100) + `nextToken` query params with an `X-Next-Token` response header, returning a bare `[]Template` array like `GET /v2/sandboxes`. The unpaginated `GET /templates` is marked deprecated and left unchanged. Backed by a new `GetTeamTemplatesWithCursor` query, which is the existing `GetTeamTemplates` projection (so `build_status` is preserved) plus a `(created_at, id)` keyset, `created_at DESC` ordering, and a limit. Includes regenerated OpenAPI clients, a DB test for ordering/pagination, and integration tests for the new endpoint. The e2b CLI repo (separate) still needs to switch to `GET /v2/templates`.
1 parent ecd13b0 commit 91e02e4

11 files changed

Lines changed: 1258 additions & 178 deletions

File tree

packages/api/internal/api/api.gen.go

Lines changed: 443 additions & 178 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
package handlers
2+
3+
import (
4+
"net/http"
5+
"time"
6+
7+
"github.com/gin-gonic/gin"
8+
9+
"github.com/e2b-dev/infra/packages/api/internal/api"
10+
"github.com/e2b-dev/infra/packages/api/internal/utils"
11+
"github.com/e2b-dev/infra/packages/db/queries"
12+
"github.com/e2b-dev/infra/packages/shared/pkg/telemetry"
13+
)
14+
15+
const (
16+
templatesDefaultLimit = int32(100)
17+
templatesMaxLimit = int32(100)
18+
)
19+
20+
// GetV2Templates lists a team's templates with cursor pagination (e.g. in the CLI).
21+
func (a *APIStore) GetV2Templates(c *gin.Context, params api.GetV2TemplatesParams) {
22+
ctx := c.Request.Context()
23+
24+
team, apiErr := a.GetTeam(ctx, c, params.TeamID)
25+
if apiErr != nil {
26+
a.sendAPIStoreError(c, apiErr.Code, apiErr.ClientMsg)
27+
telemetry.ReportCriticalError(ctx, "error when getting team and tier", apiErr.Err)
28+
29+
return
30+
}
31+
32+
if params.TeamID != nil && team.ID.String() != *params.TeamID {
33+
a.sendAPIStoreError(c, http.StatusBadRequest, "Team ID param mismatch with the API key")
34+
telemetry.ReportError(ctx, "team param mismatch with the API key", nil, telemetry.WithTeamID(team.ID.String()))
35+
36+
return
37+
}
38+
39+
telemetry.SetAttributes(ctx,
40+
telemetry.WithTeamID(team.ID.String()),
41+
)
42+
43+
pagination, err := utils.NewPagination[*api.Template](
44+
utils.PaginationParams{
45+
Limit: params.Limit,
46+
NextToken: params.NextToken,
47+
},
48+
utils.PaginationConfig{
49+
DefaultLimit: templatesDefaultLimit,
50+
MaxLimit: templatesMaxLimit,
51+
DefaultID: utils.MaxTemplateID,
52+
},
53+
)
54+
if err != nil {
55+
telemetry.ReportError(ctx, "error parsing pagination cursor", err)
56+
a.sendAPIStoreError(c, http.StatusBadRequest, "Invalid next token")
57+
58+
return
59+
}
60+
61+
rows, err := a.sqlcDB.GetTeamTemplatesWithCursor(ctx, queries.GetTeamTemplatesWithCursorParams{
62+
TeamID: team.ID,
63+
CursorCreatedAt: pagination.CursorTime(),
64+
CursorID: pagination.CursorID(),
65+
LimitPlusOne: pagination.QueryLimit(),
66+
})
67+
if err != nil {
68+
a.sendAPIStoreError(c, http.StatusInternalServerError, "Error when getting templates")
69+
telemetry.ReportCriticalError(ctx, "error when getting templates", err)
70+
71+
return
72+
}
73+
74+
telemetry.ReportEvent(ctx, "listed environments")
75+
76+
a.posthog.IdentifyAnalyticsTeam(ctx, team.ID.String(), team.Name)
77+
properties := a.posthog.GetPackageToPosthogProperties(&c.Request.Header)
78+
a.posthog.CreateAnalyticsTeamEvent(ctx, team.ID.String(), "listed environments", properties)
79+
80+
templates := make([]*api.Template, 0, len(rows))
81+
for _, item := range rows {
82+
var createdBy *api.TeamUser
83+
if item.CreatorID != nil {
84+
createdBy = &api.TeamUser{
85+
Id: *item.CreatorID,
86+
Email: nil,
87+
}
88+
}
89+
90+
envdVersion := ""
91+
if item.BuildEnvdVersion != nil {
92+
envdVersion = *item.BuildEnvdVersion
93+
}
94+
95+
diskMB := int64(0)
96+
if item.BuildTotalDiskSizeMb != nil {
97+
diskMB = *item.BuildTotalDiskSizeMb
98+
}
99+
100+
templates = append(templates, &api.Template{
101+
TemplateID: item.TemplateID,
102+
BuildID: item.BuildID.String(),
103+
CpuCount: api.CPUCount(item.BuildVcpu),
104+
MemoryMB: api.MemoryMB(item.BuildRamMb),
105+
DiskSizeMB: api.DiskSizeMB(diskMB),
106+
Public: item.Public,
107+
Aliases: item.Aliases,
108+
Names: item.Names,
109+
CreatedAt: item.CreatedAt,
110+
UpdatedAt: item.UpdatedAt,
111+
LastSpawnedAt: item.LastSpawnedAt,
112+
SpawnCount: item.SpawnCount,
113+
BuildCount: item.BuildCount,
114+
BuildStatus: getCorrespondingTemplateBuildStatus(ctx, item.BuildStatus),
115+
CreatedBy: createdBy,
116+
EnvdVersion: envdVersion,
117+
})
118+
}
119+
120+
templates = pagination.ProcessResultsWithHeader(c, templates, func(t *api.Template) (time.Time, string) {
121+
return t.CreatedAt, t.TemplateID
122+
})
123+
124+
c.JSON(http.StatusOK, templates)
125+
}

packages/api/internal/middleware/blocked_team.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ var blockedTeamAllowlist = auth.BlockedTeamAllowlist{
3030
"/templates/:templateID/builds/:buildID/logs": {},
3131
"/templates/:templateID/builds/:buildID/status": {},
3232
"/v2/sandboxes": {},
33+
"/v2/templates": {},
3334
"/v2/sandboxes/:sandboxID/logs": {},
3435
"/volumes": {},
3536
"/volumes/:volumeID": {},
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package utils
2+
3+
const (
4+
// MaxTemplateID sorts lexically after any real template ID. It is used as
5+
// the default cursor ID for the first page of a descending template listing
6+
// (mirrors MaxSandboxID).
7+
MaxTemplateID = "zzzzzzzzzzzzzzzzzzzz"
8+
)
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
package templates
2+
3+
import (
4+
"testing"
5+
"time"
6+
7+
"github.com/stretchr/testify/require"
8+
9+
"github.com/e2b-dev/infra/packages/db/pkg/testutils"
10+
"github.com/e2b-dev/infra/packages/db/queries"
11+
)
12+
13+
// firstPageCursor returns a cursor that selects the first page of a descending
14+
// listing (newer than any real row).
15+
func firstPageCursor() (time.Time, string) {
16+
return time.Now().Add(100 * 365 * 24 * time.Hour), ""
17+
}
18+
19+
func TestGetTeamTemplatesWithCursor_OrdersDescAndPaginates(t *testing.T) {
20+
t.Parallel()
21+
db := testutils.SetupDatabase(t)
22+
ctx := t.Context()
23+
24+
teamID := testutils.CreateTestTeam(t, db)
25+
26+
// Three templates with distinct, increasing created_at (index 2 is newest).
27+
templateIDs := make([]string, 3)
28+
for i := range templateIDs {
29+
templateIDs[i] = testutils.CreateTestTemplate(t, db, teamID)
30+
err := db.SqlcClient.TestsRawSQL(ctx,
31+
"UPDATE public.envs SET created_at = NOW() - ($2 || ' hours')::interval WHERE id = $1",
32+
templateIDs[i], 3-i,
33+
)
34+
require.NoError(t, err)
35+
}
36+
37+
cursorTime, cursorID := firstPageCursor()
38+
rows, err := db.SqlcClient.GetTeamTemplatesWithCursor(ctx, queries.GetTeamTemplatesWithCursorParams{
39+
TeamID: teamID,
40+
CursorCreatedAt: cursorTime,
41+
CursorID: cursorID,
42+
LimitPlusOne: 10,
43+
})
44+
require.NoError(t, err)
45+
require.Len(t, rows, 3)
46+
// Newest first.
47+
require.Equal(t, []string{templateIDs[2], templateIDs[1], templateIDs[0]},
48+
[]string{rows[0].TemplateID, rows[1].TemplateID, rows[2].TemplateID})
49+
50+
// Keyset pagination: page of 2 (request 3 = limit+1 to detect more), then
51+
// continue from the last returned row's cursor.
52+
firstPage, err := db.SqlcClient.GetTeamTemplatesWithCursor(ctx, queries.GetTeamTemplatesWithCursorParams{
53+
TeamID: teamID,
54+
CursorCreatedAt: cursorTime,
55+
CursorID: cursorID,
56+
LimitPlusOne: 3,
57+
})
58+
require.NoError(t, err)
59+
require.Len(t, firstPage, 3) // 2 + 1 sentinel
60+
last := firstPage[1] // the 2nd item is the page boundary
61+
62+
secondPage, err := db.SqlcClient.GetTeamTemplatesWithCursor(ctx, queries.GetTeamTemplatesWithCursorParams{
63+
TeamID: teamID,
64+
CursorCreatedAt: last.CreatedAt,
65+
CursorID: last.TemplateID,
66+
LimitPlusOne: 10,
67+
})
68+
require.NoError(t, err)
69+
require.Len(t, secondPage, 1)
70+
require.Equal(t, templateIDs[0], secondPage[0].TemplateID)
71+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
-- Cursor-paginated team template listing for the CLI (GET /v2/templates).
2+
--
3+
-- Same projection as GetTeamTemplates (two build laterals: latest default-tag
4+
-- build for build_status, latest ready default-tag build for the displayed
5+
-- resources), with keyset pagination added: ordered created_at DESC (newest
6+
-- first) and bounded by the (created_at, id) cursor and LIMIT.
7+
8+
-- name: GetTeamTemplatesWithCursor :many
9+
SELECT
10+
e.id AS template_id,
11+
e.created_at,
12+
e.updated_at,
13+
e.public,
14+
e.build_count,
15+
e.spawn_count,
16+
e.last_spawned_at,
17+
e.created_by AS creator_id,
18+
COALESCE(eb.id, '00000000-0000-0000-0000-000000000000'::uuid) AS build_id,
19+
COALESCE(eb.vcpu, 0)::bigint AS build_vcpu,
20+
COALESCE(eb.ram_mb, 0)::bigint AS build_ram_mb,
21+
eb.total_disk_size_mb AS build_total_disk_size_mb,
22+
eb.envd_version AS build_envd_version,
23+
COALESCE(latest_build.status_group, 'pending') AS build_status,
24+
COALESCE(ea.aliases, ARRAY[]::text[])::text[] AS aliases,
25+
COALESCE(ea.names, ARRAY[]::text[])::text[] AS names
26+
FROM public.envs AS e
27+
LEFT JOIN LATERAL (
28+
SELECT
29+
ARRAY_AGG(alias ORDER BY alias) AS aliases,
30+
ARRAY_AGG(CASE WHEN namespace IS NOT NULL THEN namespace || '/' || alias ELSE alias END ORDER BY alias) AS names
31+
FROM public.env_aliases
32+
WHERE env_id = e.id
33+
) ea ON TRUE
34+
LEFT JOIN LATERAL (
35+
SELECT b.status_group
36+
FROM public.env_build_assignments AS ba
37+
JOIN public.env_builds AS b ON b.id = ba.build_id
38+
WHERE ba.env_id = e.id AND ba.tag = 'default'
39+
ORDER BY ba.created_at DESC
40+
LIMIT 1
41+
) latest_build ON TRUE
42+
LEFT JOIN LATERAL (
43+
SELECT b.id, b.vcpu, b.ram_mb, b.total_disk_size_mb, b.envd_version
44+
FROM public.env_build_assignments AS ba
45+
JOIN public.env_builds AS b ON b.id = ba.build_id
46+
WHERE ba.env_id = e.id AND ba.tag = 'default' AND b.status_group = 'ready'
47+
ORDER BY ba.created_at DESC
48+
LIMIT 1
49+
) eb ON TRUE
50+
WHERE
51+
e.team_id = sqlc.arg(team_id)::uuid AND e.source = 'template'
52+
AND (e.created_at, e.id) < (sqlc.arg(cursor_created_at)::timestamptz, sqlc.arg(cursor_id)::text)
53+
ORDER BY e.created_at DESC, e.id DESC
54+
LIMIT sqlc.arg(limit_plus_one)::int;

0 commit comments

Comments
 (0)