Peter Sanchez: 1 Add tags delete and rename to Pinboard API bridge. Should now have complete coverage for relevant operations. 3 files changed, 226 insertions(+), 6 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/174/mbox | git am -3Learn more about email & git
Changelog-added: Added ability to rename and delete tags via the Pinboard API bridge. --- pinboard/input.go | 11 ++++ pinboard/routes.go | 97 ++++++++++++++++++++++++++++++- pinboard/routes_test.go | 124 ++++++++++++++++++++++++++++++++++++++-- 3 files changed, 226 insertions(+), 6 deletions(-) diff --git a/pinboard/input.go b/pinboard/input.go index 1a72e47..baac2a6 100644 --- a/pinboard/input.go +++ b/pinboard/input.go @@ -44,4 +44,15 @@ type AllPostInput struct { Fromdt string `query:"fromdt"` Todt string `query:"todt"` Meta string `query:"meta"` +} + +// DeleteTagInput represents the input for /v1/tags/delete +type DeleteTagInput struct { + Tag string `query:"tag" validate:"required"` +} + +// RenameTagInput represents the input for /v1/tags/rename +type RenameTagInput struct { + Old string `query:"old" validate:"required"` + New string `query:"new" validate:"required"` } \ No newline at end of file diff --git a/pinboard/routes.go b/pinboard/routes.go index 5393056..19c838a 100644 --- a/pinboard/routes.go +++ b/pinboard/routes.go @@ -847,10 +847,103 @@ func (s *Service) TagsGet(c echo.Context) error { // TagsRename handles /v1/tags/rename func (s *Service) TagsRename(c echo.Context) error { - return formatError(c, "Tag renaming is unsupported") + var input RenameTagInput + if err := c.Bind(&input); err != nil { + return formatError(c, "Invalid input parameters") + } + + if err := c.Validate(input); err != nil { + return formatError(c, err.Error()) + } + + org, err := s.getUserOrg(c) + if err != nil { + return formatError(c, "Failed to get organization") + } + + type GraphQLResponse struct { + RenameTag struct { + Success bool `json:"success"` + Message string `json:"message"` + } `json:"renameTag"` + } + + var result GraphQLResponse + op := gqlclient.NewOperation( + `mutation RenameTag($orgSlug: String!, $service: DomainService!, $tag: String!, $newTag: String!) { + renameTag(input: {orgSlug: $orgSlug, service: $service, tag: $tag, newTag: $newTag}) { + success + message + } + }`) + + op.Var("orgSlug", org.Slug) + op.Var("service", "LINKS") + op.Var("tag", input.Old) + op.Var("newTag", input.New) + + err = links.Execute(c.Request().Context(), op, &result) + if err != nil { + if graphError, ok := err.(*gqlclient.Error); ok { + return formatError(c, graphError.Error()) + } + return formatError(c, err.Error()) + } + + if !result.RenameTag.Success { + return formatError(c, "Failed to rename tag") + } + + return formatSuccess(c) } // TagsDelete handles /v1/tags/delete func (s *Service) TagsDelete(c echo.Context) error { - return formatError(c, "Tag deletion is unsupported") + var input DeleteTagInput + if err := c.Bind(&input); err != nil { + return formatError(c, "Invalid input parameters") + } + + if err := c.Validate(input); err != nil { + return formatError(c, err.Error()) + } + + org, err := s.getUserOrg(c) + if err != nil { + return formatError(c, "Failed to get organization") + } + + type GraphQLResponse struct { + DeleteTag struct { + Success bool `json:"success"` + ObjectID string `json:"objectId"` + } `json:"deleteTag"` + } + + var result GraphQLResponse + op := gqlclient.NewOperation( + `mutation DeleteTag($orgSlug: String!, $service: DomainService!, $tag: String!) { + deleteTag(input: {orgSlug: $orgSlug, service: $service, tag: $tag}) { + success + objectId + } + }`) + + op.Var("orgSlug", org.Slug) + op.Var("service", "LINKS") + op.Var("tag", input.Tag) + + err = links.Execute(c.Request().Context(), op, &result) + if err != nil { + if graphError, ok := err.(*gqlclient.Error); ok { + return formatError(c, graphError.Error()) + } + return formatError(c, err.Error()) + } + + if !result.DeleteTag.Success { + return formatError(c, "Failed to delete tag") + } + + return formatSuccess(c) } diff --git a/pinboard/routes_test.go b/pinboard/routes_test.go index 07bea5b..44831df 100644 --- a/pinboard/routes_test.go +++ b/pinboard/routes_test.go @@ -1453,7 +1453,20 @@ func TestTagEndpoints(t *testing.T) { c.False(strings.Contains(body, "<tag ")) }) - t.Run("tags/rename unsupported", func(t *testing.T) { + t.Run("tags/rename 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]any{ + "data": map[string]any{ + "renameTag": map[string]any{ + "success": true, + "message": "Tag renamed successfully", + }, + }, + })) + request := httptest.NewRequest(http.MethodGet, "/pinboard/v1/tags/rename?old=oldtag&new=newtag", nil) recorder := httptest.NewRecorder() @@ -1466,10 +1479,23 @@ func TestTagEndpoints(t *testing.T) { body := recorder.Body.String() checkXMLResponse(body, "result") - c.True(strings.Contains(body, "Tag renaming is unsupported")) + c.True(strings.Contains(body, `code="done"`)) }) - t.Run("tags/delete unsupported", func(t *testing.T) { + t.Run("tags/delete 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]any{ + "data": map[string]any{ + "deleteTag": map[string]any{ + "success": true, + "objectId": "tag123", + }, + }, + })) + request := httptest.NewRequest(http.MethodGet, "/pinboard/v1/tags/delete?tag=unwanted", nil) recorder := httptest.NewRecorder() @@ -1482,7 +1508,97 @@ func TestTagEndpoints(t *testing.T) { body := recorder.Body.String() checkXMLResponse(body, "result") - c.True(strings.Contains(body, "Tag deletion is unsupported")) + c.True(strings.Contains(body, `code="done"`)) + }) + + t.Run("tags/rename missing required fields", func(t *testing.T) { + request := httptest.NewRequest(http.MethodGet, "/pinboard/v1/tags/rename?old=oldtag", 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, "something went wrong")) + }) + + t.Run("tags/rename failure", 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]any{ + "data": map[string]any{ + "renameTag": map[string]any{ + "success": false, + "message": "Tag not found", + }, + }, + })) + + request := httptest.NewRequest(http.MethodGet, "/pinboard/v1/tags/rename?old=nonexistent&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, "Failed to rename tag")) + }) + + t.Run("tags/delete missing required field", func(t *testing.T) { + request := httptest.NewRequest(http.MethodGet, "/pinboard/v1/tags/delete", 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, "something went wrong")) + }) + + t.Run("tags/delete failure", 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]any{ + "data": map[string]any{ + "deleteTag": map[string]any{ + "success": false, + "objectId": "", + }, + }, + })) + + request := httptest.NewRequest(http.MethodGet, "/pinboard/v1/tags/delete?tag=nonexistent", 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, "Failed to delete tag")) }) t.Run("tags/get with GraphQL error", func(t *testing.T) { -- 2.49.1
Applied. To git@git.code.netlandish.com:~netlandish/links f58fc6e..8db5562 master -> master