diff --git a/README.md b/README.md new file mode 100644 index 0000000..c5c63b0 --- /dev/null +++ b/README.md @@ -0,0 +1,35 @@ +# Tapestry Bindings for Go + +Bindings for the Tapestry API documented at + +## Completness +- Profiles + - [x] Find or create a profile + - [ ] Get profiles + - [x] Get a profile by ID + - [x] Update a profile + - [ ] get followers + - [ ] get following + - [ ] Get a list of profiles in a user's network that also follow a given profile + +- Contents + - [x] Get contents + - [x] Find or create content + - [x] Get content by ID + - [x] Update content + - [x] Delete content + +- Comments + - [x] Create a comment + - [x] Get comments + - [x] Update a comment + - [x] Delete a comment + - [x] Get a comment by ID + +- Likes + - [x] Create a like + - [x] Delete a like + +- Followers + - [ ] Follow a profile + - [ ] Unfollow a profile diff --git a/api_test.go b/api_test.go new file mode 100644 index 0000000..4b1e748 --- /dev/null +++ b/api_test.go @@ -0,0 +1,333 @@ +package tapestry + +import ( + "crypto/rand" + "fmt" + "os" + "testing" + "time" +) + +var ( + client *TapestryClient + testProfile *ProfileResponse + alphabet = []byte("123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz") +) + +func generateRandomBytes(n int) ([]byte, error) { + b := make([]byte, n) + _, err := rand.Read(b) + if err != nil { + return nil, err + } + return b, nil +} + +func base58Encode(input []byte) string { + result := make([]byte, 0, len(input)*2) + for _, b := range input { + // Use each byte to index into our alphabet + idx := b % byte(len(alphabet)) + result = append(result, alphabet[idx]) + } + return string(result) +} + +func generateSolanaWallet() string { + // Solana addresses are 32 bytes + bytes, err := generateRandomBytes(32) + if err != nil { + panic("Failed to generate random bytes: " + err.Error()) + } + return base58Encode(bytes) +} + +func TestMain(m *testing.M) { + apiKey := os.Getenv("TAPESTRY_API_KEY") + baseURL := os.Getenv("TAPESTRY_API_BASE_URL") + if apiKey == "" || baseURL == "" { + panic("TAPESTRY_API_KEY and TAPESTRY_API_BASE_URL must be set") + } + + client = &TapestryClient{ + tapestryApiBaseUrl: baseURL, + apiKey: apiKey, + execution: ConfirmedParsed, + blockchain: "SOLANA", + } + + // Create a test profile for all tests + var err error + testProfile, err = client.FindOrCreateProfile(FindOrCreateProfileParameters{ + WalletAddress: generateSolanaWallet(), + Username: "test_user_" + time.Now().Format("20060102150405"), + Bio: "Test bio", + Image: "https://example.com/image.jpg", + }) + if err != nil { + panic("Failed to create test profile: " + err.Error()) + } + + os.Exit(m.Run()) +} + +func TestProfileOperations(t *testing.T) { + // Test GetProfileByID + profile, err := client.GetProfileByID(testProfile.Profile.ID) + + // log profile + fmt.Printf("Profile: %+v\n", profile) + + if err != nil { + t.Fatalf("GetProfileByID failed: %v", err) + } + if profile.Profile.Username != testProfile.Profile.Username { + t.Errorf("Expected username %s, got %s", testProfile.Profile.Username, profile.Profile.Username) + } + + // Test UpdateProfile + newUsername := "updated_user_" + time.Now().Format("20060102150405") + err = client.UpdateProfile(testProfile.Profile.ID, UpdateProfileParameters{ + Username: newUsername, + Bio: "Updated bio", + }) + if err != nil { + t.Fatalf("UpdateProfile failed: %v", err) + } + + // Verify update + updatedProfile, err := client.GetProfileByID(testProfile.Profile.ID) + if err != nil { + t.Fatalf("GetProfileByID after update failed: %v", err) + } + if updatedProfile.Profile.Username != newUsername { + t.Errorf("Expected updated username %s, got %s", newUsername, updatedProfile.Profile.Username) + } +} + +func TestContentOperations(t *testing.T) { + // Test FindOrCreateContent + contentProps := []ContentProperty{ + {Key: "title", Value: "Test Content"}, + {Key: "description", Value: "Test Description"}, + } + randomContentId := "test_content_" + time.Now().Format("20060102150405") + content, err := client.FindOrCreateContent(testProfile.Profile.ID, randomContentId, contentProps) + if err != nil { + t.Fatalf("FindOrCreateContent failed: %v", err) + } + + // Test GetContentByID + retrievedContent, err := client.GetContentByID(randomContentId) + if err != nil { + t.Fatalf("GetContentByID failed: %v", err) + } + if retrievedContent.Content.ID != content.Content.ID { + t.Errorf("Expected content ID %s, got %s", content.Content.ID, retrievedContent.Content.ID) + } + + // Test UpdateContent + updatedProps := []ContentProperty{ + {Key: "title", Value: "Updated Title"}, + {Key: "description", Value: "Updated Description"}, + } + _, err = client.UpdateContent(randomContentId, updatedProps) + if err != nil { + t.Fatalf("UpdateContent failed: %v", err) + } + + // Test GetContents + contents, err := client.GetContents( + WithProfileID(testProfile.Profile.ID), + WithPagination("1", "10"), + WithOrderBy("created_at", GetContentsSortDirectionDesc), + ) + if err != nil { + t.Fatalf("GetContents failed: %v", err) + } + if len(contents.Contents) == 0 { + t.Error("Expected at least one content item") + } + + // Test DeleteContent + err = client.DeleteContent(randomContentId) + if err != nil { + t.Fatalf("DeleteContent failed: %v", err) + } +} + +func TestCommentOperations(t *testing.T) { + // Create test content first + contentProps := []ContentProperty{ + {Key: "title", Value: "Test Content for Comments"}, + } + randomContentId := "test_content_" + time.Now().Format("20060102150405") + content, err := client.FindOrCreateContent(testProfile.Profile.ID, randomContentId, contentProps) + if err != nil { + t.Fatalf("Failed to create test content: %v", err) + } + + // Verify initial comment count is 0 + initialContent, err := client.GetContentByID(content.Content.ID) + if err != nil { + t.Fatalf("GetContentByID failed: %v", err) + } + if initialContent.SocialCounts.CommentCount != 0 { + t.Errorf("Expected initial comment count 0, got %d", initialContent.SocialCounts.CommentCount) + } + + // Test CreateComment + comment, err := client.CreateComment(CreateCommentOptions{ + ContentID: content.Content.ID, + ProfileID: testProfile.Profile.ID, + Text: "Test comment", + Properties: []CommentProperty{ + {Key: "test", Value: "property"}, + }, + }) + if err != nil { + t.Fatalf("CreateComment failed: %v", err) + } + + // Test UpdateComment + newProperty := "new property" + _, err = client.UpdateComment(comment.Comment.ID, []CommentProperty{ + {Key: "test", Value: newProperty}, + }) + if err != nil { + t.Fatalf("UpdateComment failed: %v", err) + } + // Verify comment count increased to 1 + contentAfterComment, err := client.GetContentByID(content.Content.ID) + if err != nil { + t.Fatalf("GetContentByID failed: %v", err) + } + if contentAfterComment.SocialCounts.CommentCount != 1 { + t.Errorf("Expected comment count 1, got %d", contentAfterComment.SocialCounts.CommentCount) + } + + // Test GetCommentByID - verify initial like count + commentDetail, err := client.GetCommentByID(comment.Comment.ID, testProfile.Profile.ID) + if err != nil { + t.Fatalf("GetCommentByID failed: %v", err) + } + if commentDetail.SocialCounts.LikeCount != 0 { + t.Errorf("Expected initial comment like count 0, got %d", commentDetail.SocialCounts.LikeCount) + } + + // Test liking the comment + err = client.CreateLike(comment.Comment.ID, testProfile.Profile) + if err != nil { + t.Fatalf("CreateLike on comment failed: %v", err) + } + + // Verify like count increased to 1 + commentAfterLike, err := client.GetCommentByID(comment.Comment.ID, testProfile.Profile.ID) + if err != nil { + t.Fatalf("GetCommentByID after like failed: %v", err) + } + if commentAfterLike.SocialCounts.LikeCount != 1 { + t.Errorf("Expected comment like count 1, got %d", commentAfterLike.SocialCounts.LikeCount) + } + // if !commentAfterLike.RequestingProfileSocialInfo["hasLiked"].(bool) { + // t.Error("Expected hasLiked to be true") + // } + + // Test unliking the comment + err = client.DeleteLike(comment.Comment.ID, testProfile.Profile) + if err != nil { + t.Fatalf("DeleteLike on comment failed: %v", err) + } + + // Verify like count back to 0 + commentAfterUnlike, err := client.GetCommentByID(comment.Comment.ID, testProfile.Profile.ID) + if err != nil { + t.Fatalf("GetCommentByID after unlike failed: %v", err) + } + if commentAfterUnlike.SocialCounts.LikeCount != 0 { + t.Errorf("Expected comment like count 0, got %d", commentAfterUnlike.SocialCounts.LikeCount) + } + // if commentAfterUnlike.RequestingProfileSocialInfo["hasLiked"].(bool) { + // t.Error("Expected hasLiked to be false") + // } + + // Test GetComments + comments, err := client.GetComments(GetCommentsOptions{ + ContentID: content.Content.ID, + RequestingProfileID: testProfile.Profile.ID, + Page: 1, + PageSize: 10, + }) + if err != nil { + t.Fatalf("GetComments failed: %v", err) + } + if len(comments.Comments) == 0 { + t.Error("Expected at least one comment") + } + + // Test DeleteComment + err = client.DeleteComment(comment.Comment.ID) + if err != nil { + t.Fatalf("DeleteComment failed: %v", err) + } + + // Verify comment count back to 0 + contentAfterDelete, err := client.GetContentByID(content.Content.ID) + if err != nil { + t.Fatalf("GetContentByID failed: %v", err) + } + if contentAfterDelete.SocialCounts.CommentCount != 0 { + t.Errorf("Expected comment count 0 after delete, got %d", contentAfterDelete.SocialCounts.CommentCount) + } +} + +func TestLikeOperations(t *testing.T) { + // Create test content first + contentProps := []ContentProperty{ + {Key: "title", Value: "Test Content for Likes"}, + } + randomContentId := "test_content_" + time.Now().Format("20060102150405") + content, err := client.FindOrCreateContent(testProfile.Profile.ID, randomContentId, contentProps) + if err != nil { + t.Fatalf("Failed to create test content: %v", err) + } + + // Verify initial like count is 0 + initialContent, err := client.GetContentByID(content.Content.ID) + if err != nil { + t.Fatalf("GetContentByID failed: %v", err) + } + if initialContent.SocialCounts.LikeCount != 0 { + t.Errorf("Expected initial like count 0, got %d", initialContent.SocialCounts.LikeCount) + } + + // Test CreateLike + err = client.CreateLike(content.Content.ID, testProfile.Profile) + if err != nil { + t.Fatalf("CreateLike failed: %v", err) + } + + // Verify like count increased to 1 + contentAfterLike, err := client.GetContentByID(content.Content.ID) + if err != nil { + t.Fatalf("GetContentByID failed: %v", err) + } + if contentAfterLike.SocialCounts.LikeCount != 1 { + t.Errorf("Expected like count 1, got %d", contentAfterLike.SocialCounts.LikeCount) + } + + // Test DeleteLike + err = client.DeleteLike(content.Content.ID, testProfile.Profile) + if err != nil { + t.Fatalf("DeleteLike failed: %v", err) + } + + // Verify like count back to 0 + contentAfterDelete, err := client.GetContentByID(content.Content.ID) + if err != nil { + t.Fatalf("GetContentByID failed: %v", err) + } + if contentAfterDelete.SocialCounts.LikeCount != 0 { + t.Errorf("Expected like count 0 after delete, got %d", contentAfterDelete.SocialCounts.LikeCount) + } +} diff --git a/client.go b/client.go new file mode 100644 index 0000000..83bdcd4 --- /dev/null +++ b/client.go @@ -0,0 +1,25 @@ +package tapestry + +type TapestryClient struct { + tapestryApiBaseUrl string + apiKey string + execution TapestryExecutionType + blockchain string +} + +type TapestryExecutionType string + +const ( + FastUnconfirmed TapestryExecutionType = "FAST_UNCONFIRMED" + QuickSignature TapestryExecutionType = "QUICK_SIGNATURE" + ConfirmedParsed TapestryExecutionType = "CONFIRMED_AND_PARSED" +) + +func NewTapestryClient(apiKey string, tapestryApiBaseUrl string, execution TapestryExecutionType, blockchain string) TapestryClient { + return TapestryClient{ + tapestryApiBaseUrl: tapestryApiBaseUrl, + apiKey: apiKey, + execution: execution, + blockchain: blockchain, + } +} diff --git a/comments.go b/comments.go new file mode 100644 index 0000000..be4b48d --- /dev/null +++ b/comments.go @@ -0,0 +1,247 @@ +package tapestry + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" +) + +type CommentProperty struct { + Key string `json:"key"` + Value string `json:"value"` +} +type CreateCommentOptions struct { + ContentID string `json:"contentId"` + ProfileID string `json:"profileId"` + Text string `json:"text"` + CommentID string `json:"commentId"` + Properties []CommentProperty `json:"properties"` +} + +type CreateCommentRequest struct { + CreateCommentOptions + Execution string `json:"execution"` +} + +type CreateCommentResponse struct { + Comment +} + +type GetCommentByIdResponse struct { + CommentData +} + +type GetCommentsResponse struct { + Comments []CommentData `json:"comments"` +} + +type CommentData struct { + Comment Comment `json:"comment"` + Author Author `json:"author"` + SocialCounts SocialCounts `json:"socialCounts"` + RequestingProfileSocialInfo map[string]interface{} `json:"requestingProfileSocialInfo"` +} + +type Comment struct { + Namespace string `json:"namespace"` + CreatedAt int64 `json:"created_at"` + Text string `json:"text"` + ID string `json:"id"` +} + +type Author struct { + Namespace string `json:"namespace"` + ID string `json:"id"` + Username string `json:"username"` + CreatedAt int64 `json:"created_at"` + Bio string `json:"bio"` + Image string `json:"image"` +} + +type GetCommentsOptions struct { + ContentID string + CommentID string + ProfileID string + RequestingProfileID string + Page int + PageSize int +} + +type UpdateCommentRequest struct { + Properties []CommentProperty `json:"properties"` +} + +type UpdateCommentResponse struct { + Comment +} + +func (c *TapestryClient) CreateComment(options CreateCommentOptions) (*CreateCommentResponse, error) { + url := fmt.Sprintf("%s/comments?apiKey=%s", c.tapestryApiBaseUrl, c.apiKey) + req := CreateCommentRequest{ + CreateCommentOptions: options, + Execution: string(c.execution), + } + if options.CommentID != "" { + req.CommentID = options.CommentID + } + + jsonBody, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("error marshaling request: %w", err) + } + + resp, err := http.Post(url, "application/json", strings.NewReader(string(jsonBody))) + if err != nil { + return nil, fmt.Errorf("error making request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + // read the response body + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("error reading response body: %w", err) + } + return nil, fmt.Errorf("unexpected status code: %d, response: %s", resp.StatusCode, string(body)) + } + + var commentResp CreateCommentResponse + if err := json.NewDecoder(resp.Body).Decode(&commentResp); err != nil { + return nil, fmt.Errorf("error decoding response: %w", err) + } + + return &commentResp, nil +} + +func (c *TapestryClient) GetComments(options GetCommentsOptions) (*GetCommentsResponse, error) { + baseURL := fmt.Sprintf("%s/comments?apiKey=%s", c.tapestryApiBaseUrl, c.apiKey) + + params := url.Values{} + if options.ContentID != "" { + params.Add("contentId", options.ContentID) + } + if options.CommentID != "" { + params.Add("commentId", options.CommentID) + } + if options.ProfileID != "" { + params.Add("profileId", options.ProfileID) + } + if options.RequestingProfileID != "" { + params.Add("requestingProfileId", options.RequestingProfileID) + } + + if options.Page > 0 { + params.Add("page", strconv.Itoa(options.Page)) + } + if options.PageSize > 0 { + params.Add("pageSize", strconv.Itoa(options.PageSize)) + } + + url := baseURL + if len(params) > 0 { + url += "&" + params.Encode() + } + + resp, err := http.Get(url) + if err != nil { + return nil, fmt.Errorf("error making request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + if resp.StatusCode == http.StatusNotFound { + return nil, nil + } + return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + var comments GetCommentsResponse + if err := json.NewDecoder(resp.Body).Decode(&comments); err != nil { + return nil, fmt.Errorf("error decoding response: %w", err) + } + + return &comments, nil +} + +func (c *TapestryClient) GetCommentByID(commentID string, requestingProfileID string) (*GetCommentByIdResponse, error) { + url := fmt.Sprintf("%s/comments/%s?apiKey=%s", c.tapestryApiBaseUrl, commentID, c.apiKey) + + resp, err := http.Get(url) + if err != nil { + return nil, fmt.Errorf("error making request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + var commentResp GetCommentByIdResponse + if err := json.NewDecoder(resp.Body).Decode(&commentResp); err != nil { + return nil, fmt.Errorf("error decoding response: %w", err) + } + + return &commentResp, nil +} + +func (c *TapestryClient) DeleteComment(commentID string) error { + url := fmt.Sprintf("%s/comments/%s?apiKey=%s", c.tapestryApiBaseUrl, commentID, c.apiKey) + + req, err := http.NewRequest(http.MethodDelete, url, nil) + if err != nil { + return fmt.Errorf("error creating request: %w", err) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("error making request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + return nil +} + +func (c *TapestryClient) UpdateComment(commentID string, properties []CommentProperty) (*UpdateCommentResponse, error) { + url := fmt.Sprintf("%s/comments/%s?apiKey=%s", c.tapestryApiBaseUrl, commentID, c.apiKey) + + req := UpdateCommentRequest{ + Properties: properties, + } + + jsonBody, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("error marshaling request: %w", err) + } + + httpReq, err := http.NewRequest(http.MethodPut, url, strings.NewReader(string(jsonBody))) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + httpReq.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("error making request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("unexpected status code: %d, response: %s", resp.StatusCode, string(body)) + } + + var commentResp UpdateCommentResponse + if err := json.NewDecoder(resp.Body).Decode(&commentResp); err != nil { + return nil, fmt.Errorf("error decoding response: %w", err) + } + + return &commentResp, nil +} diff --git a/contents.go b/contents.go new file mode 100644 index 0000000..0490b0c --- /dev/null +++ b/contents.go @@ -0,0 +1,284 @@ +package tapestry + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" +) + +type ContentProperty struct { + Key string `json:"key"` + Value string `json:"value"` +} + +type CreateContentRequest struct { + ProfileID string `json:"profileId"` + ID string `json:"id,omitempty"` + Properties []ContentProperty `json:"properties"` +} + +type UpdateContentRequest struct { + Properties []ContentProperty `json:"properties"` +} + +type Content struct { + Namespace string `json:"namespace"` + CreatedAt int64 `json:"created_at"` + ID string `json:"id"` + Description string `json:"description"` + Title string `json:"title"` +} + +type CreateOrUpdateContentResponse struct{ Content } +type GetContentResponse struct { + Content Content `json:"content"` + SocialCounts SocialCounts `json:"socialCounts"` +} + +type AuthorProfile struct { + ID string `json:"id"` + CreatedAt int64 `json:"created_at"` + Username string `json:"username"` + Bio string `json:"bio"` + Image string `json:"image"` +} + +type RequestingProfileSocialInfo struct { + HasLiked bool `json:"hasLiked"` +} + +type ContentListItem struct { + AuthorProfile AuthorProfile `json:"authorProfile"` + Content Content `json:"content"` + SocialCounts SocialCounts `json:"socialCounts"` + RequestingProfileSocialInfo RequestingProfileSocialInfo `json:"requestingProfileSocialInfo"` +} + +type GetContentsResponse struct { + Contents []ContentListItem `json:"contents"` + Page int `json:"page"` + PageSize int `json:"pageSize"` +} + +type SocialCounts struct { + LikeCount int `json:"likeCount"` + CommentCount int `json:"commentCount"` +} + +func (c *TapestryClient) FindOrCreateContent(profileId, id string, content []ContentProperty) (*CreateOrUpdateContentResponse, error) { + url := fmt.Sprintf("%s/contents/findOrCreate?apiKey=%s", c.tapestryApiBaseUrl, c.apiKey) + + jsonBody, err := json.Marshal(CreateContentRequest{ + ProfileID: profileId, + ID: id, + Properties: content, + }) + if err != nil { + return nil, fmt.Errorf("error marshaling request: %w", err) + } + + resp, err := http.Post(url, "application/json", strings.NewReader(string(jsonBody))) + if err != nil { + return nil, fmt.Errorf("error making request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + var contentResp CreateOrUpdateContentResponse + if err := json.NewDecoder(resp.Body).Decode(&contentResp); err != nil { + return nil, fmt.Errorf("error decoding response: %w", err) + } + + return &contentResp, nil +} + +func (c *TapestryClient) UpdateContent(contentId string, properties []ContentProperty) (*CreateOrUpdateContentResponse, error) { + url := fmt.Sprintf("%s/contents/%s?apiKey=%s", c.tapestryApiBaseUrl, contentId, c.apiKey) + + jsonBody, err := json.Marshal(UpdateContentRequest{ + Properties: properties, + }) + if err != nil { + return nil, fmt.Errorf("error marshaling request: %w", err) + } + + httpReq, err := http.NewRequest(http.MethodPut, url, strings.NewReader(string(jsonBody))) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + httpReq.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("error making request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + var contentResp CreateOrUpdateContentResponse + if err := json.NewDecoder(resp.Body).Decode(&contentResp); err != nil { + return nil, fmt.Errorf("error decoding response: %w", err) + } + + return &contentResp, nil +} + +func (c *TapestryClient) DeleteContent(contentId string) error { + url := fmt.Sprintf("%s/contents/%s?apiKey=%s", c.tapestryApiBaseUrl, contentId, c.apiKey) + + httpReq, err := http.NewRequest(http.MethodDelete, url, nil) + if err != nil { + return fmt.Errorf("error creating request: %w", err) + } + + resp, err := http.DefaultClient.Do(httpReq) + if err != nil { + return fmt.Errorf("error making request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + return nil +} + +func (c *TapestryClient) GetContentByID(contentId string) (*GetContentResponse, error) { + url := fmt.Sprintf("%s/contents/%s?apiKey=%s", c.tapestryApiBaseUrl, contentId, c.apiKey) + + resp, err := http.Get(url) + if err != nil { + return nil, fmt.Errorf("error making request: %w", err) + } + defer resp.Body.Close() + + // unauthorized may also mean not found + if resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusUnauthorized { + return nil, nil + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + var contentResp GetContentResponse + if err := json.NewDecoder(resp.Body).Decode(&contentResp); err != nil { + return nil, fmt.Errorf("error decoding response: %w", err) + } + + // not found + if contentResp.Content.ID == "" { + return nil, nil + } + + return &contentResp, nil +} + +func (c *TapestryClient) GetContents(opts ...GetContentsOption) (*GetContentsResponse, error) { + params := &getContentsParams{ + apiKey: c.apiKey, + } + + for _, opt := range opts { + opt(params) + } + + url := fmt.Sprintf("%s/contents/?%s", c.tapestryApiBaseUrl, params.encode()) + + resp, err := http.Get(url) + if err != nil { + return nil, fmt.Errorf("error making request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + var contentsResp GetContentsResponse + if err := json.NewDecoder(resp.Body).Decode(&contentsResp); err != nil { + return nil, fmt.Errorf("error decoding response: %w", err) + } + + return &contentsResp, nil +} + +type GetContentsSortDirection string + +const ( + GetContentsSortDirectionAsc GetContentsSortDirection = "ASC" + GetContentsSortDirectionDesc GetContentsSortDirection = "DESC" +) + +type getContentsParams struct { + apiKey string + orderByField string + orderByDirection GetContentsSortDirection + page string + pageSize string + profileID string + requestingProfileID string +} + +func (p *getContentsParams) encode() string { + values := make([]string, 0) + values = append(values, fmt.Sprintf("apiKey=%s", p.apiKey)) + + if p.orderByField != "" { + values = append(values, fmt.Sprintf("orderByField=%s", p.orderByField)) + } + if p.orderByDirection != "" { + values = append(values, fmt.Sprintf("orderByDirection=%s", p.orderByDirection)) + } + if p.page != "" { + values = append(values, fmt.Sprintf("page=%s", p.page)) + } + if p.pageSize != "" { + values = append(values, fmt.Sprintf("pageSize=%s", p.pageSize)) + } + if p.profileID != "" { + values = append(values, fmt.Sprintf("profileId=%s", p.profileID)) + } + if p.requestingProfileID != "" { + values = append(values, fmt.Sprintf("requestingProfileId=%s", p.requestingProfileID)) + } + + return strings.Join(values, "&") +} + +// Option pattern for configurable parameters +type GetContentsOption func(*getContentsParams) + +func WithOrderBy(field string, direction GetContentsSortDirection) GetContentsOption { + return func(p *getContentsParams) { + p.orderByField = field + p.orderByDirection = direction + } +} + +func WithPagination(page, pageSize string) GetContentsOption { + return func(p *getContentsParams) { + p.page = page + p.pageSize = pageSize + } +} + +func WithProfileID(profileID string) GetContentsOption { + return func(p *getContentsParams) { + p.profileID = profileID + } +} + +func WithRequestingProfileID(requestingProfileID string) GetContentsOption { + return func(p *getContentsParams) { + p.requestingProfileID = requestingProfileID + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..0a066bd --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/Access-Labs-Inc/accessprotocol.co/tapestry-bindings + +go 1.18 diff --git a/likes.go b/likes.go new file mode 100644 index 0000000..b0baf82 --- /dev/null +++ b/likes.go @@ -0,0 +1,65 @@ +package tapestry + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/url" +) + +type CreateLikeRequest struct { + StartId string `json:"startId"` + Execution string `json:"execution"` +} +type DeleteLikeRequest struct { + StartId string `json:"startId"` +} + +func (c *TapestryClient) CreateLike(contentID string, profile Profile) error { + url := fmt.Sprintf("%s/likes/%s?apiKey=%s&username=%s", c.tapestryApiBaseUrl, contentID, c.apiKey, url.QueryEscape(profile.Username)) + + reqBody, err := json.Marshal(CreateLikeRequest{StartId: profile.ID, Execution: "FAST_UNCONFIRMED"}) + if err != nil { + return fmt.Errorf("error encoding body: %w", err) + } + + resp, err := http.Post(url, "application/json", bytes.NewBuffer(reqBody)) + if err != nil { + return fmt.Errorf("error making request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + return nil +} + +func (c *TapestryClient) DeleteLike(contentID string, profile Profile) error { + url := fmt.Sprintf("%s/likes/%s?apiKey=%s&username=%s", c.tapestryApiBaseUrl, contentID, c.apiKey, url.QueryEscape(profile.Username)) + + reqBody, err := json.Marshal(DeleteLikeRequest{StartId: profile.ID}) + if err != nil { + return fmt.Errorf("error encoding body: %w", err) + } + + req, err := http.NewRequest(http.MethodDelete, url, bytes.NewBuffer(reqBody)) + if err != nil { + return fmt.Errorf("error creating request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("error making request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + return nil +} diff --git a/profiles.go b/profiles.go new file mode 100644 index 0000000..a8e4d0f --- /dev/null +++ b/profiles.go @@ -0,0 +1,141 @@ +package tapestry + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strings" +) + +type Profile struct { + Namespace string `json:"namespace"` + ID string `json:"id"` + Blockchain string `json:"blockchain"` + Username string `json:"username"` +} + +type ProfileResponse struct { + Profile Profile `json:"profile"` + WalletAddress string `json:"walletAddress"` +} + +type FindOrCreateProfileParameters struct { + WalletAddress string `json:"walletAddress"` + Username string `json:"username"` + Bio string `json:"bio,omitempty"` + Image string `json:"image,omitempty"` + ID string `json:"id,omitempty"` +} + +type FindOrCreateProfileRequest struct { + FindOrCreateProfileParameters + Execution string `json:"execution,omitempty"` + Blockchain string `json:"blockchain,omitempty"` +} + +type UpdateProfileParameters struct { + Username string `json:"username"` + Bio string `json:"bio,omitempty"` + Image string `json:"image,omitempty"` +} + +type UpdateProfileRequest struct { + UpdateProfileParameters + Execution string `json:"execution,omitempty"` +} + +func (c *TapestryClient) FindOrCreateProfile(params FindOrCreateProfileParameters) (*ProfileResponse, error) { + req := FindOrCreateProfileRequest{ + FindOrCreateProfileParameters: params, + Execution: string(c.execution), + Blockchain: c.blockchain, + } + + url := fmt.Sprintf("%s/profiles/findOrCreate?apiKey=%s", c.tapestryApiBaseUrl, c.apiKey) + + jsonBody, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("error marshaling request: %w", err) + } + + resp, err := http.Post(url, "application/json", strings.NewReader(string(jsonBody))) + if err != nil { + return nil, fmt.Errorf("error making request: %w", err) + } + + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + var profileResp ProfileResponse + if err := json.NewDecoder(resp.Body).Decode(&profileResp); err != nil { + return nil, fmt.Errorf("error decoding response: %w", err) + } + + return &profileResp, nil +} + +func (c *TapestryClient) UpdateProfile(id string, req UpdateProfileParameters) error { + url := fmt.Sprintf("%s/profiles/%s?apiKey=%s", c.tapestryApiBaseUrl, id, c.apiKey) + + jsonBody, err := json.Marshal(UpdateProfileRequest{ + UpdateProfileParameters: req, + Execution: string(c.execution), + }) + if err != nil { + return fmt.Errorf("error marshaling request: %w", err) + } + + httpReq, err := http.NewRequest(http.MethodPut, url, strings.NewReader(string(jsonBody))) + if err != nil { + return fmt.Errorf("error creating request: %w", err) + } + httpReq.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(httpReq) + if err != nil { + return fmt.Errorf("error making request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + return nil +} + +func (c *TapestryClient) GetProfileByID(id string) (*ProfileResponse, error) { + url := fmt.Sprintf("%s/profiles/%s?apiKey=%s", c.tapestryApiBaseUrl, id, c.apiKey) + + resp, err := http.Get(url) + if err != nil { + return nil, fmt.Errorf("error making request: %w", err) + } + + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("error reading response body: %w", err) + } + + fmt.Printf("Response body: %s\n", string(body)) + + if resp.StatusCode != http.StatusOK { + if resp.StatusCode == http.StatusNotFound { + return nil, nil // ok but not found + } + return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + var profileResp ProfileResponse + if err := json.Unmarshal(body, &profileResp); err != nil { + return nil, fmt.Errorf("error decoding response: %w", err) + } + + return &profileResp, nil +}