diff options
Diffstat (limited to 'internal/api/client')
| -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 | 
15 files changed, 415 insertions, 68 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)  } | 
