Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@

### Filters

- Add `filters rule-tree` to retrieve workspace-specific composite filter rules, with `--json` for compact output.

- **Breaking change:** `prolific filters` is now a parent command. The previous
behaviour has moved to `prolific filters list`; update any scripts or skills
that call `prolific filters` or `prolific filters -n`.
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,7 @@ Operations are grouped as they appear in [`contract_test/contract_test.go`](cont

| Operation | Method | Path | Coverage |
|---|---|---|---|
| `filters_GetRuleTree` | — | — | 🕒 `GetFilterRuleTree` — live endpoint, awaiting publication in the spec |
| `filters_GetFilters` | GET | `/api/v1/filters/` | ✅ `GetFilters` |
| `filters_SearchFilters` | — | — | 🕒 `SearchFilters` — live endpoint, awaiting publication in the spec |
| `filters_GetEligibleCount` | POST | `/api/v1/eligibility-count/` | ✅ `GetEligibilityCount` |
Expand Down
19 changes: 19 additions & 0 deletions client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ type API interface {
CreateTestParticipant(email string) (*CreateTestParticipantResponse, error)

GetFilters() (*ListFiltersResponse, error)
GetFilterRuleTree(workspaceID string) (*FilterRuleTreeResponse, error)
SearchFilters(query, workspaceID string, limit, offset int) (*SearchFiltersResponse, error)
GetEligibilityCount(payload EligibilityCountPayload) (*EligibilityCountResponse, error)
GetFilterBreakdown(payload FilterBreakdownPayload) (*FilterBreakdownResponse, error)
Expand Down Expand Up @@ -1079,6 +1080,24 @@ func (c *Client) GetFilters() (*ListFiltersResponse, error) {
return &response, nil
}

// GetFilterRuleTree retrieves the server's composite filter constraints.
// An empty workspaceID requests the default rules rather than workspace-specific rules.
func (c *Client) GetFilterRuleTree(workspaceID string) (*FilterRuleTreeResponse, error) {
var response FilterRuleTreeResponse
requestURL := "/api/v1/filters/rule-tree/"
if workspaceID != "" {
params := url.Values{"workspace_id": {workspaceID}}
requestURL += "?" + params.Encode()
}
if _, err := c.ExecuteBuilder().Get(requestURL, &response); err != nil {
return nil, err
}
if len(response.RuleTree) == 0 {
return nil, fmt.Errorf("filter rule tree response is missing or empty")
}
return &response, nil
}

// SearchFilters searches the filter catalogue by keyword. Results are returned
// in ranked order with highlight offsets and a preview of matching choices.
// workspaceID is optional and scopes the catalogue to filters accessible in
Expand Down
53 changes: 53 additions & 0 deletions client/filter_rule_tree_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package client

import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// Verify the HTTP contract until the endpoint is published in the OpenAPI spec.
func TestGetFilterRuleTree(t *testing.T) {
const tree = `{"rule_tree":{"and":{"max_children":null,"max_by_type":{"or":0},"children":{"leaf":true},"future_constraint":true}}}`
for _, tc := range []struct {
name, workspace, body string
status int
wantError bool
}{
{"default rules", "", tree, http.StatusOK, false},
{"workspace rules", "workspace & other", tree, http.StatusOK, false},
{"access denied", "", `{"detail":"access denied"}`, http.StatusForbidden, true},
{"missing tree", "", `{}`, http.StatusOK, true},
{"empty tree", "", `{"rule_tree":{}}`, http.StatusOK, true},
} {
t.Run(tc.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodGet, r.Method)
assert.Equal(t, "/api/v1/filters/rule-tree/", r.URL.Path)
assert.Equal(t, "Token test-token", r.Header.Get("Authorization"))
assert.Equal(t, tc.workspace, r.URL.Query().Get("workspace_id"))
assert.Equal(t, tc.workspace != "", r.URL.Query().Has("workspace_id"))
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(tc.status)
_, err := w.Write([]byte(tc.body))
assert.NoError(t, err)
}))
defer server.Close()
c := Client{Client: server.Client(), BaseURL: server.URL, Token: "test-token"}
result, err := c.GetFilterRuleTree(tc.workspace)
if tc.wantError {
require.Error(t, err)
require.Nil(t, result)
return
}
require.NoError(t, err)
encoded, err := json.Marshal(result)
require.NoError(t, err)
require.JSONEq(t, tree, string(encoded))
})
}
}
6 changes: 6 additions & 0 deletions client/responses.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package client

