~netlandish/links-dev

This thread contains a patchset. You're looking at the original emails, but you may wish to use the patch review UI. Review patch
1

[PATCH links] Adding getTags query to the GraphQL API.

Details
Message ID
<20250801021112.6335-1-peter@netlandish.com>
Sender timestamp
1753992669
DKIM signature
missing
Download raw message
Patch: +1071 -75
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
Details
Message ID
<DBR5KBIX3PWH.GYAKHF9ZARFL@netlandish.com>
In-Reply-To
<20250801021112.6335-1-peter@netlandish.com> (view parent)
Sender timestamp
1754036109
DKIM signature
missing
Download raw message
Applied.

To git@git.code.netlandish.com:~netlandish/links
   3ca09cc..a22df5a  master -> master
Reply to thread Export thread (mbox)