[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:
tobi 2023-07-31 15:47:35 +02:00 committed by GitHub
commit 2796a2e82f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
69 changed files with 2536 additions and 482 deletions

View file

@ -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)
}

View file

@ -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)

View file

@ -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{})
}