import (
"encoding/json"
"net/url"

"github.com/prolific-oss/cli/model"
Expand Down Expand Up @@ -85,6 +86,11 @@ type ListFiltersResponse struct {
*JSONAPIMeta
}

// FilterRuleTreeResponse preserves the server-defined constraint schema.
type FilterRuleTreeResponse struct {
RuleTree map[string]json.RawMessage `json:"rule_tree"`
}

// SearchFiltersResponse is the response for searching filters by keyword
type SearchFiltersResponse struct {
Results []model.FilterSearchResult `json:"results"`
Expand Down
1 change: 1 addition & 0 deletions cmd/filters/filters.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ $ prolific filters search "software developer"`,
cmd.AddCommand(
NewListCommand(client, w),
NewSearchCommand(client, w),
NewRuleTreeCommand(client, w),
)

return cmd
Expand Down
2 changes: 1 addition & 1 deletion cmd/filters/filters_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,5 @@ func TestNewFiltersCommand(t *testing.T) {
for _, sub := range cmd.Commands() {
names = append(names, sub.Name())
}
assert.ElementsMatch(t, []string{"list", "search"}, names)
assert.ElementsMatch(t, []string{"list", "search", "rule-tree"}, names)
}
47 changes: 47 additions & 0 deletions cmd/filters/rule_tree.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package filters

import (
"encoding/json"
"fmt"
"io"

"github.com/prolific-oss/cli/client"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)

// RuleTreeOptions holds the workspace and output settings for rule-tree.
type RuleTreeOptions struct {
WorkspaceID string
JSON bool
}

// NewRuleTreeCommand creates the `filters rule-tree` command.
func NewRuleTreeCommand(c client.API, w io.Writer) *cobra.Command {
var opts RuleTreeOptions
cmd := &cobra.Command{
Use: "rule-tree",
Short: "Get the rules for combining audience filters",
Long: `Fetch the server's audience filter rules as JSON. Uses the configured workspace
when --workspace is omitted, or default rules if no workspace is configured.`,
Example: `prolific filters rule-tree --workspace <workspace-id> --json`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
result, err := c.GetFilterRuleTree(opts.WorkspaceID)
if err != nil {
return fmt.Errorf("error: %s", err)
}
encoder := json.NewEncoder(w)
if !opts.JSON {
encoder.SetIndent("", " ")
}
if err := encoder.Encode(result); err != nil {
return fmt.Errorf("error: %s", err)
}
return nil
},
}
cmd.Flags().StringVarP(&opts.WorkspaceID, "workspace", "w", viper.GetString("workspace"), "Workspace whose rules to retrieve (defaults to configured workspace)")
cmd.Flags().BoolVarP(&opts.JSON, "json", "j", false, "Output compact JSON")
return cmd
}
47 changes: 47 additions & 0 deletions cmd/filters/rule_tree_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package filters_test

import (
"bytes"
"encoding/json"
"errors"
"testing"

"github.com/golang/mock/gomock"
"github.com/prolific-oss/cli/client"
"github.com/prolific-oss/cli/cmd/filters"
"github.com/prolific-oss/cli/mock_client"
"github.com/spf13/viper"
"github.com/stretchr/testify/require"
)

func TestRuleTreeCommand(t *testing.T) {
for _, tc := range []struct {
name, configured, workspace string
args []string
err error
}{
{name: "default rules"},
{name: "configured workspace", configured: "configured", workspace: "configured"},
{name: "explicit workspace", configured: "configured", workspace: "chosen", args: []string{"-w", "chosen", "--json"}},
{name: "API error", err: errors.New("access denied")},
} {
t.Run(tc.name, func(t *testing.T) {
viper.Set("workspace", tc.configured)
t.Cleanup(viper.Reset)
c := mock_client.NewMockAPI(gomock.NewController(t))
result := &client.FilterRuleTreeResponse{RuleTree: map[string]json.RawMessage{"and": json.RawMessage(`{"max_children":null,"children":{"leaf":true}}`)}}
c.EXPECT().GetFilterRuleTree(tc.workspace).Return(result, tc.err)
var output bytes.Buffer
cmd := filters.NewRuleTreeCommand(c, &output)
cmd.SetArgs(append([]string{}, tc.args...))
err := cmd.Execute()
if tc.err != nil {
require.EqualError(t, err, "error: access denied")
require.Empty(t, output.String())
return
}
require.NoError(t, err)
require.JSONEq(t, `{"rule_tree":{"and":{"max_children":null,"children":{"leaf":true}}}}`, output.String())
})
}
}
3 changes: 3 additions & 0 deletions contract_test/contract_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,9 @@ var operations = []operation{
{operationID: "studies_DeleteProjectStudy", skip: "OUTOFSCOPE: no CLI command for removing a study from a project"},

// Filters
{operationID: "filters_GetRuleTree", skip: "NOTINSPEC: rule-tree is implemented in the backend but not published in the spec; confirm operationId on publication", call: func(c *client.Client) {
c.GetFilterRuleTree("ws-id")
}},
{operationID: "filters_GetFilters", call: func(c *client.Client) { c.GetFilters() }},
// operationId assumed from Fern's tag_operation convention (source operationId is SearchFilters).
{operationID: "filters_SearchFilters", skip: "NOTINSPEC: live but unpublished, see prolific-oss/prolific#16270", call: func(c *client.Client) {
Expand Down
15 changes: 15 additions & 0 deletions mock_client/mock_client.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading