[feature] filter API v2: Restore keywords_attributes and statuses_attributes (#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`.
This commit is contained in:
Vyr Cossont 2024-06-14 01:11:41 -07:00 committed by GitHub
parent ee6e9b2795
commit b789fe2bc7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 656 additions and 40 deletions

View File

@ -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:
@ -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.

View File

@ -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:
@ -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)
@ -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
}

View File

@ -23,6 +23,7 @@ import (
"net/http"
"net/http/httptest"
"net/url"
"slices"
"strconv"
"strings"
@ -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)
@ -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
@ -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())
}
@ -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)
}
@ -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())
}
@ -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)
}
@ -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())
}
@ -193,7 +267,7 @@ 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())
}
@ -201,7 +275,7 @@ func (suite *FiltersTestSuite) TestPostFilterEmptyTitle() {
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())
}
@ -210,7 +284,7 @@ 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())
}
@ -218,7 +292,7 @@ func (suite *FiltersTestSuite) TestPostFilterEmptyContext() {
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())
}
@ -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())
}

View File

@ -18,6 +18,7 @@
package v2
import (
"errors"
"fmt"
"net/http"
"strconv"
@ -68,6 +69,30 @@ import (
// minLength: 1
// maxLength: 200
// -
// name: keywords_attributes[][keyword]
// in: formData
// type: array
// items:
// type: string
// description: Keywords to be added to the created filter.
// 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 newly created filter.
// collectionFormat: multi
// -
// name: context[]
// in: formData
// required: true
@ -183,6 +208,58 @@ func validateNormalizeUpdateFilter(form *apimodel.FilterUpdateRequestV2) error {
}
}
// Parse form variant of normal filter keyword update structs.
// All filter keyword update struct fields are optional.
numFormKeywords := max(
len(form.KeywordsAttributesID),
len(form.KeywordsAttributesKeyword),
len(form.KeywordsAttributesWholeWord),
len(form.KeywordsAttributesDestroy),
)
if numFormKeywords > 0 {
form.Keywords = make([]apimodel.FilterKeywordCreateUpdateDeleteRequest, 0, numFormKeywords)
for i := 0; i < numFormKeywords; i++ {
formKeyword := apimodel.FilterKeywordCreateUpdateDeleteRequest{}
if i < len(form.KeywordsAttributesID) && form.KeywordsAttributesID[i] != "" {
formKeyword.ID = &form.KeywordsAttributesID[i]
}
if i < len(form.KeywordsAttributesKeyword) && form.KeywordsAttributesKeyword[i] != "" {
formKeyword.Keyword = &form.KeywordsAttributesKeyword[i]
}
if i < len(form.KeywordsAttributesWholeWord) {
formKeyword.WholeWord = &form.KeywordsAttributesWholeWord[i]
}
if i < len(form.KeywordsAttributesDestroy) {
formKeyword.Destroy = &form.KeywordsAttributesDestroy[i]
}
form.Keywords = append(form.Keywords, formKeyword)
}
}
// Parse form variant of normal filter status update structs.
// All filter status update struct fields are optional.
numFormStatuses := max(
len(form.StatusesAttributesID),
len(form.StatusesAttributesStatusID),
len(form.StatusesAttributesDestroy),
)
if numFormStatuses > 0 {
form.Statuses = make([]apimodel.FilterStatusCreateDeleteRequest, 0, numFormStatuses)
for i := 0; i < numFormStatuses; i++ {
formStatus := apimodel.FilterStatusCreateDeleteRequest{}
if i < len(form.StatusesAttributesID) && form.StatusesAttributesID[i] != "" {
formStatus.ID = &form.StatusesAttributesID[i]
}
if i < len(form.StatusesAttributesStatusID) && form.StatusesAttributesStatusID[i] != "" {
formStatus.StatusID = &form.StatusesAttributesStatusID[i]
}
if i < len(form.StatusesAttributesDestroy) {
formStatus.Destroy = &form.StatusesAttributesDestroy[i]
}
form.Statuses = append(form.Statuses, formStatus)
}
}
// Normalize filter expiry if necessary.
// If we parsed this as JSON, expires_in
// may be either a float64 or a string.
@ -204,5 +281,42 @@ func validateNormalizeUpdateFilter(form *apimodel.FilterUpdateRequestV2) error {
}
}
// Normalize and validate updates.
for i, formKeyword := range form.Keywords {
if formKeyword.Keyword != nil {
if err := validate.FilterKeyword(*formKeyword.Keyword); err != nil {
return err
}
}
destroy := util.PtrValueOr(formKeyword.Destroy, false)
form.Keywords[i].Destroy = &destroy
if destroy && formKeyword.ID == nil {
return errors.New("can't delete a filter keyword without an ID")
} else if formKeyword.ID == nil && formKeyword.Keyword == nil {
return errors.New("can't create a filter keyword without a keyword")
}
}
for i, formStatus := range form.Statuses {
if formStatus.StatusID != nil {
if err := validate.ULID(*formStatus.StatusID, "status_id"); err != nil {
return err
}
}
destroy := util.PtrValueOr(formStatus.Destroy, false)
form.Statuses[i].Destroy = &destroy
switch {
case destroy && formStatus.ID == nil:
return errors.New("can't delete a filter status without an ID")
case formStatus.ID != nil:
return errors.New("filter status IDs here can only be used to delete them")
case formStatus.StatusID == nil:
return errors.New("can't create a filter status without a status ID")
}
}
return nil
}

