Peter Sanchez: 1 Adding getTags query to the GraphQL API. 14 files changed, 1071 insertions(+), 75 deletions(-)
Copy & paste the following snippet into your terminal to import this patchset into git:
curl -s https://lists.code.netlandish.com/~netlandish/links-dev/patches/167/mbox | git am -3Learn more about email & git
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
Applied. To git@git.code.netlandish.com:~netlandish/links 3ca09cc..a22df5a master -> master