diff options
Diffstat (limited to 'internal/api')
-rw-r--r-- | internal/api/client/media/media.go | 10 | ||||
-rw-r--r-- | internal/api/client/media/mediacreate.go | 12 | ||||
-rw-r--r-- | internal/api/client/media/mediacreate_test.go | 9 | ||||
-rw-r--r-- | internal/api/client/media/mediaget.go | 8 | ||||
-rw-r--r-- | internal/api/client/media/mediaupdate.go | 8 | ||||
-rw-r--r-- | internal/api/client/media/mediaupdate_test.go | 5 | ||||
-rw-r--r-- | internal/api/client/search/search.go | 7 | ||||
-rw-r--r-- | internal/api/client/search/searchget.go | 32 | ||||
-rw-r--r-- | internal/api/client/search/searchget_test.go | 195 | ||||
-rw-r--r-- | internal/api/client/statuses/statuscreate_test.go | 1 | ||||
-rw-r--r-- | internal/api/client/timelines/home.go | 6 | ||||
-rw-r--r-- | internal/api/client/timelines/list.go | 15 | ||||
-rw-r--r-- | internal/api/client/timelines/public.go | 6 | ||||
-rw-r--r-- | internal/api/client/timelines/tag.go | 146 | ||||
-rw-r--r-- | internal/api/client/timelines/timeline.go | 23 | ||||
-rw-r--r-- | internal/api/model/search.go | 4 | ||||
-rw-r--r-- | internal/api/model/tag.go | 4 | ||||
-rw-r--r-- | internal/api/util/parsequery.go | 41 |
18 files changed, 463 insertions, 69 deletions
diff --git a/internal/api/client/media/media.go b/internal/api/client/media/media.go index 833cba0a2..dc640d380 100644 --- a/internal/api/client/media/media.go +++ b/internal/api/client/media/media.go @@ -21,16 +21,14 @@ import ( "net/http" "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" "github.com/superseriousbusiness/gotosocial/internal/processing" ) const ( - IDKey = "id" // IDKey is the key for media attachment IDs - APIVersionKey = "api_version" // APIVersionKey is the key for which version of the API to use (v1 or v2) - APIv1 = "v1" // APIV1 corresponds to version 1 of the api - APIv2 = "v2" // APIV2 corresponds to version 2 of the api - BasePath = "/:" + APIVersionKey + "/media" // BasePath is the base API path for making media requests through v1 or v2 of the api (for mastodon API compatibility) - AttachmentWithID = BasePath + "/:" + IDKey // BasePathWithID corresponds to a media attachment with the given ID + IDKey = "id" // IDKey is the key for media attachment IDs + BasePath = "/:" + apiutil.APIVersionKey + "/media" // BasePath is the base API path for making media requests through v1 or v2 of the api (for mastodon API compatibility) + AttachmentWithID = BasePath + "/:" + IDKey // BasePathWithID corresponds to a media attachment with the given ID ) type Module struct { diff --git a/internal/api/client/media/mediacreate.go b/internal/api/client/media/mediacreate.go index 0ae3ff70d..d2264bb0d 100644 --- a/internal/api/client/media/mediacreate.go +++ b/internal/api/client/media/mediacreate.go @@ -93,10 +93,12 @@ import ( // '500': // description: internal server error func (m *Module) MediaCreatePOSTHandler(c *gin.Context) { - apiVersion := c.Param(APIVersionKey) - if apiVersion != APIv1 && apiVersion != APIv2 { - err := errors.New("api version must be one of v1 or v2 for this path") - apiutil.ErrorHandler(c, gtserror.NewErrorNotFound(err, err.Error()), m.processor.InstanceGetV1) + apiVersion, errWithCode := apiutil.ParseAPIVersion( + c.Param(apiutil.APIVersionKey), + []string{apiutil.APIv1, apiutil.APIv2}..., + ) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) return } @@ -128,7 +130,7 @@ func (m *Module) MediaCreatePOSTHandler(c *gin.Context) { return } - if apiVersion == APIv2 { + if apiVersion == apiutil.APIv2 { // the mastodon v2 media API specifies that the URL should be null // and that the client should call /api/v1/media/:id to get the URL // diff --git a/internal/api/client/media/mediacreate_test.go b/internal/api/client/media/mediacreate_test.go index 27e77f12f..471be8557 100644 --- a/internal/api/client/media/mediacreate_test.go +++ b/internal/api/client/media/mediacreate_test.go @@ -32,6 +32,7 @@ import ( "github.com/stretchr/testify/suite" mediamodule "github.com/superseriousbusiness/gotosocial/internal/api/client/media" apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/email" @@ -169,7 +170,7 @@ func (suite *MediaCreateTestSuite) TestMediaCreateSuccessful() { ctx.Request = httptest.NewRequest(http.MethodPost, "http://localhost:8080/api/v1/media", bytes.NewReader(buf.Bytes())) // the endpoint we're hitting ctx.Request.Header.Set("Content-Type", w.FormDataContentType()) ctx.Request.Header.Set("accept", "application/json") - ctx.AddParam(mediamodule.APIVersionKey, mediamodule.APIv1) + ctx.AddParam(apiutil.APIVersionKey, apiutil.APIv1) // do the actual request suite.mediaModule.MediaCreatePOSTHandler(ctx) @@ -254,7 +255,7 @@ func (suite *MediaCreateTestSuite) TestMediaCreateSuccessfulV2() { ctx.Request = httptest.NewRequest(http.MethodPost, "http://localhost:8080/api/v2/media", bytes.NewReader(buf.Bytes())) // the endpoint we're hitting ctx.Request.Header.Set("Content-Type", w.FormDataContentType()) ctx.Request.Header.Set("accept", "application/json") - ctx.AddParam(mediamodule.APIVersionKey, mediamodule.APIv2) + ctx.AddParam(apiutil.APIVersionKey, apiutil.APIv2) // do the actual request suite.mediaModule.MediaCreatePOSTHandler(ctx) @@ -337,7 +338,7 @@ func (suite *MediaCreateTestSuite) TestMediaCreateLongDescription() { ctx.Request = httptest.NewRequest(http.MethodPost, "http://localhost:8080/api/v1/media", bytes.NewReader(buf.Bytes())) // the endpoint we're hitting ctx.Request.Header.Set("Content-Type", w.FormDataContentType()) ctx.Request.Header.Set("accept", "application/json") - ctx.AddParam(mediamodule.APIVersionKey, mediamodule.APIv1) + ctx.AddParam(apiutil.APIVersionKey, apiutil.APIv1) // do the actual request suite.mediaModule.MediaCreatePOSTHandler(ctx) @@ -378,7 +379,7 @@ func (suite *MediaCreateTestSuite) TestMediaCreateTooShortDescription() { ctx.Request = httptest.NewRequest(http.MethodPost, "http://localhost:8080/api/v1/media", bytes.NewReader(buf.Bytes())) // the endpoint we're hitting ctx.Request.Header.Set("Content-Type", w.FormDataContentType()) ctx.Request.Header.Set("accept", "application/json") - ctx.AddParam(mediamodule.APIVersionKey, mediamodule.APIv1) + ctx.AddParam(apiutil.APIVersionKey, apiutil.APIv1) // do the actual request suite.mediaModule.MediaCreatePOSTHandler(ctx) diff --git a/internal/api/client/media/mediaget.go b/internal/api/client/media/mediaget.go index f06991e7f..431f73d65 100644 --- a/internal/api/client/media/mediaget.go +++ b/internal/api/client/media/mediaget.go @@ -66,9 +66,11 @@ import ( // '500': // description: internal server error func (m *Module) MediaGETHandler(c *gin.Context) { - if apiVersion := c.Param(APIVersionKey); apiVersion != APIv1 { - err := errors.New("api version must be one v1 for this path") - apiutil.ErrorHandler(c, gtserror.NewErrorNotFound(err, err.Error()), m.processor.InstanceGetV1) + if _, errWithCode := apiutil.ParseAPIVersion( + c.Param(apiutil.APIVersionKey), + []string{apiutil.APIv1, apiutil.APIv2}..., + ); errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) return } diff --git a/internal/api/client/media/mediaupdate.go b/internal/api/client/media/mediaupdate.go index 23e416c20..032cfd705 100644 --- a/internal/api/client/media/mediaupdate.go +++ b/internal/api/client/media/mediaupdate.go @@ -98,9 +98,11 @@ import ( // '500': // description: internal server error func (m *Module) MediaPUTHandler(c *gin.Context) { - if apiVersion := c.Param(APIVersionKey); apiVersion != APIv1 { - err := errors.New("api version must be one v1 for this path") - apiutil.ErrorHandler(c, gtserror.NewErrorNotFound(err, err.Error()), m.processor.InstanceGetV1) + if _, errWithCode := apiutil.ParseAPIVersion( + c.Param(apiutil.APIVersionKey), + []string{apiutil.APIv1, apiutil.APIv2}..., + ); errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) return } diff --git a/internal/api/client/media/mediaupdate_test.go b/internal/api/client/media/mediaupdate_test.go index 5fd3e2856..1af3bcf06 100644 --- a/internal/api/client/media/mediaupdate_test.go +++ b/internal/api/client/media/mediaupdate_test.go @@ -30,6 +30,7 @@ import ( "github.com/stretchr/testify/suite" mediamodule "github.com/superseriousbusiness/gotosocial/internal/api/client/media" apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/email" @@ -160,7 +161,7 @@ func (suite *MediaUpdateTestSuite) TestUpdateImage() { ctx.Request = httptest.NewRequest(http.MethodPut, fmt.Sprintf("http://localhost:8080/api/v1/media/%s", toUpdate.ID), bytes.NewReader(buf.Bytes())) // the endpoint we're hitting ctx.Request.Header.Set("Content-Type", w.FormDataContentType()) ctx.Request.Header.Set("accept", "application/json") - ctx.AddParam(mediamodule.APIVersionKey, mediamodule.APIv1) + ctx.AddParam(apiutil.APIVersionKey, apiutil.APIv1) ctx.AddParam(mediamodule.IDKey, toUpdate.ID) // do the actual request @@ -221,7 +222,7 @@ func (suite *MediaUpdateTestSuite) TestUpdateImageShortDescription() { ctx.Request = httptest.NewRequest(http.MethodPut, fmt.Sprintf("http://localhost:8080/api/v1/media/%s", toUpdate.ID), bytes.NewReader(buf.Bytes())) // the endpoint we're hitting ctx.Request.Header.Set("Content-Type", w.FormDataContentType()) ctx.Request.Header.Set("accept", "application/json") - ctx.AddParam(mediamodule.APIVersionKey, mediamodule.APIv1) + ctx.AddParam(apiutil.APIVersionKey, apiutil.APIv1) ctx.AddParam(mediamodule.IDKey, toUpdate.ID) // do the actual request diff --git a/internal/api/client/search/search.go b/internal/api/client/search/search.go index 219e30280..d413aff91 100644 --- a/internal/api/client/search/search.go +++ b/internal/api/client/search/search.go @@ -21,12 +21,12 @@ import ( "net/http" "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" "github.com/superseriousbusiness/gotosocial/internal/processing" ) const ( - BasePathV1 = "/v1/search" // Base path for serving v1 of the search API, minus the 'api' prefix. - BasePathV2 = "/v2/search" // Base path for serving v2 of the search API, minus the 'api' prefix. + BasePath = "/:" + apiutil.APIVersionKey + "/search" ) type Module struct { @@ -40,6 +40,5 @@ func New(processor *processing.Processor) *Module { } func (m *Module) Route(attachHandler func(method string, path string, f ...gin.HandlerFunc) gin.IRoutes) { - attachHandler(http.MethodGet, BasePathV1, m.SearchGETHandler) - attachHandler(http.MethodGet, BasePathV2, m.SearchGETHandler) + attachHandler(http.MethodGet, BasePath, m.SearchGETHandler) } diff --git a/internal/api/client/search/searchget.go b/internal/api/client/search/searchget.go index 33a90e078..2759feb5b 100644 --- a/internal/api/client/search/searchget.go +++ b/internal/api/client/search/searchget.go @@ -27,7 +27,7 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/oauth" ) -// SearchGETHandler swagger:operation GET /api/v1/search searchGet +// SearchGETHandler swagger:operation GET /api/{api_version}/search searchGet // // Search for statuses, accounts, or hashtags, on this instance or elsewhere. // @@ -42,6 +42,15 @@ import ( // // parameters: // - +// name: api_version +// type: string +// in: path +// description: >- +// Version of the API to use. Must be either `v1` or `v2`. +// If v1 is used, Hashtag results will be a slice of strings. +// If v2 is used, Hashtag results will be a slice of apimodel tags. +// required: true +// - // name: max_id // type: string // description: >- @@ -88,6 +97,7 @@ import ( // - `@[username]` -- search for an account with the given username on any domain. Can return multiple results. // - @[username]@[domain]` -- search for a remote account with exact username and domain. Will only ever return 1 result at most. // - `https://example.org/some/arbitrary/url` -- search for an account OR a status with the given URL. Will only ever return 1 result at most. +// - `#[hashtag_name]` -- search for a hashtag with the given hashtag name, or starting with the given hashtag name. Case insensitive. Can return multiple results. // - any arbitrary string -- search for accounts or statuses containing the given string. Can return multiple results. // in: query // required: true @@ -97,9 +107,9 @@ import ( // description: |- // Type of item to return. One of: // - `` -- empty string; return any/all results. -// - `accounts` -- return account(s). -// - `statuses` -- return status(es). -// - `hashtags` -- return hashtag(s). +// - `accounts` -- return only account(s). +// - `statuses` -- return only status(es). +// - `hashtags` -- return only hashtag(s). // If `type` is specified, paging can be performed using max_id and min_id parameters. // If `type` is not specified, see the `offset` parameter for paging. // in: query @@ -138,9 +148,7 @@ import ( // name: search results // description: Results of the search. // schema: -// type: array -// items: -// "$ref": "#/definitions/searchResult" +// "$ref": "#/definitions/searchResult" // '400': // description: bad request // '401': @@ -152,6 +160,15 @@ import ( // '500': // description: internal server error func (m *Module) SearchGETHandler(c *gin.Context) { + apiVersion, errWithCode := apiutil.ParseAPIVersion( + c.Param(apiutil.APIVersionKey), + []string{apiutil.APIv1, apiutil.APIv2}..., + ) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + authed, err := oauth.Authed(c, true, true, true, true) if err != nil { apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1) @@ -209,6 +226,7 @@ func (m *Module) SearchGETHandler(c *gin.Context) { Resolve: resolve, Following: following, ExcludeUnreviewed: excludeUnreviewed, + APIv1: apiVersion == apiutil.APIv1, } results, errWithCode := m.processor.Search().Get(c.Request.Context(), authed.Account, searchRequest) diff --git a/internal/api/client/search/searchget_test.go b/internal/api/client/search/searchget_test.go index f6a2db70a..edaac2fc1 100644 --- a/internal/api/client/search/searchget_test.go +++ b/internal/api/client/search/searchget_test.go @@ -47,6 +47,7 @@ type SearchGetTestSuite struct { func (suite *SearchGetTestSuite) getSearch( requestingAccount *gtsmodel.Account, token *gtsmodel.Token, + apiVersion string, user *gtsmodel.User, maxID *string, minID *string, @@ -62,11 +63,13 @@ func (suite *SearchGetTestSuite) getSearch( var ( recorder = httptest.NewRecorder() ctx, _ = testrig.CreateGinTestContext(recorder, nil) - requestURL = testrig.URLMustParse("/api" + search.BasePathV1) + requestURL = testrig.URLMustParse("/api" + search.BasePath) queryParts []string ) // Put the request together. + ctx.AddParam(apiutil.APIVersionKey, apiVersion) + if maxID != nil { queryParts = append(queryParts, apiutil.MaxIDKey+"="+url.QueryEscape(*maxID)) } @@ -175,6 +178,7 @@ func (suite *SearchGetTestSuite) TestSearchRemoteAccountByURI() { searchResult, err := suite.getSearch( requestingAccount, token, + apiutil.APIv2, user, maxID, minID, @@ -218,6 +222,7 @@ func (suite *SearchGetTestSuite) TestSearchRemoteAccountByNamestring() { searchResult, err := suite.getSearch( requestingAccount, token, + apiutil.APIv2, user, maxID, minID, @@ -261,6 +266,7 @@ func (suite *SearchGetTestSuite) TestSearchRemoteAccountByNamestringUppercase() searchResult, err := suite.getSearch( requestingAccount, token, + apiutil.APIv2, user, maxID, minID, @@ -304,6 +310,7 @@ func (suite *SearchGetTestSuite) TestSearchRemoteAccountByNamestringNoLeadingAt( searchResult, err := suite.getSearch( requestingAccount, token, + apiutil.APIv2, user, maxID, minID, @@ -347,6 +354,7 @@ func (suite *SearchGetTestSuite) TestSearchRemoteAccountByNamestringNoResolve() searchResult, err := suite.getSearch( requestingAccount, token, + apiutil.APIv2, user, maxID, minID, @@ -385,6 +393,7 @@ func (suite *SearchGetTestSuite) TestSearchRemoteAccountByNamestringSpecialChars searchResult, err := suite.getSearch( requestingAccount, token, + apiutil.APIv2, user, maxID, minID, @@ -426,6 +435,7 @@ func (suite *SearchGetTestSuite) TestSearchRemoteAccountByNamestringSpecialChars searchResult, err := suite.getSearch( requestingAccount, token, + apiutil.APIv2, user, maxID, minID, @@ -467,6 +477,7 @@ func (suite *SearchGetTestSuite) TestSearchLocalAccountByNamestring() { searchResult, err := suite.getSearch( requestingAccount, token, + apiutil.APIv2, user, maxID, minID, @@ -510,6 +521,7 @@ func (suite *SearchGetTestSuite) TestSearchLocalAccountByNamestringWithDomain() searchResult, err := suite.getSearch( requestingAccount, token, + apiutil.APIv2, user, maxID, minID, @@ -553,6 +565,7 @@ func (suite *SearchGetTestSuite) TestSearchNonexistingLocalAccountByNamestringRe searchResult, err := suite.getSearch( requestingAccount, token, + apiutil.APIv2, user, maxID, minID, @@ -591,6 +604,7 @@ func (suite *SearchGetTestSuite) TestSearchLocalAccountByURI() { searchResult, err := suite.getSearch( requestingAccount, token, + apiutil.APIv2, user, maxID, minID, @@ -634,6 +648,7 @@ func (suite *SearchGetTestSuite) TestSearchLocalAccountByURL() { searchResult, err := suite.getSearch( requestingAccount, token, + apiutil.APIv2, user, maxID, minID, @@ -677,6 +692,7 @@ func (suite *SearchGetTestSuite) TestSearchNonexistingLocalAccountByURL() { searchResult, err := suite.getSearch( requestingAccount, token, + apiutil.APIv2, user, maxID, minID, @@ -715,6 +731,7 @@ func (suite *SearchGetTestSuite) TestSearchStatusByURL() { searchResult, err := suite.getSearch( requestingAccount, token, + apiutil.APIv2, user, maxID, minID, @@ -758,6 +775,7 @@ func (suite *SearchGetTestSuite) TestSearchBlockedDomainURL() { searchResult, err := suite.getSearch( requestingAccount, token, + apiutil.APIv2, user, maxID, minID, @@ -798,6 +816,7 @@ func (suite *SearchGetTestSuite) TestSearchBlockedDomainNamestring() { searchResult, err := suite.getSearch( requestingAccount, token, + apiutil.APIv2, user, maxID, minID, @@ -838,6 +857,7 @@ func (suite *SearchGetTestSuite) TestSearchAAny() { searchResult, err := suite.getSearch( requestingAccount, token, + apiutil.APIv2, user, maxID, minID, @@ -878,6 +898,7 @@ func (suite *SearchGetTestSuite) TestSearchAAnyFollowingOnly() { searchResult, err := suite.getSearch( requestingAccount, token, + apiutil.APIv2, user, maxID, minID, @@ -918,6 +939,7 @@ func (suite *SearchGetTestSuite) TestSearchAStatuses() { searchResult, err := suite.getSearch( requestingAccount, token, + apiutil.APIv2, user, maxID, minID, @@ -958,6 +980,7 @@ func (suite *SearchGetTestSuite) TestSearchAAccounts() { searchResult, err := suite.getSearch( requestingAccount, token, + apiutil.APIv2, user, maxID, minID, @@ -998,6 +1021,7 @@ func (suite *SearchGetTestSuite) TestSearchAAccountsLimit1() { searchResult, err := suite.getSearch( requestingAccount, token, + apiutil.APIv2, user, maxID, minID, @@ -1038,6 +1062,7 @@ func (suite *SearchGetTestSuite) TestSearchLocalInstanceAccountByURI() { searchResult, err := suite.getSearch( requestingAccount, token, + apiutil.APIv2, user, maxID, minID, @@ -1084,6 +1109,7 @@ func (suite *SearchGetTestSuite) TestSearchInstanceAccountFull() { searchResult, err := suite.getSearch( requestingAccount, token, + apiutil.APIv2, user, maxID, minID, @@ -1130,6 +1156,7 @@ func (suite *SearchGetTestSuite) TestSearchInstanceAccountPartial() { searchResult, err := suite.getSearch( requestingAccount, token, + apiutil.APIv2, user, maxID, minID, @@ -1170,6 +1197,7 @@ func (suite *SearchGetTestSuite) TestSearchBadQueryType() { _, err := suite.getSearch( requestingAccount, token, + apiutil.APIv2, user, maxID, minID, @@ -1206,6 +1234,85 @@ func (suite *SearchGetTestSuite) TestSearchEmptyQuery() { _, err := suite.getSearch( requestingAccount, token, + apiutil.APIv2, + user, + maxID, + minID, + limit, + offset, + query, + queryType, + resolve, + following, + expectedHTTPStatus, + expectedBody) + if err != nil { + suite.FailNow(err.Error()) + } +} + +func (suite *SearchGetTestSuite) TestSearchHashtagV1() { + var ( + requestingAccount = suite.testAccounts["local_account_1"] + token = suite.testTokens["local_account_1"] + user = suite.testUsers["local_account_1"] + maxID *string = nil + minID *string = nil + limit *int = nil + offset *int = nil + resolve *bool = nil + query = "#welcome" + queryType *string = func() *string { i := "hashtags"; return &i }() + following *bool = nil + expectedHTTPStatus = http.StatusOK + expectedBody = `{"accounts":[],"statuses":[],"hashtags":[{"name":"welcome","url":"http://localhost:8080/tags/welcome","history":[]}]}` + ) + + searchResult, err := suite.getSearch( + requestingAccount, + token, + apiutil.APIv2, + user, + maxID, + minID, + limit, + offset, + query, + queryType, + resolve, + following, + expectedHTTPStatus, + expectedBody) + if err != nil { + suite.FailNow(err.Error()) + } + + suite.Len(searchResult.Accounts, 0) + suite.Len(searchResult.Statuses, 0) + suite.Len(searchResult.Hashtags, 1) +} + +func (suite *SearchGetTestSuite) TestSearchHashtagV2() { + var ( + requestingAccount = suite.testAccounts["local_account_1"] + token = suite.testTokens["local_account_1"] + user = suite.testUsers["local_account_1"] + maxID *string = nil + minID *string = nil + limit *int = nil + offset *int = nil + resolve *bool = nil + query = "#welcome" + queryType *string = func() *string { i := "hashtags"; return &i }() + following *bool = nil + expectedHTTPStatus = http.StatusOK + expectedBody = `{"accounts":[],"statuses":[],"hashtags":["welcome"]}` + ) + + searchResult, err := suite.getSearch( + requestingAccount, + token, + apiutil.APIv1, user, maxID, minID, @@ -1220,6 +1327,92 @@ func (suite *SearchGetTestSuite) TestSearchEmptyQuery() { if err != nil { suite.FailNow(err.Error()) } + + suite.Len(searchResult.Accounts, 0) + suite.Len(searchResult.Statuses, 0) + suite.Len(searchResult.Hashtags, 1) +} + +func (suite *SearchGetTestSuite) TestSearchHashtagButWithAccountSearch() { + var ( + requestingAccount = suite.testAccounts["local_account_1"] + token = suite.testTokens["local_account_1"] + user = suite.testUsers["local_account_1"] + maxID *string = nil + minID *string = nil + limit *int = nil + offset *int = nil + resolve *bool = nil + query = "#welcome" + queryType *string = func() *string { i := "accounts"; return &i }() + following *bool = nil + expectedHTTPStatus = http.StatusOK + expectedBody = `` + ) + + searchResult, err := suite.getSearch( + requestingAccount, + token, + apiutil.APIv2, + user, + maxID, + minID, + limit, + offset, + query, + queryType, + resolve, + following, + expectedHTTPStatus, + expectedBody) + if err != nil { + suite.FailNow(err.Error()) + } + + suite.Len(searchResult.Accounts, 0) + suite.Len(searchResult.Statuses, 0) + suite.Len(searchResult.Hashtags, 0) +} + +func (suite *SearchGetTestSuite) TestSearchNotHashtagButWithTypeHashtag() { + var ( + requestingAccount = suite.testAccounts["local_account_1"] + token = suite.testTokens["local_account_1"] + user = suite.testUsers["local_account_1"] + maxID *string = nil + minID *string = nil + limit *int = nil + offset *int = nil + resolve *bool = nil + query = "welco" + queryType *string = func() *string { i := "hashtags"; return &i }() + following *bool = nil + expectedHTTPStatus = http.StatusOK + expectedBody = `` + ) + + searchResult, err := suite.getSearch( + requestingAccount, + token, + apiutil.APIv2, + user, + maxID, + minID, + limit, + offset, + query, + queryType, + resolve, + following, + expectedHTTPStatus, + expectedBody) + if err != nil { + suite.FailNow(err.Error()) + } + + suite.Len(searchResult.Accounts, 0) + suite.Len(searchResult.Statuses, 0) + suite.Len(searchResult.Hashtags, 1) } func TestSearchGetTestSuite(t *testing.T) { diff --git a/internal/api/client/statuses/statuscreate_test.go b/internal/api/client/statuses/statuscreate_test.go index e84bcd816..05f24c24c 100644 --- a/internal/api/client/statuses/statuscreate_test.go +++ b/internal/api/client/statuses/statuscreate_test.go @@ -98,7 +98,6 @@ func (suite *StatusCreateTestSuite) TestPostNewStatus() { gtsTag := >smodel.Tag{} err = suite.db.GetWhere(context.Background(), []db.Where{{Key: "name", Value: "helloworld"}}, gtsTag) suite.NoError(err) - suite.Equal(statusReply.Account.ID, gtsTag.FirstSeenFromAccountID) } func (suite *StatusCreateTestSuite) TestPostNewStatusMarkdown() { diff --git a/internal/api/client/timelines/home.go b/internal/api/client/timelines/home.go index c3f075d5e..963096f59 100644 --- a/internal/api/client/timelines/home.go +++ b/internal/api/client/timelines/home.go @@ -133,9 +133,9 @@ func (m *Module) HomeTimelineGETHandler(c *gin.Context) { resp, errWithCode := m.processor.Timeline().HomeTimelineGet( c.Request.Context(), authed, - c.Query(MaxIDKey), - c.Query(SinceIDKey), - c.Query(MinIDKey), + c.Query(apiutil.MaxIDKey), + c.Query(apiutil.SinceIDKey), + c.Query(apiutil.MinIDKey), limit, local, ) diff --git a/internal/api/client/timelines/list.go b/internal/api/client/timelines/list.go index 8b4f7fad9..2e13e32cd 100644 --- a/internal/api/client/timelines/list.go +++ b/internal/api/client/timelines/list.go @@ -18,7 +18,6 @@ package timelines import ( - "errors" "net/http" "github.com/gin-gonic/gin" @@ -118,11 +117,9 @@ func (m *Module) ListTimelineGETHandler(c *gin.Context) { return } - targetListID := c.Param(IDKey) - if targetListID == "" { - err := errors.New("no list id specified") - apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1) - return + targetListID, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey)) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) } limit, errWithCode := apiutil.ParseLimit(c.Query(apiutil.LimitKey), 20, 40, 1) @@ -135,9 +132,9 @@ func (m *Module) ListTimelineGETHandler(c *gin.Context) { c.Request.Context(), authed, targetListID, - c.Query(MaxIDKey), - c.Query(SinceIDKey), - c.Query(MinIDKey), + c.Query(apiutil.MaxIDKey), + c.Query(apiutil.SinceIDKey), + c.Query(apiutil.MinIDKey), limit, ) if errWithCode != nil { diff --git a/internal/api/client/timelines/public.go b/internal/api/client/timelines/public.go index 96958e6a4..7b8acf1ca 100644 --- a/internal/api/client/timelines/public.go +++ b/internal/api/client/timelines/public.go @@ -144,9 +144,9 @@ func (m *Module) PublicTimelineGETHandler(c *gin.Context) { resp, errWithCode := m.processor.Timeline().PublicTimelineGet( c.Request.Context(), authed, - c.Query(MaxIDKey), - c.Query(SinceIDKey), - c.Query(MinIDKey), + c.Query(apiutil.MaxIDKey), + c.Query(apiutil.SinceIDKey), + c.Query(apiutil.MinIDKey), limit, local, ) diff --git a/internal/api/client/timelines/tag.go b/internal/api/client/timelines/tag.go new file mode 100644 index 000000000..58754705b --- /dev/null +++ b/internal/api/client/timelines/tag.go @@ -0,0 +1,146 @@ +// 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 <http://www.gnu.org/licenses/>. + +package timelines + +import ( + "net/http" + + "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// HomeTimelineGETHandler swagger:operation GET /api/v1/timelines/tag/{tag_name} tagTimeline +// +// See public statuses that use the given hashtag (case insensitive). +// +// The statuses will be returned in descending chronological order (newest first), with sequential IDs (bigger = newer). +// +// The returned Link header can be used to generate the previous and next queries when scrolling up or down a timeline. +// +// Example: +// +// ``` +// <https://example.org/api/v1/timelines/tag/example?limit=20&max_id=01FC3GSQ8A3MMJ43BPZSGEG29M>; rel="next", <https://example.org/api/v1/timelines/tag/example?limit=20&min_id=01FC3KJW2GYXSDDRA6RWNDM46M>; rel="prev" +// ```` +// +// --- +// tags: +// - timelines +// +// produces: +// - application/json +// +// parameters: +// - +// name: max_id +// type: string +// description: >- +// Return only statuses *OLDER* than the given max status ID. +// The status with the specified ID will not be included in the response. +// in: query +// required: false +// - +// name: since_id +// type: string +// description: >- +// Return only statuses *newer* than the given since status ID. +// The status with the specified ID will not be included in the response. +// in: query +// - +// name: min_id +// type: string +// description: >- +// Return only statuses *immediately newer* than the given since status ID. +// The status with the specified ID will not be included in the response. +// in: query +// required: false +// - +// name: limit +// type: integer +// description: Number of statuses to return. +// default: 20 +// minimum: 1 +// maximum: 40 +// in: query +// required: false +// +// security: +// - OAuth2 Bearer: +// - read:statuses +// +// responses: +// '200': +// name: statuses +// description: Array of statuses. +// schema: +// type: array +// items: +// "$ref": "#/definitions/status" +// headers: +// Link: +// type: string +// description: Links to the next and previous queries. +// '401': +// description: unauthorized +// '400': +// description: bad request +func (m *Module) TagTimelineGETHandler(c *gin.Context) { + authed, err := oauth.Authed(c, true, true, true, true) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), 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 + } + + limit, errWithCode := apiutil.ParseLimit(c.Query(apiutil.LimitKey), 20, 40, 1) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + tagName, errWithCode := apiutil.ParseTagName(c.Param(apiutil.TagNameKey)) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + resp, errWithCode := m.processor.Timeline().TagTimelineGet( + c.Request.Context(), + authed.Account, + tagName, + c.Query(apiutil.MaxIDKey), + c.Query(apiutil.SinceIDKey), + c.Query(apiutil.MinIDKey), + limit, + ) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + if resp.LinkHeader != "" { + c.Header("Link", resp.LinkHeader) + } + c.JSON(http.StatusOK, resp.Items) +} diff --git a/internal/api/client/timelines/timeline.go b/internal/api/client/timelines/timeline.go index 2580333d9..2362ca47e 100644 --- a/internal/api/client/timelines/timeline.go +++ b/internal/api/client/timelines/timeline.go @@ -21,28 +21,16 @@ import ( "net/http" "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" "github.com/superseriousbusiness/gotosocial/internal/processing" ) const ( - // BasePath is the base URI path for serving timelines, minus the 'api' prefix. - BasePath = "/v1/timelines" - IDKey = "id" - // HomeTimeline is the path for the home timeline - HomeTimeline = BasePath + "/home" - // PublicTimeline is the path for the public (and public local) timeline + BasePath = "/v1/timelines" + HomeTimeline = BasePath + "/home" PublicTimeline = BasePath + "/public" - ListTimeline = BasePath + "/list/:" + IDKey - // MaxIDKey is the url query for setting a max status ID to return - MaxIDKey = "max_id" - // SinceIDKey is the url query for returning results newer than the given ID - SinceIDKey = "since_id" - // MinIDKey is the url query for returning results immediately newer than the given ID - MinIDKey = "min_id" - // LimitKey is for specifying maximum number of results to return. - LimitKey = "limit" - // LocalKey is for specifying whether only local statuses should be returned - LocalKey = "local" + ListTimeline = BasePath + "/list/:" + apiutil.IDKey + TagTimeline = BasePath + "/tag/:" + apiutil.TagNameKey ) type Module struct { @@ -59,4 +47,5 @@ func (m *Module) Route(attachHandler func(method string, path string, f ...gin.H attachHandler(http.MethodGet, HomeTimeline, m.HomeTimelineGETHandler) attachHandler(http.MethodGet, PublicTimeline, m.PublicTimelineGETHandler) attachHandler(http.MethodGet, ListTimeline, m.ListTimelineGETHandler) + attachHandler(http.MethodGet, TagTimeline, m.TagTimelineGETHandler) } diff --git a/internal/api/model/search.go b/internal/api/model/search.go index 664bf7b26..738c5911f 100644 --- a/internal/api/model/search.go +++ b/internal/api/model/search.go @@ -28,6 +28,7 @@ type SearchRequest struct { Resolve bool Following bool ExcludeUnreviewed bool + APIv1 bool // Set to 'true' if using version 1 of the search API. } // SearchResult models a search result. @@ -36,5 +37,6 @@ type SearchRequest struct { type SearchResult struct { Accounts []*Account `json:"accounts"` Statuses []*Status `json:"statuses"` - Hashtags []*Tag `json:"hashtags"` + // Slice of strings if api v1, slice of tags if api v2. + Hashtags []any `json:"hashtags"` } diff --git a/internal/api/model/tag.go b/internal/api/model/tag.go index 66b54d7f8..ebc12e2d4 100644 --- a/internal/api/model/tag.go +++ b/internal/api/model/tag.go @@ -27,4 +27,8 @@ type Tag struct { // Web link to the hashtag. // example: https://example.org/tags/helloworld URL string `json:"url"` + // History of this hashtag's usage. + // Currently just a stub, if provided will always be an empty array. + // example: [] + History *[]any `json:"history,omitempty"` } diff --git a/internal/api/util/parsequery.go b/internal/api/util/parsequery.go index 662870910..a87c77aeb 100644 --- a/internal/api/util/parsequery.go +++ b/internal/api/util/parsequery.go @@ -20,11 +20,18 @@ package util import ( "fmt" "strconv" + "strings" "github.com/superseriousbusiness/gotosocial/internal/gtserror" ) const ( + /* API version keys */ + + APIVersionKey = "api_version" + APIv1 = "v1" + APIv2 = "v2" + /* Common keys */ IDKey = "id" @@ -44,6 +51,10 @@ const ( SearchResolveKey = "resolve" SearchTypeKey = "type" + /* Tag keys */ + + TagNameKey = "tag_name" + /* Web endpoint keys */ WebUsernameKey = "username" @@ -122,6 +133,26 @@ func ParseDomainBlockImport(value string, defaultValue bool) (bool, gtserror.Wit Parse functions for *REQUIRED* parameters. */ +func ParseAPIVersion(value string, availableVersion ...string) (string, gtserror.WithCode) { + key := APIVersionKey + + if value == "" { + return "", requiredError(key) + } + + for _, av := range availableVersion { + if value == av { + return value, nil + } + } + + err := fmt.Errorf( + "invalid API version, valid versions for this path are [%s]", + strings.Join(availableVersion, ", "), + ) + return "", gtserror.NewErrorBadRequest(err, err.Error()) +} + func ParseID(value string) (string, gtserror.WithCode) { key := IDKey @@ -152,6 +183,16 @@ func ParseSearchQuery(value string) (string, gtserror.WithCode) { return value, nil } +func ParseTagName(value string) (string, gtserror.WithCode) { + key := TagNameKey + + if value == "" { + return "", requiredError(key) + } + + return value, nil +} + func ParseWebUsername(value string) (string, gtserror.WithCode) { key := WebUsernameKey |