mirror of
https://github.com/anyproto/anytype-heart.git
synced 2025-06-09 09:35:00 +09:00
GO-4459: Refactor api into component runnable service
This commit is contained in:
parent
edc2219836
commit
892d929683
35 changed files with 204 additions and 174 deletions
|
@ -1,69 +0,0 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/anyproto/anytype-heart/cmd/api/util"
|
||||
)
|
||||
|
||||
// DisplayCodeHandler generates a new challenge and returns the challenge ID
|
||||
//
|
||||
// @Summary Open a modal window with a code in Anytype Desktop app
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} DisplayCodeResponse "Challenge ID"
|
||||
// @Failure 502 {object} util.ServerError "Internal server error"
|
||||
// @Router /auth/display_code [post]
|
||||
func DisplayCodeHandler(s *AuthService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
challengeId, err := s.GenerateNewChallenge(c.Request.Context(), "api-test")
|
||||
code := util.MapErrorCode(err, util.ErrToCode(ErrFailedGenerateChallenge, http.StatusInternalServerError))
|
||||
|
||||
if code != http.StatusOK {
|
||||
apiErr := util.CodeToAPIError(code, err.Error())
|
||||
c.JSON(code, apiErr)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, DisplayCodeResponse{ChallengeId: challengeId})
|
||||
}
|
||||
}
|
||||
|
||||
// TokenHandler retrieves an authentication token using a code and challenge ID
|
||||
//
|
||||
// @Summary Retrieve an authentication token using a code
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param challenge_id query string true "The challenge ID"
|
||||
// @Param code query string true "The 4-digit code retrieved from Anytype Desktop app"
|
||||
// @Success 200 {object} TokenResponse "Authentication token"
|
||||
// @Failure 400 {object} util.ValidationError "Invalid input"
|
||||
// @Failure 502 {object} util.ServerError "Internal server error"
|
||||
// @Router /auth/token [post]
|
||||
func TokenHandler(s *AuthService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
challengeId := c.Query("challenge_id")
|
||||
code := c.Query("code")
|
||||
|
||||
sessionToken, appKey, err := s.SolveChallengeForToken(c.Request.Context(), challengeId, code)
|
||||
errCode := util.MapErrorCode(err,
|
||||
util.ErrToCode(ErrInvalidInput, http.StatusBadRequest),
|
||||
util.ErrToCode(ErrFailedAuthenticate, http.StatusInternalServerError),
|
||||
)
|
||||
|
||||
if errCode != http.StatusOK {
|
||||
apiErr := util.CodeToAPIError(errCode, err.Error())
|
||||
c.JSON(errCode, apiErr)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, TokenResponse{
|
||||
SessionToken: sessionToken,
|
||||
AppKey: appKey,
|
||||
})
|
||||
}
|
||||
}
|
|
@ -1,10 +0,0 @@
|
|||
package auth
|
||||
|
||||
type DisplayCodeResponse struct {
|
||||
ChallengeId string `json:"challenge_id" example:"67647f5ecda913e9a2e11b26"`
|
||||
}
|
||||
|
||||
type TokenResponse struct {
|
||||
SessionToken string `json:"session_token" example:""`
|
||||
AppKey string `json:"app_key" example:""`
|
||||
}
|
|
@ -1,58 +0,0 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/anyproto/anytype-heart/pb"
|
||||
"github.com/anyproto/anytype-heart/pb/service"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrFailedGenerateChallenge = errors.New("failed to generate a new challenge")
|
||||
ErrInvalidInput = errors.New("invalid input")
|
||||
ErrFailedAuthenticate = errors.New("failed to authenticate user")
|
||||
)
|
||||
|
||||
type Service interface {
|
||||
GenerateNewChallenge(ctx context.Context, appName string) (string, error)
|
||||
SolveChallengeForToken(ctx context.Context, challengeId string, code string) (sessionToken, appKey string, err error)
|
||||
}
|
||||
|
||||
type AuthService struct {
|
||||
mw service.ClientCommandsServer
|
||||
}
|
||||
|
||||
func NewService(mw service.ClientCommandsServer) *AuthService {
|
||||
return &AuthService{mw: mw}
|
||||
}
|
||||
|
||||
// GenerateNewChallenge calls AccountLocalLinkNewChallenge and returns the challenge ID, or an error if it fails.
|
||||
func (s *AuthService) GenerateNewChallenge(ctx context.Context, appName string) (string, error) {
|
||||
resp := s.mw.AccountLocalLinkNewChallenge(ctx, &pb.RpcAccountLocalLinkNewChallengeRequest{AppName: appName})
|
||||
|
||||
if resp.Error.Code != pb.RpcAccountLocalLinkNewChallengeResponseError_NULL {
|
||||
return "", ErrFailedGenerateChallenge
|
||||
}
|
||||
|
||||
return resp.ChallengeId, nil
|
||||
}
|
||||
|
||||
// SolveChallengeForToken calls AccountLocalLinkSolveChallenge and returns the session token + app key, or an error if it fails.
|
||||
func (s *AuthService) SolveChallengeForToken(ctx context.Context, challengeId string, code string) (sessionToken string, appKey string, err error) {
|
||||
if challengeId == "" || code == "" {
|
||||
return "", "", ErrInvalidInput
|
||||
}
|
||||
|
||||
// Call AccountLocalLinkSolveChallenge to retrieve session token and app key
|
||||
resp := s.mw.AccountLocalLinkSolveChallenge(ctx, &pb.RpcAccountLocalLinkSolveChallengeRequest{
|
||||
ChallengeId: challengeId,
|
||||
Answer: code,
|
||||
})
|
||||
|
||||
if resp.Error.Code != pb.RpcAccountLocalLinkSolveChallengeResponseError_NULL {
|
||||
return "", "", ErrFailedAuthenticate
|
||||
}
|
||||
|
||||
return resp.SessionToken, resp.AppKey, nil
|
||||
}
|
|
@ -1,140 +0,0 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/anyproto/anytype-heart/pb"
|
||||
"github.com/anyproto/anytype-heart/pb/service/mock_service"
|
||||
)
|
||||
|
||||
const (
|
||||
mockedAppName = "api-test"
|
||||
mockedChallengeId = "mocked-challenge-id"
|
||||
mockedCode = "mocked-mockedCode"
|
||||
mockedSessionToken = "mocked-session-token"
|
||||
mockedAppKey = "mocked-app-key"
|
||||
)
|
||||
|
||||
type fixture struct {
|
||||
*AuthService
|
||||
mwMock *mock_service.MockClientCommandsServer
|
||||
}
|
||||
|
||||
func newFixture(t *testing.T) *fixture {
|
||||
mw := mock_service.NewMockClientCommandsServer(t)
|
||||
authService := NewService(mw)
|
||||
|
||||
return &fixture{
|
||||
AuthService: authService,
|
||||
mwMock: mw,
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthService_GenerateNewChallenge(t *testing.T) {
|
||||
t.Run("successful challenge creation", func(t *testing.T) {
|
||||
// given
|
||||
ctx := context.Background()
|
||||
fx := newFixture(t)
|
||||
|
||||
fx.mwMock.On("AccountLocalLinkNewChallenge", mock.Anything, &pb.RpcAccountLocalLinkNewChallengeRequest{AppName: mockedAppName}).
|
||||
Return(&pb.RpcAccountLocalLinkNewChallengeResponse{
|
||||
ChallengeId: mockedChallengeId,
|
||||
Error: &pb.RpcAccountLocalLinkNewChallengeResponseError{Code: pb.RpcAccountLocalLinkNewChallengeResponseError_NULL},
|
||||
}).Once()
|
||||
|
||||
// when
|
||||
challengeId, err := fx.GenerateNewChallenge(ctx, mockedAppName)
|
||||
|
||||
// then
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, mockedChallengeId, challengeId)
|
||||
})
|
||||
|
||||
t.Run("failed challenge creation", func(t *testing.T) {
|
||||
// given
|
||||
ctx := context.Background()
|
||||
fx := newFixture(t)
|
||||
|
||||
fx.mwMock.On("AccountLocalLinkNewChallenge", mock.Anything, &pb.RpcAccountLocalLinkNewChallengeRequest{AppName: mockedAppName}).
|
||||
Return(&pb.RpcAccountLocalLinkNewChallengeResponse{
|
||||
Error: &pb.RpcAccountLocalLinkNewChallengeResponseError{Code: pb.RpcAccountLocalLinkNewChallengeResponseError_UNKNOWN_ERROR},
|
||||
}).Once()
|
||||
|
||||
// when
|
||||
challengeId, err := fx.GenerateNewChallenge(ctx, mockedAppName)
|
||||
|
||||
// then
|
||||
require.Error(t, err)
|
||||
require.Equal(t, ErrFailedGenerateChallenge, err)
|
||||
require.Empty(t, challengeId)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAuthService_SolveChallengeForToken(t *testing.T) {
|
||||
t.Run("successful token retrieval", func(t *testing.T) {
|
||||
// given
|
||||
ctx := context.Background()
|
||||
fx := newFixture(t)
|
||||
|
||||
fx.mwMock.On("AccountLocalLinkSolveChallenge", mock.Anything, &pb.RpcAccountLocalLinkSolveChallengeRequest{
|
||||
ChallengeId: mockedChallengeId,
|
||||
Answer: mockedCode,
|
||||
}).
|
||||
Return(&pb.RpcAccountLocalLinkSolveChallengeResponse{
|
||||
SessionToken: mockedSessionToken,
|
||||
AppKey: mockedAppKey,
|
||||
Error: &pb.RpcAccountLocalLinkSolveChallengeResponseError{Code: pb.RpcAccountLocalLinkSolveChallengeResponseError_NULL},
|
||||
}).Once()
|
||||
|
||||
// when
|
||||
sessionToken, appKey, err := fx.SolveChallengeForToken(ctx, mockedChallengeId, mockedCode)
|
||||
|
||||
// then
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, mockedSessionToken, sessionToken)
|
||||
require.Equal(t, mockedAppKey, appKey)
|
||||
|
||||
})
|
||||
|
||||
t.Run("bad request", func(t *testing.T) {
|
||||
// given
|
||||
ctx := context.Background()
|
||||
fx := newFixture(t)
|
||||
|
||||
// when
|
||||
sessionToken, appKey, err := fx.SolveChallengeForToken(ctx, "", "")
|
||||
|
||||
// then
|
||||
require.Error(t, err)
|
||||
require.Equal(t, ErrInvalidInput, err)
|
||||
require.Empty(t, sessionToken)
|
||||
require.Empty(t, appKey)
|
||||
})
|
||||
|
||||
t.Run("failed token retrieval", func(t *testing.T) {
|
||||
// given
|
||||
ctx := context.Background()
|
||||
fx := newFixture(t)
|
||||
|
||||
fx.mwMock.On("AccountLocalLinkSolveChallenge", mock.Anything, &pb.RpcAccountLocalLinkSolveChallengeRequest{
|
||||
ChallengeId: mockedChallengeId,
|
||||
Answer: mockedCode,
|
||||
}).
|
||||
Return(&pb.RpcAccountLocalLinkSolveChallengeResponse{
|
||||
Error: &pb.RpcAccountLocalLinkSolveChallengeResponseError{Code: pb.RpcAccountLocalLinkSolveChallengeResponseError_UNKNOWN_ERROR},
|
||||
}).Once()
|
||||
|
||||
// when
|
||||
sessionToken, appKey, err := fx.SolveChallengeForToken(ctx, mockedChallengeId, mockedCode)
|
||||
|
||||
// then
|
||||
require.Error(t, err)
|
||||
require.Equal(t, ErrFailedAuthenticate, err)
|
||||
require.Empty(t, sessionToken)
|
||||
require.Empty(t, appKey)
|
||||
})
|
||||
}
|
|
@ -1,143 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/anyproto/anytype-heart/pkg/lib/logging"
|
||||
)
|
||||
|
||||
const (
|
||||
baseURL = "http://localhost:31009/v1"
|
||||
// testSpaceId = "bafyreifymx5ucm3fdc7vupfg7wakdo5qelni3jvlmawlnvjcppurn2b3di.2lcu0r85yg10d" // dev (entry space)
|
||||
// testSpaceId = "bafyreiezhzb4ggnhjwejmh67pd5grilk6jn3jt7y2rnfpbkjwekilreola.1t123w9f2lgn5" // LFLC
|
||||
// testSpaceId = "bafyreiakofsfkgb7psju346cir2hit5hinhywaybi6vhp7hx4jw7hkngje.scoxzd7vu6rz" // HPI
|
||||
// testObjectId = "bafyreidhtlbbspxecab6xf4pi5zyxcmvwy6lqzursbjouq5fxovh6y3xwu" // "Work Faster with Templates"
|
||||
// testObjectId = "bafyreib3i5uq2tztocw3wrvhdugkwoxgg2xjh2jnl5retnyky66mr5b274" // Tag Test Page (in dev space)
|
||||
// testTypeId = "bafyreie3djy4mcldt3hgeet6bnjay2iajdyi2fvx556n6wcxii7brlni3i" // Page (in dev space)
|
||||
// chatSpaceId = "bafyreigryvrmerbtfswwz5kav2uq5dlvx3hl45kxn4nflg7lz46lneqs7m.2nvj2qik6ctdy" // Anytype Wiki space
|
||||
// chatSpaceId = "bafyreiexhpzaf7uxzheubh7cjeusqukjnxfvvhh4at6bygljwvto2dttnm.2lcu0r85yg10d" // chat space
|
||||
)
|
||||
|
||||
var log = logging.Logger("rest-api")
|
||||
|
||||
// ReplacePlaceholders replaces placeholders in the endpoint with actual values from parameters.
|
||||
func ReplacePlaceholders(endpoint string, parameters map[string]interface{}) string {
|
||||
for key, value := range parameters {
|
||||
placeholder := fmt.Sprintf("{%s}", key)
|
||||
encodedValue := url.QueryEscape(fmt.Sprintf("%v", value))
|
||||
endpoint = strings.ReplaceAll(endpoint, placeholder, encodedValue)
|
||||
}
|
||||
|
||||
// Parse the base URL + endpoint
|
||||
u, err := url.Parse(baseURL + endpoint)
|
||||
if err != nil {
|
||||
log.Errorf("Failed to parse URL: %v\n", err)
|
||||
return ""
|
||||
}
|
||||
|
||||
return u.String()
|
||||
}
|
||||
|
||||
func main() {
|
||||
endpoints := []struct {
|
||||
method string
|
||||
endpoint string
|
||||
parameters map[string]interface{}
|
||||
body map[string]interface{}
|
||||
}{
|
||||
// auth
|
||||
// {"POST", "/auth/display_code", nil, nil},
|
||||
// {"POST", "/auth/token?challenge_id={challenge_id}&code={code}", map[string]interface{}{"challenge_id": "6738dfc5cda913aad90e8c2a", "code": "2931"}, nil},
|
||||
|
||||
// export
|
||||
// {"GET", "/spaces/{space_id}/objects/{object_id}/export/{format}", map[string]interface{}{"space_id": testSpaceId, "object_id": testObjectId, "format": "markdown"}, nil},
|
||||
|
||||
// spaces
|
||||
// {"POST", "/spaces", nil, map[string]interface{}{"name": "New Space"}},
|
||||
// {"GET", "/spaces?limit={limit}&offset={offset}", map[string]interface{}{"limit": 100, "offset": 0}, nil},
|
||||
// {"GET", "/spaces/{space_id}/members?limit={limit}&offset={offset}", map[string]interface{}{"space_id": testSpaceId, "limit": 100, "offset": 0}, nil},
|
||||
|
||||
// objects
|
||||
// {"GET", "/spaces/{space_id}/objects?limit={limit}&offset={offset}", map[string]interface{}{"space_id": testSpaceId, "limit": 100, "offset": 0}, nil},
|
||||
// {"GET", "/spaces/{space_id}/objects/{object_id}", map[string]interface{}{"space_id": testSpaceId, "object_id": testObjectId}, nil},
|
||||
// {"DELETE", "/spaces/{space_id}/objects/{object_id}", map[string]interface{}{"space_id": "asd", "object_id": "asd"}, nil},
|
||||
// {"POST", "/spaces/{space_id}/objects", map[string]interface{}{"space_id": testSpaceId}, map[string]interface{}{"name": "New Object from demo", "icon": "💥", "template_id": "", "object_type_unique_key": "ot-page", "with_chat": false}},
|
||||
// {"PUT", "/spaces/{space_id}/objects/{object_id}", map[string]interface{}{"space_id": testSpaceId, "object_id": testObjectId}, map[string]interface{}{"name": "Updated Object"}},
|
||||
// {"GET", "/spaces/{space_id}/object_types?limit={limit}&offset={offset}", map[string]interface{}{"space_id": testSpaceId, "limit": 100, "offset": 0}, nil},
|
||||
// {"GET", "/spaces/{space_id}/object_types/{type_id}/templates?limit={limit}&offset={offset}", map[string]interface{}{"space_id": testSpaceId, "type_id": testTypeId, "limit": 100, "offset": 0}, nil},
|
||||
|
||||
// search
|
||||
// {"GET", "/search?query={query}&object_types={object_types}&limit={limit}&offset={offset}", map[string]interface{}{"query": "new", "object_types": "", "limit": 100, "offset": 0}, nil},
|
||||
}
|
||||
|
||||
for _, ep := range endpoints {
|
||||
finalURL := ReplacePlaceholders(ep.endpoint, ep.parameters)
|
||||
|
||||
var req *http.Request
|
||||
var err error
|
||||
|
||||
if ep.body != nil {
|
||||
body, err := json.Marshal(ep.body)
|
||||
if err != nil {
|
||||
log.Errorf("Failed to marshal body for %s: %v\n", ep.endpoint, err)
|
||||
continue
|
||||
}
|
||||
req, err = http.NewRequest(ep.method, finalURL, bytes.NewBuffer(body))
|
||||
if err != nil {
|
||||
log.Errorf("Failed to create request for %s: %v\n", ep.endpoint, err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
} else {
|
||||
req, err = http.NewRequest(ep.method, finalURL, nil)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Errorf("Failed to create request for %s: %v\n", ep.endpoint, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Execute the HTTP request
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
log.Errorf("Failed to make request to %s: %v\n", finalURL, err.Error())
|
||||
continue
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Check the status code
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
log.Errorf("Failes to read response body for request to %s with code %d.", finalURL, resp.StatusCode)
|
||||
continue
|
||||
}
|
||||
log.Errorf("Request to %s returned status code %d: %v\n", finalURL, resp.StatusCode, string(body))
|
||||
continue
|
||||
}
|
||||
|
||||
// Read the response
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
log.Errorf("Failed to read response body for %s: %v\n", ep.endpoint, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Log the response
|
||||
var prettyJSON bytes.Buffer
|
||||
err = json.Indent(&prettyJSON, body, "", " ")
|
||||
if err != nil {
|
||||
log.Errorf("Failed to pretty print response body for %s: %v\n", ep.endpoint, err)
|
||||
log.Infof("%s\n", string(body))
|
||||
continue
|
||||
}
|
||||
|
||||
log.Infof("Endpoint: %s, Status Code: %d, Body: %s\n", finalURL, resp.StatusCode, prettyJSON.String())
|
||||
}
|
||||
}
|
1345
cmd/api/docs/docs.go
1345
cmd/api/docs/docs.go
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
@ -1,894 +0,0 @@
|
|||
basePath: /v1
|
||||
definitions:
|
||||
auth.DisplayCodeResponse:
|
||||
properties:
|
||||
challenge_id:
|
||||
example: 67647f5ecda913e9a2e11b26
|
||||
type: string
|
||||
type: object
|
||||
auth.TokenResponse:
|
||||
properties:
|
||||
app_key:
|
||||
example: ""
|
||||
type: string
|
||||
session_token:
|
||||
example: ""
|
||||
type: string
|
||||
type: object
|
||||
export.ObjectExportResponse:
|
||||
properties:
|
||||
path:
|
||||
type: string
|
||||
type: object
|
||||
object.Block:
|
||||
properties:
|
||||
align:
|
||||
type: string
|
||||
background_color:
|
||||
type: string
|
||||
children_ids:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
file:
|
||||
$ref: '#/definitions/object.File'
|
||||
id:
|
||||
type: string
|
||||
text:
|
||||
$ref: '#/definitions/object.Text'
|
||||
vertical_align:
|
||||
type: string
|
||||
type: object
|
||||
object.Detail:
|
||||
properties:
|
||||
details:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
id:
|
||||
type: string
|
||||
type: object
|
||||
object.File:
|
||||
properties:
|
||||
added_at:
|
||||
type: integer
|
||||
hash:
|
||||
type: string
|
||||
mime:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
size:
|
||||
type: integer
|
||||
state:
|
||||
type: string
|
||||
style:
|
||||
type: string
|
||||
target_object_id:
|
||||
type: string
|
||||
type:
|
||||
type: string
|
||||
type: object
|
||||
object.Object:
|
||||
properties:
|
||||
blocks:
|
||||
items:
|
||||
$ref: '#/definitions/object.Block'
|
||||
type: array
|
||||
details:
|
||||
items:
|
||||
$ref: '#/definitions/object.Detail'
|
||||
type: array
|
||||
icon:
|
||||
example: "\U0001F4C4"
|
||||
type: string
|
||||
id:
|
||||
example: bafyreie6n5l5nkbjal37su54cha4coy7qzuhrnajluzv5qd5jvtsrxkequ
|
||||
type: string
|
||||
layout:
|
||||
example: basic
|
||||
type: string
|
||||
name:
|
||||
example: Object Name
|
||||
type: string
|
||||
object_type:
|
||||
example: Page
|
||||
type: string
|
||||
root_id:
|
||||
type: string
|
||||
space_id:
|
||||
example: bafyreigyfkt6rbv24sbv5aq2hko3bhmv5xxlf22b4bypdu6j7hnphm3psq.23me69r569oi1
|
||||
type: string
|
||||
type:
|
||||
example: object
|
||||
type: string
|
||||
type: object
|
||||
object.ObjectResponse:
|
||||
properties:
|
||||
object:
|
||||
$ref: '#/definitions/object.Object'
|
||||
type: object
|
||||
object.ObjectTemplate:
|
||||
properties:
|
||||
icon:
|
||||
example: "\U0001F4C4"
|
||||
type: string
|
||||
id:
|
||||
example: bafyreictrp3obmnf6dwejy5o4p7bderaaia4bdg2psxbfzf44yya5uutge
|
||||
type: string
|
||||
name:
|
||||
example: Object Template Name
|
||||
type: string
|
||||
type:
|
||||
example: object_template
|
||||
type: string
|
||||
type: object
|
||||
object.ObjectType:
|
||||
properties:
|
||||
icon:
|
||||
example: "\U0001F4C4"
|
||||
type: string
|
||||
id:
|
||||
example: bafyreigyb6l5szohs32ts26ku2j42yd65e6hqy2u3gtzgdwqv6hzftsetu
|
||||
type: string
|
||||
name:
|
||||
example: Page
|
||||
type: string
|
||||
type:
|
||||
example: object_type
|
||||
type: string
|
||||
unique_key:
|
||||
example: ot-page
|
||||
type: string
|
||||
type: object
|
||||
object.Text:
|
||||
properties:
|
||||
checked:
|
||||
type: boolean
|
||||
color:
|
||||
type: string
|
||||
icon:
|
||||
type: string
|
||||
style:
|
||||
type: string
|
||||
text:
|
||||
type: string
|
||||
type: object
|
||||
pagination.PaginatedResponse-space_Member:
|
||||
properties:
|
||||
data:
|
||||
items:
|
||||
$ref: '#/definitions/space.Member'
|
||||
type: array
|
||||
pagination:
|
||||
$ref: '#/definitions/pagination.PaginationMeta'
|
||||
type: object
|
||||
pagination.PaginatedResponse-space_Space:
|
||||
properties:
|
||||
data:
|
||||
items:
|
||||
$ref: '#/definitions/space.Space'
|
||||
type: array
|
||||
pagination:
|
||||
$ref: '#/definitions/pagination.PaginationMeta'
|
||||
type: object
|
||||
pagination.PaginationMeta:
|
||||
properties:
|
||||
has_more:
|
||||
description: whether there are more items available
|
||||
example: true
|
||||
type: boolean
|
||||
limit:
|
||||
description: the current limit
|
||||
example: 100
|
||||
type: integer
|
||||
offset:
|
||||
description: the current offset
|
||||
example: 0
|
||||
type: integer
|
||||
total:
|
||||
description: the total number of items available on that endpoint
|
||||
example: 1024
|
||||
type: integer
|
||||
type: object
|
||||
space.CreateSpaceResponse:
|
||||
properties:
|
||||
space:
|
||||
$ref: '#/definitions/space.Space'
|
||||
type: object
|
||||
space.Member:
|
||||
properties:
|
||||
global_name:
|
||||
example: john.any
|
||||
type: string
|
||||
icon:
|
||||
example: http://127.0.0.1:31006/image/bafybeieptz5hvcy6txplcvphjbbh5yjc2zqhmihs3owkh5oab4ezauzqay?width=100
|
||||
type: string
|
||||
id:
|
||||
example: _participant_bafyreigyfkt6rbv24sbv5aq2hko1bhmv5xxlf22b4bypdu6j7hnphm3psq_23me69r569oi1_AAjEaEwPF4nkEh9AWkqEnzcQ8HziBB4ETjiTpvRCQvWnSMDZ
|
||||
type: string
|
||||
identity:
|
||||
example: AAjEaEwPF4nkEh7AWkqEnzcQ8HziGB4ETjiTpvRCQvWnSMDZ
|
||||
type: string
|
||||
name:
|
||||
example: John Doe
|
||||
type: string
|
||||
role:
|
||||
example: Owner
|
||||
type: string
|
||||
type:
|
||||
example: member
|
||||
type: string
|
||||
type: object
|
||||
space.Space:
|
||||
properties:
|
||||
account_space_id:
|
||||
example: bafyreihpd2knon5wbljhtfeg3fcqtg3i2pomhhnigui6lrjmzcjzep7gcy.23me69r569oi1
|
||||
type: string
|
||||
analytics_id:
|
||||
example: 624aecdd-4797-4611-9d61-a2ae5f53cf1c
|
||||
type: string
|
||||
archive_object_id:
|
||||
example: bafyreialsgoyflf3etjm3parzurivyaukzivwortf32b4twnlwpwocsrri
|
||||
type: string
|
||||
device_id:
|
||||
example: 12D3KooWGZMJ4kQVyQVXaj7gJPZr3RZ2nvd9M2Eq2pprEoPih9WF
|
||||
type: string
|
||||
gateway_url:
|
||||
example: http://127.0.0.1:31006
|
||||
type: string
|
||||
home_object_id:
|
||||
example: bafyreie4qcl3wczb4cw5hrfyycikhjyh6oljdis3ewqrk5boaav3sbwqya
|
||||
type: string
|
||||
icon:
|
||||
example: http://127.0.0.1:31006/image/bafybeieptz5hvcy6txplcvphjbbh5yjc2zqhmihs3owkh5oab4ezauzqay?width=100
|
||||
type: string
|
||||
id:
|
||||
example: bafyreigyfkt6rbv24sbv5aq2hko3bhmv5xxlf22b4bypdu6j7hnphm3psq.23me69r569oi1
|
||||
type: string
|
||||
local_storage_path:
|
||||
example: /Users/johndoe/Library/Application Support/Anytype/data/AAHTtt1wuQEnaYBNZ2Cyfcvs6DqPqxgn8VXDVk4avsUkMuha
|
||||
type: string
|
||||
marketplace_workspace_id:
|
||||
example: _anytype_marketplace
|
||||
type: string
|
||||
name:
|
||||
example: Space Name
|
||||
type: string
|
||||
network_id:
|
||||
example: N83gJpVd9MuNRZAuJLZ7LiMntTThhPc6DtzWWVjb1M3PouVU
|
||||
type: string
|
||||
profile_object_id:
|
||||
example: bafyreiaxhwreshjqwndpwtdsu4mtihaqhhmlygqnyqpfyfwlqfq3rm3gw4
|
||||
type: string
|
||||
space_view_id:
|
||||
example: bafyreigzv3vq7qwlrsin6njoduq727ssnhwd6bgyfj6nm4hv3pxoc2rxhy
|
||||
type: string
|
||||
tech_space_id:
|
||||
example: bafyreif4xuwncrjl6jajt4zrrfnylpki476nv2w64yf42ovt7gia7oypii.23me69r569oi1
|
||||
type: string
|
||||
timezone:
|
||||
example: ""
|
||||
type: string
|
||||
type:
|
||||
example: space
|
||||
type: string
|
||||
widgets_id:
|
||||
example: bafyreialj7pceh53mifm5dixlho47ke4qjmsn2uh4wsjf7xq2pnlo5xfva
|
||||
type: string
|
||||
workspace_object_id:
|
||||
example: bafyreiapey2g6e6za4zfxvlgwdy4hbbfu676gmwrhnqvjbxvrchr7elr3y
|
||||
type: string
|
||||
type: object
|
||||
util.NotFoundError:
|
||||
properties:
|
||||
error:
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
type: object
|
||||
type: object
|
||||
util.ServerError:
|
||||
properties:
|
||||
error:
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
type: object
|
||||
type: object
|
||||
util.UnauthorizedError:
|
||||
properties:
|
||||
error:
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
type: object
|
||||
type: object
|
||||
util.ValidationError:
|
||||
properties:
|
||||
error:
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
type: object
|
||||
type: object
|
||||
externalDocs:
|
||||
description: OpenAPI
|
||||
url: https://swagger.io/resources/open-api/
|
||||
host: localhost:31009
|
||||
info:
|
||||
contact:
|
||||
email: support@anytype.io
|
||||
name: Anytype Support
|
||||
url: https://anytype.io/contact
|
||||
description: This API allows interaction with Anytype resources such as spaces,
|
||||
objects, and object types.
|
||||
license:
|
||||
name: Any Source Available License 1.0
|
||||
url: https://github.com/anyproto/anytype-ts/blob/main/LICENSE.md
|
||||
termsOfService: https://anytype.io/terms_of_use
|
||||
title: Anytype API
|
||||
version: "1.0"
|
||||
paths:
|
||||
/auth/display_code:
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: Challenge ID
|
||||
schema:
|
||||
$ref: '#/definitions/auth.DisplayCodeResponse'
|
||||
"502":
|
||||
description: Internal server error
|
||||
schema:
|
||||
$ref: '#/definitions/util.ServerError'
|
||||
summary: Open a modal window with a code in Anytype Desktop app
|
||||
tags:
|
||||
- auth
|
||||
/auth/token:
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
parameters:
|
||||
- description: The challenge ID
|
||||
in: query
|
||||
name: challenge_id
|
||||
required: true
|
||||
type: string
|
||||
- description: The 4-digit code retrieved from Anytype Desktop app
|
||||
in: query
|
||||
name: code
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: Authentication token
|
||||
schema:
|
||||
$ref: '#/definitions/auth.TokenResponse'
|
||||
"400":
|
||||
description: Invalid input
|
||||
schema:
|
||||
$ref: '#/definitions/util.ValidationError'
|
||||
"502":
|
||||
description: Internal server error
|
||||
schema:
|
||||
$ref: '#/definitions/util.ServerError'
|
||||
summary: Retrieve an authentication token using a code
|
||||
tags:
|
||||
- auth
|
||||
/search:
|
||||
get:
|
||||
consumes:
|
||||
- application/json
|
||||
parameters:
|
||||
- description: The search term to filter objects by name
|
||||
in: query
|
||||
name: query
|
||||
type: string
|
||||
- collectionFormat: csv
|
||||
description: Specify object types for search
|
||||
in: query
|
||||
items:
|
||||
type: string
|
||||
name: object_types
|
||||
type: array
|
||||
- description: The number of items to skip before starting to collect the result
|
||||
set
|
||||
in: query
|
||||
name: offset
|
||||
type: integer
|
||||
- default: 100
|
||||
description: The number of items to return
|
||||
in: query
|
||||
name: limit
|
||||
type: integer
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: List of objects
|
||||
schema:
|
||||
additionalProperties:
|
||||
items:
|
||||
$ref: '#/definitions/object.Object'
|
||||
type: array
|
||||
type: object
|
||||
"403":
|
||||
description: Unauthorized
|
||||
schema:
|
||||
$ref: '#/definitions/util.UnauthorizedError'
|
||||
"404":
|
||||
description: Resource not found
|
||||
schema:
|
||||
$ref: '#/definitions/util.NotFoundError'
|
||||
"502":
|
||||
description: Internal server error
|
||||
schema:
|
||||
$ref: '#/definitions/util.ServerError'
|
||||
summary: Search and retrieve objects across all the spaces
|
||||
tags:
|
||||
- search
|
||||
/spaces:
|
||||
get:
|
||||
consumes:
|
||||
- application/json
|
||||
parameters:
|
||||
- description: The number of items to skip before starting to collect the result
|
||||
set
|
||||
in: query
|
||||
name: offset
|
||||
type: integer
|
||||
- default: 100
|
||||
description: The number of items to return
|
||||
in: query
|
||||
name: limit
|
||||
type: integer
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: List of spaces
|
||||
schema:
|
||||
$ref: '#/definitions/pagination.PaginatedResponse-space_Space'
|
||||
"403":
|
||||
description: Unauthorized
|
||||
schema:
|
||||
$ref: '#/definitions/util.UnauthorizedError'
|
||||
"404":
|
||||
description: Resource not found
|
||||
schema:
|
||||
$ref: '#/definitions/util.NotFoundError'
|
||||
"502":
|
||||
description: Internal server error
|
||||
schema:
|
||||
$ref: '#/definitions/util.ServerError'
|
||||
summary: Retrieve a list of spaces
|
||||
tags:
|
||||
- spaces
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
parameters:
|
||||
- description: Space Name
|
||||
in: body
|
||||
name: name
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: Space created successfully
|
||||
schema:
|
||||
$ref: '#/definitions/space.CreateSpaceResponse'
|
||||
"400":
|
||||
description: Bad request
|
||||
schema:
|
||||
$ref: '#/definitions/util.ValidationError'
|
||||
"403":
|
||||
description: Unauthorized
|
||||
schema:
|
||||
$ref: '#/definitions/util.UnauthorizedError'
|
||||
"502":
|
||||
description: Internal server error
|
||||
schema:
|
||||
$ref: '#/definitions/util.ServerError'
|
||||
summary: Create a new Space
|
||||
tags:
|
||||
- spaces
|
||||
/spaces/{space_id}/members:
|
||||
get:
|
||||
consumes:
|
||||
- application/json
|
||||
parameters:
|
||||
- description: The ID of the space
|
||||
in: path
|
||||
name: space_id
|
||||
required: true
|
||||
type: string
|
||||
- description: The number of items to skip before starting to collect the result
|
||||
set
|
||||
in: query
|
||||
name: offset
|
||||
type: integer
|
||||
- default: 100
|
||||
description: The number of items to return
|
||||
in: query
|
||||
name: limit
|
||||
type: integer
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: List of members
|
||||
schema:
|
||||
$ref: '#/definitions/pagination.PaginatedResponse-space_Member'
|
||||
"403":
|
||||
description: Unauthorized
|
||||
schema:
|
||||
$ref: '#/definitions/util.UnauthorizedError'
|
||||
"404":
|
||||
description: Resource not found
|
||||
schema:
|
||||
$ref: '#/definitions/util.NotFoundError'
|
||||
"502":
|
||||
description: Internal server error
|
||||
schema:
|
||||
$ref: '#/definitions/util.ServerError'
|
||||
summary: Retrieve a list of members for the specified Space
|
||||
tags:
|
||||
- spaces
|
||||
/spaces/{space_id}/object_types:
|
||||
get:
|
||||
consumes:
|
||||
- application/json
|
||||
parameters:
|
||||
- description: The ID of the space
|
||||
in: path
|
||||
name: space_id
|
||||
required: true
|
||||
type: string
|
||||
- description: The number of items to skip before starting to collect the result
|
||||
set
|
||||
in: query
|
||||
name: offset
|
||||
type: integer
|
||||
- default: 100
|
||||
description: The number of items to return
|
||||
in: query
|
||||
name: limit
|
||||
type: integer
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: List of object types
|
||||
schema:
|
||||
additionalProperties:
|
||||
$ref: '#/definitions/object.ObjectType'
|
||||
type: object
|
||||
"403":
|
||||
description: Unauthorized
|
||||
schema:
|
||||
$ref: '#/definitions/util.UnauthorizedError'
|
||||
"404":
|
||||
description: Resource not found
|
||||
schema:
|
||||
$ref: '#/definitions/util.NotFoundError'
|
||||
"502":
|
||||
description: Internal server error
|
||||
schema:
|
||||
$ref: '#/definitions/util.ServerError'
|
||||
summary: Retrieve object types in a specific space
|
||||
tags:
|
||||
- objects
|
||||
/spaces/{space_id}/object_types/{type_id}/templates:
|
||||
get:
|
||||
consumes:
|
||||
- application/json
|
||||
parameters:
|
||||
- description: The ID of the space
|
||||
in: path
|
||||
name: space_id
|
||||
required: true
|
||||
type: string
|
||||
- description: The ID of the object type
|
||||
in: path
|
||||
name: type_id
|
||||
required: true
|
||||
type: string
|
||||
- description: The number of items to skip before starting to collect the result
|
||||
set
|
||||
in: query
|
||||
name: offset
|
||||
type: integer
|
||||
- default: 100
|
||||
description: The number of items to return
|
||||
in: query
|
||||
name: limit
|
||||
type: integer
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: List of templates
|
||||
schema:
|
||||
additionalProperties:
|
||||
items:
|
||||
$ref: '#/definitions/object.ObjectTemplate'
|
||||
type: array
|
||||
type: object
|
||||
"403":
|
||||
description: Unauthorized
|
||||
schema:
|
||||
$ref: '#/definitions/util.UnauthorizedError'
|
||||
"404":
|
||||
description: Resource not found
|
||||
schema:
|
||||
$ref: '#/definitions/util.NotFoundError'
|
||||
"502":
|
||||
description: Internal server error
|
||||
schema:
|
||||
$ref: '#/definitions/util.ServerError'
|
||||
summary: Retrieve a list of templates for a specific object type in a space
|
||||
tags:
|
||||
- objects
|
||||
/spaces/{space_id}/objects:
|
||||
get:
|
||||
consumes:
|
||||
- application/json
|
||||
parameters:
|
||||
- description: The ID of the space
|
||||
in: path
|
||||
name: space_id
|
||||
required: true
|
||||
type: string
|
||||
- description: The number of items to skip before starting to collect the result
|
||||
set
|
||||
in: query
|
||||
name: offset
|
||||
type: integer
|
||||
- default: 100
|
||||
description: The number of items to return
|
||||
in: query
|
||||
name: limit
|
||||
type: integer
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: List of objects
|
||||
schema:
|
||||
additionalProperties:
|
||||
items:
|
||||
$ref: '#/definitions/object.Object'
|
||||
type: array
|
||||
type: object
|
||||
"403":
|
||||
description: Unauthorized
|
||||
schema:
|
||||
$ref: '#/definitions/util.UnauthorizedError'
|
||||
"404":
|
||||
description: Resource not found
|
||||
schema:
|
||||
$ref: '#/definitions/util.NotFoundError'
|
||||
"502":
|
||||
description: Internal server error
|
||||
schema:
|
||||
$ref: '#/definitions/util.ServerError'
|
||||
summary: Retrieve objects in a specific space
|
||||
tags:
|
||||
- objects
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
parameters:
|
||||
- description: The ID of the space
|
||||
in: path
|
||||
name: space_id
|
||||
required: true
|
||||
type: string
|
||||
- description: Object details (e.g., name)
|
||||
in: body
|
||||
name: object
|
||||
required: true
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: The created object
|
||||
schema:
|
||||
$ref: '#/definitions/object.ObjectResponse'
|
||||
"400":
|
||||
description: Bad request
|
||||
schema:
|
||||
$ref: '#/definitions/util.ValidationError'
|
||||
"403":
|
||||
description: Unauthorized
|
||||
schema:
|
||||
$ref: '#/definitions/util.UnauthorizedError'
|
||||
"502":
|
||||
description: Internal server error
|
||||
schema:
|
||||
$ref: '#/definitions/util.ServerError'
|
||||
summary: Create a new object in a specific space
|
||||
tags:
|
||||
- objects
|
||||
/spaces/{space_id}/objects/{object_id}:
|
||||
delete:
|
||||
consumes:
|
||||
- application/json
|
||||
parameters:
|
||||
- description: The ID of the space
|
||||
in: path
|
||||
name: space_id
|
||||
required: true
|
||||
type: string
|
||||
- description: The ID of the object
|
||||
in: path
|
||||
name: object_id
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: The deleted object
|
||||
schema:
|
||||
$ref: '#/definitions/object.ObjectResponse'
|
||||
"403":
|
||||
description: Unauthorized
|
||||
schema:
|
||||
$ref: '#/definitions/util.UnauthorizedError'
|
||||
"404":
|
||||
description: Resource not found
|
||||
schema:
|
||||
$ref: '#/definitions/util.NotFoundError'
|
||||
"502":
|
||||
description: Internal server error
|
||||
schema:
|
||||
$ref: '#/definitions/util.ServerError'
|
||||
summary: Delete a specific object in a space
|
||||
tags:
|
||||
- objects
|
||||
get:
|
||||
consumes:
|
||||
- application/json
|
||||
parameters:
|
||||
- description: The ID of the space
|
||||
in: path
|
||||
name: space_id
|
||||
required: true
|
||||
type: string
|
||||
- description: The ID of the object
|
||||
in: path
|
||||
name: object_id
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: The requested object
|
||||
schema:
|
||||
$ref: '#/definitions/object.ObjectResponse'
|
||||
"403":
|
||||
description: Unauthorized
|
||||
schema:
|
||||
$ref: '#/definitions/util.UnauthorizedError'
|
||||
"404":
|
||||
description: Resource not found
|
||||
schema:
|
||||
$ref: '#/definitions/util.NotFoundError'
|
||||
"502":
|
||||
description: Internal server error
|
||||
schema:
|
||||
$ref: '#/definitions/util.ServerError'
|
||||
summary: Retrieve a specific object in a space
|
||||
tags:
|
||||
- objects
|
||||
put:
|
||||
consumes:
|
||||
- application/json
|
||||
parameters:
|
||||
- description: The ID of the space
|
||||
in: path
|
||||
name: space_id
|
||||
required: true
|
||||
type: string
|
||||
- description: The ID of the object
|
||||
in: path
|
||||
name: object_id
|
||||
required: true
|
||||
type: string
|
||||
- description: The updated object details
|
||||
in: body
|
||||
name: object
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/object.Object'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: The updated object
|
||||
schema:
|
||||
$ref: '#/definitions/object.ObjectResponse'
|
||||
"400":
|
||||
description: Bad request
|
||||
schema:
|
||||
$ref: '#/definitions/util.ValidationError'
|
||||
"403":
|
||||
description: Unauthorized
|
||||
schema:
|
||||
$ref: '#/definitions/util.UnauthorizedError'
|
||||
"404":
|
||||
description: Resource not found
|
||||
schema:
|
||||
$ref: '#/definitions/util.NotFoundError'
|
||||
"502":
|
||||
description: Internal server error
|
||||
schema:
|
||||
$ref: '#/definitions/util.ServerError'
|
||||
summary: Update an existing object in a specific space
|
||||
tags:
|
||||
- objects
|
||||
/spaces/{space_id}/objects/{object_id}/export/{format}:
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
parameters:
|
||||
- description: Space ID
|
||||
in: path
|
||||
name: space_id
|
||||
required: true
|
||||
type: string
|
||||
- description: Object ID
|
||||
in: path
|
||||
name: object_id
|
||||
required: true
|
||||
type: string
|
||||
- description: Export format
|
||||
in: query
|
||||
name: format
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: Object exported successfully
|
||||
schema:
|
||||
$ref: '#/definitions/export.ObjectExportResponse'
|
||||
"400":
|
||||
description: Bad request
|
||||
schema:
|
||||
$ref: '#/definitions/util.ValidationError'
|
||||
"403":
|
||||
description: Unauthorized
|
||||
schema:
|
||||
$ref: '#/definitions/util.UnauthorizedError'
|
||||
"404":
|
||||
description: Resource not found
|
||||
schema:
|
||||
$ref: '#/definitions/util.NotFoundError'
|
||||
"502":
|
||||
description: Internal server error
|
||||
schema:
|
||||
$ref: '#/definitions/util.ServerError'
|
||||
summary: Export an object
|
||||
tags:
|
||||
- export
|
||||
securityDefinitions:
|
||||
BasicAuth:
|
||||
type: basic
|
||||
swagger: "2.0"
|
|
@ -1,59 +0,0 @@
|
|||
package export
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/anyproto/anytype-heart/cmd/api/util"
|
||||
)
|
||||
|
||||
// GetObjectExportHandler exports an object to the specified format
|
||||
//
|
||||
// @Summary Export an object
|
||||
// @Tags export
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param space_id path string true "Space ID"
|
||||
// @Param object_id path string true "Object ID"
|
||||
// @Param format query string true "Export format"
|
||||
// @Success 200 {object} ObjectExportResponse "Object exported successfully"
|
||||
// @Failure 400 {object} util.ValidationError "Bad request"
|
||||
// @Failure 403 {object} util.UnauthorizedError "Unauthorized"
|
||||
// @Failure 404 {object} util.NotFoundError "Resource not found"
|
||||
// @Failure 502 {object} util.ServerError "Internal server error"
|
||||
// @Router /spaces/{space_id}/objects/{object_id}/export/{format} [post]
|
||||
func GetObjectExportHandler(s *ExportService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
spaceId := c.Param("space_id")
|
||||
objectId := c.Param("object_id")
|
||||
format := c.Query("format")
|
||||
|
||||
objectAsRequest := ObjectExportRequest{}
|
||||
if err := c.ShouldBindJSON(&objectAsRequest); err != nil {
|
||||
apiErr := util.CodeToAPIError(http.StatusBadRequest, ErrBadInput.Error())
|
||||
c.JSON(http.StatusBadRequest, apiErr)
|
||||
return
|
||||
}
|
||||
|
||||
outputPath, err := s.GetObjectExport(c.Request.Context(), spaceId, objectId, format, objectAsRequest.Path)
|
||||
code := util.MapErrorCode(err, util.ErrToCode(ErrFailedExportObjectAsMarkdown, http.StatusInternalServerError))
|
||||
|
||||
if code != http.StatusOK {
|
||||
apiErr := util.CodeToAPIError(code, err.Error())
|
||||
c.JSON(code, apiErr)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, ObjectExportResponse{Path: outputPath})
|
||||
}
|
||||
}
|
||||
|
||||
func GetSpaceExportHandler(s *ExportService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// spaceId := c.Param("space_id")
|
||||
// format := c.Query("format")
|
||||
|
||||
c.JSON(http.StatusNotImplemented, "Not implemented")
|
||||
}
|
||||
}
|
|
@ -1,9 +0,0 @@
|
|||
package export
|
||||
|
||||
type ObjectExportRequest struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
type ObjectExportResponse struct {
|
||||
Path string `json:"path"`
|
||||
}
|
|
@ -1,61 +0,0 @@
|
|||
package export
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/anyproto/anytype-heart/pb"
|
||||
"github.com/anyproto/anytype-heart/pb/service"
|
||||
"github.com/anyproto/anytype-heart/pkg/lib/pb/model"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrFailedExportObjectAsMarkdown = errors.New("failed to export object as markdown")
|
||||
ErrBadInput = errors.New("bad input")
|
||||
)
|
||||
|
||||
type Service interface {
|
||||
GetObjectExport(ctx context.Context, spaceId string, objectId string, format string, path string) (string, error)
|
||||
}
|
||||
|
||||
type ExportService struct {
|
||||
mw service.ClientCommandsServer
|
||||
AccountInfo *model.AccountInfo
|
||||
}
|
||||
|
||||
func NewService(mw service.ClientCommandsServer) *ExportService {
|
||||
return &ExportService{mw: mw}
|
||||
}
|
||||
|
||||
// GetObjectExport retrieves an object from a space and exports it as a specific format.
|
||||
func (s *ExportService) GetObjectExport(ctx context.Context, spaceId string, objectId string, format string, path string) (string, error) {
|
||||
resp := s.mw.ObjectListExport(ctx, &pb.RpcObjectListExportRequest{
|
||||
SpaceId: spaceId,
|
||||
Path: path,
|
||||
ObjectIds: []string{objectId},
|
||||
Format: s.mapStringToFormat(format),
|
||||
Zip: false,
|
||||
IncludeNested: false,
|
||||
IncludeFiles: true,
|
||||
IsJson: false,
|
||||
IncludeArchived: false,
|
||||
})
|
||||
|
||||
if resp.Error.Code != pb.RpcObjectListExportResponseError_NULL {
|
||||
return "", ErrFailedExportObjectAsMarkdown
|
||||
}
|
||||
|
||||
return resp.Path, nil
|
||||
}
|
||||
|
||||
// mapStringToFormat maps a format string to an ExportFormat enum.
|
||||
func (s *ExportService) mapStringToFormat(format string) model.ExportFormat {
|
||||
switch format {
|
||||
case "markdown":
|
||||
return model.Export_Markdown
|
||||
case "protobuf":
|
||||
return model.Export_Protobuf
|
||||
default:
|
||||
return model.Export_Markdown
|
||||
}
|
||||
}
|
|
@ -1,60 +0,0 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"time"
|
||||
|
||||
_ "github.com/anyproto/anytype-heart/cmd/api/docs"
|
||||
"github.com/anyproto/anytype-heart/cmd/api/server"
|
||||
"github.com/anyproto/anytype-heart/core"
|
||||
"github.com/anyproto/anytype-heart/pb/service"
|
||||
)
|
||||
|
||||
const (
|
||||
serverShutdownTime = 5 * time.Second
|
||||
)
|
||||
|
||||
// RunApiServer starts the HTTP server and registers the API routes.
|
||||
//
|
||||
// @title Anytype API
|
||||
// @version 1.0
|
||||
// @description This API allows interaction with Anytype resources such as spaces, objects, and object types.
|
||||
// @termsOfService https://anytype.io/terms_of_use
|
||||
// @contact.name Anytype Support
|
||||
// @contact.url https://anytype.io/contact
|
||||
// @contact.email support@anytype.io
|
||||
// @license.name Any Source Available License 1.0
|
||||
// @license.url https://github.com/anyproto/anytype-ts/blob/main/LICENSE.md
|
||||
// @host localhost:31009
|
||||
// @BasePath /v1
|
||||
// @securityDefinitions.basic BasicAuth
|
||||
// @externalDocs.description OpenAPI
|
||||
// @externalDocs.url https://swagger.io/resources/open-api/
|
||||
func RunApiServer(ctx context.Context, mw service.ClientCommandsServer, mwInternal core.MiddlewareInternal) {
|
||||
// Create a new server instance including the router
|
||||
srv := server.NewServer(mw, mwInternal)
|
||||
|
||||
// Start the server in a goroutine so we can handle graceful shutdown
|
||||
go func() {
|
||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
fmt.Printf("API server error: %v\n", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Graceful shutdown on CTRL+C / SIGINT
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, os.Interrupt)
|
||||
<-quit
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), serverShutdownTime)
|
||||
defer cancel()
|
||||
|
||||
if err := srv.Shutdown(ctx); err != nil {
|
||||
fmt.Printf("API server shutdown failed: %v\n", err)
|
||||
}
|
||||
}
|
|
@ -1,282 +0,0 @@
|
|||
package object
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/anyproto/anytype-heart/cmd/api/pagination"
|
||||
"github.com/anyproto/anytype-heart/cmd/api/util"
|
||||
)
|
||||
|
||||
// GetObjectsHandler retrieves objects in a specific space
|
||||
//
|
||||
// @Summary Retrieve objects in a specific space
|
||||
// @Tags objects
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param space_id path string true "The ID of the space"
|
||||
// @Param offset query int false "The number of items to skip before starting to collect the result set"
|
||||
// @Param limit query int false "The number of items to return" default(100)
|
||||
// @Success 200 {object} map[string][]Object "List of objects"
|
||||
// @Failure 403 {object} util.UnauthorizedError "Unauthorized"
|
||||
// @Failure 404 {object} util.NotFoundError "Resource not found"
|
||||
// @Failure 502 {object} util.ServerError "Internal server error"
|
||||
// @Router /spaces/{space_id}/objects [get]
|
||||
func GetObjectsHandler(s *ObjectService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
spaceId := c.Param("space_id")
|
||||
offset := c.GetInt("offset")
|
||||
limit := c.GetInt("limit")
|
||||
|
||||
objects, total, hasMore, err := s.ListObjects(c.Request.Context(), spaceId, offset, limit)
|
||||
code := util.MapErrorCode(err,
|
||||
util.ErrToCode(ErrorFailedRetrieveObjects, http.StatusInternalServerError),
|
||||
util.ErrToCode(ErrNoObjectsFound, http.StatusNotFound),
|
||||
util.ErrToCode(ErrObjectNotFound, http.StatusNotFound),
|
||||
util.ErrToCode(ErrFailedRetrieveObject, http.StatusInternalServerError),
|
||||
)
|
||||
|
||||
if code != http.StatusOK {
|
||||
apiErr := util.CodeToAPIError(code, err.Error())
|
||||
c.JSON(code, apiErr)
|
||||
return
|
||||
}
|
||||
|
||||
pagination.RespondWithPagination(c, http.StatusOK, objects, total, offset, limit, hasMore)
|
||||
}
|
||||
}
|
||||
|
||||
// GetObjectHandler retrieves a specific object in a space
|
||||
//
|
||||
// @Summary Retrieve a specific object in a space
|
||||
// @Tags objects
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param space_id path string true "The ID of the space"
|
||||
// @Param object_id path string true "The ID of the object"
|
||||
// @Success 200 {object} ObjectResponse "The requested object"
|
||||
// @Failure 403 {object} util.UnauthorizedError "Unauthorized"
|
||||
// @Failure 404 {object} util.NotFoundError "Resource not found"
|
||||
// @Failure 502 {object} util.ServerError "Internal server error"
|
||||
// @Router /spaces/{space_id}/objects/{object_id} [get]
|
||||
func GetObjectHandler(s *ObjectService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
spaceId := c.Param("space_id")
|
||||
objectId := c.Param("object_id")
|
||||
|
||||
object, err := s.GetObject(c.Request.Context(), spaceId, objectId)
|
||||
code := util.MapErrorCode(err,
|
||||
util.ErrToCode(ErrObjectNotFound, http.StatusNotFound),
|
||||
util.ErrToCode(ErrFailedRetrieveObject, http.StatusInternalServerError),
|
||||
)
|
||||
|
||||
if code != http.StatusOK {
|
||||
apiErr := util.CodeToAPIError(code, err.Error())
|
||||
c.JSON(code, apiErr)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, ObjectResponse{Object: object})
|
||||
}
|
||||
}
|
||||
|
||||
// DeleteObjectHandler deletes a specific object in a space
|
||||
//
|
||||
// @Summary Delete a specific object in a space
|
||||
// @Tags objects
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param space_id path string true "The ID of the space"
|
||||
// @Param object_id path string true "The ID of the object"
|
||||
// @Success 200 {object} ObjectResponse "The deleted object"
|
||||
// @Failure 403 {object} util.UnauthorizedError "Unauthorized"
|
||||
// @Failure 404 {object} util.NotFoundError "Resource not found"
|
||||
// @Failure 502 {object} util.ServerError "Internal server error"
|
||||
// @Router /spaces/{space_id}/objects/{object_id} [delete]
|
||||
func DeleteObjectHandler(s *ObjectService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
spaceId := c.Param("space_id")
|
||||
objectId := c.Param("object_id")
|
||||
|
||||
object, err := s.DeleteObject(c.Request.Context(), spaceId, objectId)
|
||||
code := util.MapErrorCode(err,
|
||||
util.ErrToCode(ErrObjectNotFound, http.StatusNotFound),
|
||||
util.ErrToCode(ErrFailedRetrieveObject, http.StatusInternalServerError),
|
||||
util.ErrToCode(ErrFailedDeleteObject, http.StatusForbidden),
|
||||
)
|
||||
|
||||
if code != http.StatusOK {
|
||||
apiErr := util.CodeToAPIError(code, err.Error())
|
||||
c.JSON(code, apiErr)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, ObjectResponse{Object: object})
|
||||
}
|
||||
}
|
||||
|
||||
// CreateObjectHandler creates a new object in a specific space
|
||||
//
|
||||
// @Summary Create a new object in a specific space
|
||||
// @Tags objects
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param space_id path string true "The ID of the space"
|
||||
// @Param object body map[string]string true "Object details (e.g., name)"
|
||||
// @Success 200 {object} ObjectResponse "The created object"
|
||||
// @Failure 400 {object} util.ValidationError "Bad request"
|
||||
// @Failure 403 {object} util.UnauthorizedError "Unauthorized"
|
||||
// @Failure 502 {object} util.ServerError "Internal server error"
|
||||
// @Router /spaces/{space_id}/objects [post]
|
||||
func CreateObjectHandler(s *ObjectService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
spaceId := c.Param("space_id")
|
||||
|
||||
request := CreateObjectRequest{}
|
||||
if err := c.BindJSON(&request); err != nil {
|
||||
apiErr := util.CodeToAPIError(http.StatusBadRequest, err.Error())
|
||||
c.JSON(http.StatusBadRequest, apiErr)
|
||||
return
|
||||
}
|
||||
|
||||
object, err := s.CreateObject(c.Request.Context(), spaceId, request)
|
||||
code := util.MapErrorCode(err,
|
||||
util.ErrToCode(ErrInputMissingSource, http.StatusBadRequest),
|
||||
util.ErrToCode(ErrFailedCreateObject, http.StatusInternalServerError),
|
||||
util.ErrToCode(ErrFailedSetRelationFeatured, http.StatusInternalServerError),
|
||||
util.ErrToCode(ErrFailedFetchBookmark, http.StatusInternalServerError),
|
||||
util.ErrToCode(ErrObjectNotFound, http.StatusNotFound),
|
||||
util.ErrToCode(ErrFailedRetrieveObject, http.StatusInternalServerError),
|
||||
)
|
||||
|
||||
if code != http.StatusOK {
|
||||
apiErr := util.CodeToAPIError(code, err.Error())
|
||||
c.JSON(code, apiErr)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, ObjectResponse{Object: object})
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateObjectHandler updates an existing object in a specific space
|
||||
//
|
||||
// @Summary Update an existing object in a specific space
|
||||
// @Tags objects
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param space_id path string true "The ID of the space"
|
||||
// @Param object_id path string true "The ID of the object"
|
||||
// @Param object body Object true "The updated object details"
|
||||
// @Success 200 {object} ObjectResponse "The updated object"
|
||||
// @Failure 400 {object} util.ValidationError "Bad request"
|
||||
// @Failure 403 {object} util.UnauthorizedError "Unauthorized"
|
||||
// @Failure 404 {object} util.NotFoundError "Resource not found"
|
||||
// @Failure 502 {object} util.ServerError "Internal server error"
|
||||
// @Router /spaces/{space_id}/objects/{object_id} [put]
|
||||
func UpdateObjectHandler(s *ObjectService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
spaceId := c.Param("space_id")
|
||||
objectId := c.Param("object_id")
|
||||
|
||||
request := UpdateObjectRequest{}
|
||||
if err := c.BindJSON(&request); err != nil {
|
||||
apiErr := util.CodeToAPIError(http.StatusBadRequest, err.Error())
|
||||
c.JSON(http.StatusBadRequest, apiErr)
|
||||
return
|
||||
}
|
||||
|
||||
object, err := s.UpdateObject(c.Request.Context(), spaceId, objectId, request)
|
||||
code := util.MapErrorCode(err,
|
||||
util.ErrToCode(ErrNotImplemented, http.StatusNotImplemented),
|
||||
util.ErrToCode(ErrFailedUpdateObject, http.StatusInternalServerError),
|
||||
util.ErrToCode(ErrFailedRetrieveObject, http.StatusInternalServerError),
|
||||
)
|
||||
|
||||
if code != http.StatusOK {
|
||||
apiErr := util.CodeToAPIError(code, err.Error())
|
||||
c.JSON(code, apiErr)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusNotImplemented, ObjectResponse{Object: object})
|
||||
}
|
||||
}
|
||||
|
||||
// GetTypesHandler retrieves object types in a specific space
|
||||
//
|
||||
// @Summary Retrieve object types in a specific space
|
||||
// @Tags objects
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param space_id path string true "The ID of the space"
|
||||
// @Param offset query int false "The number of items to skip before starting to collect the result set"
|
||||
// @Param limit query int false "The number of items to return" default(100)
|
||||
// @Success 200 {object} map[string]ObjectType "List of object types"
|
||||
// @Failure 403 {object} util.UnauthorizedError "Unauthorized"
|
||||
// @Failure 404 {object} util.NotFoundError "Resource not found"
|
||||
// @Failure 502 {object} util.ServerError "Internal server error"
|
||||
// @Router /spaces/{space_id}/object_types [get]
|
||||
func GetTypesHandler(s *ObjectService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
spaceId := c.Param("space_id")
|
||||
offset := c.GetInt("offset")
|
||||
limit := c.GetInt("limit")
|
||||
|
||||
types, total, hasMore, err := s.ListTypes(c.Request.Context(), spaceId, offset, limit)
|
||||
code := util.MapErrorCode(err,
|
||||
util.ErrToCode(ErrFailedRetrieveTypes, http.StatusInternalServerError),
|
||||
util.ErrToCode(ErrNoTypesFound, http.StatusNotFound),
|
||||
)
|
||||
|
||||
if code != http.StatusOK {
|
||||
apiErr := util.CodeToAPIError(code, err.Error())
|
||||
c.JSON(code, apiErr)
|
||||
return
|
||||
}
|
||||
|
||||
pagination.RespondWithPagination(c, http.StatusOK, types, total, offset, limit, hasMore)
|
||||
}
|
||||
}
|
||||
|
||||
// GetTemplatesHandler retrieves a list of templates for a specific object type in a space
|
||||
//
|
||||
// @Summary Retrieve a list of templates for a specific object type in a space
|
||||
// @Tags objects
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param space_id path string true "The ID of the space"
|
||||
// @Param type_id path string true "The ID of the object type"
|
||||
// @Param offset query int false "The number of items to skip before starting to collect the result set"
|
||||
// @Param limit query int false "The number of items to return" default(100)
|
||||
// @Success 200 {object} map[string][]ObjectTemplate "List of templates"
|
||||
// @Failure 403 {object} util.UnauthorizedError "Unauthorized"
|
||||
// @Failure 404 {object} util.NotFoundError "Resource not found"
|
||||
// @Failure 502 {object} util.ServerError "Internal server error"
|
||||
// @Router /spaces/{space_id}/object_types/{type_id}/templates [get]
|
||||
func GetTemplatesHandler(s *ObjectService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
spaceId := c.Param("space_id")
|
||||
typeId := c.Param("type_id")
|
||||
offset := c.GetInt("offset")
|
||||
limit := c.GetInt("limit")
|
||||
|
||||
templates, total, hasMore, err := s.ListTemplates(c.Request.Context(), spaceId, typeId, offset, limit)
|
||||
code := util.MapErrorCode(err,
|
||||
util.ErrToCode(ErrFailedRetrieveTemplateType, http.StatusInternalServerError),
|
||||
util.ErrToCode(ErrTemplateTypeNotFound, http.StatusNotFound),
|
||||
util.ErrToCode(ErrFailedRetrieveTemplates, http.StatusInternalServerError),
|
||||
util.ErrToCode(ErrFailedRetrieveTemplate, http.StatusInternalServerError),
|
||||
util.ErrToCode(ErrNoTemplatesFound, http.StatusNotFound),
|
||||
)
|
||||
|
||||
if code != http.StatusOK {
|
||||
apiErr := util.CodeToAPIError(code, err.Error())
|
||||
c.JSON(code, apiErr)
|
||||
return
|
||||
}
|
||||
|
||||
pagination.RespondWithPagination(c, http.StatusOK, templates, total, offset, limit, hasMore)
|
||||
}
|
||||
}
|
|
@ -1,91 +0,0 @@
|
|||
package object
|
||||
|
||||
type CreateObjectRequest struct {
|
||||
Name string `json:"name"`
|
||||
Icon string `json:"icon"`
|
||||
Description string `json:"description"`
|
||||
Body string `json:"body"`
|
||||
Source string `json:"source"`
|
||||
TemplateId string `json:"template_id"`
|
||||
ObjectTypeUniqueKey string `json:"object_type_unique_key"`
|
||||
WithChat bool `json:"with_chat"`
|
||||
}
|
||||
|
||||
// TODO: Add fields to the request
|
||||
type UpdateObjectRequest struct {
|
||||
Object Object `json:"object"`
|
||||
}
|
||||
|
||||
type ObjectResponse struct {
|
||||
Object Object `json:"object"`
|
||||
}
|
||||
|
||||
type Object struct {
|
||||
Type string `json:"type" example:"object"`
|
||||
Id string `json:"id" example:"bafyreie6n5l5nkbjal37su54cha4coy7qzuhrnajluzv5qd5jvtsrxkequ"`
|
||||
Name string `json:"name" example:"Object Name"`
|
||||
Icon string `json:"icon" example:"📄"`
|
||||
Layout string `json:"layout" example:"basic"`
|
||||
ObjectType string `json:"object_type" example:"Page"`
|
||||
SpaceId string `json:"space_id" example:"bafyreigyfkt6rbv24sbv5aq2hko3bhmv5xxlf22b4bypdu6j7hnphm3psq.23me69r569oi1"`
|
||||
RootId string `json:"root_id"`
|
||||
Blocks []Block `json:"blocks"`
|
||||
Details []Detail `json:"details"`
|
||||
}
|
||||
|
||||
type Block struct {
|
||||
Id string `json:"id"`
|
||||
ChildrenIds []string `json:"children_ids"`
|
||||
BackgroundColor string `json:"background_color"`
|
||||
Align string `json:"align"`
|
||||
VerticalAlign string `json:"vertical_align"`
|
||||
Text *Text `json:"text,omitempty"`
|
||||
File *File `json:"file,omitempty"`
|
||||
}
|
||||
|
||||
type Text struct {
|
||||
Text string `json:"text"`
|
||||
Style string `json:"style"`
|
||||
Checked bool `json:"checked"`
|
||||
Color string `json:"color"`
|
||||
Icon string `json:"icon"`
|
||||
}
|
||||
|
||||
type File struct {
|
||||
Hash string `json:"hash"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Mime string `json:"mime"`
|
||||
Size int `json:"size"`
|
||||
AddedAt int `json:"added_at"`
|
||||
TargetObjectId string `json:"target_object_id"`
|
||||
State string `json:"state"`
|
||||
Style string `json:"style"`
|
||||
}
|
||||
|
||||
type Detail struct {
|
||||
Id string `json:"id"`
|
||||
Details map[string]interface{} `json:"details"`
|
||||
}
|
||||
|
||||
type Tag struct {
|
||||
Id string `json:"id" example:"bafyreiaixlnaefu3ci22zdenjhsdlyaeeoyjrsid5qhfeejzlccijbj7sq"`
|
||||
Name string `json:"name" example:"Tag Name"`
|
||||
Color string `json:"color" example:"yellow"`
|
||||
}
|
||||
|
||||
type ObjectType struct {
|
||||
Type string `json:"type" example:"object_type"`
|
||||
Id string `json:"id" example:"bafyreigyb6l5szohs32ts26ku2j42yd65e6hqy2u3gtzgdwqv6hzftsetu"`
|
||||
UniqueKey string `json:"unique_key" example:"ot-page"`
|
||||
Name string `json:"name" example:"Page"`
|
||||
Icon string `json:"icon" example:"📄"`
|
||||
RecommendedLayout string `json:"recommended_layout" example:"todo"`
|
||||
}
|
||||
|
||||
type ObjectTemplate struct {
|
||||
Type string `json:"type" example:"object_template"`
|
||||
Id string `json:"id" example:"bafyreictrp3obmnf6dwejy5o4p7bderaaia4bdg2psxbfzf44yya5uutge"`
|
||||
Name string `json:"name" example:"Object Template Name"`
|
||||
Icon string `json:"icon" example:"📄"`
|
||||
}
|
|
@ -1,526 +0,0 @@
|
|||
package object
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/gogo/protobuf/types"
|
||||
|
||||
"github.com/anyproto/anytype-heart/cmd/api/pagination"
|
||||
"github.com/anyproto/anytype-heart/cmd/api/util"
|
||||
"github.com/anyproto/anytype-heart/pb"
|
||||
"github.com/anyproto/anytype-heart/pb/service"
|
||||
"github.com/anyproto/anytype-heart/pkg/lib/bundle"
|
||||
"github.com/anyproto/anytype-heart/pkg/lib/pb/model"
|
||||
"github.com/anyproto/anytype-heart/util/pbtypes"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrObjectNotFound = errors.New("object not found")
|
||||
ErrFailedRetrieveObject = errors.New("failed to retrieve object")
|
||||
ErrorFailedRetrieveObjects = errors.New("failed to retrieve list of objects")
|
||||
ErrNoObjectsFound = errors.New("no objects found")
|
||||
ErrFailedDeleteObject = errors.New("failed to delete object")
|
||||
ErrFailedCreateObject = errors.New("failed to create object")
|
||||
ErrInputMissingSource = errors.New("source is missing for bookmark")
|
||||
ErrFailedSetRelationFeatured = errors.New("failed to set relation featured")
|
||||
ErrFailedFetchBookmark = errors.New("failed to fetch bookmark")
|
||||
ErrFailedPasteBody = errors.New("failed to paste body")
|
||||
ErrNotImplemented = errors.New("not implemented")
|
||||
ErrFailedUpdateObject = errors.New("failed to update object")
|
||||
ErrFailedRetrieveTypes = errors.New("failed to retrieve types")
|
||||
ErrNoTypesFound = errors.New("no types found")
|
||||
ErrFailedRetrieveTemplateType = errors.New("failed to retrieve template type")
|
||||
ErrTemplateTypeNotFound = errors.New("template type not found")
|
||||
ErrFailedRetrieveTemplate = errors.New("failed to retrieve template")
|
||||
ErrFailedRetrieveTemplates = errors.New("failed to retrieve templates")
|
||||
ErrNoTemplatesFound = errors.New("no templates found")
|
||||
)
|
||||
|
||||
type Service interface {
|
||||
ListObjects(ctx context.Context, spaceId string, offset int, limit int) ([]Object, int, bool, error)
|
||||
GetObject(ctx context.Context, spaceId string, objectId string) (Object, error)
|
||||
DeleteObject(ctx context.Context, spaceId string, objectId string) error
|
||||
CreateObject(ctx context.Context, spaceId string, request CreateObjectRequest) (Object, error)
|
||||
UpdateObject(ctx context.Context, spaceId string, objectId string, request UpdateObjectRequest) (Object, error)
|
||||
ListTypes(ctx context.Context, spaceId string, offset int, limit int) ([]ObjectType, int, bool, error)
|
||||
ListTemplates(ctx context.Context, spaceId string, typeId string, offset int, limit int) ([]ObjectTemplate, int, bool, error)
|
||||
}
|
||||
|
||||
type ObjectService struct {
|
||||
mw service.ClientCommandsServer
|
||||
AccountInfo *model.AccountInfo
|
||||
}
|
||||
|
||||
func NewService(mw service.ClientCommandsServer) *ObjectService {
|
||||
return &ObjectService{mw: mw}
|
||||
}
|
||||
|
||||
// ListObjects retrieves a paginated list of objects in a specific space.
|
||||
func (s *ObjectService) ListObjects(ctx context.Context, spaceId string, offset int, limit int) (objects []Object, total int, hasMore bool, err error) {
|
||||
resp := s.mw.ObjectSearch(ctx, &pb.RpcObjectSearchRequest{
|
||||
SpaceId: spaceId,
|
||||
Filters: []*model.BlockContentDataviewFilter{
|
||||
{
|
||||
RelationKey: bundle.RelationKeyLayout.String(),
|
||||
Condition: model.BlockContentDataviewFilter_In,
|
||||
Value: pbtypes.IntList([]int{
|
||||
int(model.ObjectType_basic),
|
||||
int(model.ObjectType_profile),
|
||||
int(model.ObjectType_todo),
|
||||
int(model.ObjectType_note),
|
||||
int(model.ObjectType_bookmark),
|
||||
int(model.ObjectType_set),
|
||||
int(model.ObjectType_collection),
|
||||
int(model.ObjectType_participant),
|
||||
}...),
|
||||
},
|
||||
},
|
||||
Sorts: []*model.BlockContentDataviewSort{{
|
||||
RelationKey: bundle.RelationKeyLastModifiedDate.String(),
|
||||
Type: model.BlockContentDataviewSort_Desc,
|
||||
Format: model.RelationFormat_longtext,
|
||||
IncludeTime: true,
|
||||
EmptyPlacement: model.BlockContentDataviewSort_NotSpecified,
|
||||
}},
|
||||
FullText: "",
|
||||
Offset: 0,
|
||||
Limit: 0,
|
||||
ObjectTypeFilter: []string{},
|
||||
Keys: []string{"id", "name"},
|
||||
})
|
||||
|
||||
if resp.Error.Code != pb.RpcObjectSearchResponseError_NULL {
|
||||
return nil, 0, false, ErrorFailedRetrieveObjects
|
||||
}
|
||||
|
||||
if len(resp.Records) == 0 {
|
||||
return nil, 0, false, ErrNoObjectsFound
|
||||
}
|
||||
|
||||
total = len(resp.Records)
|
||||
paginatedObjects, hasMore := pagination.Paginate(resp.Records, offset, limit)
|
||||
objects = make([]Object, 0, len(paginatedObjects))
|
||||
|
||||
for _, record := range paginatedObjects {
|
||||
object, err := s.GetObject(ctx, spaceId, record.Fields["id"].GetStringValue())
|
||||
if err != nil {
|
||||
return nil, 0, false, err
|
||||
}
|
||||
|
||||
objects = append(objects, object)
|
||||
}
|
||||
return objects, total, hasMore, nil
|
||||
}
|
||||
|
||||
// GetObject retrieves a single object by its ID in a specific space.
|
||||
func (s *ObjectService) GetObject(ctx context.Context, spaceId string, objectId string) (Object, error) {
|
||||
resp := s.mw.ObjectShow(ctx, &pb.RpcObjectShowRequest{
|
||||
SpaceId: spaceId,
|
||||
ObjectId: objectId,
|
||||
})
|
||||
|
||||
if resp.Error.Code == pb.RpcObjectShowResponseError_NOT_FOUND {
|
||||
return Object{}, ErrObjectNotFound
|
||||
}
|
||||
|
||||
if resp.Error.Code != pb.RpcObjectShowResponseError_NULL {
|
||||
return Object{}, ErrFailedRetrieveObject
|
||||
}
|
||||
|
||||
icon := util.GetIconFromEmojiOrImage(s.AccountInfo, resp.ObjectView.Details[0].Details.Fields["iconEmoji"].GetStringValue(), resp.ObjectView.Details[0].Details.Fields["iconImage"].GetStringValue())
|
||||
objectTypeName, err := util.ResolveTypeToName(s.mw, spaceId, resp.ObjectView.Details[0].Details.Fields["type"].GetStringValue())
|
||||
if err != nil {
|
||||
return Object{}, err
|
||||
}
|
||||
|
||||
object := Object{
|
||||
Type: "object",
|
||||
Id: resp.ObjectView.Details[0].Details.Fields["id"].GetStringValue(),
|
||||
Name: resp.ObjectView.Details[0].Details.Fields["name"].GetStringValue(),
|
||||
Icon: icon,
|
||||
Layout: model.ObjectTypeLayout_name[int32(resp.ObjectView.Details[0].Details.Fields["layout"].GetNumberValue())],
|
||||
ObjectType: objectTypeName,
|
||||
SpaceId: resp.ObjectView.Details[0].Details.Fields["spaceId"].GetStringValue(),
|
||||
RootId: resp.ObjectView.RootId,
|
||||
Blocks: s.GetBlocks(resp),
|
||||
Details: s.GetDetails(resp),
|
||||
}
|
||||
|
||||
return object, nil
|
||||
}
|
||||
|
||||
// DeleteObject deletes an existing object in a specific space.
|
||||
func (s *ObjectService) DeleteObject(ctx context.Context, spaceId string, objectId string) (Object, error) {
|
||||
object, err := s.GetObject(ctx, spaceId, objectId)
|
||||
if err != nil {
|
||||
return Object{}, err
|
||||
}
|
||||
|
||||
resp := s.mw.ObjectSetIsArchived(ctx, &pb.RpcObjectSetIsArchivedRequest{
|
||||
ContextId: objectId,
|
||||
IsArchived: true,
|
||||
})
|
||||
|
||||
if resp.Error.Code != pb.RpcObjectSetIsArchivedResponseError_NULL {
|
||||
return Object{}, ErrFailedDeleteObject
|
||||
}
|
||||
|
||||
return object, nil
|
||||
}
|
||||
|
||||
// CreateObject creates a new object in a specific space.
|
||||
func (s *ObjectService) CreateObject(ctx context.Context, spaceId string, request CreateObjectRequest) (Object, error) {
|
||||
if request.ObjectTypeUniqueKey == "ot-bookmark" && request.Source == "" {
|
||||
return Object{}, ErrInputMissingSource
|
||||
}
|
||||
|
||||
details := &types.Struct{
|
||||
Fields: map[string]*types.Value{
|
||||
"name": pbtypes.String(request.Name),
|
||||
"iconEmoji": pbtypes.String(request.Icon),
|
||||
"description": pbtypes.String(request.Description),
|
||||
"source": pbtypes.String(request.Source),
|
||||
},
|
||||
}
|
||||
|
||||
resp := s.mw.ObjectCreate(ctx, &pb.RpcObjectCreateRequest{
|
||||
Details: details,
|
||||
TemplateId: request.TemplateId,
|
||||
SpaceId: spaceId,
|
||||
ObjectTypeUniqueKey: request.ObjectTypeUniqueKey,
|
||||
WithChat: request.WithChat,
|
||||
})
|
||||
|
||||
if resp.Error.Code != pb.RpcObjectCreateResponseError_NULL {
|
||||
return Object{}, ErrFailedCreateObject
|
||||
}
|
||||
|
||||
// ObjectRelationAddFeatured if description was set
|
||||
if request.Description != "" {
|
||||
relAddFeatResp := s.mw.ObjectRelationAddFeatured(ctx, &pb.RpcObjectRelationAddFeaturedRequest{
|
||||
ContextId: resp.ObjectId,
|
||||
Relations: []string{"description"},
|
||||
})
|
||||
|
||||
if relAddFeatResp.Error.Code != pb.RpcObjectRelationAddFeaturedResponseError_NULL {
|
||||
object, _ := s.GetObject(ctx, spaceId, resp.ObjectId)
|
||||
return object, ErrFailedSetRelationFeatured
|
||||
}
|
||||
}
|
||||
|
||||
// ObjectBookmarkFetch after creating a bookmark object
|
||||
if request.ObjectTypeUniqueKey == "ot-bookmark" {
|
||||
bookmarkResp := s.mw.ObjectBookmarkFetch(ctx, &pb.RpcObjectBookmarkFetchRequest{
|
||||
ContextId: resp.ObjectId,
|
||||
Url: request.Source,
|
||||
})
|
||||
|
||||
if bookmarkResp.Error.Code != pb.RpcObjectBookmarkFetchResponseError_NULL {
|
||||
object, _ := s.GetObject(ctx, spaceId, resp.ObjectId)
|
||||
return object, ErrFailedFetchBookmark
|
||||
}
|
||||
}
|
||||
|
||||
// First call BlockCreate at top, then BlockPaste to paste the body
|
||||
if request.Body != "" {
|
||||
blockCreateResp := s.mw.BlockCreate(ctx, &pb.RpcBlockCreateRequest{
|
||||
ContextId: resp.ObjectId,
|
||||
TargetId: "",
|
||||
Block: &model.Block{
|
||||
Id: "",
|
||||
BackgroundColor: "",
|
||||
Align: model.Block_AlignLeft,
|
||||
VerticalAlign: model.Block_VerticalAlignTop,
|
||||
Content: &model.BlockContentOfText{
|
||||
Text: &model.BlockContentText{
|
||||
Text: "",
|
||||
Style: model.BlockContentText_Paragraph,
|
||||
Checked: false,
|
||||
Color: "",
|
||||
IconEmoji: "",
|
||||
IconImage: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
Position: model.Block_Bottom,
|
||||
})
|
||||
|
||||
if blockCreateResp.Error.Code != pb.RpcBlockCreateResponseError_NULL {
|
||||
object, _ := s.GetObject(ctx, spaceId, resp.ObjectId)
|
||||
return object, ErrFailedCreateObject
|
||||
}
|
||||
|
||||
blockPasteResp := s.mw.BlockPaste(ctx, &pb.RpcBlockPasteRequest{
|
||||
ContextId: resp.ObjectId,
|
||||
FocusedBlockId: blockCreateResp.BlockId,
|
||||
TextSlot: request.Body,
|
||||
})
|
||||
|
||||
if blockPasteResp.Error.Code != pb.RpcBlockPasteResponseError_NULL {
|
||||
object, _ := s.GetObject(ctx, spaceId, resp.ObjectId)
|
||||
return object, ErrFailedPasteBody
|
||||
}
|
||||
}
|
||||
|
||||
return s.GetObject(ctx, spaceId, resp.ObjectId)
|
||||
}
|
||||
|
||||
// UpdateObject updates an existing object in a specific space.
|
||||
func (s *ObjectService) UpdateObject(ctx context.Context, spaceId string, objectId string, request UpdateObjectRequest) (Object, error) {
|
||||
// TODO: Implement logic to update an existing object
|
||||
return Object{}, ErrNotImplemented
|
||||
}
|
||||
|
||||
// ListTypes returns a paginated list of types in a specific space.
|
||||
func (s *ObjectService) ListTypes(ctx context.Context, spaceId string, offset int, limit int) (types []ObjectType, total int, hasMore bool, err error) {
|
||||
resp := s.mw.ObjectSearch(ctx, &pb.RpcObjectSearchRequest{
|
||||
SpaceId: spaceId,
|
||||
Filters: []*model.BlockContentDataviewFilter{
|
||||
{
|
||||
RelationKey: bundle.RelationKeyLayout.String(),
|
||||
Condition: model.BlockContentDataviewFilter_Equal,
|
||||
Value: pbtypes.Int64(int64(model.ObjectType_objectType)),
|
||||
},
|
||||
{
|
||||
RelationKey: bundle.RelationKeyIsHidden.String(),
|
||||
Condition: model.BlockContentDataviewFilter_NotEqual,
|
||||
Value: pbtypes.Bool(true),
|
||||
},
|
||||
},
|
||||
Sorts: []*model.BlockContentDataviewSort{
|
||||
{
|
||||
RelationKey: "name",
|
||||
Type: model.BlockContentDataviewSort_Asc,
|
||||
},
|
||||
},
|
||||
Keys: []string{"id", "uniqueKey", "name", "iconEmoji", "recommendedLayout"},
|
||||
})
|
||||
|
||||
if resp.Error.Code != pb.RpcObjectSearchResponseError_NULL {
|
||||
return nil, 0, false, ErrFailedRetrieveTypes
|
||||
}
|
||||
|
||||
if len(resp.Records) == 0 {
|
||||
return nil, 0, false, ErrNoTypesFound
|
||||
}
|
||||
|
||||
total = len(resp.Records)
|
||||
paginatedTypes, hasMore := pagination.Paginate(resp.Records, offset, limit)
|
||||
objectTypes := make([]ObjectType, 0, len(paginatedTypes))
|
||||
|
||||
for _, record := range paginatedTypes {
|
||||
objectTypes = append(objectTypes, ObjectType{
|
||||
Type: "object_type",
|
||||
Id: record.Fields["id"].GetStringValue(),
|
||||
UniqueKey: record.Fields["uniqueKey"].GetStringValue(),
|
||||
Name: record.Fields["name"].GetStringValue(),
|
||||
Icon: record.Fields["iconEmoji"].GetStringValue(),
|
||||
RecommendedLayout: model.ObjectTypeLayout_name[int32(record.Fields["recommendedLayout"].GetNumberValue())],
|
||||
})
|
||||
}
|
||||
return objectTypes, total, hasMore, nil
|
||||
}
|
||||
|
||||
// ListTemplates returns a paginated list of templates in a specific space.
|
||||
func (s *ObjectService) ListTemplates(ctx context.Context, spaceId string, typeId string, offset int, limit int) (templates []ObjectTemplate, total int, hasMore bool, err error) {
|
||||
// First, determine the type ID of "ot-template" in the space
|
||||
templateTypeIdResp := s.mw.ObjectSearch(ctx, &pb.RpcObjectSearchRequest{
|
||||
SpaceId: spaceId,
|
||||
Filters: []*model.BlockContentDataviewFilter{
|
||||
{
|
||||
RelationKey: bundle.RelationKeyUniqueKey.String(),
|
||||
Condition: model.BlockContentDataviewFilter_Equal,
|
||||
Value: pbtypes.String("ot-template"),
|
||||
},
|
||||
},
|
||||
Keys: []string{"id"},
|
||||
})
|
||||
|
||||
if templateTypeIdResp.Error.Code != pb.RpcObjectSearchResponseError_NULL {
|
||||
return nil, 0, false, ErrFailedRetrieveTemplateType
|
||||
}
|
||||
|
||||
if len(templateTypeIdResp.Records) == 0 {
|
||||
return nil, 0, false, ErrTemplateTypeNotFound
|
||||
}
|
||||
|
||||
// Then, search all objects of the template type and filter by the target object type
|
||||
templateTypeId := templateTypeIdResp.Records[0].Fields["id"].GetStringValue()
|
||||
templateObjectsResp := s.mw.ObjectSearch(ctx, &pb.RpcObjectSearchRequest{
|
||||
SpaceId: spaceId,
|
||||
Filters: []*model.BlockContentDataviewFilter{
|
||||
{
|
||||
RelationKey: bundle.RelationKeyType.String(),
|
||||
Condition: model.BlockContentDataviewFilter_Equal,
|
||||
Value: pbtypes.String(templateTypeId),
|
||||
},
|
||||
},
|
||||
Keys: []string{"id", "targetObjectType", "name", "iconEmoji"},
|
||||
})
|
||||
|
||||
if templateObjectsResp.Error.Code != pb.RpcObjectSearchResponseError_NULL {
|
||||
return nil, 0, false, ErrFailedRetrieveTemplates
|
||||
}
|
||||
|
||||
if len(templateObjectsResp.Records) == 0 {
|
||||
return nil, 0, false, ErrNoTemplatesFound
|
||||
}
|
||||
|
||||
templateIds := make([]string, 0)
|
||||
for _, record := range templateObjectsResp.Records {
|
||||
if record.Fields["targetObjectType"].GetStringValue() == typeId {
|
||||
templateIds = append(templateIds, record.Fields["id"].GetStringValue())
|
||||
}
|
||||
}
|
||||
|
||||
total = len(templateIds)
|
||||
paginatedTemplates, hasMore := pagination.Paginate(templateIds, offset, limit)
|
||||
templates = make([]ObjectTemplate, 0, len(paginatedTemplates))
|
||||
|
||||
// Finally, open each template and populate the response
|
||||
for _, templateId := range paginatedTemplates {
|
||||
templateResp := s.mw.ObjectShow(ctx, &pb.RpcObjectShowRequest{
|
||||
SpaceId: spaceId,
|
||||
ObjectId: templateId,
|
||||
})
|
||||
|
||||
if templateResp.Error.Code != pb.RpcObjectShowResponseError_NULL {
|
||||
return nil, 0, false, ErrFailedRetrieveTemplate
|
||||
}
|
||||
|
||||
templates = append(templates, ObjectTemplate{
|
||||
Type: "object_template",
|
||||
Id: templateId,
|
||||
Name: templateResp.ObjectView.Details[0].Details.Fields["name"].GetStringValue(),
|
||||
Icon: templateResp.ObjectView.Details[0].Details.Fields["iconEmoji"].GetStringValue(),
|
||||
})
|
||||
}
|
||||
|
||||
return templates, total, hasMore, nil
|
||||
}
|
||||
|
||||
// GetDetails returns the list of details from the ObjectShowResponse.
|
||||
func (s *ObjectService) GetDetails(resp *pb.RpcObjectShowResponse) []Detail {
|
||||
return []Detail{
|
||||
{
|
||||
Id: "lastModifiedDate",
|
||||
Details: map[string]interface{}{
|
||||
"lastModifiedDate": resp.ObjectView.Details[0].Details.Fields["lastModifiedDate"].GetNumberValue(),
|
||||
},
|
||||
},
|
||||
{
|
||||
Id: "createdDate",
|
||||
Details: map[string]interface{}{
|
||||
"createdDate": resp.ObjectView.Details[0].Details.Fields["createdDate"].GetNumberValue(),
|
||||
},
|
||||
},
|
||||
{
|
||||
Id: "tags",
|
||||
Details: map[string]interface{}{
|
||||
"tags": s.getTags(resp),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// getTags returns the list of tags from the ObjectShowResponse
|
||||
func (s *ObjectService) getTags(resp *pb.RpcObjectShowResponse) []Tag {
|
||||
tags := []Tag{}
|
||||
|
||||
tagField, ok := resp.ObjectView.Details[0].Details.Fields["tag"]
|
||||
if !ok {
|
||||
return tags
|
||||
}
|
||||
|
||||
for _, tagId := range tagField.GetListValue().Values {
|
||||
id := tagId.GetStringValue()
|
||||
for _, detail := range resp.ObjectView.Details {
|
||||
if detail.Id == id {
|
||||
tags = append(tags, Tag{
|
||||
Id: id,
|
||||
Name: detail.Details.Fields["name"].GetStringValue(),
|
||||
Color: detail.Details.Fields["relationOptionColor"].GetStringValue(),
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return tags
|
||||
}
|
||||
|
||||
// GetBlocks returns the list of blocks from the ObjectShowResponse.
|
||||
func (s *ObjectService) GetBlocks(resp *pb.RpcObjectShowResponse) []Block {
|
||||
blocks := []Block{}
|
||||
|
||||
for _, block := range resp.ObjectView.Blocks {
|
||||
var text *Text
|
||||
var file *File
|
||||
|
||||
switch content := block.Content.(type) {
|
||||
case *model.BlockContentOfText:
|
||||
text = &Text{
|
||||
Text: content.Text.Text,
|
||||
Style: model.BlockContentTextStyle_name[int32(content.Text.Style)],
|
||||
Checked: content.Text.Checked,
|
||||
Color: content.Text.Color,
|
||||
Icon: util.GetIconFromEmojiOrImage(s.AccountInfo, content.Text.IconEmoji, content.Text.IconImage),
|
||||
}
|
||||
case *model.BlockContentOfFile:
|
||||
file = &File{
|
||||
Hash: content.File.Hash,
|
||||
Name: content.File.Name,
|
||||
Type: model.BlockContentFileType_name[int32(content.File.Type)],
|
||||
Mime: content.File.Mime,
|
||||
Size: content.File.Size(),
|
||||
AddedAt: int(content.File.AddedAt),
|
||||
TargetObjectId: content.File.TargetObjectId,
|
||||
State: model.BlockContentFileState_name[int32(content.File.State)],
|
||||
Style: model.BlockContentFileStyle_name[int32(content.File.Style)],
|
||||
}
|
||||
// TODO: other content types?
|
||||
}
|
||||
|
||||
blocks = append(blocks, Block{
|
||||
Id: block.Id,
|
||||
ChildrenIds: block.ChildrenIds,
|
||||
BackgroundColor: block.BackgroundColor,
|
||||
Align: mapAlign(block.Align),
|
||||
VerticalAlign: mapVerticalAlign(block.VerticalAlign),
|
||||
Text: text,
|
||||
File: file,
|
||||
})
|
||||
}
|
||||
|
||||
return blocks
|
||||
}
|
||||
|
||||
// mapAlign maps the protobuf BlockAlign to a string.
|
||||
func mapAlign(align model.BlockAlign) string {
|
||||
switch align {
|
||||
case model.Block_AlignLeft:
|
||||
return "left"
|
||||
case model.Block_AlignCenter:
|
||||
return "center"
|
||||
case model.Block_AlignRight:
|
||||
return "right"
|
||||
case model.Block_AlignJustify:
|
||||
return "justify"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// mapVerticalAlign maps the protobuf BlockVerticalAlign to a string.
|
||||
func mapVerticalAlign(align model.BlockVerticalAlign) string {
|
||||
switch align {
|
||||
case model.Block_VerticalAlignTop:
|
||||
return "top"
|
||||
case model.Block_VerticalAlignMiddle:
|
||||
return "middle"
|
||||
case model.Block_VerticalAlignBottom:
|
||||
return "bottom"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
|
@ -1,568 +0,0 @@
|
|||
package object
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/gogo/protobuf/types"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/anyproto/anytype-heart/pb"
|
||||
"github.com/anyproto/anytype-heart/pb/service/mock_service"
|
||||
"github.com/anyproto/anytype-heart/pkg/lib/bundle"
|
||||
"github.com/anyproto/anytype-heart/pkg/lib/pb/model"
|
||||
"github.com/anyproto/anytype-heart/util/pbtypes"
|
||||
)
|
||||
|
||||
const (
|
||||
offset = 0
|
||||
limit = 100
|
||||
mockedSpaceId = "mocked-space-id"
|
||||
mockedObjectId = "mocked-object-id"
|
||||
mockedNewObjectId = "mocked-new-object-id"
|
||||
mockedTechSpaceId = "mocked-tech-space-id"
|
||||
gatewayUrl = "http://localhost:31006"
|
||||
)
|
||||
|
||||
type fixture struct {
|
||||
*ObjectService
|
||||
mwMock *mock_service.MockClientCommandsServer
|
||||
}
|
||||
|
||||
func newFixture(t *testing.T) *fixture {
|
||||
mw := mock_service.NewMockClientCommandsServer(t)
|
||||
objectService := NewService(mw)
|
||||
objectService.AccountInfo = &model.AccountInfo{
|
||||
TechSpaceId: mockedTechSpaceId,
|
||||
GatewayUrl: gatewayUrl,
|
||||
}
|
||||
|
||||
return &fixture{
|
||||
ObjectService: objectService,
|
||||
mwMock: mw,
|
||||
}
|
||||
}
|
||||
|
||||
func TestObjectService_ListObjects(t *testing.T) {
|
||||
t.Run("successfully get objects for a space", func(t *testing.T) {
|
||||
// given
|
||||
ctx := context.Background()
|
||||
fx := newFixture(t)
|
||||
|
||||
fx.mwMock.On("ObjectSearch", mock.Anything, &pb.RpcObjectSearchRequest{
|
||||
SpaceId: mockedSpaceId,
|
||||
Filters: []*model.BlockContentDataviewFilter{
|
||||
{
|
||||
RelationKey: bundle.RelationKeyLayout.String(),
|
||||
Condition: model.BlockContentDataviewFilter_In,
|
||||
Value: pbtypes.IntList([]int{
|
||||
int(model.ObjectType_basic),
|
||||
int(model.ObjectType_profile),
|
||||
int(model.ObjectType_todo),
|
||||
int(model.ObjectType_note),
|
||||
int(model.ObjectType_bookmark),
|
||||
int(model.ObjectType_set),
|
||||
int(model.ObjectType_collection),
|
||||
int(model.ObjectType_participant),
|
||||
}...),
|
||||
},
|
||||
},
|
||||
Sorts: []*model.BlockContentDataviewSort{{
|
||||
RelationKey: bundle.RelationKeyLastModifiedDate.String(),
|
||||
Type: model.BlockContentDataviewSort_Desc,
|
||||
Format: model.RelationFormat_longtext,
|
||||
IncludeTime: true,
|
||||
EmptyPlacement: model.BlockContentDataviewSort_NotSpecified,
|
||||
}},
|
||||
FullText: "",
|
||||
Offset: 0,
|
||||
Limit: 0,
|
||||
ObjectTypeFilter: []string{},
|
||||
Keys: []string{"id", "name"},
|
||||
}).Return(&pb.RpcObjectSearchResponse{
|
||||
Records: []*types.Struct{
|
||||
{
|
||||
Fields: map[string]*types.Value{
|
||||
"id": pbtypes.String(mockedObjectId),
|
||||
"name": pbtypes.String("My Object"),
|
||||
"type": pbtypes.String("ot-page"),
|
||||
"layout": pbtypes.Float64(float64(model.ObjectType_basic)),
|
||||
"iconEmoji": pbtypes.String("📄"),
|
||||
},
|
||||
},
|
||||
},
|
||||
Error: &pb.RpcObjectSearchResponseError{Code: pb.RpcObjectSearchResponseError_NULL},
|
||||
}).Once()
|
||||
|
||||
// Mock object show for object details
|
||||
fx.mwMock.On("ObjectShow", mock.Anything, &pb.RpcObjectShowRequest{
|
||||
SpaceId: mockedSpaceId,
|
||||
ObjectId: mockedObjectId,
|
||||
}).Return(&pb.RpcObjectShowResponse{
|
||||
ObjectView: &model.ObjectView{
|
||||
RootId: mockedObjectId,
|
||||
Details: []*model.ObjectViewDetailsSet{
|
||||
{
|
||||
Details: &types.Struct{
|
||||
Fields: map[string]*types.Value{
|
||||
"id": pbtypes.String(mockedObjectId),
|
||||
"name": pbtypes.String("My Object"),
|
||||
"type": pbtypes.String("ot-page"),
|
||||
"iconEmoji": pbtypes.String("📄"),
|
||||
"lastModifiedDate": pbtypes.Float64(999999),
|
||||
"createdDate": pbtypes.Float64(888888),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Error: &pb.RpcObjectShowResponseError{Code: pb.RpcObjectShowResponseError_NULL},
|
||||
}).Once()
|
||||
|
||||
// Mock type resolution
|
||||
fx.mwMock.On("ObjectSearch", mock.Anything, &pb.RpcObjectSearchRequest{
|
||||
SpaceId: mockedSpaceId,
|
||||
Filters: []*model.BlockContentDataviewFilter{
|
||||
{
|
||||
RelationKey: "uniqueKey",
|
||||
Condition: model.BlockContentDataviewFilter_Equal,
|
||||
Value: pbtypes.String("ot-page"),
|
||||
},
|
||||
},
|
||||
Keys: []string{"name"},
|
||||
}).Return(&pb.RpcObjectSearchResponse{
|
||||
Records: []*types.Struct{
|
||||
{
|
||||
Fields: map[string]*types.Value{
|
||||
"name": pbtypes.String("Page"),
|
||||
},
|
||||
},
|
||||
},
|
||||
Error: &pb.RpcObjectSearchResponseError{Code: pb.RpcObjectSearchResponseError_NULL},
|
||||
}).Once()
|
||||
|
||||
// when
|
||||
objects, total, hasMore, err := fx.ListObjects(ctx, mockedSpaceId, offset, limit)
|
||||
|
||||
// then
|
||||
require.NoError(t, err)
|
||||
require.Len(t, objects, 1)
|
||||
require.Equal(t, mockedObjectId, objects[0].Id)
|
||||
require.Equal(t, "My Object", objects[0].Name)
|
||||
require.Equal(t, "Page", objects[0].ObjectType)
|
||||
require.Equal(t, "📄", objects[0].Icon)
|
||||
require.Equal(t, 3, len(objects[0].Details))
|
||||
|
||||
for _, detail := range objects[0].Details {
|
||||
if detail.Id == "createdDate" {
|
||||
require.Equal(t, float64(888888), detail.Details["createdDate"])
|
||||
} else if detail.Id == "lastModifiedDate" {
|
||||
require.Equal(t, float64(999999), detail.Details["lastModifiedDate"])
|
||||
} else if detail.Id == "tags" {
|
||||
require.Empty(t, detail.Details["tags"])
|
||||
} else {
|
||||
t.Errorf("unexpected detail id: %s", detail.Id)
|
||||
}
|
||||
}
|
||||
|
||||
require.Equal(t, 1, total)
|
||||
require.False(t, hasMore)
|
||||
})
|
||||
|
||||
t.Run("no objects found", func(t *testing.T) {
|
||||
// given
|
||||
ctx := context.Background()
|
||||
fx := newFixture(t)
|
||||
|
||||
fx.mwMock.On("ObjectSearch", mock.Anything, mock.Anything).
|
||||
Return(&pb.RpcObjectSearchResponse{
|
||||
Records: []*types.Struct{},
|
||||
Error: &pb.RpcObjectSearchResponseError{Code: pb.RpcObjectSearchResponseError_NULL},
|
||||
}).Once()
|
||||
|
||||
// when
|
||||
objects, total, hasMore, err := fx.ListObjects(ctx, "empty-space", offset, limit)
|
||||
|
||||
// then
|
||||
require.ErrorIs(t, err, ErrNoObjectsFound)
|
||||
require.Len(t, objects, 0)
|
||||
require.Equal(t, 0, total)
|
||||
require.False(t, hasMore)
|
||||
})
|
||||
}
|
||||
|
||||
func TestObjectService_GetObject(t *testing.T) {
|
||||
t.Run("object found", func(t *testing.T) {
|
||||
// given
|
||||
ctx := context.Background()
|
||||
fx := newFixture(t)
|
||||
|
||||
fx.mwMock.On("ObjectShow", mock.Anything, &pb.RpcObjectShowRequest{
|
||||
SpaceId: mockedSpaceId,
|
||||
ObjectId: mockedObjectId,
|
||||
}).
|
||||
Return(&pb.RpcObjectShowResponse{
|
||||
Error: &pb.RpcObjectShowResponseError{Code: pb.RpcObjectShowResponseError_NULL},
|
||||
ObjectView: &model.ObjectView{
|
||||
RootId: mockedObjectId,
|
||||
Details: []*model.ObjectViewDetailsSet{
|
||||
{
|
||||
Details: &types.Struct{
|
||||
Fields: map[string]*types.Value{
|
||||
"id": pbtypes.String(mockedObjectId),
|
||||
"name": pbtypes.String("Found Object"),
|
||||
"type": pbtypes.String("ot-page"),
|
||||
"iconEmoji": pbtypes.String("🔍"),
|
||||
"lastModifiedDate": pbtypes.Float64(999999),
|
||||
"createdDate": pbtypes.Float64(888888),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}, nil).Once()
|
||||
|
||||
// Mock type resolution
|
||||
fx.mwMock.On("ObjectSearch", mock.Anything, &pb.RpcObjectSearchRequest{
|
||||
SpaceId: mockedSpaceId,
|
||||
Filters: []*model.BlockContentDataviewFilter{
|
||||
{
|
||||
RelationKey: "uniqueKey",
|
||||
Condition: model.BlockContentDataviewFilter_Equal,
|
||||
Value: pbtypes.String("ot-page"),
|
||||
},
|
||||
},
|
||||
Keys: []string{"name"},
|
||||
}).Return(&pb.RpcObjectSearchResponse{
|
||||
Error: &pb.RpcObjectSearchResponseError{Code: pb.RpcObjectSearchResponseError_NULL},
|
||||
Records: []*types.Struct{
|
||||
{
|
||||
Fields: map[string]*types.Value{
|
||||
"name": pbtypes.String("Page"),
|
||||
},
|
||||
},
|
||||
},
|
||||
}, nil).Once()
|
||||
|
||||
// when
|
||||
object, err := fx.GetObject(ctx, mockedSpaceId, mockedObjectId)
|
||||
|
||||
// then
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, mockedObjectId, object.Id)
|
||||
require.Equal(t, "Found Object", object.Name)
|
||||
require.Equal(t, "Page", object.ObjectType)
|
||||
require.Equal(t, "🔍", object.Icon)
|
||||
require.Equal(t, 3, len(object.Details))
|
||||
|
||||
for _, detail := range object.Details {
|
||||
if detail.Id == "createdDate" {
|
||||
require.Equal(t, float64(888888), detail.Details["createdDate"])
|
||||
} else if detail.Id == "lastModifiedDate" {
|
||||
require.Equal(t, float64(999999), detail.Details["lastModifiedDate"])
|
||||
} else if detail.Id == "tags" {
|
||||
require.Empty(t, detail.Details["tags"])
|
||||
} else {
|
||||
t.Errorf("unexpected detail id: %s", detail.Id)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("object not found", func(t *testing.T) {
|
||||
// given
|
||||
ctx := context.Background()
|
||||
fx := newFixture(t)
|
||||
|
||||
fx.mwMock.On("ObjectShow", mock.Anything, mock.Anything).
|
||||
Return(&pb.RpcObjectShowResponse{
|
||||
Error: &pb.RpcObjectShowResponseError{Code: pb.RpcObjectShowResponseError_NOT_FOUND},
|
||||
}, nil).Once()
|
||||
|
||||
// when
|
||||
object, err := fx.GetObject(ctx, mockedSpaceId, "missing-obj")
|
||||
|
||||
// then
|
||||
require.ErrorIs(t, err, ErrObjectNotFound)
|
||||
require.Empty(t, object)
|
||||
})
|
||||
}
|
||||
|
||||
func TestObjectService_CreateObject(t *testing.T) {
|
||||
t.Run("successful object creation", func(t *testing.T) {
|
||||
// given
|
||||
ctx := context.Background()
|
||||
fx := newFixture(t)
|
||||
|
||||
fx.mwMock.On("ObjectCreate", mock.Anything, &pb.RpcObjectCreateRequest{
|
||||
Details: &types.Struct{
|
||||
Fields: map[string]*types.Value{
|
||||
"name": pbtypes.String("New Object"),
|
||||
"iconEmoji": pbtypes.String("🆕"),
|
||||
},
|
||||
},
|
||||
TemplateId: "",
|
||||
SpaceId: mockedSpaceId,
|
||||
ObjectTypeUniqueKey: "",
|
||||
WithChat: false,
|
||||
}).Return(&pb.RpcObjectCreateResponse{
|
||||
ObjectId: mockedNewObjectId,
|
||||
Details: &types.Struct{
|
||||
Fields: map[string]*types.Value{
|
||||
"id": pbtypes.String(mockedNewObjectId),
|
||||
"name": pbtypes.String("New Object"),
|
||||
"iconEmoji": pbtypes.String("🆕"),
|
||||
"spaceId": pbtypes.String(mockedSpaceId),
|
||||
},
|
||||
},
|
||||
Error: &pb.RpcObjectCreateResponseError{Code: pb.RpcObjectCreateResponseError_NULL},
|
||||
}).Once()
|
||||
|
||||
// Mock object show for object details
|
||||
fx.mwMock.On("ObjectShow", mock.Anything, &pb.RpcObjectShowRequest{
|
||||
SpaceId: mockedSpaceId,
|
||||
ObjectId: mockedNewObjectId,
|
||||
}).Return(&pb.RpcObjectShowResponse{
|
||||
ObjectView: &model.ObjectView{
|
||||
RootId: mockedNewObjectId,
|
||||
Details: []*model.ObjectViewDetailsSet{
|
||||
{
|
||||
Details: &types.Struct{
|
||||
Fields: map[string]*types.Value{
|
||||
"id": pbtypes.String(mockedNewObjectId),
|
||||
"name": pbtypes.String("New Object"),
|
||||
"type": pbtypes.String("ot-page"),
|
||||
"iconEmoji": pbtypes.String("🆕"),
|
||||
"spaceId": pbtypes.String(mockedSpaceId),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Error: &pb.RpcObjectShowResponseError{Code: pb.RpcObjectShowResponseError_NULL},
|
||||
}).Once()
|
||||
|
||||
// Mock type resolution
|
||||
fx.mwMock.On("ObjectSearch", mock.Anything, &pb.RpcObjectSearchRequest{
|
||||
SpaceId: mockedSpaceId,
|
||||
Filters: []*model.BlockContentDataviewFilter{
|
||||
{
|
||||
RelationKey: "uniqueKey",
|
||||
Condition: model.BlockContentDataviewFilter_Equal,
|
||||
Value: pbtypes.String("ot-page"),
|
||||
},
|
||||
},
|
||||
Keys: []string{"name"},
|
||||
}).Return(&pb.RpcObjectSearchResponse{
|
||||
Error: &pb.RpcObjectSearchResponseError{Code: pb.RpcObjectSearchResponseError_NULL},
|
||||
Records: []*types.Struct{
|
||||
{
|
||||
Fields: map[string]*types.Value{
|
||||
"name": pbtypes.String("Page"),
|
||||
},
|
||||
},
|
||||
},
|
||||
}).Once()
|
||||
|
||||
// when
|
||||
object, err := fx.CreateObject(ctx, mockedSpaceId, CreateObjectRequest{
|
||||
Name: "New Object",
|
||||
Icon: "🆕",
|
||||
// TODO: use actual values
|
||||
TemplateId: "",
|
||||
ObjectTypeUniqueKey: "",
|
||||
WithChat: false,
|
||||
})
|
||||
|
||||
// then
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, mockedNewObjectId, object.Id)
|
||||
require.Equal(t, "New Object", object.Name)
|
||||
require.Equal(t, "Page", object.ObjectType)
|
||||
require.Equal(t, "🆕", object.Icon)
|
||||
require.Equal(t, mockedSpaceId, object.SpaceId)
|
||||
})
|
||||
|
||||
t.Run("creation error", func(t *testing.T) {
|
||||
// given
|
||||
ctx := context.Background()
|
||||
fx := newFixture(t)
|
||||
|
||||
fx.mwMock.On("ObjectCreate", mock.Anything, mock.Anything).
|
||||
Return(&pb.RpcObjectCreateResponse{
|
||||
Error: &pb.RpcObjectCreateResponseError{Code: pb.RpcObjectCreateResponseError_UNKNOWN_ERROR},
|
||||
}).Once()
|
||||
|
||||
// when
|
||||
object, err := fx.CreateObject(ctx, mockedSpaceId, CreateObjectRequest{
|
||||
Name: "Fail Object",
|
||||
Icon: "",
|
||||
})
|
||||
|
||||
// then
|
||||
require.ErrorIs(t, err, ErrFailedCreateObject)
|
||||
require.Empty(t, object)
|
||||
})
|
||||
}
|
||||
|
||||
func TestObjectService_UpdateObject(t *testing.T) {
|
||||
t.Run("not implemented", func(t *testing.T) {
|
||||
// given
|
||||
ctx := context.Background()
|
||||
fx := newFixture(t)
|
||||
|
||||
// when
|
||||
object, err := fx.UpdateObject(ctx, mockedSpaceId, mockedObjectId, UpdateObjectRequest{
|
||||
Object: Object{
|
||||
Name: "Updated Object",
|
||||
},
|
||||
})
|
||||
|
||||
// then
|
||||
require.ErrorIs(t, err, ErrNotImplemented)
|
||||
require.Empty(t, object)
|
||||
})
|
||||
|
||||
// TODO: further tests
|
||||
}
|
||||
|
||||
func TestObjectService_ListTypes(t *testing.T) {
|
||||
t.Run("types found", func(t *testing.T) {
|
||||
// given
|
||||
ctx := context.Background()
|
||||
fx := newFixture(t)
|
||||
|
||||
fx.mwMock.On("ObjectSearch", mock.Anything, mock.Anything).
|
||||
Return(&pb.RpcObjectSearchResponse{
|
||||
Records: []*types.Struct{
|
||||
{
|
||||
Fields: map[string]*types.Value{
|
||||
"id": pbtypes.String("type-1"),
|
||||
"name": pbtypes.String("Type One"),
|
||||
"uniqueKey": pbtypes.String("type-one-key"),
|
||||
"iconEmoji": pbtypes.String("🗂️"),
|
||||
},
|
||||
},
|
||||
},
|
||||
Error: &pb.RpcObjectSearchResponseError{Code: pb.RpcObjectSearchResponseError_NULL},
|
||||
}).Once()
|
||||
|
||||
// when
|
||||
types, total, hasMore, err := fx.ListTypes(ctx, mockedSpaceId, offset, limit)
|
||||
|
||||
// then
|
||||
require.NoError(t, err)
|
||||
require.Len(t, types, 1)
|
||||
require.Equal(t, "type-1", types[0].Id)
|
||||
require.Equal(t, "Type One", types[0].Name)
|
||||
require.Equal(t, "type-one-key", types[0].UniqueKey)
|
||||
require.Equal(t, "🗂️", types[0].Icon)
|
||||
require.Equal(t, 1, total)
|
||||
require.False(t, hasMore)
|
||||
})
|
||||
|
||||
t.Run("no types found", func(t *testing.T) {
|
||||
// given
|
||||
ctx := context.Background()
|
||||
fx := newFixture(t)
|
||||
|
||||
fx.mwMock.On("ObjectSearch", mock.Anything, mock.Anything).
|
||||
Return(&pb.RpcObjectSearchResponse{
|
||||
Records: []*types.Struct{},
|
||||
Error: &pb.RpcObjectSearchResponseError{Code: pb.RpcObjectSearchResponseError_NULL},
|
||||
}).Once()
|
||||
|
||||
// when
|
||||
types, total, hasMore, err := fx.ListTypes(ctx, "empty-space", offset, limit)
|
||||
|
||||
// then
|
||||
require.ErrorIs(t, err, ErrNoTypesFound)
|
||||
require.Len(t, types, 0)
|
||||
require.Equal(t, 0, total)
|
||||
require.False(t, hasMore)
|
||||
})
|
||||
}
|
||||
|
||||
func TestObjectService_ListTemplates(t *testing.T) {
|
||||
t.Run("templates found", func(t *testing.T) {
|
||||
// given
|
||||
ctx := context.Background()
|
||||
fx := newFixture(t)
|
||||
|
||||
// Mock template type search
|
||||
fx.mwMock.On("ObjectSearch", mock.Anything, mock.Anything).Return(&pb.RpcObjectSearchResponse{
|
||||
Records: []*types.Struct{
|
||||
{
|
||||
Fields: map[string]*types.Value{
|
||||
"id": pbtypes.String("template-type-id"),
|
||||
"uniqueKey": pbtypes.String("ot-template"),
|
||||
},
|
||||
},
|
||||
},
|
||||
Error: &pb.RpcObjectSearchResponseError{Code: pb.RpcObjectSearchResponseError_NULL},
|
||||
}).Once()
|
||||
|
||||
// Mock actual template objects search
|
||||
fx.mwMock.On("ObjectSearch", mock.Anything, mock.Anything).Return(&pb.RpcObjectSearchResponse{
|
||||
Records: []*types.Struct{
|
||||
{
|
||||
Fields: map[string]*types.Value{
|
||||
"id": pbtypes.String("template-1"),
|
||||
"targetObjectType": pbtypes.String("target-type-id"),
|
||||
},
|
||||
},
|
||||
},
|
||||
Error: &pb.RpcObjectSearchResponseError{Code: pb.RpcObjectSearchResponseError_NULL},
|
||||
}).Once()
|
||||
|
||||
// Mock object show for template details
|
||||
fx.mwMock.On("ObjectShow", mock.Anything, mock.Anything).Return(&pb.RpcObjectShowResponse{
|
||||
Error: &pb.RpcObjectShowResponseError{Code: pb.RpcObjectShowResponseError_NULL},
|
||||
ObjectView: &model.ObjectView{
|
||||
Details: []*model.ObjectViewDetailsSet{
|
||||
{
|
||||
Details: &types.Struct{
|
||||
Fields: map[string]*types.Value{
|
||||
"name": pbtypes.String("Template Name"),
|
||||
"iconEmoji": pbtypes.String("📝"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}, nil).Once()
|
||||
|
||||
// when
|
||||
templates, total, hasMore, err := fx.ListTemplates(ctx, mockedSpaceId, "target-type-id", offset, limit)
|
||||
|
||||
// then
|
||||
require.NoError(t, err)
|
||||
require.Len(t, templates, 1)
|
||||
require.Equal(t, "template-1", templates[0].Id)
|
||||
require.Equal(t, "Template Name", templates[0].Name)
|
||||
require.Equal(t, "📝", templates[0].Icon)
|
||||
require.Equal(t, 1, total)
|
||||
require.False(t, hasMore)
|
||||
})
|
||||
|
||||
t.Run("no template type found", func(t *testing.T) {
|
||||
// given
|
||||
ctx := context.Background()
|
||||
fx := newFixture(t)
|
||||
|
||||
fx.mwMock.On("ObjectSearch", mock.Anything, mock.Anything).
|
||||
Return(&pb.RpcObjectSearchResponse{
|
||||
Records: []*types.Struct{},
|
||||
Error: &pb.RpcObjectSearchResponseError{Code: pb.RpcObjectSearchResponseError_NULL},
|
||||
}).Once()
|
||||
|
||||
// when
|
||||
templates, total, hasMore, err := fx.ListTemplates(ctx, mockedSpaceId, "missing-type-id", offset, limit)
|
||||
|
||||
// then
|
||||
require.ErrorIs(t, err, ErrTemplateTypeNotFound)
|
||||
require.Len(t, templates, 0)
|
||||
require.Equal(t, 0, total)
|
||||
require.False(t, hasMore)
|
||||
})
|
||||
}
|
|
@ -1,13 +0,0 @@
|
|||
package pagination
|
||||
|
||||
type PaginationMeta struct {
|
||||
Total int `json:"total" example:"1024"` // the total number of items available on that endpoint
|
||||
Offset int `json:"offset" example:"0"` // the current offset
|
||||
Limit int `json:"limit" example:"100"` // the current limit
|
||||
HasMore bool `json:"has_more" example:"true"` // whether there are more items available
|
||||
}
|
||||
|
||||
type PaginatedResponse[T any] struct {
|
||||
Data []T `json:"data"`
|
||||
Pagination PaginationMeta `json:"pagination"`
|
||||
}
|
|
@ -1,82 +0,0 @@
|
|||
package pagination
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Config holds pagination configuration options.
|
||||
type Config struct {
|
||||
DefaultPage int
|
||||
DefaultPageSize int
|
||||
MinPageSize int
|
||||
MaxPageSize int
|
||||
}
|
||||
|
||||
// New creates a Gin middleware for pagination with the provided Config.
|
||||
func New(cfg Config) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
page := getIntQueryParam(c, "offset", cfg.DefaultPage)
|
||||
size := getIntQueryParam(c, "limit", cfg.DefaultPageSize)
|
||||
|
||||
if size < cfg.MinPageSize || size > cfg.MaxPageSize {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{
|
||||
"error": fmt.Sprintf("limit must be between %d and %d", cfg.MinPageSize, cfg.MaxPageSize),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.Set("offset", page)
|
||||
c.Set("limit", size)
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// getIntQueryParam retrieves an integer query parameter or falls back to a default value.
|
||||
func getIntQueryParam(c *gin.Context, key string, defaultValue int) int {
|
||||
valStr := c.DefaultQuery(key, strconv.Itoa(defaultValue))
|
||||
val, err := strconv.Atoi(valStr)
|
||||
if err != nil || val < 0 {
|
||||
return defaultValue
|
||||
}
|
||||
return val
|
||||
}
|
||||
|
||||
// RespondWithPagination sends a paginated JSON response.
|
||||
func RespondWithPagination[T any](c *gin.Context, statusCode int, data []T, total int, offset int, limit int, hasMore bool) {
|
||||
c.JSON(statusCode, PaginatedResponse[T]{
|
||||
Data: data,
|
||||
Pagination: PaginationMeta{
|
||||
Total: total,
|
||||
Offset: offset,
|
||||
Limit: limit,
|
||||
HasMore: hasMore,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Paginate slices the records based on the offset and limit, and determines if more records are available.
|
||||
func Paginate[T any](records []T, offset int, limit int) ([]T, bool) {
|
||||
if offset < 0 || limit < 1 {
|
||||
return []T{}, len(records) > 0
|
||||
}
|
||||
|
||||
total := len(records)
|
||||
if offset > total {
|
||||
offset = total
|
||||
}
|
||||
|
||||
end := offset + limit
|
||||
if end > total {
|
||||
end = total
|
||||
}
|
||||
|
||||
paginated := records[offset:end]
|
||||
hasMore := end < total
|
||||
|
||||
return paginated, hasMore
|
||||
}
|
|
@ -1,254 +0,0 @@
|
|||
package pagination
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestNew(t *testing.T) {
|
||||
commonConfig := Config{
|
||||
DefaultPage: 0,
|
||||
DefaultPageSize: 10,
|
||||
MinPageSize: 1,
|
||||
MaxPageSize: 50,
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
queryParams map[string]string
|
||||
overrideConfig func(cfg Config) Config
|
||||
expectedStatus int
|
||||
expectedOffset int
|
||||
expectedLimit int
|
||||
}{
|
||||
{
|
||||
name: "Valid offset and limit",
|
||||
queryParams: map[string]string{
|
||||
"offset": "10",
|
||||
"limit": "20",
|
||||
},
|
||||
overrideConfig: nil,
|
||||
expectedStatus: http.StatusOK,
|
||||
expectedOffset: 10,
|
||||
expectedLimit: 20,
|
||||
},
|
||||
{
|
||||
name: "Offset missing, use default",
|
||||
queryParams: map[string]string{
|
||||
"limit": "20",
|
||||
},
|
||||
overrideConfig: nil,
|
||||
expectedStatus: http.StatusOK,
|
||||
expectedOffset: 0,
|
||||
expectedLimit: 20,
|
||||
},
|
||||
{
|
||||
name: "Limit missing, use default",
|
||||
queryParams: map[string]string{
|
||||
"offset": "5",
|
||||
},
|
||||
overrideConfig: nil,
|
||||
expectedStatus: http.StatusOK,
|
||||
expectedOffset: 5,
|
||||
expectedLimit: 10,
|
||||
},
|
||||
{
|
||||
name: "Limit below minimum",
|
||||
queryParams: map[string]string{
|
||||
"offset": "5",
|
||||
"limit": "0",
|
||||
},
|
||||
overrideConfig: nil,
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "Limit above maximum",
|
||||
queryParams: map[string]string{
|
||||
"offset": "5",
|
||||
"limit": "100",
|
||||
},
|
||||
overrideConfig: nil,
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "Negative offset, use default",
|
||||
queryParams: map[string]string{
|
||||
"offset": "-5",
|
||||
"limit": "10",
|
||||
},
|
||||
overrideConfig: nil,
|
||||
expectedStatus: http.StatusOK,
|
||||
expectedOffset: 0,
|
||||
expectedLimit: 10,
|
||||
},
|
||||
{
|
||||
name: "Custom min and max page size",
|
||||
queryParams: map[string]string{
|
||||
"offset": "5",
|
||||
"limit": "15",
|
||||
},
|
||||
overrideConfig: func(cfg Config) Config {
|
||||
cfg.MinPageSize = 10
|
||||
cfg.MaxPageSize = 20
|
||||
return cfg
|
||||
},
|
||||
expectedStatus: http.StatusOK,
|
||||
expectedOffset: 5,
|
||||
expectedLimit: 15,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Apply overrideConfig if provided
|
||||
cfg := commonConfig
|
||||
if tt.overrideConfig != nil {
|
||||
cfg = tt.overrideConfig(cfg)
|
||||
}
|
||||
|
||||
// Set up Gin
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(New(cfg))
|
||||
|
||||
// Define a test endpoint
|
||||
r.GET("/", func(c *gin.Context) {
|
||||
offset, _ := c.Get("offset")
|
||||
limit, _ := c.Get("limit")
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"offset": offset,
|
||||
"limit": limit,
|
||||
})
|
||||
})
|
||||
|
||||
// Create a test request
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
q := req.URL.Query()
|
||||
for k, v := range tt.queryParams {
|
||||
q.Add(k, v)
|
||||
}
|
||||
req.URL.RawQuery = q.Encode()
|
||||
|
||||
// Perform the request
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
// Check the response
|
||||
assert.Equal(t, tt.expectedStatus, w.Code)
|
||||
|
||||
if w.Code == http.StatusOK {
|
||||
var resp map[string]interface{}
|
||||
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Validate offset and limit
|
||||
if offset, ok := resp["offset"].(float64); ok {
|
||||
assert.Equal(t, float64(tt.expectedOffset), offset)
|
||||
}
|
||||
if limit, ok := resp["limit"].(float64); ok {
|
||||
assert.Equal(t, float64(tt.expectedLimit), limit)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaginate(t *testing.T) {
|
||||
type args struct {
|
||||
records []int
|
||||
offset int
|
||||
limit int
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
wantPaginated []int
|
||||
wantHasMore bool
|
||||
}{
|
||||
{
|
||||
name: "Offset=0, Limit=2 (first two items)",
|
||||
args: args{
|
||||
records: []int{1, 2, 3, 4, 5},
|
||||
offset: 0,
|
||||
limit: 2,
|
||||
},
|
||||
wantPaginated: []int{1, 2},
|
||||
wantHasMore: true, // items remain: [3,4,5]
|
||||
},
|
||||
{
|
||||
name: "Offset=2, Limit=2 (middle slice)",
|
||||
args: args{
|
||||
records: []int{1, 2, 3, 4, 5},
|
||||
offset: 2,
|
||||
limit: 2,
|
||||
},
|
||||
wantPaginated: []int{3, 4},
|
||||
wantHasMore: true, // item 5 remains
|
||||
},
|
||||
{
|
||||
name: "Offset=4, Limit=2 (tail of the slice)",
|
||||
args: args{
|
||||
records: []int{1, 2, 3, 4, 5},
|
||||
offset: 4,
|
||||
limit: 2,
|
||||
},
|
||||
wantPaginated: []int{5},
|
||||
wantHasMore: false,
|
||||
},
|
||||
{
|
||||
name: "Offset > length (should return empty)",
|
||||
args: args{
|
||||
records: []int{1, 2, 3, 4, 5},
|
||||
offset: 10,
|
||||
limit: 2,
|
||||
},
|
||||
wantPaginated: []int{},
|
||||
wantHasMore: false,
|
||||
},
|
||||
{
|
||||
name: "Limit > length (should return entire slice)",
|
||||
args: args{
|
||||
records: []int{1, 2, 3},
|
||||
offset: 0,
|
||||
limit: 10,
|
||||
},
|
||||
wantPaginated: []int{1, 2, 3},
|
||||
wantHasMore: false,
|
||||
},
|
||||
{
|
||||
name: "Zero limit (no items returned)",
|
||||
args: args{
|
||||
records: []int{1, 2, 3, 4, 5},
|
||||
offset: 1,
|
||||
limit: 0,
|
||||
},
|
||||
wantPaginated: []int{},
|
||||
wantHasMore: true, // items remain: [2,3,4,5]
|
||||
},
|
||||
{
|
||||
name: "Negative offset and limit (should return empty)",
|
||||
args: args{
|
||||
records: []int{1, 2, 3, 4, 5},
|
||||
offset: -1,
|
||||
limit: -1,
|
||||
},
|
||||
wantPaginated: []int{},
|
||||
wantHasMore: true, // items remain: [1,2,3,4,5]
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
gotPaginated, gotHasMore := Paginate(tt.args.records, tt.args.offset, tt.args.limit)
|
||||
|
||||
assert.Equal(t, tt.wantPaginated, gotPaginated, "Paginate() gotPaginated = %v, want %v", gotPaginated, tt.wantPaginated)
|
||||
assert.Equal(t, tt.wantHasMore, gotHasMore, "Paginate() gotHasMore = %v, want %v", gotHasMore, tt.wantHasMore)
|
||||
})
|
||||
}
|
||||
}
|
|
@ -1,50 +0,0 @@
|
|||
package search
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/anyproto/anytype-heart/cmd/api/pagination"
|
||||
"github.com/anyproto/anytype-heart/cmd/api/space"
|
||||
"github.com/anyproto/anytype-heart/cmd/api/util"
|
||||
)
|
||||
|
||||
// SearchHandler searches and retrieves objects across all the spaces
|
||||
//
|
||||
// @Summary Search and retrieve objects across all the spaces
|
||||
// @Tags search
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param query query string false "The search term to filter objects by name"
|
||||
// @Param object_types query []string false "Specify object types for search"
|
||||
// @Param offset query int false "The number of items to skip before starting to collect the result set"
|
||||
// @Param limit query int false "The number of items to return" default(100)
|
||||
// @Success 200 {object} map[string][]object.Object "List of objects"
|
||||
// @Failure 403 {object} util.UnauthorizedError "Unauthorized"
|
||||
// @Failure 404 {object} util.NotFoundError "Resource not found"
|
||||
// @Failure 502 {object} util.ServerError "Internal server error"
|
||||
// @Router /search [get]
|
||||
func SearchHandler(s *SearchService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
searchQuery := c.Query("query")
|
||||
objectTypes := c.QueryArray("object_types")
|
||||
offset := c.GetInt("offset")
|
||||
limit := c.GetInt("limit")
|
||||
|
||||
objects, total, hasMore, err := s.Search(c, searchQuery, objectTypes, offset, limit)
|
||||
code := util.MapErrorCode(err,
|
||||
util.ErrToCode(ErrNoObjectsFound, http.StatusNotFound),
|
||||
util.ErrToCode(ErrFailedSearchObjects, http.StatusInternalServerError),
|
||||
util.ErrToCode(space.ErrNoSpacesFound, http.StatusNotFound),
|
||||
)
|
||||
|
||||
if code != http.StatusOK {
|
||||
apiErr := util.CodeToAPIError(code, err.Error())
|
||||
c.JSON(code, apiErr)
|
||||
return
|
||||
}
|
||||
|
||||
pagination.RespondWithPagination(c, http.StatusOK, objects, total, offset, limit, hasMore)
|
||||
}
|
||||
}
|
|
@ -1,217 +0,0 @@
|
|||
package search
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/anyproto/anytype-heart/cmd/api/object"
|
||||
"github.com/anyproto/anytype-heart/cmd/api/pagination"
|
||||
"github.com/anyproto/anytype-heart/cmd/api/space"
|
||||
"github.com/anyproto/anytype-heart/cmd/api/util"
|
||||
"github.com/anyproto/anytype-heart/pb"
|
||||
"github.com/anyproto/anytype-heart/pb/service"
|
||||
"github.com/anyproto/anytype-heart/pkg/lib/bundle"
|
||||
"github.com/anyproto/anytype-heart/pkg/lib/pb/model"
|
||||
"github.com/anyproto/anytype-heart/util/pbtypes"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrFailedSearchObjects = errors.New("failed to retrieve objects from space")
|
||||
ErrNoObjectsFound = errors.New("no objects found")
|
||||
)
|
||||
|
||||
type Service interface {
|
||||
Search(ctx context.Context, searchQuery string, objectTypes []string, offset, limit int) (objects []object.Object, total int, hasMore bool, err error)
|
||||
}
|
||||
|
||||
type SearchService struct {
|
||||
mw service.ClientCommandsServer
|
||||
spaceService *space.SpaceService
|
||||
objectService *object.ObjectService
|
||||
AccountInfo *model.AccountInfo
|
||||
}
|
||||
|
||||
func NewService(mw service.ClientCommandsServer, spaceService *space.SpaceService, objectService *object.ObjectService) *SearchService {
|
||||
return &SearchService{mw: mw, spaceService: spaceService, objectService: objectService}
|
||||
}
|
||||
|
||||
// Search retrieves a paginated list of objects from all spaces that match the search parameters.
|
||||
func (s *SearchService) Search(ctx context.Context, searchQuery string, objectTypes []string, offset, limit int) (objects []object.Object, total int, hasMore bool, err error) {
|
||||
spaces, _, _, err := s.spaceService.ListSpaces(ctx, 0, 100)
|
||||
if err != nil {
|
||||
return nil, 0, false, err
|
||||
}
|
||||
|
||||
baseFilters := s.prepareBaseFilters()
|
||||
queryFilters := s.prepareQueryFilter(searchQuery)
|
||||
|
||||
results := make([]object.Object, 0)
|
||||
for _, space := range spaces {
|
||||
// Resolve object type IDs per space, as they are unique per space
|
||||
objectTypeFilters := s.prepareObjectTypeFilters(space.Id, objectTypes)
|
||||
filters := s.combineFilters(model.BlockContentDataviewFilter_And, baseFilters, queryFilters, objectTypeFilters)
|
||||
|
||||
objResp := s.mw.ObjectSearch(ctx, &pb.RpcObjectSearchRequest{
|
||||
SpaceId: space.Id,
|
||||
Filters: filters,
|
||||
Sorts: []*model.BlockContentDataviewSort{{
|
||||
RelationKey: bundle.RelationKeyLastModifiedDate.String(),
|
||||
Type: model.BlockContentDataviewSort_Desc,
|
||||
Format: model.RelationFormat_date,
|
||||
IncludeTime: true,
|
||||
EmptyPlacement: model.BlockContentDataviewSort_NotSpecified,
|
||||
}},
|
||||
Keys: []string{"id", "name"},
|
||||
// TODO split limit between spaces
|
||||
// Limit: paginationLimitPerSpace,
|
||||
// FullText: searchTerm,
|
||||
})
|
||||
|
||||
if objResp.Error.Code != pb.RpcObjectSearchResponseError_NULL {
|
||||
return nil, 0, false, ErrFailedSearchObjects
|
||||
}
|
||||
|
||||
if len(objResp.Records) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, record := range objResp.Records {
|
||||
object, err := s.objectService.GetObject(ctx, space.Id, record.Fields["id"].GetStringValue())
|
||||
if err != nil {
|
||||
return nil, 0, false, err
|
||||
}
|
||||
results = append(results, object)
|
||||
}
|
||||
}
|
||||
|
||||
if len(results) == 0 {
|
||||
return nil, 0, false, ErrNoObjectsFound
|
||||
}
|
||||
|
||||
// sort after lastModifiedDate to achieve descending sort order across all spaces
|
||||
sort.Slice(results, func(i, j int) bool {
|
||||
return results[i].Details[0].Details["lastModifiedDate"].(float64) > results[j].Details[0].Details["lastModifiedDate"].(float64)
|
||||
})
|
||||
|
||||
// TODO: solve global pagination vs per space pagination
|
||||
total = len(results)
|
||||
paginatedResults, hasMore := pagination.Paginate(results, offset, limit)
|
||||
return paginatedResults, total, hasMore, nil
|
||||
}
|
||||
|
||||
// makeAndCondition combines multiple filter groups with the given operator.
|
||||
func (s *SearchService) combineFilters(operator model.BlockContentDataviewFilterOperator, filterGroups ...[]*model.BlockContentDataviewFilter) []*model.BlockContentDataviewFilter {
|
||||
nestedFilters := make([]*model.BlockContentDataviewFilter, 0)
|
||||
for _, group := range filterGroups {
|
||||
if len(group) > 0 {
|
||||
nestedFilters = append(nestedFilters, group...)
|
||||
}
|
||||
}
|
||||
|
||||
if len(nestedFilters) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return []*model.BlockContentDataviewFilter{
|
||||
{
|
||||
Operator: operator,
|
||||
NestedFilters: nestedFilters,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// prepareBaseFilters returns a list of default filters that should be applied to all search queries.
|
||||
func (s *SearchService) prepareBaseFilters() []*model.BlockContentDataviewFilter {
|
||||
return []*model.BlockContentDataviewFilter{
|
||||
{
|
||||
Operator: model.BlockContentDataviewFilter_No,
|
||||
RelationKey: bundle.RelationKeyLayout.String(),
|
||||
Condition: model.BlockContentDataviewFilter_In,
|
||||
Value: pbtypes.IntList([]int{
|
||||
int(model.ObjectType_basic),
|
||||
int(model.ObjectType_profile),
|
||||
int(model.ObjectType_todo),
|
||||
int(model.ObjectType_note),
|
||||
int(model.ObjectType_bookmark),
|
||||
int(model.ObjectType_set),
|
||||
int(model.ObjectType_collection),
|
||||
int(model.ObjectType_participant),
|
||||
}...),
|
||||
},
|
||||
{
|
||||
Operator: model.BlockContentDataviewFilter_No,
|
||||
RelationKey: bundle.RelationKeyIsHidden.String(),
|
||||
Condition: model.BlockContentDataviewFilter_NotEqual,
|
||||
Value: pbtypes.Bool(true),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// prepareQueryFilter combines object name and snippet filters with an OR condition.
|
||||
func (s *SearchService) prepareQueryFilter(searchQuery string) []*model.BlockContentDataviewFilter {
|
||||
if searchQuery == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return []*model.BlockContentDataviewFilter{
|
||||
{
|
||||
Operator: model.BlockContentDataviewFilter_Or,
|
||||
NestedFilters: []*model.BlockContentDataviewFilter{
|
||||
{
|
||||
Operator: model.BlockContentDataviewFilter_No,
|
||||
RelationKey: bundle.RelationKeyName.String(),
|
||||
Condition: model.BlockContentDataviewFilter_Like,
|
||||
Value: pbtypes.String(searchQuery),
|
||||
},
|
||||
{
|
||||
Operator: model.BlockContentDataviewFilter_No,
|
||||
RelationKey: bundle.RelationKeySnippet.String(),
|
||||
Condition: model.BlockContentDataviewFilter_Like,
|
||||
Value: pbtypes.String(searchQuery),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// prepareObjectTypeFilters combines object type filters with an OR condition.
|
||||
func (s *SearchService) prepareObjectTypeFilters(spaceId string, objectTypes []string) []*model.BlockContentDataviewFilter {
|
||||
if len(objectTypes) == 0 || objectTypes[0] == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Prepare nested filters for each object type
|
||||
nestedFilters := make([]*model.BlockContentDataviewFilter, 0, len(objectTypes))
|
||||
for _, objectType := range objectTypes {
|
||||
typeId := objectType
|
||||
|
||||
if strings.HasPrefix(objectType, "ot-") {
|
||||
var err error
|
||||
typeId, err = util.ResolveUniqueKeyToTypeId(s.mw, spaceId, objectType)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
nestedFilters = append(nestedFilters, &model.BlockContentDataviewFilter{
|
||||
Operator: model.BlockContentDataviewFilter_No,
|
||||
RelationKey: bundle.RelationKeyType.String(),
|
||||
Condition: model.BlockContentDataviewFilter_Equal,
|
||||
Value: pbtypes.String(typeId),
|
||||
})
|
||||
}
|
||||
|
||||
if len(nestedFilters) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Combine all filters with an OR operator
|
||||
return []*model.BlockContentDataviewFilter{
|
||||
{
|
||||
Operator: model.BlockContentDataviewFilter_Or,
|
||||
NestedFilters: nestedFilters,
|
||||
},
|
||||
}
|
||||
}
|
|
@ -1,243 +0,0 @@
|
|||
package search
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/gogo/protobuf/types"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/anyproto/anytype-heart/cmd/api/object"
|
||||
"github.com/anyproto/anytype-heart/cmd/api/space"
|
||||
"github.com/anyproto/anytype-heart/pb"
|
||||
"github.com/anyproto/anytype-heart/pb/service/mock_service"
|
||||
"github.com/anyproto/anytype-heart/pkg/lib/bundle"
|
||||
"github.com/anyproto/anytype-heart/pkg/lib/pb/model"
|
||||
"github.com/anyproto/anytype-heart/util/pbtypes"
|
||||
)
|
||||
|
||||
const (
|
||||
offset = 0
|
||||
limit = 100
|
||||
techSpaceId = "tech-space-id"
|
||||
gatewayUrl = "http://localhost:31006"
|
||||
)
|
||||
|
||||
type fixture struct {
|
||||
*SearchService
|
||||
mwMock *mock_service.MockClientCommandsServer
|
||||
}
|
||||
|
||||
func newFixture(t *testing.T) *fixture {
|
||||
mw := mock_service.NewMockClientCommandsServer(t)
|
||||
|
||||
spaceService := space.NewService(mw)
|
||||
spaceService.AccountInfo = &model.AccountInfo{TechSpaceId: techSpaceId}
|
||||
objectService := object.NewService(mw)
|
||||
objectService.AccountInfo = &model.AccountInfo{TechSpaceId: techSpaceId}
|
||||
searchService := NewService(mw, spaceService, objectService)
|
||||
searchService.AccountInfo = &model.AccountInfo{
|
||||
TechSpaceId: techSpaceId,
|
||||
GatewayUrl: gatewayUrl,
|
||||
}
|
||||
|
||||
return &fixture{
|
||||
SearchService: searchService,
|
||||
mwMock: mw,
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchService_Search(t *testing.T) {
|
||||
t.Run("objects found globally", func(t *testing.T) {
|
||||
// given
|
||||
ctx := context.Background()
|
||||
fx := newFixture(t)
|
||||
|
||||
// Mock retrieving spaces first
|
||||
fx.mwMock.On("ObjectSearch", mock.Anything, &pb.RpcObjectSearchRequest{
|
||||
SpaceId: techSpaceId,
|
||||
Filters: []*model.BlockContentDataviewFilter{
|
||||
{
|
||||
RelationKey: bundle.RelationKeyLayout.String(),
|
||||
Condition: model.BlockContentDataviewFilter_Equal,
|
||||
Value: pbtypes.Int64(int64(model.ObjectType_spaceView)),
|
||||
},
|
||||
{
|
||||
RelationKey: bundle.RelationKeySpaceLocalStatus.String(),
|
||||
Condition: model.BlockContentDataviewFilter_Equal,
|
||||
Value: pbtypes.Int64(int64(model.SpaceStatus_Ok)),
|
||||
},
|
||||
{
|
||||
RelationKey: bundle.RelationKeySpaceRemoteStatus.String(),
|
||||
Condition: model.BlockContentDataviewFilter_Equal,
|
||||
Value: pbtypes.Int64(int64(model.SpaceStatus_Ok)),
|
||||
},
|
||||
},
|
||||
Sorts: []*model.BlockContentDataviewSort{
|
||||
{
|
||||
RelationKey: "spaceOrder",
|
||||
Type: model.BlockContentDataviewSort_Asc,
|
||||
NoCollate: true,
|
||||
EmptyPlacement: model.BlockContentDataviewSort_End,
|
||||
},
|
||||
},
|
||||
Keys: []string{"targetSpaceId", "name", "iconEmoji", "iconImage"},
|
||||
}).Return(&pb.RpcObjectSearchResponse{
|
||||
Records: []*types.Struct{
|
||||
{
|
||||
Fields: map[string]*types.Value{
|
||||
"targetSpaceId": pbtypes.String("space-1"),
|
||||
},
|
||||
},
|
||||
},
|
||||
Error: &pb.RpcObjectSearchResponseError{Code: pb.RpcObjectSearchResponseError_NULL},
|
||||
}).Once()
|
||||
|
||||
// Mock workspace opening
|
||||
fx.mwMock.On("WorkspaceOpen", mock.Anything, &pb.RpcWorkspaceOpenRequest{
|
||||
SpaceId: "space-1",
|
||||
WithChat: true,
|
||||
}).Return(&pb.RpcWorkspaceOpenResponse{
|
||||
Info: &model.AccountInfo{
|
||||
TechSpaceId: "space-1",
|
||||
},
|
||||
Error: &pb.RpcWorkspaceOpenResponseError{Code: pb.RpcWorkspaceOpenResponseError_NULL},
|
||||
}).Once()
|
||||
|
||||
// Mock objects in space-1
|
||||
fx.mwMock.On("ObjectSearch", mock.Anything, mock.Anything).Return(&pb.RpcObjectSearchResponse{
|
||||
Records: []*types.Struct{
|
||||
{
|
||||
Fields: map[string]*types.Value{
|
||||
"id": pbtypes.String("obj-global-1"),
|
||||
"name": pbtypes.String("Global Object"),
|
||||
},
|
||||
},
|
||||
},
|
||||
Error: &pb.RpcObjectSearchResponseError{Code: pb.RpcObjectSearchResponseError_NULL},
|
||||
}).Twice()
|
||||
|
||||
// Mock object show for object blocks and details
|
||||
fx.mwMock.On("ObjectShow", mock.Anything, &pb.RpcObjectShowRequest{
|
||||
SpaceId: "space-1",
|
||||
ObjectId: "obj-global-1",
|
||||
}).Return(&pb.RpcObjectShowResponse{
|
||||
ObjectView: &model.ObjectView{
|
||||
RootId: "root-123",
|
||||
Blocks: []*model.Block{
|
||||
{
|
||||
Id: "root-123",
|
||||
Restrictions: &model.BlockRestrictions{
|
||||
Read: false,
|
||||
Edit: false,
|
||||
Remove: false,
|
||||
Drag: false,
|
||||
DropOn: false,
|
||||
},
|
||||
ChildrenIds: []string{"header", "text-block", "relation-block"},
|
||||
},
|
||||
{
|
||||
Id: "header",
|
||||
Restrictions: &model.BlockRestrictions{
|
||||
Read: false,
|
||||
Edit: true,
|
||||
Remove: true,
|
||||
Drag: true,
|
||||
DropOn: true,
|
||||
},
|
||||
ChildrenIds: []string{"title", "featuredRelations"},
|
||||
},
|
||||
{
|
||||
Id: "text-block",
|
||||
Content: &model.BlockContentOfText{
|
||||
Text: &model.BlockContentText{
|
||||
Text: "This is a sample text block",
|
||||
Style: model.BlockContentText_Paragraph,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Details: []*model.ObjectViewDetailsSet{
|
||||
{
|
||||
Id: "root-123",
|
||||
Details: &types.Struct{
|
||||
Fields: map[string]*types.Value{
|
||||
"id": pbtypes.String("obj-global-1"),
|
||||
"name": pbtypes.String("Global Object"),
|
||||
"layout": pbtypes.Int64(int64(model.ObjectType_basic)),
|
||||
"iconEmoji": pbtypes.String("🌐"),
|
||||
"lastModifiedDate": pbtypes.Float64(999999),
|
||||
"createdDate": pbtypes.Float64(888888),
|
||||
"spaceId": pbtypes.String("space-1"),
|
||||
"tag": pbtypes.StringList([]string{"tag-1", "tag-2"}),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Id: "tag-1",
|
||||
Details: &types.Struct{
|
||||
Fields: map[string]*types.Value{
|
||||
"name": pbtypes.String("Important"),
|
||||
"relationOptionColor": pbtypes.String("red"),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Id: "tag-2",
|
||||
Details: &types.Struct{
|
||||
Fields: map[string]*types.Value{
|
||||
"name": pbtypes.String("Optional"),
|
||||
"relationOptionColor": pbtypes.String("blue"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Error: &pb.RpcObjectShowResponseError{Code: pb.RpcObjectShowResponseError_NULL},
|
||||
}, nil).Once()
|
||||
|
||||
// when
|
||||
objects, total, hasMore, err := fx.Search(ctx, "search-term", []string{}, offset, limit)
|
||||
|
||||
// then
|
||||
require.NoError(t, err)
|
||||
require.Len(t, objects, 1)
|
||||
require.Equal(t, "object", objects[0].Type)
|
||||
require.Equal(t, "space-1", objects[0].SpaceId)
|
||||
require.Equal(t, "Global Object", objects[0].Name)
|
||||
require.Equal(t, "obj-global-1", objects[0].Id)
|
||||
require.Equal(t, "basic", objects[0].Layout)
|
||||
require.Equal(t, "🌐", objects[0].Icon)
|
||||
require.Equal(t, "This is a sample text block", objects[0].Blocks[2].Text.Text)
|
||||
|
||||
// check details
|
||||
for _, detail := range objects[0].Details {
|
||||
if detail.Id == "createdDate" {
|
||||
require.Equal(t, float64(888888), detail.Details["createdDate"])
|
||||
} else if detail.Id == "lastModifiedDate" {
|
||||
require.Equal(t, float64(999999), detail.Details["lastModifiedDate"])
|
||||
}
|
||||
}
|
||||
|
||||
// check tags
|
||||
tags := []object.Tag{}
|
||||
for _, detail := range objects[0].Details {
|
||||
if tagList, ok := detail.Details["tags"].([]object.Tag); ok {
|
||||
for _, tag := range tagList {
|
||||
tags = append(tags, tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
require.Len(t, tags, 2)
|
||||
require.Equal(t, "tag-1", tags[0].Id)
|
||||
require.Equal(t, "Important", tags[0].Name)
|
||||
require.Equal(t, "red", tags[0].Color)
|
||||
require.Equal(t, "tag-2", tags[1].Id)
|
||||
require.Equal(t, "Optional", tags[1].Name)
|
||||
require.Equal(t, "blue", tags[1].Color)
|
||||
|
||||
require.Equal(t, 1, total)
|
||||
require.False(t, hasMore)
|
||||
})
|
||||
}
|
|
@ -1,49 +0,0 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/anyproto/anytype-heart/core/anytype/account"
|
||||
)
|
||||
|
||||
// initAccountInfo retrieves the account information from the account service.
|
||||
func (s *Server) initAccountInfo() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// TODO: consider not fetching account info on every request; currently used to avoid inconsistencies on logout/login
|
||||
app := s.mwInternal.GetApp()
|
||||
if app == nil {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "failed to get app instance"})
|
||||
return
|
||||
}
|
||||
|
||||
accInfo, err := app.Component(account.CName).(account.Service).GetInfo(context.Background())
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to get account info: %v", err)})
|
||||
return
|
||||
}
|
||||
|
||||
s.exportService.AccountInfo = accInfo
|
||||
s.objectService.AccountInfo = accInfo
|
||||
s.spaceService.AccountInfo = accInfo
|
||||
s.searchService.AccountInfo = accInfo
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// ensureAuthenticated is a middleware that ensures the request is authenticated.
|
||||
func (s *Server) ensureAuthenticated() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// token := c.GetHeader("Authorization")
|
||||
// if token == "" {
|
||||
// c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
||||
// return
|
||||
// }
|
||||
|
||||
// TODO: Validate the token and retrieve user information; this is mock example
|
||||
c.Next()
|
||||
}
|
||||
}
|
|
@ -1,64 +0,0 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
swaggerFiles "github.com/swaggo/files"
|
||||
ginSwagger "github.com/swaggo/gin-swagger"
|
||||
|
||||
"github.com/anyproto/anytype-heart/cmd/api/auth"
|
||||
"github.com/anyproto/anytype-heart/cmd/api/export"
|
||||
"github.com/anyproto/anytype-heart/cmd/api/object"
|
||||
"github.com/anyproto/anytype-heart/cmd/api/pagination"
|
||||
"github.com/anyproto/anytype-heart/cmd/api/search"
|
||||
"github.com/anyproto/anytype-heart/cmd/api/space"
|
||||
)
|
||||
|
||||
// NewRouter builds and returns a *gin.Engine with all routes configured.
|
||||
func (s *Server) NewRouter() *gin.Engine {
|
||||
router := gin.Default()
|
||||
|
||||
// Pagination middleware setup
|
||||
paginator := pagination.New(pagination.Config{
|
||||
DefaultPage: 0,
|
||||
DefaultPageSize: 100,
|
||||
MinPageSize: 1,
|
||||
MaxPageSize: 1000,
|
||||
})
|
||||
|
||||
// Swagger route
|
||||
router.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
|
||||
|
||||
// API routes
|
||||
v1 := router.Group("/v1")
|
||||
v1.Use(paginator)
|
||||
v1.Use(s.initAccountInfo())
|
||||
v1.Use(s.ensureAuthenticated())
|
||||
{
|
||||
// Auth
|
||||
v1.POST("/auth/display_code", auth.DisplayCodeHandler(s.authService))
|
||||
v1.POST("/auth/token", auth.TokenHandler(s.authService))
|
||||
|
||||
// Export
|
||||
v1.POST("/spaces/:space_id/objects/:object_id/export/:format", export.GetObjectExportHandler(s.exportService))
|
||||
v1.POST("/spaces/:space_id/objects/export/:format", export.GetSpaceExportHandler(s.exportService))
|
||||
|
||||
// Object
|
||||
v1.GET("/spaces/:space_id/objects", object.GetObjectsHandler(s.objectService))
|
||||
v1.GET("/spaces/:space_id/objects/:object_id", object.GetObjectHandler(s.objectService))
|
||||
v1.DELETE("/spaces/:space_id/objects/:object_id", object.DeleteObjectHandler(s.objectService))
|
||||
v1.GET("/spaces/:space_id/object_types", object.GetTypesHandler(s.objectService))
|
||||
v1.GET("/spaces/:space_id/object_types/:typeId/templates", object.GetTemplatesHandler(s.objectService))
|
||||
v1.POST("/spaces/:space_id/objects", object.CreateObjectHandler(s.objectService))
|
||||
v1.PUT("/spaces/:space_id/objects/:object_id", object.UpdateObjectHandler(s.objectService))
|
||||
|
||||
// Search
|
||||
v1.GET("/search", search.SearchHandler(s.searchService))
|
||||
|
||||
// Space
|
||||
v1.GET("/spaces", space.GetSpacesHandler(s.spaceService))
|
||||
v1.GET("/spaces/:space_id/members", space.GetMembersHandler(s.spaceService))
|
||||
v1.POST("/spaces", space.CreateSpaceHandler(s.spaceService))
|
||||
}
|
||||
|
||||
return router
|
||||
}
|
|
@ -1,69 +0,0 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/anyproto/anytype-heart/cmd/api/auth"
|
||||
"github.com/anyproto/anytype-heart/cmd/api/export"
|
||||
"github.com/anyproto/anytype-heart/cmd/api/object"
|
||||
"github.com/anyproto/anytype-heart/cmd/api/search"
|
||||
"github.com/anyproto/anytype-heart/cmd/api/space"
|
||||
"github.com/anyproto/anytype-heart/core"
|
||||
"github.com/anyproto/anytype-heart/pb/service"
|
||||
)
|
||||
|
||||
const (
|
||||
httpPort = ":31009"
|
||||
readHeaderTimeout = 5 * time.Second
|
||||
)
|
||||
|
||||
// Server wraps the HTTP server and service logic.
|
||||
type Server struct {
|
||||
engine *gin.Engine
|
||||
srv *http.Server
|
||||
|
||||
mwInternal core.MiddlewareInternal
|
||||
authService *auth.AuthService
|
||||
exportService *export.ExportService
|
||||
objectService *object.ObjectService
|
||||
spaceService *space.SpaceService
|
||||
searchService *search.SearchService
|
||||
}
|
||||
|
||||
// NewServer constructs a new Server with default config and sets up the routes.
|
||||
func NewServer(mw service.ClientCommandsServer, mwInternal core.MiddlewareInternal) *Server {
|
||||
s := &Server{
|
||||
mwInternal: mwInternal,
|
||||
authService: auth.NewService(mw),
|
||||
exportService: export.NewService(mw),
|
||||
objectService: object.NewService(mw),
|
||||
spaceService: space.NewService(mw),
|
||||
}
|
||||
|
||||
s.searchService = search.NewService(mw, s.spaceService, s.objectService)
|
||||
s.engine = s.NewRouter()
|
||||
s.srv = &http.Server{
|
||||
Addr: httpPort,
|
||||
Handler: s.engine,
|
||||
ReadHeaderTimeout: readHeaderTimeout,
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// ListenAndServe starts the HTTP server.
|
||||
func (s *Server) ListenAndServe() error {
|
||||
fmt.Printf("Starting API server on %s\n", httpPort)
|
||||
return s.srv.ListenAndServe()
|
||||
}
|
||||
|
||||
// Shutdown gracefully stops the server.
|
||||
func (s *Server) Shutdown(ctx context.Context) error {
|
||||
fmt.Println("Shutting down API server...")
|
||||
return s.srv.Shutdown(ctx)
|
||||
}
|
|
@ -1,117 +0,0 @@
|
|||
package space
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/anyproto/anytype-heart/cmd/api/pagination"
|
||||
"github.com/anyproto/anytype-heart/cmd/api/util"
|
||||
)
|
||||
|
||||
// GetSpacesHandler retrieves a list of spaces
|
||||
//
|
||||
// @Summary Retrieve a list of spaces
|
||||
// @Tags spaces
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param offset query int false "The number of items to skip before starting to collect the result set"
|
||||
// @Param limit query int false "The number of items to return" default(100)
|
||||
// @Success 200 {object} pagination.PaginatedResponse[Space] "List of spaces"
|
||||
// @Failure 403 {object} util.UnauthorizedError "Unauthorized"
|
||||
// @Failure 404 {object} util.NotFoundError "Resource not found"
|
||||
// @Failure 502 {object} util.ServerError "Internal server error"
|
||||
// @Router /spaces [get]
|
||||
func GetSpacesHandler(s *SpaceService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
offset := c.GetInt("offset")
|
||||
limit := c.GetInt("limit")
|
||||
|
||||
spaces, total, hasMore, err := s.ListSpaces(c.Request.Context(), offset, limit)
|
||||
code := util.MapErrorCode(err,
|
||||
util.ErrToCode(ErrNoSpacesFound, http.StatusNotFound),
|
||||
util.ErrToCode(ErrFailedListSpaces, http.StatusInternalServerError),
|
||||
util.ErrToCode(ErrFailedOpenWorkspace, http.StatusInternalServerError),
|
||||
)
|
||||
|
||||
if code != http.StatusOK {
|
||||
apiErr := util.CodeToAPIError(code, err.Error())
|
||||
c.JSON(code, apiErr)
|
||||
return
|
||||
}
|
||||
|
||||
pagination.RespondWithPagination(c, http.StatusOK, spaces, total, offset, limit, hasMore)
|
||||
}
|
||||
}
|
||||
|
||||
// CreateSpaceHandler creates a new space
|
||||
//
|
||||
// @Summary Create a new Space
|
||||
// @Tags spaces
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param name body string true "Space Name"
|
||||
// @Success 200 {object} CreateSpaceResponse "Space created successfully"
|
||||
// @Failure 400 {object} util.ValidationError "Bad request"
|
||||
// @Failure 403 {object} util.UnauthorizedError "Unauthorized"
|
||||
// @Failure 502 {object} util.ServerError "Internal server error"
|
||||
// @Router /spaces [post]
|
||||
func CreateSpaceHandler(s *SpaceService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
nameRequest := CreateSpaceRequest{}
|
||||
if err := c.BindJSON(&nameRequest); err != nil {
|
||||
apiErr := util.CodeToAPIError(http.StatusBadRequest, err.Error())
|
||||
c.JSON(http.StatusBadRequest, apiErr)
|
||||
return
|
||||
}
|
||||
|
||||
space, err := s.CreateSpace(c.Request.Context(), nameRequest.Name)
|
||||
code := util.MapErrorCode(err,
|
||||
util.ErrToCode(ErrFailedCreateSpace, http.StatusInternalServerError),
|
||||
)
|
||||
|
||||
if code != http.StatusOK {
|
||||
apiErr := util.CodeToAPIError(code, err.Error())
|
||||
c.JSON(code, apiErr)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, CreateSpaceResponse{Space: space})
|
||||
}
|
||||
}
|
||||
|
||||
// GetMembersHandler retrieves a list of members for the specified space
|
||||
//
|
||||
// @Summary Retrieve a list of members for the specified Space
|
||||
// @Tags spaces
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param space_id path string true "The ID of the space"
|
||||
// @Param offset query int false "The number of items to skip before starting to collect the result set"
|
||||
// @Param limit query int false "The number of items to return" default(100)
|
||||
// @Success 200 {object} pagination.PaginatedResponse[Member] "List of members"
|
||||
// @Failure 403 {object} util.UnauthorizedError "Unauthorized"
|
||||
// @Failure 404 {object} util.NotFoundError "Resource not found"
|
||||
// @Failure 502 {object} util.ServerError "Internal server error"
|
||||
// @Router /spaces/{space_id}/members [get]
|
||||
func GetMembersHandler(s *SpaceService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
spaceId := c.Param("space_id")
|
||||
offset := c.GetInt("offset")
|
||||
limit := c.GetInt("limit")
|
||||
|
||||
members, total, hasMore, err := s.ListMembers(c.Request.Context(), spaceId, offset, limit)
|
||||
code := util.MapErrorCode(err,
|
||||
util.ErrToCode(ErrNoMembersFound, http.StatusNotFound),
|
||||
util.ErrToCode(ErrFailedListMembers, http.StatusInternalServerError),
|
||||
)
|
||||
|
||||
if code != http.StatusOK {
|
||||
apiErr := util.CodeToAPIError(code, err.Error())
|
||||
c.JSON(code, apiErr)
|
||||
return
|
||||
}
|
||||
|
||||
pagination.RespondWithPagination(c, http.StatusOK, members, total, offset, limit, hasMore)
|
||||
}
|
||||
}
|
|
@ -1,41 +0,0 @@
|
|||
package space
|
||||
|
||||
type CreateSpaceRequest struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type CreateSpaceResponse struct {
|
||||
Space Space `json:"space"`
|
||||
}
|
||||
|
||||
type Space struct {
|
||||
Type string `json:"type" example:"space"`
|
||||
Id string `json:"id" example:"bafyreigyfkt6rbv24sbv5aq2hko3bhmv5xxlf22b4bypdu6j7hnphm3psq.23me69r569oi1"`
|
||||
Name string `json:"name" example:"Space Name"`
|
||||
Icon string `json:"icon" example:"http://127.0.0.1:31006/image/bafybeieptz5hvcy6txplcvphjbbh5yjc2zqhmihs3owkh5oab4ezauzqay?width=100"`
|
||||
HomeObjectId string `json:"home_object_id" example:"bafyreie4qcl3wczb4cw5hrfyycikhjyh6oljdis3ewqrk5boaav3sbwqya"`
|
||||
ArchiveObjectId string `json:"archive_object_id" example:"bafyreialsgoyflf3etjm3parzurivyaukzivwortf32b4twnlwpwocsrri"`
|
||||
ProfileObjectId string `json:"profile_object_id" example:"bafyreiaxhwreshjqwndpwtdsu4mtihaqhhmlygqnyqpfyfwlqfq3rm3gw4"`
|
||||
MarketplaceWorkspaceId string `json:"marketplace_workspace_id" example:"_anytype_marketplace"`
|
||||
WorkspaceObjectId string `json:"workspace_object_id" example:"bafyreiapey2g6e6za4zfxvlgwdy4hbbfu676gmwrhnqvjbxvrchr7elr3y"`
|
||||
DeviceId string `json:"device_id" example:"12D3KooWGZMJ4kQVyQVXaj7gJPZr3RZ2nvd9M2Eq2pprEoPih9WF"`
|
||||
AccountSpaceId string `json:"account_space_id" example:"bafyreihpd2knon5wbljhtfeg3fcqtg3i2pomhhnigui6lrjmzcjzep7gcy.23me69r569oi1"`
|
||||
WidgetsId string `json:"widgets_id" example:"bafyreialj7pceh53mifm5dixlho47ke4qjmsn2uh4wsjf7xq2pnlo5xfva"`
|
||||
SpaceViewId string `json:"space_view_id" example:"bafyreigzv3vq7qwlrsin6njoduq727ssnhwd6bgyfj6nm4hv3pxoc2rxhy"`
|
||||
TechSpaceId string `json:"tech_space_id" example:"bafyreif4xuwncrjl6jajt4zrrfnylpki476nv2w64yf42ovt7gia7oypii.23me69r569oi1"`
|
||||
GatewayUrl string `json:"gateway_url" example:"http://127.0.0.1:31006"`
|
||||
LocalStoragePath string `json:"local_storage_path" example:"/Users/johndoe/Library/Application Support/Anytype/data/AAHTtt1wuQEnaYBNZ2Cyfcvs6DqPqxgn8VXDVk4avsUkMuha"`
|
||||
Timezone string `json:"timezone" example:""`
|
||||
AnalyticsId string `json:"analytics_id" example:"624aecdd-4797-4611-9d61-a2ae5f53cf1c"`
|
||||
NetworkId string `json:"network_id" example:"N83gJpVd9MuNRZAuJLZ7LiMntTThhPc6DtzWWVjb1M3PouVU"`
|
||||
}
|
||||
|
||||
type Member struct {
|
||||
Type string `json:"type" example:"member"`
|
||||
Id string `json:"id" example:"_participant_bafyreigyfkt6rbv24sbv5aq2hko1bhmv5xxlf22b4bypdu6j7hnphm3psq_23me69r569oi1_AAjEaEwPF4nkEh9AWkqEnzcQ8HziBB4ETjiTpvRCQvWnSMDZ"`
|
||||
Name string `json:"name" example:"John Doe"`
|
||||
Icon string `json:"icon" example:"http://127.0.0.1:31006/image/bafybeieptz5hvcy6txplcvphjbbh5yjc2zqhmihs3owkh5oab4ezauzqay?width=100"`
|
||||
Identity string `json:"identity" example:"AAjEaEwPF4nkEh7AWkqEnzcQ8HziGB4ETjiTpvRCQvWnSMDZ"`
|
||||
GlobalName string `json:"global_name" example:"john.any"`
|
||||
Role string `json:"role" enum:"Reader,Writer,Owner,NoPermission" example:"Owner"`
|
||||
}
|
|
@ -1,222 +0,0 @@
|
|||
package space
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"math/big"
|
||||
|
||||
"github.com/gogo/protobuf/types"
|
||||
|
||||
"github.com/anyproto/anytype-heart/cmd/api/pagination"
|
||||
"github.com/anyproto/anytype-heart/cmd/api/util"
|
||||
"github.com/anyproto/anytype-heart/pb"
|
||||
"github.com/anyproto/anytype-heart/pb/service"
|
||||
"github.com/anyproto/anytype-heart/pkg/lib/bundle"
|
||||
"github.com/anyproto/anytype-heart/pkg/lib/pb/model"
|
||||
"github.com/anyproto/anytype-heart/util/pbtypes"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNoSpacesFound = errors.New("no spaces found")
|
||||
ErrFailedListSpaces = errors.New("failed to retrieve list of spaces")
|
||||
ErrFailedOpenWorkspace = errors.New("failed to open workspace")
|
||||
ErrFailedGenerateRandomIcon = errors.New("failed to generate random icon")
|
||||
ErrFailedCreateSpace = errors.New("failed to create space")
|
||||
ErrBadInput = errors.New("bad input")
|
||||
ErrNoMembersFound = errors.New("no members found")
|
||||
ErrFailedListMembers = errors.New("failed to retrieve list of members")
|
||||
)
|
||||
|
||||
type Service interface {
|
||||
ListSpaces(ctx context.Context, offset int, limit int) ([]Space, int, bool, error)
|
||||
CreateSpace(ctx context.Context, name string) (Space, error)
|
||||
ListMembers(ctx context.Context, spaceId string, offset int, limit int) ([]Member, int, bool, error)
|
||||
}
|
||||
|
||||
type SpaceService struct {
|
||||
mw service.ClientCommandsServer
|
||||
AccountInfo *model.AccountInfo
|
||||
}
|
||||
|
||||
func NewService(mw service.ClientCommandsServer) *SpaceService {
|
||||
return &SpaceService{mw: mw}
|
||||
}
|
||||
|
||||
// ListSpaces returns a paginated list of spaces for the account.
|
||||
func (s *SpaceService) ListSpaces(ctx context.Context, offset int, limit int) (spaces []Space, total int, hasMore bool, err error) {
|
||||
resp := s.mw.ObjectSearch(ctx, &pb.RpcObjectSearchRequest{
|
||||
SpaceId: s.AccountInfo.TechSpaceId,
|
||||
Filters: []*model.BlockContentDataviewFilter{
|
||||
{
|
||||
Operator: model.BlockContentDataviewFilter_No,
|
||||
RelationKey: bundle.RelationKeyLayout.String(),
|
||||
Condition: model.BlockContentDataviewFilter_Equal,
|
||||
Value: pbtypes.Int64(int64(model.ObjectType_spaceView)),
|
||||
},
|
||||
{
|
||||
Operator: model.BlockContentDataviewFilter_No,
|
||||
RelationKey: bundle.RelationKeySpaceLocalStatus.String(),
|
||||
Condition: model.BlockContentDataviewFilter_Equal,
|
||||
Value: pbtypes.Int64(int64(model.SpaceStatus_Ok)),
|
||||
},
|
||||
{
|
||||
Operator: model.BlockContentDataviewFilter_No,
|
||||
RelationKey: bundle.RelationKeySpaceRemoteStatus.String(),
|
||||
Condition: model.BlockContentDataviewFilter_Equal,
|
||||
Value: pbtypes.Int64(int64(model.SpaceStatus_Ok)),
|
||||
},
|
||||
},
|
||||
Sorts: []*model.BlockContentDataviewSort{
|
||||
{
|
||||
RelationKey: "spaceOrder",
|
||||
Type: model.BlockContentDataviewSort_Asc,
|
||||
NoCollate: true,
|
||||
EmptyPlacement: model.BlockContentDataviewSort_End,
|
||||
},
|
||||
},
|
||||
Keys: []string{"targetSpaceId", "name", "iconEmoji", "iconImage"},
|
||||
})
|
||||
|
||||
if resp.Error.Code != pb.RpcObjectSearchResponseError_NULL {
|
||||
return nil, 0, false, ErrFailedListSpaces
|
||||
}
|
||||
|
||||
if len(resp.Records) == 0 {
|
||||
return nil, 0, false, ErrNoSpacesFound
|
||||
}
|
||||
|
||||
total = len(resp.Records)
|
||||
paginatedRecords, hasMore := pagination.Paginate(resp.Records, offset, limit)
|
||||
spaces = make([]Space, 0, len(paginatedRecords))
|
||||
|
||||
for _, record := range paginatedRecords {
|
||||
workspace, err := s.getWorkspaceInfo(record.Fields["targetSpaceId"].GetStringValue())
|
||||
if err != nil {
|
||||
return nil, 0, false, err
|
||||
}
|
||||
|
||||
// TODO: name and icon are only returned here; fix that
|
||||
workspace.Name = record.Fields["name"].GetStringValue()
|
||||
workspace.Icon = util.GetIconFromEmojiOrImage(s.AccountInfo, record.Fields["iconEmoji"].GetStringValue(), record.Fields["iconImage"].GetStringValue())
|
||||
|
||||
spaces = append(spaces, workspace)
|
||||
}
|
||||
|
||||
return spaces, total, hasMore, nil
|
||||
}
|
||||
|
||||
// CreateSpace creates a new space with the given name and returns the space info.
|
||||
func (s *SpaceService) CreateSpace(ctx context.Context, name string) (Space, error) {
|
||||
iconOption, err := rand.Int(rand.Reader, big.NewInt(13))
|
||||
if err != nil {
|
||||
return Space{}, ErrFailedGenerateRandomIcon
|
||||
}
|
||||
|
||||
// Create new workspace with a random icon and import default use case
|
||||
resp := s.mw.WorkspaceCreate(ctx, &pb.RpcWorkspaceCreateRequest{
|
||||
Details: &types.Struct{
|
||||
Fields: map[string]*types.Value{
|
||||
"iconOption": pbtypes.Float64(float64(iconOption.Int64())),
|
||||
"name": pbtypes.String(name),
|
||||
"spaceDashboardId": pbtypes.String("lastOpened"),
|
||||
},
|
||||
},
|
||||
UseCase: pb.RpcObjectImportUseCaseRequest_GET_STARTED,
|
||||
WithChat: true,
|
||||
})
|
||||
|
||||
if resp.Error.Code != pb.RpcWorkspaceCreateResponseError_NULL {
|
||||
return Space{}, ErrFailedCreateSpace
|
||||
}
|
||||
|
||||
return s.getWorkspaceInfo(resp.SpaceId)
|
||||
}
|
||||
|
||||
// ListMembers returns a paginated list of members in the space with the given ID.
|
||||
func (s *SpaceService) ListMembers(ctx context.Context, spaceId string, offset int, limit int) (members []Member, total int, hasMore bool, err error) {
|
||||
resp := s.mw.ObjectSearch(ctx, &pb.RpcObjectSearchRequest{
|
||||
SpaceId: spaceId,
|
||||
Filters: []*model.BlockContentDataviewFilter{
|
||||
{
|
||||
RelationKey: bundle.RelationKeyLayout.String(),
|
||||
Condition: model.BlockContentDataviewFilter_Equal,
|
||||
Value: pbtypes.Int64(int64(model.ObjectType_participant)),
|
||||
},
|
||||
{
|
||||
RelationKey: bundle.RelationKeyParticipantStatus.String(),
|
||||
Condition: model.BlockContentDataviewFilter_Equal,
|
||||
Value: pbtypes.Int64(int64(model.ParticipantStatus_Active)),
|
||||
},
|
||||
},
|
||||
Sorts: []*model.BlockContentDataviewSort{
|
||||
{
|
||||
RelationKey: "name",
|
||||
Type: model.BlockContentDataviewSort_Asc,
|
||||
},
|
||||
},
|
||||
Keys: []string{"id", "name", "iconEmoji", "iconImage", "identity", "globalName", "participantPermissions"},
|
||||
})
|
||||
|
||||
if resp.Error.Code != pb.RpcObjectSearchResponseError_NULL {
|
||||
return nil, 0, false, ErrFailedListMembers
|
||||
}
|
||||
|
||||
if len(resp.Records) == 0 {
|
||||
return nil, 0, false, ErrNoMembersFound
|
||||
}
|
||||
|
||||
total = len(resp.Records)
|
||||
paginatedMembers, hasMore := pagination.Paginate(resp.Records, offset, limit)
|
||||
members = make([]Member, 0, len(paginatedMembers))
|
||||
|
||||
for _, record := range paginatedMembers {
|
||||
icon := util.GetIconFromEmojiOrImage(s.AccountInfo, record.Fields["iconEmoji"].GetStringValue(), record.Fields["iconImage"].GetStringValue())
|
||||
|
||||
member := Member{
|
||||
Type: "space_member",
|
||||
Id: record.Fields["id"].GetStringValue(),
|
||||
Name: record.Fields["name"].GetStringValue(),
|
||||
Icon: icon,
|
||||
Identity: record.Fields["identity"].GetStringValue(),
|
||||
GlobalName: record.Fields["globalName"].GetStringValue(),
|
||||
Role: model.ParticipantPermissions_name[int32(record.Fields["participantPermissions"].GetNumberValue())],
|
||||
}
|
||||
|
||||
members = append(members, member)
|
||||
}
|
||||
|
||||
return members, total, hasMore, nil
|
||||
}
|
||||
|
||||
// getWorkspaceInfo returns the workspace info for the space with the given ID.
|
||||
func (s *SpaceService) getWorkspaceInfo(spaceId string) (space Space, err error) {
|
||||
workspaceResponse := s.mw.WorkspaceOpen(context.Background(), &pb.RpcWorkspaceOpenRequest{
|
||||
SpaceId: spaceId,
|
||||
WithChat: true,
|
||||
})
|
||||
|
||||
if workspaceResponse.Error.Code != pb.RpcWorkspaceOpenResponseError_NULL {
|
||||
return Space{}, ErrFailedOpenWorkspace
|
||||
}
|
||||
|
||||
return Space{
|
||||
Type: "space",
|
||||
Id: spaceId,
|
||||
HomeObjectId: workspaceResponse.Info.HomeObjectId,
|
||||
ArchiveObjectId: workspaceResponse.Info.ArchiveObjectId,
|
||||
ProfileObjectId: workspaceResponse.Info.ProfileObjectId,
|
||||
MarketplaceWorkspaceId: workspaceResponse.Info.MarketplaceWorkspaceId,
|
||||
WorkspaceObjectId: workspaceResponse.Info.WorkspaceObjectId,
|
||||
DeviceId: workspaceResponse.Info.DeviceId,
|
||||
AccountSpaceId: workspaceResponse.Info.AccountSpaceId,
|
||||
WidgetsId: workspaceResponse.Info.WidgetsId,
|
||||
SpaceViewId: workspaceResponse.Info.SpaceViewId,
|
||||
TechSpaceId: workspaceResponse.Info.TechSpaceId,
|
||||
GatewayUrl: workspaceResponse.Info.GatewayUrl,
|
||||
LocalStoragePath: workspaceResponse.Info.LocalStoragePath,
|
||||
Timezone: workspaceResponse.Info.TimeZone,
|
||||
AnalyticsId: workspaceResponse.Info.AnalyticsId,
|
||||
NetworkId: workspaceResponse.Info.NetworkId,
|
||||
}, nil
|
||||
}
|
|
@ -1,316 +0,0 @@
|
|||
package space
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/gogo/protobuf/types"
|
||||
|
||||
"github.com/anyproto/anytype-heart/pb"
|
||||
"github.com/anyproto/anytype-heart/pb/service/mock_service"
|
||||
"github.com/anyproto/anytype-heart/pkg/lib/bundle"
|
||||
"github.com/anyproto/anytype-heart/pkg/lib/pb/model"
|
||||
"github.com/anyproto/anytype-heart/util/pbtypes"
|
||||
)
|
||||
|
||||
const (
|
||||
offset = 0
|
||||
limit = 100
|
||||
techSpaceId = "tech-space-id"
|
||||
gatewayUrl = "http://localhost:31006"
|
||||
iconImage = "bafyreialsgoyflf3etjm3parzurivyaukzivwortf32b4twnlwpwocsrri"
|
||||
)
|
||||
|
||||
type fixture struct {
|
||||
*SpaceService
|
||||
mwMock *mock_service.MockClientCommandsServer
|
||||
}
|
||||
|
||||
func newFixture(t *testing.T) *fixture {
|
||||
mw := mock_service.NewMockClientCommandsServer(t)
|
||||
spaceService := NewService(mw)
|
||||
spaceService.AccountInfo = &model.AccountInfo{
|
||||
TechSpaceId: techSpaceId,
|
||||
GatewayUrl: gatewayUrl,
|
||||
}
|
||||
|
||||
return &fixture{
|
||||
SpaceService: spaceService,
|
||||
mwMock: mw,
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpaceService_ListSpaces(t *testing.T) {
|
||||
t.Run("successful retrieval of spaces", func(t *testing.T) {
|
||||
// given
|
||||
fx := newFixture(t)
|
||||
|
||||
fx.mwMock.On("ObjectSearch", mock.Anything, &pb.RpcObjectSearchRequest{
|
||||
SpaceId: techSpaceId,
|
||||
Filters: []*model.BlockContentDataviewFilter{
|
||||
{
|
||||
RelationKey: bundle.RelationKeyLayout.String(),
|
||||
Condition: model.BlockContentDataviewFilter_Equal,
|
||||
Value: pbtypes.Int64(int64(model.ObjectType_spaceView)),
|
||||
},
|
||||
{
|
||||
RelationKey: bundle.RelationKeySpaceLocalStatus.String(),
|
||||
Condition: model.BlockContentDataviewFilter_Equal,
|
||||
Value: pbtypes.Int64(int64(model.SpaceStatus_Ok)),
|
||||
},
|
||||
{
|
||||
RelationKey: bundle.RelationKeySpaceRemoteStatus.String(),
|
||||
Condition: model.BlockContentDataviewFilter_Equal,
|
||||
Value: pbtypes.Int64(int64(model.SpaceStatus_Ok)),
|
||||
},
|
||||
},
|
||||
Sorts: []*model.BlockContentDataviewSort{
|
||||
{
|
||||
RelationKey: "spaceOrder",
|
||||
Type: model.BlockContentDataviewSort_Asc,
|
||||
NoCollate: true,
|
||||
EmptyPlacement: model.BlockContentDataviewSort_End,
|
||||
},
|
||||
},
|
||||
Keys: []string{"targetSpaceId", "name", "iconEmoji", "iconImage"},
|
||||
}).Return(&pb.RpcObjectSearchResponse{
|
||||
Records: []*types.Struct{
|
||||
{
|
||||
Fields: map[string]*types.Value{
|
||||
"name": pbtypes.String("Another Workspace"),
|
||||
"targetSpaceId": pbtypes.String("another-space-id"),
|
||||
"iconEmoji": pbtypes.String(""),
|
||||
"iconImage": pbtypes.String(iconImage),
|
||||
},
|
||||
},
|
||||
{
|
||||
Fields: map[string]*types.Value{
|
||||
"name": pbtypes.String("My Workspace"),
|
||||
"targetSpaceId": pbtypes.String("my-space-id"),
|
||||
"iconEmoji": pbtypes.String("🚀"),
|
||||
"iconImage": pbtypes.String(""),
|
||||
},
|
||||
},
|
||||
},
|
||||
Error: &pb.RpcObjectSearchResponseError{Code: pb.RpcObjectSearchResponseError_NULL},
|
||||
}).Once()
|
||||
|
||||
fx.mwMock.On("WorkspaceOpen", mock.Anything, mock.Anything).Return(&pb.RpcWorkspaceOpenResponse{
|
||||
Error: &pb.RpcWorkspaceOpenResponseError{Code: pb.RpcWorkspaceOpenResponseError_NULL},
|
||||
Info: &model.AccountInfo{
|
||||
HomeObjectId: "home-object-id",
|
||||
ArchiveObjectId: "archive-object-id",
|
||||
ProfileObjectId: "profile-object-id",
|
||||
MarketplaceWorkspaceId: "marketplace-workspace-id",
|
||||
WorkspaceObjectId: "workspace-object-id",
|
||||
DeviceId: "device-id",
|
||||
AccountSpaceId: "account-space-id",
|
||||
WidgetsId: "widgets-id",
|
||||
SpaceViewId: "space-view-id",
|
||||
TechSpaceId: "tech-space-id",
|
||||
GatewayUrl: "gateway-url",
|
||||
LocalStoragePath: "local-storage-path",
|
||||
TimeZone: "time-zone",
|
||||
AnalyticsId: "analytics-id",
|
||||
NetworkId: "network-id",
|
||||
},
|
||||
}, nil).Twice()
|
||||
|
||||
// when
|
||||
spaces, total, hasMore, err := fx.ListSpaces(nil, offset, limit)
|
||||
|
||||
// then
|
||||
require.NoError(t, err)
|
||||
require.Len(t, spaces, 2)
|
||||
require.Equal(t, "Another Workspace", spaces[0].Name)
|
||||
require.Equal(t, "another-space-id", spaces[0].Id)
|
||||
require.Regexpf(t, regexp.MustCompile(gatewayUrl+`/image/`+iconImage), spaces[0].Icon, "Icon URL does not match")
|
||||
require.Equal(t, "My Workspace", spaces[1].Name)
|
||||
require.Equal(t, "my-space-id", spaces[1].Id)
|
||||
require.Equal(t, "🚀", spaces[1].Icon)
|
||||
require.Equal(t, 2, total)
|
||||
require.False(t, hasMore)
|
||||
})
|
||||
|
||||
t.Run("no spaces found", func(t *testing.T) {
|
||||
// given
|
||||
fx := newFixture(t)
|
||||
|
||||
fx.mwMock.On("ObjectSearch", mock.Anything, mock.Anything).
|
||||
Return(&pb.RpcObjectSearchResponse{
|
||||
Records: []*types.Struct{},
|
||||
Error: &pb.RpcObjectSearchResponseError{Code: pb.RpcObjectSearchResponseError_NULL},
|
||||
}).Once()
|
||||
|
||||
// when
|
||||
spaces, total, hasMore, err := fx.ListSpaces(nil, offset, limit)
|
||||
|
||||
// then
|
||||
require.ErrorIs(t, err, ErrNoSpacesFound)
|
||||
require.Len(t, spaces, 0)
|
||||
require.Equal(t, 0, total)
|
||||
require.False(t, hasMore)
|
||||
})
|
||||
|
||||
t.Run("failed workspace open", func(t *testing.T) {
|
||||
// given
|
||||
fx := newFixture(t)
|
||||
|
||||
fx.mwMock.On("ObjectSearch", mock.Anything, mock.Anything).
|
||||
Return(&pb.RpcObjectSearchResponse{
|
||||
Records: []*types.Struct{
|
||||
{
|
||||
Fields: map[string]*types.Value{
|
||||
"name": pbtypes.String("My Workspace"),
|
||||
"targetSpaceId": pbtypes.String("my-space-id"),
|
||||
"iconEmoji": pbtypes.String("🚀"),
|
||||
"iconImage": pbtypes.String(""),
|
||||
},
|
||||
},
|
||||
},
|
||||
Error: &pb.RpcObjectSearchResponseError{Code: pb.RpcObjectSearchResponseError_NULL},
|
||||
}).Once()
|
||||
|
||||
fx.mwMock.On("WorkspaceOpen", mock.Anything, mock.Anything).
|
||||
Return(&pb.RpcWorkspaceOpenResponse{
|
||||
Error: &pb.RpcWorkspaceOpenResponseError{Code: pb.RpcWorkspaceOpenResponseError_UNKNOWN_ERROR},
|
||||
}, nil).Once()
|
||||
|
||||
// when
|
||||
spaces, total, hasMore, err := fx.ListSpaces(nil, offset, limit)
|
||||
|
||||
// then
|
||||
require.ErrorIs(t, err, ErrFailedOpenWorkspace)
|
||||
require.Len(t, spaces, 0)
|
||||
require.Equal(t, 0, total)
|
||||
require.False(t, hasMore)
|
||||
})
|
||||
}
|
||||
|
||||
func TestSpaceService_CreateSpace(t *testing.T) {
|
||||
t.Run("successful create space", func(t *testing.T) {
|
||||
// given
|
||||
fx := newFixture(t)
|
||||
fx.mwMock.On("WorkspaceCreate", mock.Anything, mock.Anything).
|
||||
Return(&pb.RpcWorkspaceCreateResponse{
|
||||
Error: &pb.RpcWorkspaceCreateResponseError{Code: pb.RpcWorkspaceCreateResponseError_NULL},
|
||||
SpaceId: "new-space-id",
|
||||
}).Once()
|
||||
|
||||
fx.mwMock.On("WorkspaceOpen", mock.Anything, mock.Anything).Return(&pb.RpcWorkspaceOpenResponse{
|
||||
Error: &pb.RpcWorkspaceOpenResponseError{Code: pb.RpcWorkspaceOpenResponseError_NULL},
|
||||
Info: &model.AccountInfo{
|
||||
HomeObjectId: "home-object-id",
|
||||
ArchiveObjectId: "archive-object-id",
|
||||
ProfileObjectId: "profile-object-id",
|
||||
MarketplaceWorkspaceId: "marketplace-workspace-id",
|
||||
WorkspaceObjectId: "workspace-object-id",
|
||||
DeviceId: "device-id",
|
||||
AccountSpaceId: "account-space-id",
|
||||
WidgetsId: "widgets-id",
|
||||
SpaceViewId: "space-view-id",
|
||||
TechSpaceId: "tech-space-id",
|
||||
GatewayUrl: "gateway-url",
|
||||
LocalStoragePath: "local-storage-path",
|
||||
TimeZone: "time-zone",
|
||||
AnalyticsId: "analytics-id",
|
||||
NetworkId: "network-id",
|
||||
},
|
||||
}, nil).Once()
|
||||
|
||||
// when
|
||||
space, err := fx.CreateSpace(nil, "New Space")
|
||||
|
||||
// then
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "new-space-id", space.Id)
|
||||
})
|
||||
|
||||
t.Run("failed workspace creation", func(t *testing.T) {
|
||||
// given
|
||||
fx := newFixture(t)
|
||||
fx.mwMock.On("WorkspaceCreate", mock.Anything, mock.Anything).
|
||||
Return(&pb.RpcWorkspaceCreateResponse{
|
||||
Error: &pb.RpcWorkspaceCreateResponseError{Code: pb.RpcWorkspaceCreateResponseError_UNKNOWN_ERROR},
|
||||
}).Once()
|
||||
|
||||
// when
|
||||
space, err := fx.CreateSpace(nil, "New Space")
|
||||
|
||||
// then
|
||||
require.ErrorIs(t, err, ErrFailedCreateSpace)
|
||||
require.Equal(t, Space{}, space)
|
||||
})
|
||||
}
|
||||
|
||||
func TestSpaceService_ListMembers(t *testing.T) {
|
||||
t.Run("successfully get members", func(t *testing.T) {
|
||||
// given
|
||||
fx := newFixture(t)
|
||||
|
||||
fx.mwMock.On("ObjectSearch", mock.Anything, mock.Anything).
|
||||
Return(&pb.RpcObjectSearchResponse{
|
||||
Records: []*types.Struct{
|
||||
{
|
||||
Fields: map[string]*types.Value{
|
||||
"id": pbtypes.String("member-1"),
|
||||
"name": pbtypes.String("John Doe"),
|
||||
"iconEmoji": pbtypes.String("👤"),
|
||||
"identity": pbtypes.String("AAjEaEwPF4nkEh7AWkqEnzcQ8HziGB4ETjiTpvRCQvWnSMDZ"),
|
||||
"globalName": pbtypes.String("john.any"),
|
||||
},
|
||||
},
|
||||
{
|
||||
Fields: map[string]*types.Value{
|
||||
"id": pbtypes.String("member-2"),
|
||||
"name": pbtypes.String("Jane Doe"),
|
||||
"iconImage": pbtypes.String(iconImage),
|
||||
"identity": pbtypes.String("AAjLbEwPF4nkEh7AWkqEnzcQ8HziGB4ETjiTpvRCQvWnSMD4"),
|
||||
"globalName": pbtypes.String("jane.any"),
|
||||
},
|
||||
},
|
||||
},
|
||||
Error: &pb.RpcObjectSearchResponseError{Code: pb.RpcObjectSearchResponseError_NULL},
|
||||
}).Once()
|
||||
|
||||
// when
|
||||
members, total, hasMore, err := fx.ListMembers(nil, "space-id", offset, limit)
|
||||
|
||||
// then
|
||||
require.NoError(t, err)
|
||||
require.Len(t, members, 2)
|
||||
require.Equal(t, "member-1", members[0].Id)
|
||||
require.Equal(t, "John Doe", members[0].Name)
|
||||
require.Equal(t, "👤", members[0].Icon)
|
||||
require.Equal(t, "john.any", members[0].GlobalName)
|
||||
require.Equal(t, "member-2", members[1].Id)
|
||||
require.Equal(t, "Jane Doe", members[1].Name)
|
||||
require.Regexpf(t, regexp.MustCompile(gatewayUrl+`/image/`+iconImage), members[1].Icon, "Icon URL does not match")
|
||||
require.Equal(t, "jane.any", members[1].GlobalName)
|
||||
require.Equal(t, 2, total)
|
||||
require.False(t, hasMore)
|
||||
})
|
||||
|
||||
t.Run("no members found", func(t *testing.T) {
|
||||
// given
|
||||
fx := newFixture(t)
|
||||
|
||||
fx.mwMock.On("ObjectSearch", mock.Anything, mock.Anything).
|
||||
Return(&pb.RpcObjectSearchResponse{
|
||||
Records: []*types.Struct{},
|
||||
Error: &pb.RpcObjectSearchResponseError{Code: pb.RpcObjectSearchResponseError_NULL},
|
||||
}).Once()
|
||||
|
||||
// when
|
||||
members, total, hasMore, err := fx.ListMembers(nil, "space-id", offset, limit)
|
||||
|
||||
// then
|
||||
require.ErrorIs(t, err, ErrNoMembersFound)
|
||||
require.Len(t, members, 0)
|
||||
require.Equal(t, 0, total)
|
||||
require.False(t, hasMore)
|
||||
})
|
||||
}
|
|
@ -1,100 +0,0 @@
|
|||
package util
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type ServerError struct {
|
||||
Error struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
|
||||
type ValidationError struct {
|
||||
Error struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
|
||||
type UnauthorizedError struct {
|
||||
Error struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
|
||||
type NotFoundError struct {
|
||||
Error struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
|
||||
type errCodeMapping struct {
|
||||
target error
|
||||
code int
|
||||
}
|
||||
|
||||
// ErrToCode just returns a mapping to pair a target error with a code
|
||||
func ErrToCode(target error, code int) errCodeMapping {
|
||||
return errCodeMapping{
|
||||
target: target,
|
||||
code: code,
|
||||
}
|
||||
}
|
||||
|
||||
// MapErrorCode checks if err matches any “target” in the mappings,
|
||||
// returning the first matching code. If none match, returns 500.
|
||||
func MapErrorCode(err error, mappings ...errCodeMapping) int {
|
||||
if err == nil {
|
||||
return http.StatusOK
|
||||
}
|
||||
for _, m := range mappings {
|
||||
if errors.Is(err, m.target) {
|
||||
return m.code
|
||||
}
|
||||
}
|
||||
|
||||
return http.StatusInternalServerError
|
||||
}
|
||||
|
||||
// CodeToAPIError returns an instance of the correct struct
|
||||
// for the given HTTP code, embedding the supplied message.
|
||||
func CodeToAPIError(code int, message string) any {
|
||||
switch code {
|
||||
case http.StatusNotFound:
|
||||
return NotFoundError{
|
||||
Error: struct {
|
||||
Message string `json:"message"`
|
||||
}{
|
||||
Message: message,
|
||||
},
|
||||
}
|
||||
|
||||
case http.StatusUnauthorized:
|
||||
return UnauthorizedError{
|
||||
Error: struct {
|
||||
Message string `json:"message"`
|
||||
}{
|
||||
Message: message,
|
||||
},
|
||||
}
|
||||
|
||||
case http.StatusBadRequest:
|
||||
return ValidationError{
|
||||
Error: struct {
|
||||
Message string `json:"message"`
|
||||
}{
|
||||
Message: message,
|
||||
},
|
||||
}
|
||||
|
||||
default:
|
||||
return ServerError{
|
||||
Error: struct {
|
||||
Message string `json:"message"`
|
||||
}{
|
||||
Message: message,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,98 +0,0 @@
|
|||
package util
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/anyproto/anytype-heart/pb"
|
||||
"github.com/anyproto/anytype-heart/pb/service"
|
||||
"github.com/anyproto/anytype-heart/pkg/lib/bundle"
|
||||
"github.com/anyproto/anytype-heart/pkg/lib/pb/model"
|
||||
"github.com/anyproto/anytype-heart/util/pbtypes"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrFailedSearchType = errors.New("failed to search for type")
|
||||
ErrorTypeNotFound = errors.New("type not found")
|
||||
)
|
||||
|
||||
// GetIconFromEmojiOrImage returns the icon to use for the object, which can be either an emoji or an image url
|
||||
func GetIconFromEmojiOrImage(accountInfo *model.AccountInfo, iconEmoji string, iconImage string) string {
|
||||
if iconEmoji != "" {
|
||||
return iconEmoji
|
||||
}
|
||||
|
||||
if iconImage != "" {
|
||||
return GetGatewayURLForMedia(accountInfo, iconImage, true)
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetGatewayURLForMedia returns the URL of file gateway for the media object with the given ID
|
||||
func GetGatewayURLForMedia(accountInfo *model.AccountInfo, objectId string, isIcon bool) string {
|
||||
widthParam := ""
|
||||
if isIcon {
|
||||
widthParam = "?width=100"
|
||||
}
|
||||
return fmt.Sprintf("%s/image/%s%s", accountInfo.GatewayUrl, objectId, widthParam)
|
||||
}
|
||||
|
||||
// ResolveTypeToName resolves the type ID to the name of the type, e.g. "ot-page" to "Page" or "bafyreigyb6l5szohs32ts26ku2j42yd65e6hqy2u3gtzgdwqv6hzftsetu" to "Custom Type"
|
||||
func ResolveTypeToName(mw service.ClientCommandsServer, spaceId string, typeId string) (typeName string, err error) {
|
||||
// Can't look up preinstalled types based on relation key, therefore need to use unique key
|
||||
relKey := bundle.RelationKeyId.String()
|
||||
if strings.Contains(typeId, "ot-") {
|
||||
relKey = bundle.RelationKeyUniqueKey.String()
|
||||
}
|
||||
|
||||
// Call ObjectSearch for object of specified type and return the name
|
||||
resp := mw.ObjectSearch(context.Background(), &pb.RpcObjectSearchRequest{
|
||||
SpaceId: spaceId,
|
||||
Filters: []*model.BlockContentDataviewFilter{
|
||||
{
|
||||
RelationKey: relKey,
|
||||
Condition: model.BlockContentDataviewFilter_Equal,
|
||||
Value: pbtypes.String(typeId),
|
||||
},
|
||||
},
|
||||
Keys: []string{"name"},
|
||||
})
|
||||
|
||||
if resp.Error.Code != pb.RpcObjectSearchResponseError_NULL {
|
||||
return "", ErrFailedSearchType
|
||||
}
|
||||
|
||||
if len(resp.Records) == 0 {
|
||||
return "", ErrorTypeNotFound
|
||||
}
|
||||
|
||||
return resp.Records[0].Fields["name"].GetStringValue(), nil
|
||||
}
|
||||
|
||||
func ResolveUniqueKeyToTypeId(mw service.ClientCommandsServer, spaceId string, uniqueKey string) (typeId string, err error) {
|
||||
// Call ObjectSearch for type with unique key and return the type's ID
|
||||
resp := mw.ObjectSearch(context.Background(), &pb.RpcObjectSearchRequest{
|
||||
SpaceId: spaceId,
|
||||
Filters: []*model.BlockContentDataviewFilter{
|
||||
{
|
||||
RelationKey: bundle.RelationKeyUniqueKey.String(),
|
||||
Condition: model.BlockContentDataviewFilter_Equal,
|
||||
Value: pbtypes.String(uniqueKey),
|
||||
},
|
||||
},
|
||||
Keys: []string{"id"},
|
||||
})
|
||||
|
||||
if resp.Error.Code != pb.RpcObjectSearchResponseError_NULL {
|
||||
return "", ErrFailedSearchType
|
||||
}
|
||||
|
||||
if len(resp.Records) == 0 {
|
||||
return "", ErrorTypeNotFound
|
||||
}
|
||||
|
||||
return resp.Records[0].Fields["id"].GetStringValue(), nil
|
||||
}
|
|
@ -30,8 +30,8 @@ import (
|
|||
jaegercfg "github.com/uber/jaeger-client-go/config"
|
||||
"google.golang.org/grpc"
|
||||
|
||||
"github.com/anyproto/anytype-heart/cmd/api"
|
||||
"github.com/anyproto/anytype-heart/core"
|
||||
"github.com/anyproto/anytype-heart/core/api"
|
||||
"github.com/anyproto/anytype-heart/core/event"
|
||||
"github.com/anyproto/anytype-heart/metrics"
|
||||
"github.com/anyproto/anytype-heart/pb"
|
||||
|
@ -248,10 +248,7 @@ func main() {
|
|||
}
|
||||
|
||||
// run rest api server
|
||||
var mwInternal core.MiddlewareInternal = mw
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
go api.RunApiServer(ctx, mw, mwInternal)
|
||||
api.SetMiddlewareParams(mw)
|
||||
|
||||
for {
|
||||
sig := <-signalChan
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue