mirror of
https://github.com/superseriousbusiness/gotosocial.git
synced 2025-11-18 13:27:28 -06:00
[feature] Hashtag federation (in/out), hashtag client API endpoints (#2032)
* update go-fed * do the things * remove unused columns from tags * update to latest lingo from main * further tag shenanigans * serve stub page at tag endpoint * we did it lads * tests, oh tests, ohhh tests, oh tests (doo doo doo doo) * swagger docs * document hashtag usage + federation * instanceGet * don't bother parsing tag href * rename whereStartsWith -> whereStartsLike * remove GetOrCreateTag * dont cache status tag timelineability
This commit is contained in:
parent
ed2477ebea
commit
2796a2e82f
69 changed files with 2536 additions and 482 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
//
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,7 @@ func (suite *SearchGetTestSuite) TestSearchEmptyQuery() {
|
|||
_, err := suite.getSearch(
|
||||
requestingAccount,
|
||||
token,
|
||||
apiutil.APIv2,
|
||||
user,
|
||||
maxID,
|
||||
minID,
|
||||
|
|
@ -1222,6 +1251,170 @@ func (suite *SearchGetTestSuite) TestSearchEmptyQuery() {
|
|||
}
|
||||
}
|
||||
|
||||
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,
|
||||
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) 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) {
|
||||
suite.Run(t, &SearchGetTestSuite{})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
146
internal/api/client/timelines/tag.go
Normal file
146
internal/api/client/timelines/tag.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue