mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 17:51:29 +00:00
feat: M20 Enhanced Query API — sort, time-range filters, cursor pagination, sparse fields, deployments endpoint
V2 (free) query enhancements for certificates:
- `sort` param with direction (`?sort=-notAfter` for descending)
- Time-range filters: `expires_before`, `expires_after`, `created_after`, `updated_after`
- Cursor-based pagination (`?cursor=token&page_size=100`) alongside page-based
- Sparse field selection (`?fields=id,commonName,status`)
- Additional filters: `agent_id`, `profile_id`
- New endpoint: `GET /api/v1/certificates/{id}/deployments`
25 new tests (12 handler + 13 e2e) covering all M20 features.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@ package postgres
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
@@ -68,12 +69,59 @@ func (r *CertificateRepository) List(ctx context.Context, filter *repository.Cer
|
||||
args = append(args, filter.IssuerID)
|
||||
argCount++
|
||||
}
|
||||
if filter.ProfileID != "" {
|
||||
whereConditions = append(whereConditions, fmt.Sprintf("certificate_profile_id = $%d", argCount))
|
||||
args = append(args, filter.ProfileID)
|
||||
argCount++
|
||||
}
|
||||
if filter.ExpiresBefore != nil {
|
||||
whereConditions = append(whereConditions, fmt.Sprintf("expires_at < $%d", argCount))
|
||||
args = append(args, filter.ExpiresBefore)
|
||||
argCount++
|
||||
}
|
||||
if filter.ExpiresAfter != nil {
|
||||
whereConditions = append(whereConditions, fmt.Sprintf("expires_at > $%d", argCount))
|
||||
args = append(args, filter.ExpiresAfter)
|
||||
argCount++
|
||||
}
|
||||
if filter.CreatedAfter != nil {
|
||||
whereConditions = append(whereConditions, fmt.Sprintf("created_at > $%d", argCount))
|
||||
args = append(args, filter.CreatedAfter)
|
||||
argCount++
|
||||
}
|
||||
if filter.UpdatedAfter != nil {
|
||||
whereConditions = append(whereConditions, fmt.Sprintf("updated_at > $%d", argCount))
|
||||
args = append(args, filter.UpdatedAfter)
|
||||
argCount++
|
||||
}
|
||||
if filter.AgentID != "" {
|
||||
// Filter by agent_id via deployment_targets and certificate_target_mappings
|
||||
whereConditions = append(whereConditions, fmt.Sprintf(`id IN (
|
||||
SELECT DISTINCT certificate_id FROM certificate_target_mappings ctm
|
||||
JOIN deployment_targets dt ON ctm.target_id = dt.id
|
||||
WHERE dt.agent_id = $%d
|
||||
)`, argCount))
|
||||
args = append(args, filter.AgentID)
|
||||
argCount++
|
||||
}
|
||||
|
||||
whereClause := ""
|
||||
if len(whereConditions) > 0 {
|
||||
whereClause = "WHERE " + strings.Join(whereConditions, " AND ")
|
||||
}
|
||||
|
||||
// Handle cursor-based pagination
|
||||
if filter.Cursor != "" {
|
||||
createdAt, id, err := decodeCursor(filter.Cursor)
|
||||
if err == nil {
|
||||
// Add cursor condition: (created_at, id) < (cursor_time, cursor_id)
|
||||
whereConditions = append(whereConditions, fmt.Sprintf("(created_at, id) < ($%d, $%d)", argCount, argCount+1))
|
||||
args = append(args, createdAt, id)
|
||||
argCount += 2
|
||||
whereClause = "WHERE " + strings.Join(whereConditions, " AND ")
|
||||
}
|
||||
}
|
||||
|
||||
// Get total count
|
||||
countQuery := fmt.Sprintf("SELECT COUNT(*) FROM managed_certificates %s", whereClause)
|
||||
var total int
|
||||
@@ -81,18 +129,59 @@ func (r *CertificateRepository) List(ctx context.Context, filter *repository.Cer
|
||||
return nil, 0, fmt.Errorf("failed to count certificates: %w", err)
|
||||
}
|
||||
|
||||
// Determine sort field and direction
|
||||
sortField := "created_at"
|
||||
sortDir := "DESC"
|
||||
sortFieldMap := map[string]string{
|
||||
"notAfter": "expires_at",
|
||||
"expiresAt": "expires_at",
|
||||
"createdAt": "created_at",
|
||||
"updatedAt": "updated_at",
|
||||
"commonName": "common_name",
|
||||
"name": "name",
|
||||
"status": "status",
|
||||
"environment": "environment",
|
||||
}
|
||||
if filter.Sort != "" {
|
||||
if mappedField, ok := sortFieldMap[filter.Sort]; ok {
|
||||
sortField = mappedField
|
||||
}
|
||||
}
|
||||
if filter.SortDesc {
|
||||
sortDir = "DESC"
|
||||
} else {
|
||||
sortDir = "ASC"
|
||||
}
|
||||
|
||||
// Get paginated results
|
||||
offset := (filter.Page - 1) * filter.PerPage
|
||||
pageSize := filter.PerPage
|
||||
if filter.PageSize > 0 && filter.PageSize <= 500 {
|
||||
pageSize = filter.PageSize
|
||||
}
|
||||
|
||||
var limitClause string
|
||||
var offset int
|
||||
if filter.Cursor != "" {
|
||||
// Cursor-based pagination
|
||||
limitClause = fmt.Sprintf("LIMIT $%d", argCount)
|
||||
args = append(args, pageSize)
|
||||
argCount++
|
||||
} else {
|
||||
// Page-based pagination
|
||||
offset = (filter.Page - 1) * pageSize
|
||||
limitClause = fmt.Sprintf("LIMIT $%d OFFSET $%d", argCount, argCount+1)
|
||||
args = append(args, pageSize, offset)
|
||||
argCount += 2
|
||||
}
|
||||
|
||||
query := fmt.Sprintf(`
|
||||
SELECT id, name, common_name, sans, environment, owner_id, team_id, issuer_id, renewal_policy_id,
|
||||
certificate_profile_id, status, expires_at, tags, last_renewal_at, last_deployment_at, revoked_at, revocation_reason, created_at, updated_at
|
||||
FROM managed_certificates
|
||||
%s
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $%d OFFSET $%d
|
||||
`, whereClause, argCount, argCount+1)
|
||||
|
||||
args = append(args, filter.PerPage, offset)
|
||||
ORDER BY %s %s
|
||||
%s
|
||||
`, whereClause, sortField, sortDir, limitClause)
|
||||
|
||||
rows, err := r.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
@@ -401,3 +490,26 @@ func scanCertificate(scanner interface {
|
||||
|
||||
return &cert, nil
|
||||
}
|
||||
|
||||
// decodeCursor extracts a timestamp and ID from a cursor token.
|
||||
func decodeCursor(cursor string) (time.Time, string, error) {
|
||||
raw, err := base64.URLEncoding.DecodeString(cursor)
|
||||
if err != nil {
|
||||
return time.Time{}, "", fmt.Errorf("invalid cursor: %w", err)
|
||||
}
|
||||
parts := strings.SplitN(string(raw), ":", 2)
|
||||
if len(parts) != 2 {
|
||||
return time.Time{}, "", fmt.Errorf("invalid cursor format")
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339Nano, parts[0])
|
||||
if err != nil {
|
||||
return time.Time{}, "", fmt.Errorf("invalid cursor timestamp: %w", err)
|
||||
}
|
||||
return t, parts[1], nil
|
||||
}
|
||||
|
||||
// encodeCursor creates an opaque cursor token from a timestamp and ID.
|
||||
func encodeCursor(createdAt time.Time, id string) string {
|
||||
raw := createdAt.Format(time.RFC3339Nano) + ":" + id
|
||||
return base64.URLEncoding.EncodeToString([]byte(raw))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user