Skip to content

Commit

Permalink
[feature] filter API v2: Restore keywords_attributes and statuses_att…
Browse files Browse the repository at this point in the history
…ributes (#2995)

These filter API v2 features were cut late in development because the form encoding version is hard to implement correctly and because I thought no clients actually used `keywords_attributes`. Unfortunately, Phanpy does use `keywords_attributes`.
  • Loading branch information
VyrCossont authored Jun 14, 2024
1 parent ee6e9b2 commit b789fe2
Show file tree
Hide file tree
Showing 8 changed files with 656 additions and 40 deletions.
42 changes: 42 additions & 0 deletions docs/api/swagger.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9245,6 +9245,27 @@ paths:
in: formData
name: filter_action
type: string
- collectionFormat: multi
description: Keywords to be added (if not using id param) or updated (if using id param).
in: formData
items:
type: string
name: keywords_attributes[][keyword]
type: array
- collectionFormat: multi
description: Should each keyword consider word boundaries?
in: formData
items:
type: boolean
name: keywords_attributes[][whole_word]
type: array
- collectionFormat: multi
description: Statuses to be added to the filter.
in: formData
items:
type: string
name: statuses_attributes[][status_id]
type: array
produces:
- application/json
responses:
Expand Down Expand Up @@ -9360,6 +9381,27 @@ paths:
name: title
required: true
type: string
- collectionFormat: multi
description: Keywords to be added to the created filter.
in: formData
items:
type: string
name: keywords_attributes[][keyword]
type: array
- collectionFormat: multi
description: Should each keyword consider word boundaries?
in: formData
items:
type: boolean
name: keywords_attributes[][whole_word]
type: array
- collectionFormat: multi
description: Statuses to be added to the newly created filter.
in: formData
items:
type: string
name: statuses_attributes[][status_id]
type: array
- collectionFormat: multi
description: |-
The contexts in which the filter should be applied.
Expand Down
61 changes: 61 additions & 0 deletions internal/api/client/filters/v2/filterpost.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,30 @@ import (
// - warn
// - hide
// default: warn
// -
// name: keywords_attributes[][keyword]
// in: formData
// type: array
// items:
// type: string
// description: Keywords to be added (if not using id param) or updated (if using id param).
// collectionFormat: multi
// -
// name: keywords_attributes[][whole_word]
// in: formData
// type: array
// items:
// type: boolean
// description: Should each keyword consider word boundaries?
// collectionFormat: multi
// -
// name: statuses_attributes[][status_id]
// in: formData
// type: array
// items:
// type: string
// description: Statuses to be added to the filter.
// collectionFormat: multi
//
// security:
// - OAuth2 Bearer:
Expand Down Expand Up @@ -176,6 +200,30 @@ func validateNormalizeCreateFilter(form *apimodel.FilterCreateRequestV2) error {
return err
}

// Parse form variant of normal filter keyword creation structs.
if len(form.KeywordsAttributesKeyword) > 0 {
form.Keywords = make([]apimodel.FilterKeywordCreateUpdateRequest, 0, len(form.KeywordsAttributesKeyword))
for i, keyword := range form.KeywordsAttributesKeyword {
formKeyword := apimodel.FilterKeywordCreateUpdateRequest{
Keyword: keyword,
}
if i < len(form.KeywordsAttributesWholeWord) {
formKeyword.WholeWord = &form.KeywordsAttributesWholeWord[i]
}
form.Keywords = append(form.Keywords, formKeyword)
}
}

// Parse form variant of normal filter status creation structs.
if len(form.StatusesAttributesStatusID) > 0 {
form.Statuses = make([]apimodel.FilterStatusCreateRequest, 0, len(form.StatusesAttributesStatusID))
for _, statusID := range form.StatusesAttributesStatusID {
form.Statuses = append(form.Statuses, apimodel.FilterStatusCreateRequest{
StatusID: statusID,
})
}
}

// Apply defaults for missing fields.
form.FilterAction = util.Ptr(action)

Expand All @@ -200,5 +248,18 @@ func validateNormalizeCreateFilter(form *apimodel.FilterCreateRequestV2) error {
}
}

// Normalize and validate new keywords and statuses.
for i, formKeyword := range form.Keywords {
if err := validate.FilterKeyword(formKeyword.Keyword); err != nil {
return err
}
form.Keywords[i].WholeWord = util.Ptr(util.PtrValueOr(formKeyword.WholeWord, false))
}
for _, formStatus := range form.Statuses {
if err := validate.ULID(formStatus.StatusID, "status_id"); err != nil {
return err
}
}

return nil
}
102 changes: 88 additions & 14 deletions internal/api/client/filters/v2/filterpost_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (
"net/http"
"net/http/httptest"
"net/url"
"slices"
"strconv"
"strings"

Expand All @@ -35,7 +36,7 @@ import (
"github.com/superseriousbusiness/gotosocial/testrig"
)

func (suite *FiltersTestSuite) postFilter(title *string, context *[]string, action *string, expiresIn *int, requestJson *string, expectedHTTPStatus int, expectedBody string) (*apimodel.FilterV2, error) {
func (suite *FiltersTestSuite) postFilter(title *string, context *[]string, action *string, expiresIn *int, keywordsAttributesKeyword *[]string, keywordsAttributesWholeWord *[]bool, statusesAttributesStatusID *[]string, requestJson *string, expectedHTTPStatus int, expectedBody string) (*apimodel.FilterV2, error) {
// instantiate recorder + test context
recorder := httptest.NewRecorder()
ctx, _ := testrig.CreateGinTestContext(recorder, nil)
Expand Down Expand Up @@ -64,6 +65,19 @@ func (suite *FiltersTestSuite) postFilter(title *string, context *[]string, acti
if expiresIn != nil {
ctx.Request.Form["expires_in"] = []string{strconv.Itoa(*expiresIn)}
}
if keywordsAttributesKeyword != nil {
ctx.Request.Form["keywords_attributes[][keyword]"] = *keywordsAttributesKeyword
}
if keywordsAttributesWholeWord != nil {
formatted := []string{}
for _, value := range *keywordsAttributesWholeWord {
formatted = append(formatted, strconv.FormatBool(value))
}
ctx.Request.Form["keywords_attributes[][whole_word]"] = formatted
}
if statusesAttributesStatusID != nil {
ctx.Request.Form["statuses_attributes[][status_id]"] = *statusesAttributesStatusID
}
}

// trigger the handler
Expand Down Expand Up @@ -111,7 +125,12 @@ func (suite *FiltersTestSuite) TestPostFilterFull() {
context := []string{"home", "public"}
action := "warn"
expiresIn := 86400
filter, err := suite.postFilter(&title, &context, &action, &expiresIn, nil, http.StatusOK, "")
// Checked in lexical order by keyword, so keep this sorted.
keywordsAttributesKeyword := []string{"GNU", "Linux"}
keywordsAttributesWholeWord := []bool{true, false}
// Checked in lexical order by status ID, so keep this sorted.
statusAttributesStatusID := []string{"01HEN2QRFA8H3C6QPN7RD4KSR6", "01HEWV37MHV8BAC8ANFGVRRM5D"}
filter, err := suite.postFilter(&title, &context, &action, &expiresIn, &keywordsAttributesKeyword, &keywordsAttributesWholeWord, &statusAttributesStatusID, nil, http.StatusOK, "")
if err != nil {
suite.FailNow(err.Error())
}
Expand All @@ -126,8 +145,25 @@ func (suite *FiltersTestSuite) TestPostFilterFull() {
if suite.NotNil(filter.ExpiresAt) {
suite.NotEmpty(*filter.ExpiresAt)
}
suite.Empty(filter.Keywords)
suite.Empty(filter.Statuses)

if suite.Len(filter.Keywords, len(keywordsAttributesKeyword)) {
slices.SortFunc(filter.Keywords, func(lhs, rhs apimodel.FilterKeyword) int {
return strings.Compare(lhs.Keyword, rhs.Keyword)
})
for i, filterKeyword := range filter.Keywords {
suite.Equal(keywordsAttributesKeyword[i], filterKeyword.Keyword)
suite.Equal(keywordsAttributesWholeWord[i], filterKeyword.WholeWord)
}
}

if suite.Len(filter.Statuses, len(statusAttributesStatusID)) {
slices.SortFunc(filter.Statuses, func(lhs, rhs apimodel.FilterStatus) int {
return strings.Compare(lhs.StatusID, rhs.StatusID)
})
for i, filterStatus := range filter.Statuses {
suite.Equal(statusAttributesStatusID[i], filterStatus.StatusID)
}
}

suite.checkStreamed(homeStream, true, "", stream.EventTypeFiltersChanged)
}
Expand All @@ -141,9 +177,27 @@ func (suite *FiltersTestSuite) TestPostFilterFullJSON() {
"context": ["home", "public"],
"filter_action": "warn",
"whole_word": true,
"expires_in": 86400.1
"expires_in": 86400.1,
"keywords_attributes": [
{
"keyword": "GNU",
"whole_word": true
},
{
"keyword": "Linux",
"whole_word": false
}
],
"statuses_attributes": [
{
"status_id": "01HEN2QRFA8H3C6QPN7RD4KSR6"
},
{
"status_id": "01HEWV37MHV8BAC8ANFGVRRM5D"
}
]
}`
filter, err := suite.postFilter(nil, nil, nil, nil, &requestJson, http.StatusOK, "")
filter, err := suite.postFilter(nil, nil, nil, nil, nil, nil, nil, &requestJson, http.StatusOK, "")
if err != nil {
suite.FailNow(err.Error())
}
Expand All @@ -160,8 +214,28 @@ func (suite *FiltersTestSuite) TestPostFilterFullJSON() {
if suite.NotNil(filter.ExpiresAt) {
suite.NotEmpty(*filter.ExpiresAt)
}
suite.Empty(filter.Keywords)
suite.Empty(filter.Statuses)

if suite.Len(filter.Keywords, 2) {
slices.SortFunc(filter.Keywords, func(lhs, rhs apimodel.FilterKeyword) int {
return strings.Compare(lhs.Keyword, rhs.Keyword)
})

suite.Equal("GNU", filter.Keywords[0].Keyword)
suite.True(filter.Keywords[0].WholeWord)

suite.Equal("Linux", filter.Keywords[1].Keyword)
suite.False(filter.Keywords[1].WholeWord)
}

if suite.Len(filter.Statuses, 2) {
slices.SortFunc(filter.Statuses, func(lhs, rhs apimodel.FilterStatus) int {
return strings.Compare(lhs.StatusID, rhs.StatusID)
})

suite.Equal("01HEN2QRFA8H3C6QPN7RD4KSR6", filter.Statuses[0].StatusID)

suite.Equal("01HEWV37MHV8BAC8ANFGVRRM5D", filter.Statuses[1].StatusID)
}

suite.checkStreamed(homeStream, true, "", stream.EventTypeFiltersChanged)
}
Expand All @@ -171,7 +245,7 @@ func (suite *FiltersTestSuite) TestPostFilterMinimal() {

title := "GNU/Linux"
context := []string{"home"}
filter, err := suite.postFilter(&title, &context, nil, nil, nil, http.StatusOK, "")
filter, err := suite.postFilter(&title, &context, nil, nil, nil, nil, nil, nil, http.StatusOK, "")
if err != nil {
suite.FailNow(err.Error())
}
Expand All @@ -193,15 +267,15 @@ func (suite *FiltersTestSuite) TestPostFilterMinimal() {
func (suite *FiltersTestSuite) TestPostFilterEmptyTitle() {
title := ""
context := []string{"home"}
_, err := suite.postFilter(&title, &context, nil, nil, nil, http.StatusUnprocessableEntity, "")
_, err := suite.postFilter(&title, &context, nil, nil, nil, nil, nil, nil, http.StatusUnprocessableEntity, "")
if err != nil {
suite.FailNow(err.Error())
}
}

func (suite *FiltersTestSuite) TestPostFilterMissingTitle() {
context := []string{"home"}
_, err := suite.postFilter(nil, &context, nil, nil, nil, http.StatusUnprocessableEntity, "")
_, err := suite.postFilter(nil, &context, nil, nil, nil, nil, nil, nil, http.StatusUnprocessableEntity, "")
if err != nil {
suite.FailNow(err.Error())
}
Expand All @@ -210,15 +284,15 @@ func (suite *FiltersTestSuite) TestPostFilterMissingTitle() {
func (suite *FiltersTestSuite) TestPostFilterEmptyContext() {
title := "GNU/Linux"
context := []string{}
_, err := suite.postFilter(&title, &context, nil, nil, nil, http.StatusUnprocessableEntity, "")
_, err := suite.postFilter(&title, &context, nil, nil, nil, nil, nil, nil, http.StatusUnprocessableEntity, "")
if err != nil {
suite.FailNow(err.Error())
}
}

func (suite *FiltersTestSuite) TestPostFilterMissingContext() {
title := "GNU/Linux"
_, err := suite.postFilter(&title, nil, nil, nil, nil, http.StatusUnprocessableEntity, "")
_, err := suite.postFilter(&title, nil, nil, nil, nil, nil, nil, nil, http.StatusUnprocessableEntity, "")
if err != nil {
suite.FailNow(err.Error())
}
Expand All @@ -227,7 +301,7 @@ func (suite *FiltersTestSuite) TestPostFilterMissingContext() {
// Creating another filter with the same title should fail.
func (suite *FiltersTestSuite) TestPostFilterTitleConflict() {
title := suite.testFilters["local_account_1_filter_1"].Title
_, err := suite.postFilter(&title, nil, nil, nil, nil, http.StatusUnprocessableEntity, "")
_, err := suite.postFilter(&title, nil, nil, nil, nil, nil, nil, nil, http.StatusUnprocessableEntity, "")
if err != nil {
suite.FailNow(err.Error())
}
Expand Down
Loading

0 comments on commit b789fe2

Please sign in to comment.