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:
@@ -1,8 +1,12 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// PagedResponse represents a paginated API response.
|
||||
@@ -13,6 +17,14 @@ type PagedResponse struct {
|
||||
PerPage int `json:"per_page"`
|
||||
}
|
||||
|
||||
// CursorPagedResponse represents a cursor-paginated API response.
|
||||
type CursorPagedResponse struct {
|
||||
Data interface{} `json:"data"`
|
||||
Total int64 `json:"total"`
|
||||
NextCursor string `json:"next_cursor,omitempty"`
|
||||
PageSize int `json:"page_size"`
|
||||
}
|
||||
|
||||
// ErrorResponse represents a standard error response.
|
||||
type ErrorResponse struct {
|
||||
Error string `json:"error"`
|
||||
@@ -49,3 +61,72 @@ func ErrorWithRequestID(w http.ResponseWriter, status int, message, requestID st
|
||||
w.WriteHeader(status)
|
||||
return json.NewEncoder(w).Encode(errResp)
|
||||
}
|
||||
|
||||
// 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))
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// filterFields removes fields not in the allowed list from the response data.
|
||||
// Works with both single objects and slices.
|
||||
func filterFields(data interface{}, fields []string) interface{} {
|
||||
if len(fields) == 0 {
|
||||
return data
|
||||
}
|
||||
|
||||
// Create field set for O(1) lookup
|
||||
fieldSet := make(map[string]bool, len(fields))
|
||||
for _, f := range fields {
|
||||
fieldSet[f] = true
|
||||
}
|
||||
|
||||
// Marshal to JSON, then unmarshal to generic structure
|
||||
bytes, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return data
|
||||
}
|
||||
|
||||
// Try as array first
|
||||
var arr []map[string]interface{}
|
||||
if err := json.Unmarshal(bytes, &arr); err == nil {
|
||||
for i := range arr {
|
||||
for key := range arr[i] {
|
||||
if !fieldSet[key] {
|
||||
delete(arr[i], key)
|
||||
}
|
||||
}
|
||||
}
|
||||
return arr
|
||||
}
|
||||
|
||||
// Try as object
|
||||
var obj map[string]interface{}
|
||||
if err := json.Unmarshal(bytes, &obj); err == nil {
|
||||
for key := range obj {
|
||||
if !fieldSet[key] {
|
||||
delete(obj, key)
|
||||
}
|
||||
}
|
||||
return obj
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user