~netlandish/links-dev

links: Add tags delete and rename to Pinboard API bridge. Should now have complete coverage for relevant operations. v1 APPLIED

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(-)
Export patchset (mbox)
How do I use this?

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 -3
Learn more about email & git

[PATCH links] Add tags delete and rename to Pinboard API bridge. Should now have complete coverage for relevant operations. Export this patch

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