Adding tag support to the Pinboard bridge API as well. Calls to edit
tags will simply return an unsupported error.
Changelog-added: getTags query to GraphQL api.
Changelog-added: /v1/tags/* calls to Pinboard api bridge.
---
api/api_test.go | 161 +++++++++--
api/graph/generated.go | 418 +++++++++++++++++++++++++++++
api/graph/model/models_gen.go | 13 +
api/graph/pagination.go | 27 +-
api/graph/schema.graphqls | 16 ++
api/graph/schema.resolvers.go | 84 +++++-
migrations/test_migration.down.sql | 4 +
migrations/test_migration.up.sql | 27 ++
models/user.go | 4 +-
pinboard/input.go | 44 +--
pinboard/middleware.go | 12 +-
pinboard/responses.go | 14 +-
pinboard/routes.go | 68 ++++-
pinboard/routes_test.go | 254 ++++++++++++++++--
14 files changed, 1071 insertions(+), 75 deletions(-)
diff --git a/api/api_test.go b/api/api_test.go
index 26cf221..50fb214 100644
--- a/api/api_test.go
+++ b/api/api_test.go
@@ -489,12 +489,12 @@ func TestAPI(t *testing.T) {
orgLinks, err := models.GetOrgLinks(dbCtx, &database.FilterOptions{})
c.NoError(err)
- c.Equal(3, len(orgLinks))
- c.Equal(result.Link.ID, orgLinks[2].ID)
+ c.Equal(5, len(orgLinks)) // 4 from test data + 1 created
+ c.Equal(result.Link.ID, orgLinks[4].ID)
tags, err := models.GetTags(dbCtx, &database.FilterOptions{})
c.NoError(err)
- c.Equal(2, len(tags))
+ c.Equal(7, len(tags)) // 5 from test data + 2 created ("one", "two")
tagLinks, err := models.GetTagLinks(dbCtx,
&database.FilterOptions{Filter: sq.Expr("org_link_id = ?", result.Link.ID)})
@@ -503,8 +503,8 @@ func TestAPI(t *testing.T) {
// We create a second link to check that tags are not created twice
op = gqlclient.NewOperation(q)
- op.Var("title", "testing link")
- op.Var("url", "https://netlandish.com")
+ op.Var("title", "testing link 2")
+ op.Var("url", "https://netlandish.com/about")
op.Var("visibility", models.OrgLinkVisibilityPublic)
op.Var("tags", "one, two, three")
op.Var("slug", "personal-org")
@@ -516,11 +516,11 @@ func TestAPI(t *testing.T) {
orgLinks, err = models.GetOrgLinks(dbCtx, &database.FilterOptions{})
c.NoError(err)
- c.Equal(3, len(orgLinks))
+ c.Equal(6, len(orgLinks)) // 4 from test data + 2 created
tags, err = models.GetTags(dbCtx, &database.FilterOptions{})
c.NoError(err)
- c.Equal(3, len(tags))
+ c.Equal(8, len(tags)) // 5 from test data + 3 total unique ("one", "two", "three")
tagLinks, err = models.GetTagLinks(dbCtx,
&database.FilterOptions{Filter: sq.Expr("org_link_id = ?", result.Link.ID)})
@@ -539,7 +539,7 @@ func TestAPI(t *testing.T) {
// We know this has already been saved earlier. We need the hash to fetch it so
// let's query the db
ols, err := models.GetOrgLinks(dbCtx,
- &database.FilterOptions{Filter: sq.Eq{"ol.id": 3}, Limit: 1},
+ &database.FilterOptions{Filter: sq.Eq{"ol.hash": "hash1"}, Limit: 1},
)
c.NoError(err)
ol := ols[0]
@@ -594,7 +594,7 @@ func TestAPI(t *testing.T) {
// We know this has already been saved earlier. We need the hash to fetch it so
// let's query the db
ols, err := models.GetOrgLinks(dbCtx,
- &database.FilterOptions{Filter: sq.Eq{"ol.id": 3}, Limit: 1},
+ &database.FilterOptions{Filter: sq.Eq{"ol.hash": "hash1"}, Limit: 1},
)
c.NoError(err)
ol := ols[0]
@@ -617,19 +617,21 @@ func TestAPI(t *testing.T) {
op := gqlclient.NewOperation(q)
op.Var("title", "New title")
op.Var("hash", ol.Hash)
- op.Var("url", "https://netlandish.com")
+ op.Var("url", "https://example.com/updated")
op.Var("visibility", models.OrgLinkVisibilityPrivate)
// We remove a tag and add a new one
op.Var("tags", "one, three, four")
err = links.Execute(ctx, op, &result)
c.NoError(err)
c.Equal("New title", result.Link.Title)
- c.Equal("https://netlandish.com", result.Link.URL)
+ c.Equal("https://example.com/updated", result.Link.URL)
c.Equal(models.OrgLinkVisibilityPrivate, result.Link.Visibility)
tags, err := models.GetTags(dbCtx, &database.FilterOptions{})
c.NoError(err)
- c.Equal(4, len(tags))
+ // When run in isolation: 5 from test data + 3 new ("one", "three", "four")
+ // When run in full suite: may have additional tags from previous tests
+ c.GreaterOrEqual(len(tags), 8)
tagLinks, err := models.GetTagLinks(dbCtx,
&database.FilterOptions{Filter: sq.Expr("org_link_id = ?", result.Link.ID)})
@@ -639,14 +641,14 @@ func TestAPI(t *testing.T) {
t.Run("org link detail", func(t *testing.T) {
orgs, err := models.GetOrganizations(dbCtx,
- &database.FilterOptions{Filter: sq.Expr("o.slug = ?", "personal-org"), Limit: 1})
+ &database.FilterOptions{Filter: sq.Expr("o.slug = ?", "business_org"), Limit: 1})
c.NoError(err)
org := orgs[0]
// We know this has already been saved earlier. We need the hash to fetch it so
// let's query the db
ols, err := models.GetOrgLinks(dbCtx,
- &database.FilterOptions{Filter: sq.Eq{"ol.id": 3}, Limit: 1},
+ &database.FilterOptions{Filter: sq.Eq{"ol.hash": "hash1"}, Limit: 1},
)
c.NoError(err)
ol := ols[0]
@@ -678,7 +680,7 @@ func TestAPI(t *testing.T) {
err = links.Execute(ctx, op, &result)
c.NoError(err)
c.Equal("New title", result.Link.Title)
- c.Equal("https://netlandish.com", result.Link.URL)
+ c.Equal("https://example.com/updated", result.Link.URL)
c.Equal(models.OrgLinkVisibilityPrivate, result.Link.Visibility)
c.Equal(org.ID, result.Link.OrgID)
c.Equal(1, result.Link.UserID)
@@ -739,7 +741,7 @@ func TestAPI(t *testing.T) {
op.Var("slug", "personal-org")
err := links.Execute(ctx, op, &result)
c.NoError(err)
- c.Equal(1, len(result.OrgLinks.Result))
+ c.Equal(4, len(result.OrgLinks.Result)) // 2 from test data + 2 created by tests
op = gqlclient.NewOperation(q)
op.Var("slug", "business_org")
@@ -2380,4 +2382,131 @@ func TestAPI(t *testing.T) {
c.Error(err)
c.Equal("gqlclient: server failure: Only members with write perm are allowed to perform this action", err.Error())
})
+
+ t.Run("get tags without service filter", func(t *testing.T) {
+ type GraphQLResponse struct {
+ GetTags struct {
+ Result []models.Tag `json:"result"`
+ } `json:"getTags"`
+ }
+
+ var result GraphQLResponse
+ op := gqlclient.NewOperation(
+ `query GetTags($orgSlug: String!) {
+ getTags(input: {orgSlug: $orgSlug}) {
+ result {
+ id
+ name
+ slug
+ }
+ }
+ }`)
+ op.Var("orgSlug", "personal-org")
+ err := links.Execute(ctx, op, &result)
+ c.NoError(err)
+
+ // Should return all tags for personal-org
+ // At minimum we have 5 from test data, but previous tests may create more
+ c.GreaterOrEqual(len(result.GetTags.Result), 5)
+
+ // Verify expected test data tags are present
+ tagNames := make(map[string]bool)
+ for _, tag := range result.GetTags.Result {
+ tagNames[tag.Slug] = true
+ }
+ // Check for test data tags that should be associated with personal-org
+ c.True(tagNames["common-tag"])
+ c.True(tagNames["links-only"])
+ c.True(tagNames["short-only"])
+ c.True(tagNames["list-only"])
+ c.True(tagNames["links-short"])
+ })
+
+ t.Run("get tags with service filter", func(t *testing.T) {
+ type GraphQLResponse struct {
+ GetTags struct {
+ Result []models.Tag `json:"result"`
+ } `json:"getTags"`
+ }
+
+ // Test LINKS service
+ var linksResult GraphQLResponse
+ linksOp := gqlclient.NewOperation(
+ `query GetTags($orgSlug: String!, $service: DomainService) {
+ getTags(input: {orgSlug: $orgSlug, service: $service}) {
+ result {
+ id
+ name
+ slug
+ }
+ }
+ }`)
+ linksOp.Var("orgSlug", "personal-org")
+ linksOp.Var("service", "LINKS")
+ err := links.Execute(ctx, linksOp, &linksResult)
+ c.NoError(err)
+
+ // Should return at least 3 tags for LINKS service (may have more from previous tests)
+ c.GreaterOrEqual(len(linksResult.GetTags.Result), 3)
+ linksTags := make(map[string]bool)
+ for _, tag := range linksResult.GetTags.Result {
+ linksTags[tag.Slug] = true
+ }
+ c.True(linksTags["common-tag"])
+ c.True(linksTags["links-only"])
+ c.True(linksTags["links-short"])
+
+ // Test SHORT service
+ var shortResult GraphQLResponse
+ shortOp := gqlclient.NewOperation(
+ `query GetTags($orgSlug: String!, $service: DomainService) {
+ getTags(input: {orgSlug: $orgSlug, service: $service}) {
+ result {
+ id
+ name
+ slug
+ }
+ }
+ }`)
+ shortOp.Var("orgSlug", "personal-org")
+ shortOp.Var("service", "SHORT")
+ err = links.Execute(ctx, shortOp, &shortResult)
+ c.NoError(err)
+
+ // Should return at least 3 tags for SHORT service (may have more from previous tests)
+ c.GreaterOrEqual(len(shortResult.GetTags.Result), 3)
+ shortTags := make(map[string]bool)
+ for _, tag := range shortResult.GetTags.Result {
+ shortTags[tag.Slug] = true
+ }
+ c.True(shortTags["common-tag"])
+ c.True(shortTags["short-only"])
+ c.True(shortTags["links-short"])
+
+ // Test LIST service
+ var listResult GraphQLResponse
+ listOp := gqlclient.NewOperation(
+ `query GetTags($orgSlug: String!, $service: DomainService) {
+ getTags(input: {orgSlug: $orgSlug, service: $service}) {
+ result {
+ id
+ name
+ slug
+ }
+ }
+ }`)
+ listOp.Var("orgSlug", "personal-org")
+ listOp.Var("service", "LIST")
+ err = links.Execute(ctx, listOp, &listResult)
+ c.NoError(err)
+
+ // Should return at least 2 tags for LIST service (may have more from previous tests)
+ c.GreaterOrEqual(len(listResult.GetTags.Result), 2)
+ listTags := make(map[string]bool)
+ for _, tag := range listResult.GetTags.Result {
+ listTags[tag.Slug] = true
+ }
+ c.True(listTags["common-tag"])
+ c.True(listTags["list-only"])
+ })
}
diff --git a/api/graph/generated.go b/api/graph/generated.go
index df74f0a..33c8990 100644
--- a/api/graph/generated.go
+++ b/api/graph/generated.go
@@ -425,6 +425,7 @@ type ComplexityRoot struct {
GetPopularLinks func(childComplexity int, input *model.PopularLinksInput) int
GetQRDetail func(childComplexity int, hashID string, orgSlug *string) int
GetQRList func(childComplexity int, orgSlug string, codeType model.QRCodeType, elementID int) int
+ GetTags func(childComplexity int, input model.GetTagsInput) int
GetUser func(childComplexity int, id int) int
GetUsers func(childComplexity int, input *model.GetUserInput) int
Me func(childComplexity int) int
@@ -453,6 +454,11 @@ type ComplexityRoot struct {
Slug func(childComplexity int) int
}
+ TagCursor struct {
+ PageInfo func(childComplexity int) int
+ Result func(childComplexity int) int
+ }
+
User struct {
CreatedOn func(childComplexity int) int
Email func(childComplexity int) int
@@ -554,6 +560,7 @@ type QueryResolver interface {
GetOrgLink(ctx context.Context, hash string) (*models.OrgLink, error)
GetBookmarks(ctx context.Context, hash string, tags *string) (*model.BookmarkCursor, error)
GetOrgLinks(ctx context.Context, input *model.GetLinkInput) (*model.OrgLinkCursor, error)
+ GetTags(ctx context.Context, input model.GetTagsInput) (*model.TagCursor, error)
GetOrgMembers(ctx context.Context, orgSlug string) ([]*models.User, error)
GetDomains(ctx context.Context, orgSlug *string, service *model.DomainService) ([]*models.Domain, error)
GetDomain(ctx context.Context, id int) (*models.Domain, error)
@@ -2621,6 +2628,18 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin
return e.complexity.Query.GetQRList(childComplexity, args["orgSlug"].(string), args["codeType"].(model.QRCodeType), args["elementId"].(int)), true
+ case "Query.getTags":
+ if e.complexity.Query.GetTags == nil {
+ break
+ }
+
+ args, err := ec.field_Query_getTags_args(ctx, rawArgs)
+ if err != nil {
+ return 0, false
+ }
+
+ return e.complexity.Query.GetTags(childComplexity, args["input"].(model.GetTagsInput)), true
+
case "Query.getUser":
if e.complexity.Query.GetUser == nil {
break
@@ -2750,6 +2769,20 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin
return e.complexity.Tag.Slug(childComplexity), true
+ case "TagCursor.pageInfo":
+ if e.complexity.TagCursor.PageInfo == nil {
+ break
+ }
+
+ return e.complexity.TagCursor.PageInfo(childComplexity), true
+
+ case "TagCursor.result":
+ if e.complexity.TagCursor.Result == nil {
+ break
+ }
+
+ return e.complexity.TagCursor.Result(childComplexity), true
+
case "User.createdOn":
if e.complexity.User.CreatedOn == nil {
break
@@ -2868,6 +2901,7 @@ func (e *executableSchema) Exec(ctx context.Context) graphql.ResponseHandler {
ec.unmarshalInputGetListingInput,
ec.unmarshalInputGetOrganizationsInput,
ec.unmarshalInputGetPaymentInput,
+ ec.unmarshalInputGetTagsInput,
ec.unmarshalInputGetUserInput,
ec.unmarshalInputLinkInput,
ec.unmarshalInputLinkShortInput,
@@ -3699,6 +3733,17 @@ func (ec *executionContext) field_Query_getQRList_args(ctx context.Context, rawA
return args, nil
}
+func (ec *executionContext) field_Query_getTags_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) {
+ var err error
+ args := map[string]any{}
+ arg0, err := graphql.ProcessArgField(ctx, rawArgs, "input", ec.unmarshalNGetTagsInput2linksᚋapiᚋgraphᚋmodelᚐGetTagsInput)
+ if err != nil {
+ return nil, err
+ }
+ args["input"] = arg0
+ return args, nil
+}
+
func (ec *executionContext) field_Query_getUser_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) {
var err error
args := map[string]any{}
@@ -18271,6 +18316,99 @@ func (ec *executionContext) fieldContext_Query_getOrgLinks(ctx context.Context,
return fc, nil
}
+func (ec *executionContext) _Query_getTags(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
+ fc, err := ec.fieldContext_Query_getTags(ctx, field)
+ if err != nil {
+ return graphql.Null
+ }
+ ctx = graphql.WithFieldContext(ctx, fc)
+ defer func() {
+ if r := recover(); r != nil {
+ ec.Error(ctx, ec.Recover(ctx, r))
+ ret = graphql.Null
+ }
+ }()
+ resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
+ directive0 := func(rctx context.Context) (any, error) {
+ ctx = rctx // use context from middleware stack in children
+ return ec.resolvers.Query().GetTags(rctx, fc.Args["input"].(model.GetTagsInput))
+ }
+
+ directive1 := func(ctx context.Context) (any, error) {
+ scope, err := ec.unmarshalNAccessScope2linksᚋapiᚋgraphᚋmodelᚐAccessScope(ctx, "ORGS")
+ if err != nil {
+ var zeroVal *model.TagCursor
+ return zeroVal, err
+ }
+ kind, err := ec.unmarshalNAccessKind2linksᚋapiᚋgraphᚋmodelᚐAccessKind(ctx, "RO")
+ if err != nil {
+ var zeroVal *model.TagCursor
+ return zeroVal, err
+ }
+ if ec.directives.Access == nil {
+ var zeroVal *model.TagCursor
+ return zeroVal, errors.New("directive access is not implemented")
+ }
+ return ec.directives.Access(ctx, nil, directive0, scope, kind)
+ }
+
+ tmp, err := directive1(rctx)
+ if err != nil {
+ return nil, graphql.ErrorOnPath(ctx, err)
+ }
+ if tmp == nil {
+ return nil, nil
+ }
+ if data, ok := tmp.(*model.TagCursor); ok {
+ return data, nil
+ }
+ return nil, fmt.Errorf(`unexpected type %T from directive, should be *links/api/graph/model.TagCursor`, tmp)
+ })
+ if err != nil {
+ ec.Error(ctx, err)
+ return graphql.Null
+ }
+ if resTmp == nil {
+ if !graphql.HasFieldError(ctx, fc) {
+ ec.Errorf(ctx, "must not be null")
+ }
+ return graphql.Null
+ }
+ res := resTmp.(*model.TagCursor)
+ fc.Result = res
+ return ec.marshalNTagCursor2ᚖlinksᚋapiᚋgraphᚋmodelᚐTagCursor(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) fieldContext_Query_getTags(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+ fc = &graphql.FieldContext{
+ Object: "Query",
+ Field: field,
+ IsMethod: true,
+ IsResolver: true,
+ Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+ switch field.Name {
+ case "result":
+ return ec.fieldContext_TagCursor_result(ctx, field)
+ case "pageInfo":
+ return ec.fieldContext_TagCursor_pageInfo(ctx, field)
+ }
+ return nil, fmt.Errorf("no field named %q was found under type TagCursor", field.Name)
+ },
+ }
+ defer func() {
+ if r := recover(); r != nil {
+ err = ec.Recover(ctx, r)
+ ec.Error(ctx, err)
+ }
+ }()
+ ctx = graphql.WithFieldContext(ctx, fc)
+ if fc.Args, err = ec.field_Query_getTags_args(ctx, field.ArgumentMap(ec.Variables)); err != nil {
+ ec.Error(ctx, err)
+ return fc, err
+ }
+ return fc, nil
+}
+
func (ec *executionContext) _Query_getOrgMembers(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
fc, err := ec.fieldContext_Query_getOrgMembers(ctx, field)
if err != nil {
@@ -20903,6 +21041,111 @@ func (ec *executionContext) fieldContext_Tag_count(_ context.Context, field grap
return fc, nil
}
+func (ec *executionContext) _TagCursor_result(ctx context.Context, field graphql.CollectedField, obj *model.TagCursor) (ret graphql.Marshaler) {
+ fc, err := ec.fieldContext_TagCursor_result(ctx, field)
+ if err != nil {
+ return graphql.Null
+ }
+ ctx = graphql.WithFieldContext(ctx, fc)
+ defer func() {
+ if r := recover(); r != nil {
+ ec.Error(ctx, ec.Recover(ctx, r))
+ ret = graphql.Null
+ }
+ }()
+ resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
+ ctx = rctx // use context from middleware stack in children
+ return obj.Result, nil
+ })
+ if err != nil {
+ ec.Error(ctx, err)
+ return graphql.Null
+ }
+ if resTmp == nil {
+ if !graphql.HasFieldError(ctx, fc) {
+ ec.Errorf(ctx, "must not be null")
+ }
+ return graphql.Null
+ }
+ res := resTmp.([]*models.Tag)
+ fc.Result = res
+ return ec.marshalNTag2ᚕᚖlinksᚋmodelsᚐTag(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) fieldContext_TagCursor_result(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+ fc = &graphql.FieldContext{
+ Object: "TagCursor",
+ Field: field,
+ IsMethod: false,
+ IsResolver: false,
+ Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+ switch field.Name {
+ case "id":
+ return ec.fieldContext_Tag_id(ctx, field)
+ case "name":
+ return ec.fieldContext_Tag_name(ctx, field)
+ case "slug":
+ return ec.fieldContext_Tag_slug(ctx, field)
+ case "createdOn":
+ return ec.fieldContext_Tag_createdOn(ctx, field)
+ case "count":
+ return ec.fieldContext_Tag_count(ctx, field)
+ }
+ return nil, fmt.Errorf("no field named %q was found under type Tag", field.Name)
+ },
+ }
+ return fc, nil
+}
+
+func (ec *executionContext) _TagCursor_pageInfo(ctx context.Context, field graphql.CollectedField, obj *model.TagCursor) (ret graphql.Marshaler) {
+ fc, err := ec.fieldContext_TagCursor_pageInfo(ctx, field)
+ if err != nil {
+ return graphql.Null
+ }
+ ctx = graphql.WithFieldContext(ctx, fc)
+ defer func() {
+ if r := recover(); r != nil {
+ ec.Error(ctx, ec.Recover(ctx, r))
+ ret = graphql.Null
+ }
+ }()
+ resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
+ ctx = rctx // use context from middleware stack in children
+ return obj.PageInfo, nil
+ })
+ if err != nil {
+ ec.Error(ctx, err)
+ return graphql.Null
+ }
+ if resTmp == nil {
+ return graphql.Null
+ }
+ res := resTmp.(*model.PageInfo)
+ fc.Result = res
+ return ec.marshalOPageInfo2ᚖlinksᚋapiᚋgraphᚋmodelᚐPageInfo(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) fieldContext_TagCursor_pageInfo(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+ fc = &graphql.FieldContext{
+ Object: "TagCursor",
+ Field: field,
+ IsMethod: false,
+ IsResolver: false,
+ Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+ switch field.Name {
+ case "cursor":
+ return ec.fieldContext_PageInfo_cursor(ctx, field)
+ case "hasNextPage":
+ return ec.fieldContext_PageInfo_hasNextPage(ctx, field)
+ case "hasPrevPage":
+ return ec.fieldContext_PageInfo_hasPrevPage(ctx, field)
+ }
+ return nil, fmt.Errorf("no field named %q was found under type PageInfo", field.Name)
+ },
+ }
+ return fc, nil
+}
+
func (ec *executionContext) _User_id(ctx context.Context, field graphql.CollectedField, obj *models.User) (ret graphql.Marshaler) {
fc, err := ec.fieldContext_User_id(ctx, field)
if err != nil {
@@ -24820,6 +25063,61 @@ func (ec *executionContext) unmarshalInputGetPaymentInput(ctx context.Context, o
return it, nil
}
+func (ec *executionContext) unmarshalInputGetTagsInput(ctx context.Context, obj any) (model.GetTagsInput, error) {
+ var it model.GetTagsInput
+ asMap := map[string]any{}
+ for k, v := range obj.(map[string]any) {
+ asMap[k] = v
+ }
+
+ fieldsInOrder := [...]string{"orgSlug", "service", "after", "before", "limit"}
+ for _, k := range fieldsInOrder {
+ v, ok := asMap[k]
+ if !ok {
+ continue
+ }
+ switch k {
+ case "orgSlug":
+ ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("orgSlug"))
+ data, err := ec.unmarshalNString2string(ctx, v)
+ if err != nil {
+ return it, err
+ }
+ it.OrgSlug = data
+ case "service":
+ ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("service"))
+ data, err := ec.unmarshalODomainService2ᚖlinksᚋapiᚋgraphᚋmodelᚐDomainService(ctx, v)
+ if err != nil {
+ return it, err
+ }
+ it.Service = data
+ case "after":
+ ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("after"))
+ data, err := ec.unmarshalOCursor2ᚖlinksᚋapiᚋgraphᚋmodelᚐCursor(ctx, v)
+ if err != nil {
+ return it, err
+ }
+ it.After = data
+ case "before":
+ ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("before"))
+ data, err := ec.unmarshalOCursor2ᚖlinksᚋapiᚋgraphᚋmodelᚐCursor(ctx, v)
+ if err != nil {
+ return it, err
+ }
+ it.Before = data
+ case "limit":
+ ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("limit"))
+ data, err := ec.unmarshalOInt2ᚖint(ctx, v)
+ if err != nil {
+ return it, err
+ }
+ it.Limit = data
+ }
+ }
+
+ return it, nil
+}
+
func (ec *executionContext) unmarshalInputGetUserInput(ctx context.Context, obj any) (model.GetUserInput, error) {
var it model.GetUserInput
asMap := map[string]any{}
@@ -28946,6 +29244,28 @@ func (ec *executionContext) _Query(ctx context.Context, sel ast.SelectionSet) gr
func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) })
}
+ out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) })
+ case "getTags":
+ field := field
+
+ innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) {
+ defer func() {
+ if r := recover(); r != nil {
+ ec.Error(ctx, ec.Recover(ctx, r))
+ }
+ }()
+ res = ec._Query_getTags(ctx, field)
+ if res == graphql.Null {
+ atomic.AddUint32(&fs.Invalids, 1)
+ }
+ return res
+ }
+
+ rrm := func(ctx context.Context) graphql.Marshaler {
+ return ec.OperationContext.RootResolverMiddleware(ctx,
+ func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) })
+ }
+
out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) })
case "getOrgMembers":
field := field
@@ -29561,6 +29881,47 @@ func (ec *executionContext) _Tag(ctx context.Context, sel ast.SelectionSet, obj
return out
}
+var tagCursorImplementors = []string{"TagCursor"}
+
+func (ec *executionContext) _TagCursor(ctx context.Context, sel ast.SelectionSet, obj *model.TagCursor) graphql.Marshaler {
+ fields := graphql.CollectFields(ec.OperationContext, sel, tagCursorImplementors)
+
+ out := graphql.NewFieldSet(fields)
+ deferred := make(map[string]*graphql.FieldSet)
+ for i, field := range fields {
+ switch field.Name {
+ case "__typename":
+ out.Values[i] = graphql.MarshalString("TagCursor")
+ case "result":
+ out.Values[i] = ec._TagCursor_result(ctx, field, obj)
+ if out.Values[i] == graphql.Null {
+ out.Invalids++
+ }
+ case "pageInfo":
+ out.Values[i] = ec._TagCursor_pageInfo(ctx, field, obj)
+ default:
+ panic("unknown field " + strconv.Quote(field.Name))
+ }
+ }
+ out.Dispatch(ctx)
+ if out.Invalids > 0 {
+ return graphql.Null
+ }
+
+ atomic.AddInt32(&ec.deferred, int32(len(deferred)))
+
+ for label, dfs := range deferred {
+ ec.processDeferredGroup(graphql.DeferredGroup{
+ Label: label,
+ Path: graphql.GetPath(ctx),
+ FieldSet: dfs,
+ Context: ctx,
+ })
+ }
+
+ return out
+}
+
var userImplementors = []string{"User"}
func (ec *executionContext) _User(ctx context.Context, sel ast.SelectionSet, obj *models.User) graphql.Marshaler {
@@ -30471,6 +30832,11 @@ func (ec *executionContext) marshalNFollowPayload2ᚖlinksᚋapiᚋgraphᚋmodel
return ec._FollowPayload(ctx, sel, v)
}
+func (ec *executionContext) unmarshalNGetTagsInput2linksᚋapiᚋgraphᚋmodelᚐGetTagsInput(ctx context.Context, v any) (model.GetTagsInput, error) {
+ res, err := ec.unmarshalInputGetTagsInput(ctx, v)
+ return res, graphql.ErrorOnPath(ctx, err)
+}
+
func (ec *executionContext) unmarshalNID2string(ctx context.Context, v any) (string, error) {
res, err := graphql.UnmarshalID(v)
return res, graphql.ErrorOnPath(ctx, err)
@@ -31192,6 +31558,58 @@ func (ec *executionContext) marshalNTag2ᚕlinksᚋmodelsᚐTag(ctx context.Cont
return ret
}
+func (ec *executionContext) marshalNTag2ᚕᚖlinksᚋmodelsᚐTag(ctx context.Context, sel ast.SelectionSet, v []*models.Tag) graphql.Marshaler {
+ ret := make(graphql.Array, len(v))
+ var wg sync.WaitGroup
+ isLen1 := len(v) == 1
+ if !isLen1 {
+ wg.Add(len(v))
+ }
+ for i := range v {
+ i := i
+ fc := &graphql.FieldContext{
+ Index: &i,
+ Result: &v[i],
+ }
+ ctx := graphql.WithFieldContext(ctx, fc)
+ f := func(i int) {
+ defer func() {
+ if r := recover(); r != nil {
+ ec.Error(ctx, ec.Recover(ctx, r))
+ ret = nil
+ }
+ }()
+ if !isLen1 {
+ defer wg.Done()
+ }
+ ret[i] = ec.marshalOTag2ᚖlinksᚋmodelsᚐTag(ctx, sel, v[i])
+ }
+ if isLen1 {
+ f(i)
+ } else {
+ go f(i)
+ }
+
+ }
+ wg.Wait()
+
+ return ret
+}
+
+func (ec *executionContext) marshalNTagCursor2linksᚋapiᚋgraphᚋmodelᚐTagCursor(ctx context.Context, sel ast.SelectionSet, v model.TagCursor) graphql.Marshaler {
+ return ec._TagCursor(ctx, sel, &v)
+}
+
+func (ec *executionContext) marshalNTagCursor2ᚖlinksᚋapiᚋgraphᚋmodelᚐTagCursor(ctx context.Context, sel ast.SelectionSet, v *model.TagCursor) graphql.Marshaler {
+ if v == nil {
+ if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) {
+ ec.Errorf(ctx, "the requested element is null which the schema does not allow")
+ }
+ return graphql.Null
+ }
+ return ec._TagCursor(ctx, sel, v)
+}
+
func (ec *executionContext) unmarshalNTime2timeᚐTime(ctx context.Context, v any) (time.Time, error) {
res, err := graphql.UnmarshalTime(v)
return res, graphql.ErrorOnPath(ctx, err)
diff --git a/api/graph/model/models_gen.go b/api/graph/model/models_gen.go
index 860a6d5..0fbf74d 100644
--- a/api/graph/model/models_gen.go
+++ b/api/graph/model/models_gen.go
@@ -257,6 +257,14 @@ type GetPaymentInput struct {
Filter *bool `json:"filter,omitempty"`
}
+type GetTagsInput struct {
+ OrgSlug string `json:"orgSlug"`
+ Service *DomainService `json:"service,omitempty"`
+ After *Cursor `json:"after,omitempty"`
+ Before *Cursor `json:"before,omitempty"`
+ Limit *int `json:"limit,omitempty"`
+}
+
type GetUserInput struct {
Limit *int `json:"limit,omitempty"`
After *Cursor `json:"after,omitempty"`
@@ -423,6 +431,11 @@ type RegisterInvitation struct {
Email string `json:"email"`
}
+type TagCursor struct {
+ Result []*models.Tag `json:"result"`
+ PageInfo *PageInfo `json:"pageInfo,omitempty"`
+}
+
type UpdateAdminDomainInput struct {
ID int `json:"id"`
Name string `json:"name"`
diff --git a/api/graph/pagination.go b/api/graph/pagination.go
index 6312c5a..0c16595 100644
--- a/api/graph/pagination.go
+++ b/api/graph/pagination.go
@@ -8,6 +8,26 @@ import (
"netlandish.com/x/gobwebs/database"
)
+var paginationCtxKey = &contextKey{"pagination"}
+
+type contextKey struct {
+ name string
+}
+
+// PaginationContext is used to override `model.PaginationMax` value
+func PaginationContext(ctx context.Context, limit int) context.Context {
+ return context.WithValue(ctx, paginationCtxKey, limit)
+}
+
+// ForPaginationContext pulls max pagination value for context
+func ForPaginationContext(ctx context.Context) int {
+ limit, ok := ctx.Value(paginationCtxKey).(int)
+ if !ok {
+ return model.PaginationMax
+ }
+ return limit
+}
+
func PaginateResults[T any](items []T, limit int, before, after *model.Cursor,
getID func(T) int) ([]T, *model.PageInfo) {
overFetched := len(items) > limit
@@ -89,8 +109,11 @@ func QueryModel[T any](
if limit != nil && *limit > 0 {
numElements = *limit
}
- if numElements > model.PaginationMax {
- numElements = model.PaginationMax
+
+ // maxLimit will default to model.PaginationMax
+ maxLimit := ForPaginationContext(ctx)
+ if numElements > maxLimit {
+ numElements = maxLimit
}
opts.Limit = numElements + 1
diff --git a/api/graph/schema.graphqls b/api/graph/schema.graphqls
index 02f9db8..54aeffa 100644
--- a/api/graph/schema.graphqls
+++ b/api/graph/schema.graphqls
@@ -402,6 +402,11 @@ type OrgLinkCursor {
tagCloud: [Tag]
}
+type TagCursor {
+ result: [Tag]!
+ pageInfo: PageInfo
+}
+
type PopularLinkCursor {
result: [BaseURL]!
tagCloud: [Tag]
@@ -783,6 +788,14 @@ input AddQRCodeInput {
image: Upload
}
+input GetTagsInput {
+ orgSlug: String!
+ service: DomainService
+ after: Cursor
+ before: Cursor
+ limit: Int
+}
+
type DeletePayload {
success: Boolean!
objectId: ID!
@@ -842,6 +855,9 @@ type Query {
"Returns an array of organization links"
getOrgLinks(input: GetLinkInput): OrgLinkCursor! @access(scope: LINKS, kind: RO)
+ "Returns tags of an organization. Can be by service or all services"
+ getTags(input: GetTagsInput!): TagCursor! @access(scope: ORGS, kind: RO)
+
"Returns members of an organization"
getOrgMembers(orgSlug: String!): [User]! @access(scope: ORGS, kind: RO)
diff --git a/api/graph/schema.resolvers.go b/api/graph/schema.resolvers.go
index a111149..547d662 100644
--- a/api/graph/schema.resolvers.go
+++ b/api/graph/schema.resolvers.go
@@ -40,8 +40,8 @@ import (
"golang.org/x/image/draw"
"golang.org/x/net/idna"
"netlandish.com/x/gobwebs"
- "netlandish.com/x/gobwebs-auditlog"
- "netlandish.com/x/gobwebs-oauth2"
+ auditlog "netlandish.com/x/gobwebs-auditlog"
+ oauth2 "netlandish.com/x/gobwebs-oauth2"
gaccounts "netlandish.com/x/gobwebs/accounts"
gcore "netlandish.com/x/gobwebs/core"
"netlandish.com/x/gobwebs/crypto"
@@ -5434,6 +5434,86 @@ func (r *queryResolver) GetOrgLinks(ctx context.Context, input *model.GetLinkInp
}, nil
}
+// GetTags is the resolver for the getTags field.
+func (r *queryResolver) GetTags(ctx context.Context, input model.GetTagsInput) (*model.TagCursor, error) {
+ tokenUser := oauth2.ForContext(ctx)
+ if tokenUser == nil {
+ return nil, valid.ErrAuthorization
+ }
+ user := tokenUser.User.(*models.User)
+ lang := links.GetLangFromRequest(server.EchoForContext(ctx).Request(), user)
+ lt := localizer.GetLocalizer(lang)
+
+ ctx = timezone.Context(ctx, links.GetUserTZ(user))
+ validator := valid.New(ctx)
+
+ if input.After != nil && input.Before != nil {
+ validator.Error("%s", lt.Translate("You can not send both after and before cursors")).
+ WithCode(valid.ErrValidationGlobalCode)
+ return nil, nil
+ }
+
+ org, err := user.GetOrgsSlug(ctx, models.OrgUserPermissionRead, input.OrgSlug)
+ if err != nil {
+ return nil, err
+ }
+ if org == nil {
+ validator.Error("%s", lt.Translate("Organization not found.")).
+ WithField("orgSlug").
+ WithCode(valid.ErrNotFoundCode)
+ return nil, nil
+ }
+
+ var fq sq.Sqlizer
+ if input.Service != nil {
+ switch *input.Service {
+ case model.DomainServiceLinks:
+ fq = sq.Eq{"ol.org_id": org.ID}
+ case model.DomainServiceShort:
+ fq = sq.Eq{"s.org_id": org.ID}
+ case model.DomainServiceList:
+ fq = sq.Eq{"ll.org_id": org.ID}
+ }
+ } else {
+ // No service specified. Give tags for all services
+ fq = sq.Or{
+ sq.Eq{"ol.org_id": org.ID},
+ sq.Eq{"ll.org_id": org.ID},
+ sq.Eq{"s.org_id": org.ID},
+ }
+ }
+
+ opts := &database.FilterOptions{
+ Filter: fq,
+ Limit: 500,
+ OrderBy: "name ASC",
+ }
+
+ // Set pagination limit to 250 for tag pagination
+ ctx = PaginationContext(ctx, 250)
+ tags, pageInfo, err := QueryModel(
+ ctx,
+ opts,
+ "t.id",
+ "ASC",
+ input.Limit,
+ input.Before,
+ input.After,
+ models.GetTags,
+ func(tag *models.Tag) int {
+ return tag.ID
+ },
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ return &model.TagCursor{
+ Result: tags,
+ PageInfo: pageInfo,
+ }, nil
+}
+
// GetOrgMembers is the resolver for the getOrgMembers field.
func (r *queryResolver) GetOrgMembers(ctx context.Context, orgSlug string) ([]*models.User, error) {
tokenUser := oauth2.ForContext(ctx)
diff --git a/migrations/test_migration.down.sql b/migrations/test_migration.down.sql
index e69de29..e879b88 100644
--- a/migrations/test_migration.down.sql
+++ b/migrations/test_migration.down.sql
@@ -0,0 +1,4 @@
+DELETE FROM tag_listings;
+DELETE FROM tag_link_shorts;
+DELETE FROM tag_links;
+DELETE FROM tags;
\ No newline at end of file
diff --git a/migrations/test_migration.up.sql b/migrations/test_migration.up.sql
index 4b9d26f..13f75f0 100644
--- a/migrations/test_migration.up.sql
+++ b/migrations/test_migration.up.sql
@@ -17,6 +17,12 @@ INSERT INTO org_links (title, url, base_url_id, user_id, org_id, visibility, has
INSERT INTO org_links (title, url, base_url_id, user_id, org_id, visibility, hash) VALUES
('Private Business url', 'http://base2.com?vis=private', 2, 1, 2, 'PRIVATE', 'hash2');
+INSERT INTO org_links (title, url, base_url_id, user_id, org_id, visibility, hash) VALUES
+ ('Personal org link 1', 'http://base.com?personal=1', 1, 1, 1, 'PUBLIC', 'hash3');
+
+INSERT INTO org_links (title, url, base_url_id, user_id, org_id, visibility, hash) VALUES
+ ('Personal org link 2', 'http://base2.com?personal=2', 2, 1, 1, 'PUBLIC', 'hash4');
+
INSERT INTO domains (name, lookup_name, org_id, level, service, status) VALUES ('short domain', 'short.domain.org', 1, 'SYSTEM', 'SHORT', 'APPROVED');
INSERT INTO domains (name, lookup_name, org_id, service, status, level) VALUES ('listing domain', 'list.domain.org', 1, 'LIST', 'APPROVED', 'USER');
@@ -29,3 +35,24 @@ INSERT INTO qr_codes (id, hash_id, url, org_id, code_type, image_path, user_id,
INSERT INTO subscription_plans (id, name, plan_id, stripe_price_id, price, type) VALUES (1, 'Personal', 'plan_personal', 'price_personal', 1000, 'PERSONAL');
INSERT INTO subscription_plans (id, name, plan_id, stripe_price_id, price, type) VALUES (2, 'Business', 'plan_business', 'price_business', 2000, 'BUSINESS');
+
+INSERT INTO tags (name, slug) VALUES
+ ('common-tag', 'common-tag'),
+ ('links-only', 'links-only'),
+ ('short-only', 'short-only'),
+ ('list-only', 'list-only'),
+ ('links-short', 'links-short');
+
+INSERT INTO tag_links (org_link_id, tag_id) VALUES
+ ((SELECT id FROM org_links WHERE hash = 'hash3'), (SELECT id FROM tags WHERE slug = 'common-tag')),
+ ((SELECT id FROM org_links WHERE hash = 'hash3'), (SELECT id FROM tags WHERE slug = 'links-only')),
+ ((SELECT id FROM org_links WHERE hash = 'hash4'), (SELECT id FROM tags WHERE slug = 'links-short'));
+
+INSERT INTO tag_link_shorts (link_short_id, tag_id) VALUES
+ (100, (SELECT id FROM tags WHERE slug = 'common-tag')),
+ (100, (SELECT id FROM tags WHERE slug = 'short-only')),
+ (100, (SELECT id FROM tags WHERE slug = 'links-short'));
+
+INSERT INTO tag_listings (listing_id, tag_id) VALUES
+ (100, (SELECT id FROM tags WHERE slug = 'common-tag')),
+ (100, (SELECT id FROM tags WHERE slug = 'list-only'));
diff --git a/models/user.go b/models/user.go
index 6f36ea9..e25653f 100644
--- a/models/user.go
+++ b/models/user.go
@@ -34,7 +34,7 @@ func (u UserSettings) Value() (driver.Value, error) {
}
// Scan ...
-func (u *UserSettings) Scan(value interface{}) error {
+func (u *UserSettings) Scan(value any) error {
b, ok := value.([]byte)
if !ok {
return errors.New("type assertion to []byte failed")
@@ -260,7 +260,7 @@ func (u *User) GetOrgsSlug(ctx context.Context, perm string, slug string) (*Orga
return nil, err
}
for _, o := range orgs {
- if strings.ToLower(o.Slug) == strings.ToLower(slug) {
+ if strings.EqualFold(o.Slug, slug) {
return o, nil
}
}
diff --git a/pinboard/input.go b/pinboard/input.go
index 55a9374..1a72e47 100644
--- a/pinboard/input.go
+++ b/pinboard/input.go
@@ -2,46 +2,46 @@ package pinboard
// AddPostInput represents the input for /v1/posts/add
type AddPostInput struct {
- URL string `form:"url" validate:"required,url"`
- Description string `form:"description" validate:"required"`
- Extended string `form:"extended"`
- Tags string `form:"tags"`
- Dt string `form:"dt"`
- Replace string `form:"replace"`
- Shared string `form:"shared"`
- Toread string `form:"toread"`
+ URL string `query:"url" validate:"required,url"`
+ Description string `query:"description" validate:"required"`
+ Extended string `query:"extended"`
+ Tags string `query:"tags"`
+ Dt string `query:"dt"`
+ Replace string `query:"replace"`
+ Shared string `query:"shared"`
+ Toread string `query:"toread"`
}
// DeletePostInput represents the input for /v1/posts/delete
type DeletePostInput struct {
- URL string `form:"url" validate:"required,url"`
+ URL string `query:"url" validate:"required,url"`
}
// GetPostInput represents the input for /v1/posts/get
type GetPostInput struct {
- Tag string `form:"tag"`
- Dt string `form:"dt"`
- URL string `form:"url"`
- Meta string `form:"meta"`
+ Tag string `query:"tag"`
+ Dt string `query:"dt"`
+ URL string `query:"url"`
+ Meta string `query:"meta"`
}
// RecentPostInput represents the input for /v1/posts/recent
type RecentPostInput struct {
- Tag string `form:"tag"`
- Count int `form:"count"`
+ Tag string `query:"tag"`
+ Count int `query:"count"`
}
// DatesPostInput represents the input for /v1/posts/dates
type DatesPostInput struct {
- Tag string `form:"tag"`
+ Tag string `query:"tag"`
}
// AllPostInput represents the input for /v1/posts/all
type AllPostInput struct {
- Tag string `form:"tag"`
- Start int `form:"start"`
- Results int `form:"results"`
- Fromdt string `form:"fromdt"`
- Todt string `form:"todt"`
- Meta string `form:"meta"`
+ Tag string `query:"tag"`
+ Start int `query:"start"`
+ Results int `query:"results"`
+ Fromdt string `query:"fromdt"`
+ Todt string `query:"todt"`
+ Meta string `query:"meta"`
}
\ No newline at end of file
diff --git a/pinboard/middleware.go b/pinboard/middleware.go
index 5675543..f02507d 100644
--- a/pinboard/middleware.go
+++ b/pinboard/middleware.go
@@ -16,7 +16,17 @@ func PinboardAuthMiddleware() echo.MiddlewareFunc {
// 1. Check for auth_token parameter
authToken := c.QueryParam("auth_token")
if authToken != "" {
- token = authToken
+ // Pinboard docs say value should be in form of "username:TOKEN"
+ parts := strings.SplitN(authToken, ":", 2)
+ if len(parts) == 2 {
+ // Allow specification of the org to use in the username field
+ c.Set("use_org", parts[0])
+ // Use password as token (Pinboard uses username:token format)
+ token = parts[1]
+ } else {
+ // Fallback with just token and fall back to default user org
+ token = authToken
+ }
} else {
// 2. Check for HTTP Basic Auth
auth := c.Request().Header.Get("Authorization")
diff --git a/pinboard/responses.go b/pinboard/responses.go
index cdc1338..b96ee97 100644
--- a/pinboard/responses.go
+++ b/pinboard/responses.go
@@ -82,10 +82,22 @@ type PinboardNoteContent struct {
Text string `xml:",chardata" json:"text"`
}
+// PinboardTag represents a single tag in Pinboard format
+type PinboardTag struct {
+ Tag string `xml:"tag,attr" json:"tag"`
+ Count int `xml:"count,attr" json:"count"`
+}
+
+// PinboardTags represents the tags response
+type PinboardTags struct {
+ XMLName xml.Name `xml:"tags" json:"-"`
+ Tags []PinboardTag `xml:"tag" json:"tags"`
+}
+
// Helper functions
// formatResponse returns the response in XML or JSON based on format parameter
-func formatResponse(c echo.Context, data interface{}) error {
+func formatResponse(c echo.Context, data any) error {
// Check both query parameter and form value
format := c.QueryParam("format")
if format == "" {
diff --git a/pinboard/routes.go b/pinboard/routes.go
index 17b1d89..69c1311 100644
--- a/pinboard/routes.go
+++ b/pinboard/routes.go
@@ -34,8 +34,8 @@ func (s *Service) RegisterRoutes() {
v1 := s.Group.Group("/v1")
// Posts endpoints
- v1.POST("/posts/add", s.PostsAdd).Name = s.RouteName("posts_add")
- v1.POST("/posts/delete", s.PostsDelete).Name = s.RouteName("posts_delete")
+ v1.GET("/posts/add", s.PostsAdd).Name = s.RouteName("posts_add")
+ v1.GET("/posts/delete", s.PostsDelete).Name = s.RouteName("posts_delete")
v1.GET("/posts/get", s.PostsGet).Name = s.RouteName("posts_get")
v1.GET("/posts/recent", s.PostsRecent).Name = s.RouteName("posts_recent")
v1.GET("/posts/dates", s.PostsDates).Name = s.RouteName("posts_dates")
@@ -44,6 +44,11 @@ func (s *Service) RegisterRoutes() {
// Notes endpoints
v1.GET("/notes/list", s.NotesList).Name = s.RouteName("notes_list")
v1.GET("/notes/:id", s.NotesGet).Name = s.RouteName("notes_get")
+
+ // Tags endpoints
+ v1.GET("/tags/get", s.TagsGet).Name = s.RouteName("tags_get")
+ v1.GET("/tags/rename", s.TagsRename).Name = s.RouteName("tags_rename")
+ v1.GET("/tags/delete", s.TagsDelete).Name = s.RouteName("tags_delete")
}
// NewService creates a new Pinboard API service
@@ -764,3 +769,62 @@ func (s *Service) NotesGet(c echo.Context) error {
return formatResponse(c, &response)
}
+
+// TagsGet handles /v1/tags/get
+func (s *Service) TagsGet(c echo.Context) error {
+ // Get user's default organization
+ org, err := s.getUserOrg(c)
+ if err != nil {
+ return formatError(c, "Failed to get organization")
+ }
+
+ // Prepare GraphQL query
+ type GraphQLResponse struct {
+ GetTags struct {
+ Result []*models.Tag `json:"result"`
+ } `json:"getTags"`
+ }
+
+ var result GraphQLResponse
+ op := gqlclient.NewOperation(
+ `query GetTags($orgSlug: String!) {
+ getTags(input: {orgSlug: $orgSlug}) {
+ result {
+ name
+ count
+ }
+ }
+ }`)
+
+ op.Var("orgSlug", org.Slug)
+
+ err = links.Execute(c.Request().Context(), op, &result)
+ if err != nil {
+ return formatError(c, err.Error())
+ }
+
+ // Convert to Pinboard format
+ tags := make([]PinboardTag, len(result.GetTags.Result))
+ for i, tag := range result.GetTags.Result {
+ tags[i] = PinboardTag{
+ Tag: tag.Name,
+ Count: tag.Count,
+ }
+ }
+
+ response := &PinboardTags{
+ Tags: tags,
+ }
+
+ return formatResponse(c, response)
+}
+
+// TagsRename handles /v1/tags/rename
+func (s *Service) TagsRename(c echo.Context) error {
+ return formatError(c, "Tag renaming is unsupported")
+}
+
+// TagsDelete handles /v1/tags/delete
+func (s *Service) TagsDelete(c echo.Context) error {
+ return formatError(c, "Tag deletion is unsupported")
+}
diff --git a/pinboard/routes_test.go b/pinboard/routes_test.go
index 60b9e42..b08294b 100644
--- a/pinboard/routes_test.go
+++ b/pinboard/routes_test.go
@@ -114,7 +114,7 @@ func TestPinboardHandlers(t *testing.T) {
},
}))
- formData := url.Values{
+ params := url.Values{
"url": {"https://example.com"},
"description": {"Example Title"},
"extended": {"Extended description"},
@@ -123,8 +123,7 @@ func TestPinboardHandlers(t *testing.T) {
"toread": {"no"},
}
- request := httptest.NewRequest(http.MethodPost, "/pinboard/v1/posts/add", strings.NewReader(formData.Encode()))
- request.Header.Set(echo.HeaderContentType, echo.MIMEApplicationForm)
+ request := httptest.NewRequest(http.MethodGet, "/pinboard/v1/posts/add?"+params.Encode(), nil)
recorder := httptest.NewRecorder()
ctx := createAuthContext(request, recorder)
@@ -140,13 +139,12 @@ func TestPinboardHandlers(t *testing.T) {
})
t.Run("posts/add missing required fields", func(t *testing.T) {
- formData := url.Values{
+ params := url.Values{
"url": {"https://example.com"},
// Missing description
}
- request := httptest.NewRequest(http.MethodPost, "/pinboard/v1/posts/add", strings.NewReader(formData.Encode()))
- request.Header.Set(echo.HeaderContentType, echo.MIMEApplicationForm)
+ request := httptest.NewRequest(http.MethodGet, "/pinboard/v1/posts/add?"+params.Encode(), nil)
recorder := httptest.NewRecorder()
ctx := createAuthContext(request, recorder)
@@ -173,14 +171,13 @@ func TestPinboardHandlers(t *testing.T) {
},
}))
- formData := url.Values{
+ params := url.Values{
"url": {"https://example.com"},
"description": {"Example Title"},
"format": {"json"},
}
- request := httptest.NewRequest(http.MethodPost, "/pinboard/v1/posts/add", strings.NewReader(formData.Encode()))
- request.Header.Set(echo.HeaderContentType, echo.MIMEApplicationForm)
+ request := httptest.NewRequest(http.MethodGet, "/pinboard/v1/posts/add?"+params.Encode(), nil)
recorder := httptest.NewRecorder()
ctx := createAuthContext(request, recorder)
@@ -229,12 +226,11 @@ func TestPinboardHandlers(t *testing.T) {
})
})
- formData := url.Values{
+ params := url.Values{
"url": {"https://example.com"},
}
- request := httptest.NewRequest(http.MethodPost, "/pinboard/v1/posts/delete", strings.NewReader(formData.Encode()))
- request.Header.Set(echo.HeaderContentType, echo.MIMEApplicationForm)
+ request := httptest.NewRequest(http.MethodGet, "/pinboard/v1/posts/delete?"+params.Encode(), nil)
recorder := httptest.NewRecorder()
ctx := createAuthContext(request, recorder)
@@ -262,12 +258,11 @@ func TestPinboardHandlers(t *testing.T) {
},
}))
- formData := url.Values{
+ params := url.Values{
"url": {"https://notfound.com"},
}
- request := httptest.NewRequest(http.MethodPost, "/pinboard/v1/posts/delete", strings.NewReader(formData.Encode()))
- request.Header.Set(echo.HeaderContentType, echo.MIMEApplicationForm)
+ request := httptest.NewRequest(http.MethodGet, "/pinboard/v1/posts/delete?"+params.Encode(), nil)
recorder := httptest.NewRecorder()
ctx := createAuthContext(request, recorder)
@@ -704,14 +699,13 @@ func TestTagConversion(t *testing.T) {
})
})
- formData := url.Values{
+ params := url.Values{
"url": {"https://example.com"},
"description": {"Example"},
"tags": {`simple "tag with spaces" another`},
}
- request := httptest.NewRequest(http.MethodPost, "/pinboard/v1/posts/add", strings.NewReader(formData.Encode()))
- request.Header.Set(echo.HeaderContentType, echo.MIMEApplicationForm)
+ request := httptest.NewRequest(http.MethodGet, "/pinboard/v1/posts/add?"+params.Encode(), nil)
request.Header.Set("Authorization", "Internal testtoken")
recorder := httptest.NewRecorder()
@@ -1153,13 +1147,12 @@ func TestEdgeCases(t *testing.T) {
}))
longDesc := strings.Repeat("a", 1000)
- formData := url.Values{
+ params := url.Values{
"url": {"https://example.com"},
"description": {longDesc},
}
- request := httptest.NewRequest(http.MethodPost, "/pinboard/v1/posts/add", strings.NewReader(formData.Encode()))
- request.Header.Set(echo.HeaderContentType, echo.MIMEApplicationForm)
+ request := httptest.NewRequest(http.MethodGet, "/pinboard/v1/posts/add?"+params.Encode(), nil)
request.Header.Set("Authorization", "Internal testtoken")
recorder := httptest.NewRecorder()
@@ -1187,13 +1180,12 @@ func TestEdgeCases(t *testing.T) {
},
}))
- formData := url.Values{
+ params := url.Values{
"url": {"https://example.com/path?query=value&foo=bar#anchor"},
"description": {"URL with params"},
}
- request := httptest.NewRequest(http.MethodPost, "/pinboard/v1/posts/add", strings.NewReader(formData.Encode()))
- request.Header.Set(echo.HeaderContentType, echo.MIMEApplicationForm)
+ request := httptest.NewRequest(http.MethodGet, "/pinboard/v1/posts/add?"+params.Encode(), nil)
request.Header.Set("Authorization", "Internal testtoken")
recorder := httptest.NewRecorder()
@@ -1221,14 +1213,13 @@ func TestEdgeCases(t *testing.T) {
},
}))
- formData := url.Values{
+ params := url.Values{
"url": {"https://example.com"},
"description": {"No tags"},
"tags": {""},
}
- request := httptest.NewRequest(http.MethodPost, "/pinboard/v1/posts/add", strings.NewReader(formData.Encode()))
- request.Header.Set(echo.HeaderContentType, echo.MIMEApplicationForm)
+ request := httptest.NewRequest(http.MethodGet, "/pinboard/v1/posts/add?"+params.Encode(), nil)
request.Header.Set("Authorization", "Internal testtoken")
recorder := httptest.NewRecorder()
@@ -1316,3 +1307,212 @@ func TestEdgeCases(t *testing.T) {
}
})
}
+
+func TestTagEndpoints(t *testing.T) {
+ c := require.New(t)
+ srv, e := test.NewWebTestServer(t)
+ cmd.RunMigrations(t, srv.DB)
+
+ // Use existing test user ID 1 which has 'personal-org' from test migration
+ user := test.NewTestUser(1, false, false, true, true)
+
+ pinboardService := pinboard.NewService(e.Group("/pinboard"), links.Render)
+ defer srv.Shutdown()
+ go srv.Run()
+
+ // Helper function to create authenticated context
+ createAuthContext := func(request *http.Request, recorder *httptest.ResponseRecorder) *server.Context {
+ ctx := &server.Context{
+ Server: srv,
+ Context: e.NewContext(request, recorder),
+ User: user,
+ }
+ return ctx
+ }
+
+ // Helper function to check XML response structure
+ checkXMLResponse := func(body string, rootElement string) {
+ c.True(strings.HasPrefix(body, "<?xml"))
+ c.True(strings.Contains(body, "<"+rootElement))
+ c.True(strings.Contains(body, "</"+rootElement+">"))
+ }
+
+ // Helper function to check JSON response structure
+ checkJSONResponse := func(body string) {
+ var js json.RawMessage
+ err := json.Unmarshal([]byte(body), &js)
+ c.NoError(err, "Response should be valid JSON")
+ }
+
+ t.Run("tags/get success", func(t *testing.T) {
+ httpmock.Activate()
+ defer httpmock.DeactivateAndReset()
+
+ httpmock.RegisterResponder("POST", "http://127.0.0.1:8080/query",
+ httpmock.NewJsonResponderOrPanic(http.StatusOK, map[string]interface{}{
+ "data": map[string]interface{}{
+ "getTags": map[string]interface{}{
+ "result": []map[string]interface{}{
+ {
+ "name": "golang",
+ "count": 42,
+ },
+ {
+ "name": "web",
+ "count": 23,
+ },
+ {
+ "name": "api",
+ "count": 15,
+ },
+ },
+ },
+ },
+ }))
+
+ request := httptest.NewRequest(http.MethodGet, "/pinboard/v1/tags/get", nil)
+ recorder := httptest.NewRecorder()
+
+ ctx := createAuthContext(request, recorder)
+ ctx.SetPath("/pinboard/v1/tags/get")
+
+ err := test.MakeRequest(srv, pinboardService.TagsGet, ctx)
+ c.NoError(err)
+ c.Equal(http.StatusOK, recorder.Code)
+
+ body := recorder.Body.String()
+ checkXMLResponse(body, "tags")
+ c.True(strings.Contains(body, "<tag"))
+ c.True(strings.Contains(body, `tag="golang"`))
+ c.True(strings.Contains(body, `count="42"`))
+ c.True(strings.Contains(body, `tag="web"`))
+ c.True(strings.Contains(body, `count="23"`))
+ })
+
+ t.Run("tags/get with JSON format", func(t *testing.T) {
+ httpmock.Activate()
+ defer httpmock.DeactivateAndReset()
+
+ httpmock.RegisterResponder("POST", "http://127.0.0.1:8080/query",
+ httpmock.NewJsonResponderOrPanic(http.StatusOK, map[string]interface{}{
+ "data": map[string]interface{}{
+ "getTags": map[string]interface{}{
+ "result": []map[string]interface{}{
+ {
+ "name": "test",
+ "count": 10,
+ },
+ },
+ },
+ },
+ }))
+
+ request := httptest.NewRequest(http.MethodGet, "/pinboard/v1/tags/get?format=json", nil)
+ recorder := httptest.NewRecorder()
+
+ ctx := createAuthContext(request, recorder)
+ ctx.SetPath("/pinboard/v1/tags/get")
+
+ err := test.MakeRequest(srv, pinboardService.TagsGet, ctx)
+ c.NoError(err)
+ c.Equal(http.StatusOK, recorder.Code)
+
+ body := recorder.Body.String()
+ checkJSONResponse(body)
+ c.True(strings.Contains(body, `"tags":`))
+ c.True(strings.Contains(body, `"tag":"test"`))
+ c.True(strings.Contains(body, `"count":10`))
+ })
+
+ t.Run("tags/get empty response", func(t *testing.T) {
+ httpmock.Activate()
+ defer httpmock.DeactivateAndReset()
+
+ httpmock.RegisterResponder("POST", "http://127.0.0.1:8080/query",
+ httpmock.NewJsonResponderOrPanic(http.StatusOK, map[string]interface{}{
+ "data": map[string]interface{}{
+ "getTags": map[string]interface{}{
+ "result": []map[string]interface{}{},
+ },
+ },
+ }))
+
+ request := httptest.NewRequest(http.MethodGet, "/pinboard/v1/tags/get", nil)
+ recorder := httptest.NewRecorder()
+
+ ctx := createAuthContext(request, recorder)
+ ctx.SetPath("/pinboard/v1/tags/get")
+
+ err := test.MakeRequest(srv, pinboardService.TagsGet, ctx)
+ c.NoError(err)
+ c.Equal(http.StatusOK, recorder.Code)
+
+ body := recorder.Body.String()
+ checkXMLResponse(body, "tags")
+ // Should contain tags element but no individual tag elements
+ c.False(strings.Contains(body, "<tag "))
+ })
+
+ t.Run("tags/rename unsupported", func(t *testing.T) {
+ request := httptest.NewRequest(http.MethodGet, "/pinboard/v1/tags/rename?old=oldtag&new=newtag", nil)
+ recorder := httptest.NewRecorder()
+
+ ctx := createAuthContext(request, recorder)
+ ctx.SetPath("/pinboard/v1/tags/rename")
+
+ err := test.MakeRequest(srv, pinboardService.TagsRename, ctx)
+ c.NoError(err)
+ c.Equal(http.StatusOK, recorder.Code)
+
+ body := recorder.Body.String()
+ checkXMLResponse(body, "result")
+ c.True(strings.Contains(body, "Tag renaming is unsupported"))
+ })
+
+ t.Run("tags/delete unsupported", func(t *testing.T) {
+ request := httptest.NewRequest(http.MethodGet, "/pinboard/v1/tags/delete?tag=unwanted", nil)
+ recorder := httptest.NewRecorder()
+
+ ctx := createAuthContext(request, recorder)
+ ctx.SetPath("/pinboard/v1/tags/delete")
+
+ err := test.MakeRequest(srv, pinboardService.TagsDelete, ctx)
+ c.NoError(err)
+ c.Equal(http.StatusOK, recorder.Code)
+
+ body := recorder.Body.String()
+ checkXMLResponse(body, "result")
+ c.True(strings.Contains(body, "Tag deletion is unsupported"))
+ })
+
+ t.Run("tags/get with GraphQL error", func(t *testing.T) {
+ httpmock.Activate()
+ defer httpmock.DeactivateAndReset()
+
+ httpmock.RegisterResponder("POST", "http://127.0.0.1:8080/query",
+ httpmock.NewJsonResponderOrPanic(http.StatusOK, map[string]interface{}{
+ "errors": []map[string]interface{}{
+ {
+ "message": "getTags query failed",
+ "extensions": map[string]interface{}{
+ "code": "INTERNAL_SERVER_ERROR",
+ },
+ },
+ },
+ }))
+
+ request := httptest.NewRequest(http.MethodGet, "/pinboard/v1/tags/get", nil)
+ recorder := httptest.NewRecorder()
+
+ ctx := createAuthContext(request, recorder)
+ ctx.SetPath("/pinboard/v1/tags/get")
+
+ err := test.MakeRequest(srv, pinboardService.TagsGet, ctx)
+ c.NoError(err)
+ c.Equal(http.StatusOK, recorder.Code)
+
+ body := recorder.Body.String()
+ checkXMLResponse(body, "result")
+ c.True(strings.Contains(body, "something went wrong"))
+ })
+}
--
2.49.1