diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c2ab32..d8ada8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`. diff --git a/README.md b/README.md index 1143349..e42cecf 100644 --- a/README.md +++ b/README.md @@ -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` | diff --git a/client/client.go b/client/client.go index 89039f3..d1f7e53 100644 --- a/client/client.go +++ b/client/client.go @@ -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) @@ -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 diff --git a/client/filter_rule_tree_test.go b/client/filter_rule_tree_test.go new file mode 100644 index 0000000..2e7b991 --- /dev/null +++ b/client/filter_rule_tree_test.go @@ -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)) + }) + } +} diff --git a/client/responses.go b/client/responses.go index 904c74b..c9349e6 100644 --- a/client/responses.go +++ b/client/responses.go @@ -1,6 +1,7 @@ package client import ( + "encoding/json" "net/url" "github.com/prolific-oss/cli/model" @@ -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"` diff --git a/cmd/filters/filters.go b/cmd/filters/filters.go index fe83e37..1a3a426 100644 --- a/cmd/filters/filters.go +++ b/cmd/filters/filters.go @@ -36,6 +36,7 @@ $ prolific filters search "software developer"`, cmd.AddCommand( NewListCommand(client, w), NewSearchCommand(client, w), + NewRuleTreeCommand(client, w), ) return cmd diff --git a/cmd/filters/filters_test.go b/cmd/filters/filters_test.go index 51565e0..da3a0c4 100644 --- a/cmd/filters/filters_test.go +++ b/cmd/filters/filters_test.go @@ -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) } diff --git a/cmd/filters/rule_tree.go b/cmd/filters/rule_tree.go new file mode 100644 index 0000000..37086b8 --- /dev/null +++ b/cmd/filters/rule_tree.go @@ -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 --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 +} diff --git a/cmd/filters/rule_tree_test.go b/cmd/filters/rule_tree_test.go new file mode 100644 index 0000000..9f19480 --- /dev/null +++ b/cmd/filters/rule_tree_test.go @@ -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()) + }) + } +} diff --git a/contract_test/contract_test.go b/contract_test/contract_test.go index b18001e..968bde4 100644 --- a/contract_test/contract_test.go +++ b/contract_test/contract_test.go @@ -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) { diff --git a/mock_client/mock_client.go b/mock_client/mock_client.go index 503576e..2b5391f 100644 --- a/mock_client/mock_client.go +++ b/mock_client/mock_client.go @@ -733,6 +733,21 @@ func (mr *MockAPIMockRecorder) GetFilterBreakdown(payload interface{}) *gomock.C return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetFilterBreakdown", reflect.TypeOf((*MockAPI)(nil).GetFilterBreakdown), payload) } +// GetFilterRuleTree mocks base method. +func (m *MockAPI) GetFilterRuleTree(workspaceID string) (*client.FilterRuleTreeResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetFilterRuleTree", workspaceID) + ret0, _ := ret[0].(*client.FilterRuleTreeResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetFilterRuleTree indicates an expected call of GetFilterRuleTree. +func (mr *MockAPIMockRecorder) GetFilterRuleTree(workspaceID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetFilterRuleTree", reflect.TypeOf((*MockAPI)(nil).GetFilterRuleTree), workspaceID) +} + // GetFilterSet mocks base method. func (m *MockAPI) GetFilterSet(ID string) (*model.FilterSet, error) { m.ctrl.T.Helper()