From 5097e6d2782600dc29f930dc14bc7fb0746a4fb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nicole=20miko=C5=82ajczyk?= Date: Mon, 2 Jun 2025 11:46:17 +0200 Subject: [PATCH] [feature] /api/v1/follow_requests/outgoing (#4224) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: nicole mikołajczyk # Description This pull request adds a new endpoint which returns a list of pending follow requests requested by the user. The test is adapted from the GET /api/v1/follow_requests test. ## Checklist - [x] I/we have read the [GoToSocial contribution guidelines](https://codeberg.org/superseriousbusiness/gotosocial/src/branch/main/CONTRIBUTING.md). - [ ] I/we have discussed the proposed changes already, either in an issue on the repository, or in the Matrix chat. - [x] I/we have not leveraged AI to create the proposed changes. - [x] I/we have performed a self-review of added code. - [x] I/we have written code that is legible and maintainable by others. - [x] I/we have commented the added code, particularly in hard-to-understand areas. - [x] I/we have made any necessary changes to documentation. - [x] I/we have added tests that cover new code. - [x] I/we have run tests and they pass locally with the changes. - [x] I/we have run `go fmt ./...` and `golangci-lint run`. Reviewed-on: https://codeberg.org/superseriousbusiness/gotosocial/pulls/4224 Co-authored-by: nicole mikołajczyk Co-committed-by: nicole mikołajczyk --- docs/api/swagger.yaml | 59 ++++ .../client/followrequests/followrequest.go | 3 + .../api/client/followrequests/getoutgoing.go | 145 ++++++++ .../client/followrequests/getoutgoing_test.go | 328 ++++++++++++++++++ internal/processing/account/follow_request.go | 39 +++ 5 files changed, 574 insertions(+) create mode 100644 internal/api/client/followrequests/getoutgoing.go create mode 100644 internal/api/client/followrequests/getoutgoing_test.go diff --git a/docs/api/swagger.yaml b/docs/api/swagger.yaml index 3ed915e05..52d024304 100644 --- a/docs/api/swagger.yaml +++ b/docs/api/swagger.yaml @@ -8745,6 +8745,65 @@ paths: summary: Reject/deny follow request from the given account ID. tags: - follow_requests + /api/v1/follow_requests/outgoing: + get: + description: |- + The next and previous queries can be parsed from the returned Link header. + Example: + + ``` + ; rel="next", ; rel="prev" + ```` + operationId: getOutgoingFollowRequests + parameters: + - description: 'Return only follow requested accounts *OLDER* than the given max ID. The follow requestee with the specified ID will not be included in the response. NOTE: the ID is of the internal follow request, NOT any of the returned accounts.' + in: query + name: max_id + type: string + - description: 'Return only follow requested accounts *NEWER* than the given since ID. The follow requestee with the specified ID will not be included in the response. NOTE: the ID is of the internal follow request, NOT any of the returned accounts.' + in: query + name: since_id + type: string + - description: 'Return only follow requested accounts *IMMEDIATELY NEWER* than the given min ID. The follow requestee with the specified ID will not be included in the response. NOTE: the ID is of the internal follow request, NOT any of the returned accounts.' + in: query + name: min_id + type: string + - default: 40 + description: Number of follow requested accounts to return. + in: query + maximum: 80 + minimum: 1 + name: limit + type: integer + produces: + - application/json + responses: + "200": + description: "" + headers: + Link: + description: Links to the next and previous queries. + type: string + schema: + items: + $ref: '#/definitions/account' + type: array + "400": + description: bad request + "401": + description: unauthorized + "404": + description: not found + "406": + description: not acceptable + "500": + description: internal server error + security: + - OAuth2 Bearer: + - read:follows + summary: Get an array of accounts that you have requested to follow. + tags: + - follow_requests /api/v1/followed_tags: get: operationId: getFollowedTags diff --git a/internal/api/client/followrequests/followrequest.go b/internal/api/client/followrequests/followrequest.go index 4d4f0837a..97a7a87d9 100644 --- a/internal/api/client/followrequests/followrequest.go +++ b/internal/api/client/followrequests/followrequest.go @@ -36,6 +36,8 @@ const ( AuthorizePath = BasePathWithID + "/authorize" // RejectPath is used for rejecting follow requests RejectPath = BasePathWithID + "/reject" + // OutgoingPath is used for fetching the list of accounts you requested to follow. + OutgoingPath = BasePath + "/outgoing" ) type Module struct { @@ -50,6 +52,7 @@ func New(processor *processing.Processor) *Module { func (m *Module) Route(attachHandler func(method string, path string, f ...gin.HandlerFunc) gin.IRoutes) { attachHandler(http.MethodGet, BasePath, m.FollowRequestGETHandler) + attachHandler(http.MethodGet, OutgoingPath, m.OutgoingFollowRequestGETHandler) attachHandler(http.MethodPost, AuthorizePath, m.FollowRequestAuthorizePOSTHandler) attachHandler(http.MethodPost, RejectPath, m.FollowRequestRejectPOSTHandler) } diff --git a/internal/api/client/followrequests/getoutgoing.go b/internal/api/client/followrequests/getoutgoing.go new file mode 100644 index 000000000..4da6dfad8 --- /dev/null +++ b/internal/api/client/followrequests/getoutgoing.go @@ -0,0 +1,145 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package followrequests + +import ( + "net/http" + + apiutil "code.superseriousbusiness.org/gotosocial/internal/api/util" + "code.superseriousbusiness.org/gotosocial/internal/gtserror" + "code.superseriousbusiness.org/gotosocial/internal/paging" + "github.com/gin-gonic/gin" +) + +// OutgoingFollowRequestGETHandler swagger:operation GET /api/v1/follow_requests/outgoing getOutgoingFollowRequests +// +// Get an array of accounts that you have requested to follow. +// +// The next and previous queries can be parsed from the returned Link header. +// Example: +// +// ``` +// ; rel="next", ; rel="prev" +// ```` +// +// --- +// tags: +// - follow_requests +// +// produces: +// - application/json +// +// parameters: +// - +// name: max_id +// type: string +// description: >- +// Return only follow requested accounts *OLDER* than the given max ID. +// The follow requestee with the specified ID will not be included in the response. +// NOTE: the ID is of the internal follow request, NOT any of the returned accounts. +// in: query +// required: false +// - +// name: since_id +// type: string +// description: >- +// Return only follow requested accounts *NEWER* than the given since ID. +// The follow requestee with the specified ID will not be included in the response. +// NOTE: the ID is of the internal follow request, NOT any of the returned accounts. +// in: query +// required: false +// - +// name: min_id +// type: string +// description: >- +// Return only follow requested accounts *IMMEDIATELY NEWER* than the given min ID. +// The follow requestee with the specified ID will not be included in the response. +// NOTE: the ID is of the internal follow request, NOT any of the returned accounts. +// in: query +// required: false +// - +// name: limit +// type: integer +// description: Number of follow requested accounts to return. +// default: 40 +// minimum: 1 +// maximum: 80 +// in: query +// required: false +// +// security: +// - OAuth2 Bearer: +// - read:follows +// +// responses: +// '200': +// headers: +// Link: +// type: string +// description: Links to the next and previous queries. +// schema: +// type: array +// items: +// "$ref": "#/definitions/account" +// '400': +// description: bad request +// '401': +// description: unauthorized +// '404': +// description: not found +// '406': +// description: not acceptable +// '500': +// description: internal server error +func (m *Module) OutgoingFollowRequestGETHandler(c *gin.Context) { + authed, errWithCode := apiutil.TokenAuth(c, + true, true, true, true, + apiutil.ScopeReadFollows, + ) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1) + return + } + + page, errWithCode := paging.ParseIDPage(c, + 1, // min limit + 80, // max limit + 40, // default limit + ) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + resp, errWithCode := m.processor.Account().OutgoingFollowRequestsGet(c.Request.Context(), authed.Account, page) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + if resp.LinkHeader != "" { + c.Header("Link", resp.LinkHeader) + } + + apiutil.JSON(c, http.StatusOK, resp.Items) +} diff --git a/internal/api/client/followrequests/getoutgoing_test.go b/internal/api/client/followrequests/getoutgoing_test.go new file mode 100644 index 000000000..6919ea5c8 --- /dev/null +++ b/internal/api/client/followrequests/getoutgoing_test.go @@ -0,0 +1,328 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package followrequests_test + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strconv" + "testing" + "time" + + "code.superseriousbusiness.org/gotosocial/internal/api/model" + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" + "github.com/tomnomnom/linkheader" +) + +type GetOutgoingTestSuite struct { + FollowRequestStandardTestSuite +} + +func (suite *GetOutgoingTestSuite) TestGetOutgoing() { + requestingAccount := suite.testAccounts["local_account_1"] + targetAccount := suite.testAccounts["remote_account_2"] + + // put a follow request in the database + fr := >smodel.FollowRequest{ + ID: "01JWKX18JFKWXKXK5FSKVM4HDP", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + URI: fmt.Sprintf("%s/follow/01JWKX18JFKWXKXK5FSKVM4HDP", requestingAccount.URI), + AccountID: requestingAccount.ID, + TargetAccountID: targetAccount.ID, + } + + err := suite.db.Put(suite.T().Context(), fr) + suite.NoError(err) + + recorder := httptest.NewRecorder() + ctx := suite.newContext(recorder, http.MethodGet, []byte{}, "/api/v1/follow_requests/outgoing", "") + + // call the handler + suite.followRequestModule.OutgoingFollowRequestGETHandler(ctx) + + // 1. we should have OK because our request was valid + suite.Equal(http.StatusOK, recorder.Code) + + // 2. we should have no error message in the result body + result := recorder.Result() + defer result.Body.Close() + + // check the response + b, err := io.ReadAll(result.Body) + assert.NoError(suite.T(), err) + dst := new(bytes.Buffer) + err = json.Indent(dst, b, "", " ") + suite.NoError(err) + suite.Equal(`[ + { + "id": "01FHMQX3GAABWSM0S2VZEC2SWC", + "username": "Some_User", + "acct": "Some_User@example.org", + "display_name": "some user", + "locked": true, + "discoverable": true, + "bot": false, + "created_at": "2020-08-10T12:13:28.000Z", + "note": "i'm a real son of a gun", + "url": "http://example.org/@Some_User", + "avatar": "", + "avatar_static": "", + "header": "http://localhost:8080/assets/default_header.webp", + "header_static": "http://localhost:8080/assets/default_header.webp", + "header_description": "Flat gray background (default header).", + "followers_count": 0, + "following_count": 0, + "statuses_count": 1, + "last_status_at": "2023-11-02", + "emojis": [], + "fields": [], + "group": false + } +]`, dst.String()) +} + +func (suite *GetOutgoingTestSuite) TestGetOutgoingPageNewestToOldestLimit2() { + suite.testGetOutgoingPage(2, "newestToOldest") +} + +func (suite *GetOutgoingTestSuite) TestGetOutgoingPageNewestToOldestLimit4() { + suite.testGetOutgoingPage(4, "newestToOldest") +} + +func (suite *GetOutgoingTestSuite) TestGetOutgoingPageNewestToOldestLimit6() { + suite.testGetOutgoingPage(6, "newestToOldest") +} + +func (suite *GetOutgoingTestSuite) TestGetOutgoingPageOldestToNewestLimit2() { + suite.testGetOutgoingPage(2, "oldestToNewest") +} + +func (suite *GetOutgoingTestSuite) TestGetOutgoingPageOldestToNewestLimit4() { + suite.testGetOutgoingPage(4, "oldestToNewest") +} + +func (suite *GetOutgoingTestSuite) TestGetOutgoingPageOldestToNewestLimit6() { + suite.testGetOutgoingPage(6, "oldestToNewest") +} + +func (suite *GetOutgoingTestSuite) testGetOutgoingPage(limit int, direction string) { + ctx := suite.T().Context() + + // The authed local account we are going to use for HTTP requests + requestingAccount := suite.testAccounts["local_account_1"] + suite.clearAccountRelations(requestingAccount.ID) + + // Get current time. + now := time.Now() + + var i int + + // Have each account in the testrig follow req the + // account requesting their followers from the API. + for _, targetAccount := range suite.testAccounts { + if requestingAccount.ID == targetAccount.ID { + // we cannot be our own target... + continue + } + + // Get next simple ID. + id := strconv.Itoa(i) + i++ + + // put a follow request in the database + err := suite.db.PutFollowRequest(ctx, >smodel.FollowRequest{ + ID: id, + CreatedAt: now, + UpdatedAt: now, + URI: fmt.Sprintf("%s/follow/%s", requestingAccount.URI, id), + AccountID: requestingAccount.ID, + TargetAccountID: targetAccount.ID, + }) + suite.NoError(err) + + // Bump now by 1 second. + now = now.Add(time.Second) + } + + // Get _ALL_ follow requests we expect to see without any paging (this filters invisible). + apiRsp, err := suite.processor.Account().OutgoingFollowRequestsGet(ctx, requestingAccount, nil) + suite.NoError(err) + expectAccounts := apiRsp.Items // interfaced{} account slice + + // Iteratively set + // link query string. + var query string + + switch direction { + case "newestToOldest": + // Set the starting query to page from + // newest (ie., first entry in slice). + acc := expectAccounts[0].(*model.Account) + newest, _ := suite.db.GetFollowRequest(ctx, requestingAccount.ID, acc.ID) + expectAccounts = expectAccounts[1:] + query = fmt.Sprintf("limit=%d&max_id=%s", limit, newest.ID) + + case "oldestToNewest": + // Set the starting query to page from + // oldest (ie., last entry in slice). + acc := expectAccounts[len(expectAccounts)-1].(*model.Account) + oldest, _ := suite.db.GetFollowRequest(ctx, requestingAccount.ID, acc.ID) + expectAccounts = expectAccounts[:len(expectAccounts)-1] + query = fmt.Sprintf("limit=%d&min_id=%s", limit, oldest.ID) + } + + for p := 0; ; p++ { + // Prepare new request for endpoint + recorder := httptest.NewRecorder() + ctx := suite.newContext(recorder, http.MethodGet, []byte{}, "/api/v1/follow_requests/outgoing", "") + ctx.Request.URL.RawQuery = query // setting provided next query value + + // call the handler and check for valid response code. + suite.T().Logf("direction=%q page=%d query=%q", direction, p, query) + suite.followRequestModule.OutgoingFollowRequestGETHandler(ctx) + suite.Equal(http.StatusOK, recorder.Code) + + var accounts []*model.Account + + // Decode response body into API account models + result := recorder.Result() + dec := json.NewDecoder(result.Body) + err := dec.Decode(&accounts) + suite.NoError(err) + _ = result.Body.Close() + + var ( + + // start provides the starting index for loop in accounts. + start func([]*model.Account) int + + // iter performs the loop iter step with index. + iter func(int) int + + // check performs the loop conditional check against index and accounts. + check func(int, []*model.Account) bool + + // expect pulls the next account to check against from expectAccounts. + expect func([]interface{}) interface{} + + // trunc drops the last checked account from expectAccounts. + trunc func([]interface{}) []interface{} + ) + + switch direction { + case "newestToOldest": + // When paging newest to oldest (ie., first page to last page): + // - iter from start of received accounts + // - iterate backward through received accounts + // - stop when we reach last index of received accounts + // - compare each received with the first index of expected accounts + // - after each compare, drop the first index of expected accounts + start = func([]*model.Account) int { return 0 } + iter = func(i int) int { return i + 1 } + check = func(idx int, i []*model.Account) bool { return idx < len(i) } + expect = func(i []interface{}) interface{} { return i[0] } + trunc = func(i []interface{}) []interface{} { return i[1:] } + + case "oldestToNewest": + // When paging oldest to newest (ie., last page to first page): + // - iter from end of received accounts + // - iterate backward through received accounts + // - stop when we reach first index of received accounts + // - compare each received with the last index of expected accounts + // - after each compare, drop the last index of expected accounts + start = func(i []*model.Account) int { return len(i) - 1 } + iter = func(i int) int { return i - 1 } + check = func(idx int, _ []*model.Account) bool { return idx >= 0 } + expect = func(i []interface{}) interface{} { return i[len(i)-1] } + trunc = func(i []interface{}) []interface{} { return i[:len(i)-1] } + } + + for i := start(accounts); check(i, accounts); i = iter(i) { + // Get next expected account. + iface := expect(expectAccounts) + + // Check that expected account matches received. + expectAccID := iface.(*model.Account).ID + receivdAccID := accounts[i].ID + suite.Equal(expectAccID, receivdAccID, "unexpected account at position in response on page=%d", p) + + // Drop checked from expected accounts. + expectAccounts = trunc(expectAccounts) + } + + if len(expectAccounts) == 0 { + // Reached end. + break + } + + // Parse response link header values. + values := result.Header.Values("Link") + links := linkheader.ParseMultiple(values) + + var filteredLinks linkheader.Links + if direction == "newestToOldest" { + filteredLinks = links.FilterByRel("next") + } else { + filteredLinks = links.FilterByRel("prev") + } + + suite.NotEmpty(filteredLinks, "no next link provided with more remaining accounts on page=%d", p) + + // A ref link header was set. + link := filteredLinks[0] + + // Parse URI from URI string. + uri, err := url.Parse(link.URL) + suite.NoError(err) + + // Set next raw query value. + query = uri.RawQuery + } +} + +func (suite *GetOutgoingTestSuite) clearAccountRelations(id string) { + // Esnure no account blocks exist between accounts. + _ = suite.db.DeleteAccountBlocks( + suite.T().Context(), + id, + ) + + // Ensure no account follows exist between accounts. + _ = suite.db.DeleteAccountFollows( + suite.T().Context(), + id, + ) + + // Ensure no account follow_requests exist between accounts. + _ = suite.db.DeleteAccountFollowRequests( + suite.T().Context(), + id, + ) +} + +func TestGetOutgoingTestSuite(t *testing.T) { + suite.Run(t, &GetOutgoingTestSuite{}) +} diff --git a/internal/processing/account/follow_request.go b/internal/processing/account/follow_request.go index a1969ac23..88cadd7d0 100644 --- a/internal/processing/account/follow_request.go +++ b/internal/processing/account/follow_request.go @@ -117,3 +117,42 @@ func (p *Processor) FollowRequestsGet(ctx context.Context, requestingAccount *gt Prev: page.Prev(lo, hi), }), nil } + +// OutgoingFollowRequestsGet fetches a list of the accounts with a pending follow request originating from the given requestingAccount (the currently authorized account). +func (p *Processor) OutgoingFollowRequestsGet(ctx context.Context, requestingAccount *gtsmodel.Account, page *paging.Page) (*apimodel.PageableResponse, gtserror.WithCode) { + // Fetch follow requests originating from the given requesting account model. + followRequests, err := p.state.DB.GetAccountFollowRequesting(ctx, requestingAccount.ID, page) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + return nil, gtserror.NewErrorInternalError(err) + } + + // Check for empty response. + count := len(followRequests) + if count == 0 { + return paging.EmptyResponse(), nil + } + + // Get the lowest and highest + // ID values, used for paging. + lo := followRequests[count-1].ID + hi := followRequests[0].ID + + // Func to fetch follow source at index. + getIdx := func(i int) *gtsmodel.Account { + return followRequests[i].TargetAccount + } + + // Get a filtered slice of public API account models. + items := p.c.GetVisibleAPIAccountsPaged(ctx, + requestingAccount, + getIdx, + count, + ) + + return paging.PackageResponse(paging.ResponseParams{ + Items: items, + Path: "/api/v1/follow_requests/outgoing", + Next: page.Next(lo, hi), + Prev: page.Prev(lo, hi), + }), nil +}