View File

@ -23,6 +23,7 @@ import (
"net/http"
"net/http/httptest"
"net/url"
"slices"
"strconv"
"strings"
@ -35,7 +36,7 @@ import (
"github.com/superseriousbusiness/gotosocial/testrig"
)
func (suite *FiltersTestSuite) putFilter(filterID string, title *string, context *[]string, action *string, expiresIn *int, requestJson *string, expectedHTTPStatus int, expectedBody string) (*apimodel.FilterV2, error) {
func (suite *FiltersTestSuite) putFilter(filterID string, title *string, context *[]string, action *string, expiresIn *int, keywordsAttributesID *[]string, keywordsAttributesKeyword *[]string, keywordsAttributesWholeWord *[]bool, keywordsAttributesDestroy *[]bool, statusesAttributesID *[]string, statusesAttributesStatusID *[]string, statusesAttributesDestroy *[]bool, requestJson *string, expectedHTTPStatus int, expectedBody string) (*apimodel.FilterV2, error) {
// instantiate recorder + test context
recorder := httptest.NewRecorder()
ctx, _ := testrig.CreateGinTestContext(recorder, nil)
@ -64,6 +65,39 @@ func (suite *FiltersTestSuite) putFilter(filterID string, title *string, context
if expiresIn != nil {
ctx.Request.Form["expires_in"] = []string{strconv.Itoa(*expiresIn)}
}
if keywordsAttributesID != nil {
ctx.Request.Form["keywords_attributes[][id]"] = *keywordsAttributesID
}
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 keywordsAttributesWholeWord != nil {
formatted := []string{}
for _, value := range *keywordsAttributesDestroy {
formatted = append(formatted, strconv.FormatBool(value))
}
ctx.Request.Form["keywords_attributes[][_destroy]"] = formatted
}
if statusesAttributesID != nil {
ctx.Request.Form["statuses_attributes[][id]"] = *statusesAttributesID
}
if statusesAttributesStatusID != nil {
ctx.Request.Form["statuses_attributes[][status_id]"] = *statusesAttributesStatusID
}
if statusesAttributesDestroy != nil {
formatted := []string{}
for _, value := range *statusesAttributesDestroy {
formatted = append(formatted, strconv.FormatBool(value))
}
ctx.Request.Form["statuses_attributes[][_destroy]"] = formatted
}
}
ctx.AddParam("id", filterID)
@ -114,7 +148,18 @@ func (suite *FiltersTestSuite) TestPutFilterFull() {
context := []string{"home", "public"}
action := "hide"
expiresIn := 86400
filter, err := suite.putFilter(id, &title, &context, &action, &expiresIn, nil, http.StatusOK, "")
// Tests attributes arrays that aren't the same length, just in case.
keywordsAttributesID := []string{
suite.testFilterKeywords["local_account_1_filter_2_keyword_1"].ID,
suite.testFilterKeywords["local_account_1_filter_2_keyword_2"].ID,
}
keywordsAttributesKeyword := []string{"fū", "", "blah"}
// If using the form version of this API, you have to always set whole_word to the previous value for that keyword;
// there's no way to represent a nullable boolean in it.
keywordsAttributesWholeWord := []bool{true, false, true}
keywordsAttributesDestroy := []bool{false, true}
statusesAttributesStatusID := []string{suite.testStatuses["remote_account_1_status_2"].ID}
filter, err := suite.putFilter(id, &title, &context, &action, &expiresIn, &keywordsAttributesID, &keywordsAttributesKeyword, &keywordsAttributesWholeWord, &keywordsAttributesDestroy, nil, &statusesAttributesStatusID, nil, nil, http.StatusOK, "")
if err != nil {
suite.FailNow(err.Error())
}
@ -129,8 +174,29 @@ func (suite *FiltersTestSuite) TestPutFilterFull() {
if suite.NotNil(filter.ExpiresAt) {
suite.NotEmpty(*filter.ExpiresAt)
}
suite.Len(filter.Keywords, 3)
suite.Len(filter.Statuses, 0)
if suite.Len(filter.Keywords, 3) {
slices.SortFunc(filter.Keywords, func(lhs, rhs apimodel.FilterKeyword) int {
return strings.Compare(lhs.ID, rhs.ID)
})
suite.Equal("fū", filter.Keywords[0].Keyword)
suite.True(filter.Keywords[0].WholeWord)
suite.Equal("quux", filter.Keywords[1].Keyword)
suite.True(filter.Keywords[1].WholeWord)
suite.Equal("blah", filter.Keywords[2].Keyword)
suite.True(filter.Keywords[1].WholeWord)
}
if suite.Len(filter.Statuses, 1) {
slices.SortFunc(filter.Statuses, func(lhs, rhs apimodel.FilterStatus) int {
return strings.Compare(lhs.ID, rhs.ID)
})
suite.Equal(suite.testStatuses["remote_account_1_status_2"].ID, filter.Statuses[0].StatusID)
}
suite.checkStreamed(homeStream, true, "", stream.EventTypeFiltersChanged)
}
@ -144,9 +210,28 @@ func (suite *FiltersTestSuite) TestPutFilterFullJSON() {
"title": "messy synoptic varblabbles",
"context": ["home", "public"],
"filter_action": "hide",
"expires_in": 86400.1
"expires_in": 86400.1,
"keywords_attributes": [
{
"id": "01HN277Y11ENG4EC1ERMAC9FH4",
"keyword": "fū"
},
{
"id": "01HN278494N88BA2FY4DZ5JTNS",
"_destroy": true
},
{
"keyword": "blah",
"whole_word": true
}
],
"statuses_attributes": [
{
"status_id": "01HEN2QRFA8H3C6QPN7RD4KSR6"
}
]
}`
filter, err := suite.putFilter(id, nil, nil, nil, nil, &requestJson, http.StatusOK, "")
filter, err := suite.putFilter(id, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, &requestJson, http.StatusOK, "")
if err != nil {
suite.FailNow(err.Error())
}
@ -163,8 +248,29 @@ func (suite *FiltersTestSuite) TestPutFilterFullJSON() {
if suite.NotNil(filter.ExpiresAt) {
suite.NotEmpty(*filter.ExpiresAt)
}
suite.Len(filter.Keywords, 3)
suite.Len(filter.Statuses, 0)
if suite.Len(filter.Keywords, 3) {
slices.SortFunc(filter.Keywords, func(lhs, rhs apimodel.FilterKeyword) int {
return strings.Compare(lhs.ID, rhs.ID)
})
suite.Equal("fū", filter.Keywords[0].Keyword)
suite.True(filter.Keywords[0].WholeWord)
suite.Equal("quux", filter.Keywords[1].Keyword)
suite.True(filter.Keywords[1].WholeWord)
suite.Equal("blah", filter.Keywords[2].Keyword)
suite.True(filter.Keywords[1].WholeWord)
}
if suite.Len(filter.Statuses, 1) {
slices.SortFunc(filter.Statuses, func(lhs, rhs apimodel.FilterStatus) int {
return strings.Compare(lhs.ID, rhs.ID)
})
suite.Equal("01HEN2QRFA8H3C6QPN7RD4KSR6", filter.Statuses[0].StatusID)
}
suite.checkStreamed(homeStream, true, "", stream.EventTypeFiltersChanged)
}
@ -175,7 +281,7 @@ func (suite *FiltersTestSuite) TestPutFilterMinimal() {
id := suite.testFilters["local_account_1_filter_1"].ID
title := "GNU/Linux"
context := []string{"home"}
filter, err := suite.putFilter(id, &title, &context, nil, nil, nil, http.StatusOK, "")
filter, err := suite.putFilter(id, &title, &context, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, http.StatusOK, "")
if err != nil {
suite.FailNow(err.Error())
}
@ -196,7 +302,7 @@ func (suite *FiltersTestSuite) TestPutFilterEmptyTitle() {
id := suite.testFilters["local_account_1_filter_1"].ID
title := ""
context := []string{"home"}
_, err := suite.putFilter(id, &title, &context, nil, nil, nil, http.StatusUnprocessableEntity, `{"error":"Unprocessable Entity: filter title must be provided, and must be no more than 200 chars"}`)
_, err := suite.putFilter(id, &title, &context, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, http.StatusUnprocessableEntity, `{"error":"Unprocessable Entity: filter title must be provided, and must be no more than 200 chars"}`)
if err != nil {
suite.FailNow(err.Error())
}
@ -206,7 +312,7 @@ func (suite *FiltersTestSuite) TestPutFilterEmptyContext() {
id := suite.testFilters["local_account_1_filter_1"].ID
title := "GNU/Linux"
context := []string{}
_, err := suite.putFilter(id, &title, &context, nil, nil, nil, http.StatusUnprocessableEntity, `{"error":"Unprocessable Entity: at least one filter context is required"}`)
_, err := suite.putFilter(id, &title, &context, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, http.StatusUnprocessableEntity, `{"error":"Unprocessable Entity: at least one filter context is required"}`)
if err != nil {
suite.FailNow(err.Error())
}
@ -216,7 +322,7 @@ func (suite *FiltersTestSuite) TestPutFilterEmptyContext() {
func (suite *FiltersTestSuite) TestPutFilterTitleConflict() {
id := suite.testFilters["local_account_1_filter_1"].ID
title := suite.testFilters["local_account_1_filter_2"].Title
_, err := suite.putFilter(id, &title, nil, nil, nil, nil, http.StatusConflict, `{"error":"Conflict: you already have a filter with this title"}`)
_, err := suite.putFilter(id, &title, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, http.StatusConflict, `{"error":"Conflict: you already have a filter with this title"}`)
if err != nil {
suite.FailNow(err.Error())
}
@ -226,7 +332,7 @@ func (suite *FiltersTestSuite) TestPutAnotherAccountsFilter() {
id := suite.testFilters["local_account_2_filter_1"].ID
title := "GNU/Linux"
context := []string{"home"}
_, err := suite.putFilter(id, &title, &context, nil, nil, nil, http.StatusNotFound, `{"error":"Not Found"}`)
_, err := suite.putFilter(id, &title, &context, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, http.StatusNotFound, `{"error":"Not Found"}`)
if err != nil {
suite.FailNow(err.Error())
}
@ -236,7 +342,7 @@ func (suite *FiltersTestSuite) TestPutNonexistentFilter() {
id := "not_even_a_real_ULID"
phrase := "GNU/Linux"
context := []string{"home"}
_, err := suite.putFilter(id, &phrase, &context, nil, nil, nil, http.StatusNotFound, `{"error":"Not Found"}`)
_, err := suite.putFilter(id, &phrase, &context, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, http.StatusNotFound, `{"error":"Not Found"}`)
if err != nil {
suite.FailNow(err.Error())
}

View File

@ -135,9 +135,21 @@ type FilterCreateRequestV2 struct {
//
// Example: 86400
ExpiresInI interface{} `json:"expires_in"`
// Keywords to be added to the newly created filter.
Keywords []FilterKeywordCreateUpdateRequest `form:"-" json:"keywords_attributes" xml:"keywords_attributes"`
// Form data version of Keywords[].Keyword.
KeywordsAttributesKeyword []string `form:"keywords_attributes[][keyword]" json:"-" xml:"-"`
// Form data version of Keywords[].WholeWord.
KeywordsAttributesWholeWord []bool `form:"keywords_attributes[][whole_word]" json:"-" xml:"-"`
// Statuses to be added to the newly created filter.
Statuses []FilterStatusCreateRequest `form:"-" json:"statuses_attributes" xml:"statuses_attributes"`
// Form data version of Statuses[].StatusID.
StatusesAttributesStatusID []string `form:"statuses_attributes[][status_id]" json:"-" xml:"-"`
}
// FilterKeywordCreateUpdateRequest captures params for creating or updating a filter keyword.
// FilterKeywordCreateUpdateRequest captures params for creating or updating a filter keyword while creating a v2 filter or as a standalone operation.
//
// swagger:ignore
type FilterKeywordCreateUpdateRequest struct {
@ -152,7 +164,7 @@ type FilterKeywordCreateUpdateRequest struct {
WholeWord *bool `form:"whole_word" json:"whole_word" xml:"whole_word"`
}
// FilterStatusCreateRequest captures params for creating a filter status.
// FilterStatusCreateRequest captures params for a status while creating a v2 filter or filter status.
//
// swagger:ignore
type FilterStatusCreateRequest struct {
@ -188,4 +200,57 @@ type FilterUpdateRequestV2 struct {
//
// Example: 86400
ExpiresInI interface{} `json:"expires_in"`
// Keywords to be added to the filter, modified, or removed.
Keywords []FilterKeywordCreateUpdateDeleteRequest `form:"-" json:"keywords_attributes" xml:"keywords_attributes"`
// Form data version of Keywords[].ID.
KeywordsAttributesID []string `form:"keywords_attributes[][id]" json:"-" xml:"-"`
// Form data version of Keywords[].Keyword.
KeywordsAttributesKeyword []string `form:"keywords_attributes[][keyword]" json:"-" xml:"-"`
// Form data version of Keywords[].WholeWord.
KeywordsAttributesWholeWord []bool `form:"keywords_attributes[][whole_word]" json:"-" xml:"-"`
// Form data version of Keywords[].Destroy.
KeywordsAttributesDestroy []bool `form:"keywords_attributes[][_destroy]" json:"-" xml:"-"`
// Statuses to be added to the filter, or removed.
Statuses []FilterStatusCreateDeleteRequest `form:"-" json:"statuses_attributes" xml:"statuses_attributes"`
// Form data version of Statuses[].ID.
StatusesAttributesID []string `form:"statuses_attributes[][id]" json:"-" xml:"-"`
// Form data version of Statuses[].ID.
StatusesAttributesStatusID []string `form:"statuses_attributes[][status_id]" json:"-" xml:"-"`
// Form data version of Statuses[].Destroy.
StatusesAttributesDestroy []bool `form:"statuses_attributes[][_destroy]" json:"-" xml:"-"`
}
// FilterKeywordCreateUpdateDeleteRequest captures params for creating, updating, or deleting a keyword while updating a v2 filter.
//
// swagger:ignore
type FilterKeywordCreateUpdateDeleteRequest struct {
// The ID of the filter keyword entry in the database.
// Optional: use to modify or delete an existing keyword instead of adding a new one.
ID *string `json:"id" xml:"id"`
// The text to be filtered.
//
// Example: fnord
// Maximum length: 40
Keyword *string `json:"keyword" xml:"keyword"`
// Should the filter keyword consider word boundaries?
//
// Example: true
WholeWord *bool `json:"whole_word" xml:"whole_word"`
// Remove this filter keyword. Requires an ID.
Destroy *bool `json:"_destroy" xml:"_destroy"`
}
// FilterStatusCreateDeleteRequest captures params for creating or deleting a status while updating a v2 filter.
//
// swagger:ignore
type FilterStatusCreateDeleteRequest struct {
// The ID of the filter status entry in the database.
// Optional: use to delete an existing status instead of adding a new one.
ID *string `json:"id" xml:"id"`
// The status ID to be filtered.
StatusID *string `json:"status_id" xml:"status_id"`
// Remove this filter status. Requires an ID.
Destroy *bool `json:"_destroy" xml:"_destroy"`
}

View File

@ -63,6 +63,29 @@ func (p *Processor) Create(ctx context.Context, account *gtsmodel.Account, form
}
}
for _, formKeyword := range form.Keywords {
filterKeyword := &gtsmodel.FilterKeyword{
ID: id.NewULID(),
AccountID: account.ID,
FilterID: filter.ID,
Filter: filter,
Keyword: formKeyword.Keyword,
WholeWord: formKeyword.WholeWord,
}
filter.Keywords = append(filter.Keywords, filterKeyword)
}
for _, formStatus := range form.Statuses {
filterStatus := &gtsmodel.FilterStatus{
ID: id.NewULID(),
AccountID: account.ID,
FilterID: filter.ID,
Filter: filter,
StatusID: formStatus.StatusID,
}
filter.Statuses = append(filter.Statuses, filterStatus)
}
if err := p.state.DB.PutFilter(ctx, filter); err != nil {
if errors.Is(err, db.ErrAlreadyExists) {
err = errors.New("duplicate title, keyword, or status")

View File

@ -27,6 +27,7 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/id"
"github.com/superseriousbusiness/gotosocial/internal/typeutils"
"github.com/superseriousbusiness/gotosocial/internal/util"
)
@ -39,6 +40,8 @@ func (p *Processor) Update(
filterID string,
form *apimodel.FilterUpdateRequestV2,
) (*apimodel.FilterV2, gtserror.WithCode) {
var errWithCode gtserror.WithCode
// Get the filter by ID, with existing keywords and statuses.
filter, err := p.state.DB.GetFilterByID(ctx, filterID)
if err != nil {
@ -103,13 +106,17 @@ func (p *Processor) Update(
}
}
// Temporarily detach keywords and statuses from filter, since we're not updating them below.
filterKeywords := filter.Keywords
filterStatuses := filter.Statuses
filter.Keywords = nil
filter.Statuses = nil
filterKeywordColumns, deleteFilterKeywordIDs, errWithCode := applyKeywordChanges(filter, form.Keywords)
if err != nil {
return nil, errWithCode
}
if err := p.state.DB.UpdateFilter(ctx, filter, filterColumns, nil, nil, nil); err != nil {
deleteFilterStatusIDs, errWithCode := applyStatusChanges(filter, form.Statuses)
if err != nil {
return nil, errWithCode
}
if err := p.state.DB.UpdateFilter(ctx, filter, filterColumns, filterKeywordColumns, deleteFilterKeywordIDs, deleteFilterStatusIDs); err != nil {
if errors.Is(err, db.ErrAlreadyExists) {
err = errors.New("you already have a filter with this title")
return nil, gtserror.NewErrorConflict(err, err.Error())
@ -117,10 +124,6 @@ func (p *Processor) Update(
return nil, gtserror.NewErrorInternalError(err)
}
// Re-attach keywords and statuses before returning.
filter.Keywords = filterKeywords
filter.Statuses = filterStatuses
apiFilter, errWithCode := p.apiFilter(ctx, filter)
if errWithCode != nil {
return nil, errWithCode
@ -131,3 +134,131 @@ func (p *Processor) Update(
return apiFilter, nil
}
// applyKeywordChanges applies the provided changes to the filter's keywords in place,
// and returns a list of lists of filter columns to update, and a list of filter keyword IDs to delete.
func applyKeywordChanges(filter *gtsmodel.Filter, formKeywords []apimodel.FilterKeywordCreateUpdateDeleteRequest) ([][]string, []string, gtserror.WithCode) {
if len(formKeywords) == 0 {
// Detach currently existing keywords from the filter so we don't change them.
filter.Keywords = nil
return nil, nil, nil
}
deleteFilterKeywordIDs := []string{}
filterKeywordsByID := map[string]*gtsmodel.FilterKeyword{}
filterKeywordColumnsByID := map[string][]string{}
for _, filterKeyword := range filter.Keywords {
filterKeywordsByID[filterKeyword.ID] = filterKeyword
}
for _, formKeyword := range formKeywords {
if formKeyword.ID != nil {
id := *formKeyword.ID
filterKeyword, ok := filterKeywordsByID[id]
if !ok {
return nil, nil, gtserror.NewErrorNotFound(
fmt.Errorf("couldn't find filter keyword '%s' to update or delete", id),
)
}
// Process deletes.
if *formKeyword.Destroy {
delete(filterKeywordsByID, id)
deleteFilterKeywordIDs = append(deleteFilterKeywordIDs, id)
continue
}
// Process updates.
columns := make([]string, 0, 2)
if formKeyword.Keyword != nil {
columns = append(columns, "keyword")
filterKeyword.Keyword = *formKeyword.Keyword
}
if formKeyword.WholeWord != nil {
columns = append(columns, "whole_word")
filterKeyword.WholeWord = formKeyword.WholeWord
}
filterKeywordColumnsByID[id] = columns
continue
}
// Process creates.
filterKeyword := &gtsmodel.FilterKeyword{
ID: id.NewULID(),
AccountID: filter.AccountID,
FilterID: filter.ID,
Filter: filter,
Keyword: *formKeyword.Keyword,
WholeWord: util.Ptr(util.PtrValueOr(formKeyword.WholeWord, false)),
}
filterKeywordsByID[filterKeyword.ID] = filterKeyword
// Don't need to set columns, as we're using all of them.
}
// Replace the filter's keywords list with our updated version.
filterKeywordColumns := [][]string{}
filter.Keywords = nil
for id, filterKeyword := range filterKeywordsByID {
filter.Keywords = append(filter.Keywords, filterKeyword)
// Okay to use the nil slice zero value for entries being created instead of updated.
filterKeywordColumns = append(filterKeywordColumns, filterKeywordColumnsByID[id])
}
return filterKeywordColumns, deleteFilterKeywordIDs, nil
}
// applyKeywordChanges applies the provided changes to the filter's keywords in place,
// and returns a list of filter status IDs to delete.
func applyStatusChanges(filter *gtsmodel.Filter, formStatuses []apimodel.FilterStatusCreateDeleteRequest) ([]string, gtserror.WithCode) {
if len(formStatuses) == 0 {
// Detach currently existing statuses from the filter so we don't change them.
filter.Statuses = nil
return nil, nil
}
deleteFilterStatusIDs := []string{}
filterStatusesByID := map[string]*gtsmodel.FilterStatus{}
for _, filterStatus := range filter.Statuses {
filterStatusesByID[filterStatus.ID] = filterStatus
}
for _, formStatus := range formStatuses {
if formStatus.ID != nil {
id := *formStatus.ID
_, ok := filterStatusesByID[id]
if !ok {
return nil, gtserror.NewErrorNotFound(
fmt.Errorf("couldn't find filter status '%s' to delete", id),
)
}
// Process deletes.
if *formStatus.Destroy {
delete(filterStatusesByID, id)
deleteFilterStatusIDs = append(deleteFilterStatusIDs, id)
continue
}
// Filter statuses don't have updates.
continue
}
// Process creates.
filterStatus := &gtsmodel.FilterStatus{
ID: id.NewULID(),
AccountID: filter.AccountID,
FilterID: filter.ID,
Filter: filter,
StatusID: *formStatus.StatusID,
}
filterStatusesByID[filterStatus.ID] = filterStatus
}
// Replace the filter's keywords list with our updated version.
filter.Statuses = nil
for _, filterStatus := range filterStatusesByID {
filter.Statuses = append(filter.Statuses, filterStatus)
}
return deleteFilterStatusIDs, nil
}