1
0
Fork 0
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:
Jannis Metrikat 2025-01-12 00:21:56 +01:00
parent edc2219836
commit 892d929683
No known key found for this signature in database
GPG key ID: B223CAC5AAF85615
35 changed files with 204 additions and 174 deletions

View file

@ -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,
})
}
}

View file

@ -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:""`
}

View file

@ -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
}

View file

@ -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)
})
}

View file

@ -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())
}
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -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"

View file

@ -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")
}
}

View file

@ -1,9 +0,0 @@
package export
type ObjectExportRequest struct {
Path string `json:"path"`
}
type ObjectExportResponse struct {
Path string `json:"path"`
}

View file

@ -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
}
}

View file

@ -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)
}
}

View file

@ -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)
}
}

View file

@ -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:"📄"`
}

View file

@ -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"
}
}

View file

@ -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)
})
}

View file

@ -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"`
}

View file

@ -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
}

View file

@ -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)
})
}
}

View file

@ -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)
}
}

View file

@ -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,
},
}
}

View file

@ -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)
})
}

View file

@ -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()
}
}

View file

@ -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
}

View file

@ -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)
}

View file

@ -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)
}
}

View file

@ -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"`
}

View file

@ -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
}

View file

@ -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)
})
}

View file

@ -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,
},
}
}
}

View file

@ -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
}

View file

@ -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