summaryrefslogtreecommitdiff
path: root/internal/api
diff options
context:
space:
mode:
Diffstat (limited to 'internal/api')
-rw-r--r--internal/api/client/media/media.go10
-rw-r--r--internal/api/client/media/mediacreate.go12
-rw-r--r--internal/api/client/media/mediacreate_test.go9
-rw-r--r--internal/api/client/media/mediaget.go8
-rw-r--r--internal/api/client/media/mediaupdate.go8
-rw-r--r--internal/api/client/media/mediaupdate_test.go5
-rw-r--r--internal/api/client/search/search.go7
-rw-r--r--internal/api/client/search/searchget.go32
-rw-r--r--internal/api/client/search/searchget_test.go195
-rw-r--r--internal/api/client/statuses/statuscreate_test.go1
-rw-r--r--internal/api/client/timelines/home.go6
-rw-r--r--internal/api/client/timelines/list.go15
-rw-r--r--internal/api/client/timelines/public.go6
-rw-r--r--internal/api/client/timelines/tag.go146
-rw-r--r--internal/api/client/timelines/timeline.go23
-rw-r--r--internal/api/model/search.go4
-rw-r--r--internal/api/model/tag.go4
-rw-r--r--internal/api/util/parsequery.go41
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 := &gtsmodel.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