1
0
Fork 0
mirror of https://github.com/anyproto/anytype-heart.git synced 2025-06-08 05:47:07 +09:00

GO-4459: Refactor object service, add tests again

This commit is contained in:
Jannis Metrikat 2025-01-01 15:21:28 +01:00
parent 2bb2285551
commit 3520002bee
No known key found for this signature in database
GPG key ID: B223CAC5AAF85615
9 changed files with 1052 additions and 301 deletions

View file

@ -578,7 +578,13 @@ const docTemplate = `{
"200": {
"description": "The created object",
"schema": {
"$ref": "#/definitions/object.Object"
"$ref": "#/definitions/object.CreateObjectResponse"
}
},
"400": {
"description": "Bad request",
"schema": {
"$ref": "#/definitions/util.ValidationError"
}
},
"403": {
@ -691,7 +697,13 @@ const docTemplate = `{
"200": {
"description": "The updated object",
"schema": {
"$ref": "#/definitions/object.Object"
"$ref": "#/definitions/object.UpdateObjectResponse"
}
},
"400": {
"description": "Bad request",
"schema": {
"$ref": "#/definitions/util.ValidationError"
}
},
"403": {
@ -768,6 +780,14 @@ const docTemplate = `{
}
}
},
"object.CreateObjectResponse": {
"type": "object",
"properties": {
"object": {
"$ref": "#/definitions/object.Object"
}
}
},
"object.Detail": {
"type": "object",
"properties": {
@ -922,6 +942,14 @@ const docTemplate = `{
}
}
},
"object.UpdateObjectResponse": {
"type": "object",
"properties": {
"object": {
"$ref": "#/definitions/object.Object"
}
}
},
"pagination.PaginatedResponse-space_Member": {
"type": "object",
"properties": {

View file

@ -572,7 +572,13 @@
"200": {
"description": "The created object",
"schema": {
"$ref": "#/definitions/object.Object"
"$ref": "#/definitions/object.CreateObjectResponse"
}
},
"400": {
"description": "Bad request",
"schema": {
"$ref": "#/definitions/util.ValidationError"
}
},
"403": {
@ -685,7 +691,13 @@
"200": {
"description": "The updated object",
"schema": {
"$ref": "#/definitions/object.Object"
"$ref": "#/definitions/object.UpdateObjectResponse"
}
},
"400": {
"description": "Bad request",
"schema": {
"$ref": "#/definitions/util.ValidationError"
}
},
"403": {
@ -762,6 +774,14 @@
}
}
},
"object.CreateObjectResponse": {
"type": "object",
"properties": {
"object": {
"$ref": "#/definitions/object.Object"
}
}
},
"object.Detail": {
"type": "object",
"properties": {
@ -916,6 +936,14 @@
}
}
},
"object.UpdateObjectResponse": {
"type": "object",
"properties": {
"object": {
"$ref": "#/definitions/object.Object"
}
}
},
"pagination.PaginatedResponse-space_Member": {
"type": "object",
"properties": {

View file

@ -34,6 +34,11 @@ definitions:
vertical_align:
type: string
type: object
object.CreateObjectResponse:
properties:
object:
$ref: '#/definitions/object.Object'
type: object
object.Detail:
properties:
details:
@ -140,6 +145,11 @@ definitions:
text:
type: string
type: object
object.UpdateObjectResponse:
properties:
object:
$ref: '#/definitions/object.Object'
type: object
pagination.PaginatedResponse-space_Member:
properties:
data:
@ -687,7 +697,11 @@ paths:
"200":
description: The created object
schema:
$ref: '#/definitions/object.Object'
$ref: '#/definitions/object.CreateObjectResponse'
"400":
description: Bad request
schema:
$ref: '#/definitions/util.ValidationError'
"403":
description: Unauthorized
schema:
@ -762,7 +776,11 @@ paths:
"200":
description: The updated object
schema:
$ref: '#/definitions/object.Object'
$ref: '#/definitions/object.UpdateObjectResponse'
"400":
description: Bad request
schema:
$ref: '#/definitions/util.ValidationError'
"403":
description: Unauthorized
schema:

View file

@ -4,24 +4,11 @@ import (
"net/http"
"github.com/gin-gonic/gin"
"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/pkg/lib/bundle"
"github.com/anyproto/anytype-heart/pkg/lib/pb/model"
"github.com/anyproto/anytype-heart/util/pbtypes"
)
type CreateObjectRequest struct {
Name string `json:"name"`
Icon string `json:"icon"`
TemplateId string `json:"template_id"`
ObjectTypeUniqueKey string `json:"object_type_unique_key"`
WithChat bool `json:"with_chat"`
}
// GetObjectsHandler retrieves objects in a specific space
//
// @Summary Retrieve objects in a specific space
@ -42,77 +29,19 @@ func GetObjectsHandler(s *ObjectService) gin.HandlerFunc {
offset := c.GetInt("offset")
limit := c.GetInt("limit")
resp := s.mw.ObjectSearch(c.Request.Context(), &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,
}},
Keys: []string{"id", "name", "type", "layout", "iconEmoji", "iconImage"},
})
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),
)
if resp.Error.Code != pb.RpcObjectSearchResponseError_NULL {
c.JSON(http.StatusInternalServerError, gin.H{"message": "Failed to retrieve list of objects."})
if code != http.StatusOK {
apiErr := util.CodeToAPIError(code, err.Error())
c.JSON(code, apiErr)
return
}
if len(resp.Records) == 0 {
c.JSON(http.StatusNotFound, gin.H{"message": "No objects found."})
return
}
paginatedObjects, hasMore := pagination.Paginate(resp.Records, offset, limit)
objects := make([]Object, 0, len(paginatedObjects))
for _, record := range paginatedObjects {
icon := util.GetIconFromEmojiOrImage(s.AccountInfo, record.Fields["iconEmoji"].GetStringValue(), record.Fields["iconImage"].GetStringValue())
objectTypeName, err := util.ResolveTypeToName(s.mw, spaceId, record.Fields["type"].GetStringValue())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"message": "Failed to resolve object type name."})
return
}
objectShowResp := s.mw.ObjectShow(c.Request.Context(), &pb.RpcObjectShowRequest{
SpaceId: spaceId,
ObjectId: record.Fields["id"].GetStringValue(),
})
object := Object{
// TODO fix type inconsistency
Type: model.ObjectTypeLayout_name[int32(record.Fields["layout"].GetNumberValue())],
Id: record.Fields["id"].GetStringValue(),
Name: record.Fields["name"].GetStringValue(),
Icon: icon,
ObjectType: objectTypeName,
SpaceId: spaceId,
RootId: objectShowResp.ObjectView.RootId,
Blocks: s.GetBlocks(objectShowResp),
Details: s.GetDetails(objectShowResp),
}
objects = append(objects, object)
}
pagination.RespondWithPagination(c, http.StatusOK, objects, len(resp.Records), offset, limit, hasMore)
pagination.RespondWithPagination(c, http.StatusOK, objects, total, offset, limit, hasMore)
}
}
@ -134,38 +63,19 @@ func GetObjectHandler(s *ObjectService) gin.HandlerFunc {
spaceId := c.Param("space_id")
objectId := c.Param("object_id")
resp := s.mw.ObjectShow(c.Request.Context(), &pb.RpcObjectShowRequest{
SpaceId: spaceId,
ObjectId: objectId,
})
object, err := s.GetObject(c.Request.Context(), spaceId, objectId)
code := util.MapErrorCode(err,
util.ErrToCode(ErrObjectNotFound, http.StatusNotFound),
util.ErrToCode(ErrFailedRetrieveObject, http.StatusInternalServerError),
)
if resp.Error.Code != pb.RpcObjectShowResponseError_NULL {
if resp.Error.Code == pb.RpcObjectShowResponseError_NOT_FOUND {
c.JSON(http.StatusNotFound, gin.H{"message": "Object not found", "space_id": spaceId, "object_id": objectId})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"message": "Failed to retrieve object."})
if code != http.StatusOK {
apiErr := util.CodeToAPIError(code, err.Error())
c.JSON(code, apiErr)
return
}
objectTypeName, err := util.ResolveTypeToName(s.mw, spaceId, resp.ObjectView.Details[0].Details.Fields["type"].GetStringValue())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"message": "Failed to resolve object type name."})
return
}
object := Object{
Type: "object",
Id: objectId,
Name: resp.ObjectView.Details[0].Details.Fields["name"].GetStringValue(),
Icon: resp.ObjectView.Details[0].Details.Fields["iconEmoji"].GetStringValue(),
ObjectType: objectTypeName,
RootId: resp.ObjectView.RootId,
Blocks: s.GetBlocks(resp),
Details: s.GetDetails(resp),
}
c.JSON(http.StatusOK, gin.H{"object": object})
c.JSON(http.StatusOK, GetObjectResponse{Object: object})
}
}
@ -177,7 +87,8 @@ func GetObjectHandler(s *ObjectService) gin.HandlerFunc {
// @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} Object "The created object"
// @Success 200 {object} CreateObjectResponse "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]
@ -187,42 +98,23 @@ func CreateObjectHandler(s *ObjectService) gin.HandlerFunc {
request := CreateObjectRequest{}
if err := c.BindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"message": "Invalid JSON"})
c.JSON(http.StatusBadRequest, util.CodeToAPIError(http.StatusBadRequest, ErrBadInput.Error()))
return
}
resp := s.mw.ObjectCreate(c.Request.Context(), &pb.RpcObjectCreateRequest{
Details: &types.Struct{
Fields: map[string]*types.Value{
"name": pbtypes.String(request.Name),
"iconEmoji": pbtypes.String(request.Icon),
},
},
TemplateId: request.TemplateId,
SpaceId: spaceId,
ObjectTypeUniqueKey: request.ObjectTypeUniqueKey,
WithChat: request.WithChat,
})
object, err := s.CreateObject(c.Request.Context(), spaceId, request)
code := util.MapErrorCode(err,
util.ErrToCode(ErrFailedCreateObject, http.StatusInternalServerError),
util.ErrToCode(ErrFailedRetrieveObject, http.StatusInternalServerError),
)
if resp.Error.Code != pb.RpcObjectCreateResponseError_NULL {
c.JSON(http.StatusInternalServerError, gin.H{"message": "Failed to create a new object."})
if code != http.StatusOK {
apiErr := util.CodeToAPIError(code, err.Error())
c.JSON(code, apiErr)
return
}
object := Object{
Type: "object",
Id: resp.ObjectId,
Name: resp.Details.Fields["name"].GetStringValue(),
Icon: resp.Details.Fields["iconEmoji"].GetStringValue(),
ObjectType: request.ObjectTypeUniqueKey,
SpaceId: resp.Details.Fields["spaceId"].GetStringValue(),
// TODO populate other fields
// RootId: resp.RootId,
// Blocks: []Block{},
// Details: []Detail{},
}
c.JSON(http.StatusOK, gin.H{"object": object})
c.JSON(http.StatusOK, CreateObjectResponse{Object: object})
}
}
@ -235,7 +127,8 @@ func CreateObjectHandler(s *ObjectService) gin.HandlerFunc {
// @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} Object "The updated object"
// @Success 200 {object} UpdateObjectResponse "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"
@ -244,12 +137,31 @@ func UpdateObjectHandler(s *ObjectService) gin.HandlerFunc {
return func(c *gin.Context) {
spaceId := c.Param("space_id")
objectId := c.Param("object_id")
// TODO: Implement logic to update an existing object
c.JSON(http.StatusNotImplemented, gin.H{"message": "Not implemented yet", "space_id": spaceId, "object_id": objectId})
request := UpdateObjectRequest{}
if err := c.BindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, util.CodeToAPIError(http.StatusBadRequest, ErrBadInput.Error()))
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, UpdateObjectResponse{Object: object})
}
}
// GetObjectTypesHandler retrieves object types in a specific space
// GetTypesHandler retrieves object types in a specific space
//
// @Summary Retrieve object types in a specific space
// @Tags types_and_templates
@ -263,63 +175,29 @@ func UpdateObjectHandler(s *ObjectService) gin.HandlerFunc {
// @Failure 404 {object} util.NotFoundError "Resource not found"
// @Failure 502 {object} util.ServerError "Internal server error"
// @Router /spaces/{space_id}/objectTypes [get]
func GetObjectTypesHandler(s *ObjectService) gin.HandlerFunc {
func GetTypesHandler(s *ObjectService) gin.HandlerFunc {
return func(c *gin.Context) {
spaceId := c.Param("space_id")
offset := c.GetInt("offset")
limit := c.GetInt("limit")
resp := s.mw.ObjectSearch(c.Request.Context(), &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"},
})
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 resp.Error.Code != pb.RpcObjectSearchResponseError_NULL {
c.JSON(http.StatusInternalServerError, gin.H{"message": "Failed to retrieve object types."})
if code != http.StatusOK {
apiErr := util.CodeToAPIError(code, err.Error())
c.JSON(code, apiErr)
return
}
if len(resp.Records) == 0 {
c.JSON(http.StatusNotFound, gin.H{"message": "No object types found."})
return
}
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(),
})
}
pagination.RespondWithPagination(c, http.StatusOK, objectTypes, len(resp.Records), offset, limit, hasMore)
pagination.RespondWithPagination(c, http.StatusOK, types, total, offset, limit, hasMore)
}
}
// GetObjectTypeTemplatesHandler retrieves a list of templates for a specific object type in a space
// 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 types_and_templates
@ -334,91 +212,28 @@ func GetObjectTypesHandler(s *ObjectService) gin.HandlerFunc {
// @Failure 404 {object} util.NotFoundError "Resource not found"
// @Failure 502 {object} util.ServerError "Internal server error"
// @Router /spaces/{space_id}/objectTypes/{typeId}/templates [get]
func GetObjectTypeTemplatesHandler(s *ObjectService) gin.HandlerFunc {
func GetTemplatesHandler(s *ObjectService) gin.HandlerFunc {
return func(c *gin.Context) {
spaceId := c.Param("space_id")
typeId := c.Param("typeId")
offset := c.GetInt("offset")
limit := c.GetInt("limit")
// First, determine the type ID of "ot-template" in the space
templateTypeIdResp := s.mw.ObjectSearch(c.Request.Context(), &pb.RpcObjectSearchRequest{
SpaceId: spaceId,
Filters: []*model.BlockContentDataviewFilter{
{
RelationKey: bundle.RelationKeyUniqueKey.String(),
Condition: model.BlockContentDataviewFilter_Equal,
Value: pbtypes.String("ot-template"),
},
},
Keys: []string{"id"},
})
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 templateTypeIdResp.Error.Code != pb.RpcObjectSearchResponseError_NULL {
c.JSON(http.StatusInternalServerError, gin.H{"message": "Failed to retrieve template type."})
if code != http.StatusOK {
apiErr := util.CodeToAPIError(code, err.Error())
c.JSON(code, apiErr)
return
}
if len(templateTypeIdResp.Records) == 0 {
c.JSON(http.StatusNotFound, gin.H{"message": "Template type not found."})
return
}
templateTypeId := templateTypeIdResp.Records[0].Fields["id"].GetStringValue()
// Then, search all objects of the template type and filter by the target object type
templateObjectsResp := s.mw.ObjectSearch(c.Request.Context(), &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 {
c.JSON(http.StatusInternalServerError, gin.H{"message": "Failed to retrieve template objects."})
return
}
if len(templateObjectsResp.Records) == 0 {
c.JSON(http.StatusNotFound, gin.H{"message": "No templates found."})
return
}
templateIds := make([]string, 0)
for _, record := range templateObjectsResp.Records {
if record.Fields["targetObjectType"].GetStringValue() == typeId {
templateIds = append(templateIds, record.Fields["id"].GetStringValue())
}
}
// Finally, open each template and populate the response
paginatedTemplates, hasMore := pagination.Paginate(templateIds, offset, limit)
templates := make([]ObjectTemplate, 0, len(paginatedTemplates))
for _, templateId := range paginatedTemplates {
templateResp := s.mw.ObjectShow(c.Request.Context(), &pb.RpcObjectShowRequest{
SpaceId: spaceId,
ObjectId: templateId,
})
if templateResp.Error.Code != pb.RpcObjectShowResponseError_NULL {
c.JSON(http.StatusInternalServerError, gin.H{"message": "Failed to retrieve template."})
return
}
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(),
})
}
pagination.RespondWithPagination(c, http.StatusOK, templates, len(templateIds), offset, limit, hasMore)
pagination.RespondWithPagination(c, http.StatusOK, templates, total, offset, limit, hasMore)
}
}

View file

@ -1,5 +1,29 @@
package object
type GetObjectResponse struct {
Object Object `json:"object"`
}
type CreateObjectRequest struct {
Name string `json:"name"`
Icon string `json:"icon"`
TemplateId string `json:"template_id"`
ObjectTypeUniqueKey string `json:"object_type_unique_key"`
WithChat bool `json:"with_chat"`
}
type CreateObjectResponse struct {
Object Object `json:"object"`
}
type UpdateObjectRequest struct {
Object Object `json:"object"`
}
type UpdateObjectResponse struct {
Object Object `json:"object"`
}
type Object struct {
Type string `json:"type" example:"object"`
Id string `json:"id" example:"bafyreie6n5l5nkbjal37su54cha4coy7qzuhrnajluzv5qd5jvtsrxkequ"`

View file

@ -1,27 +1,45 @@
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 (
ErrFailedGenerateChallenge = errors.New("failed to generate a new challenge")
ErrInvalidInput = errors.New("invalid input")
ErrorFailedAuthenticate = errors.New("failed to authenticate user")
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")
ErrFailedCreateObject = errors.New("failed to create object")
ErrBadInput = errors.New("bad input")
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() ([]Object, error)
GetObject(id string) (Object, error)
CreateObject(obj Object) (Object, error)
UpdateObject(obj Object) (Object, error)
ListTypes() ([]ObjectType, error)
ListTemplates() ([]ObjectTemplate, error)
ListObjects(ctx context.Context, spaceId string, offset int, limit int) ([]Object, int, bool, error)
GetObject(ctx context.Context, spaceId string, objectId string) (Object, error)
CreateObject(ctx context.Context, spaceId string, obj Object) (Object, error)
UpdateObject(ctx context.Context, spaceId string, obj Object) (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 {
@ -29,41 +47,290 @@ type ObjectService struct {
AccountInfo *model.AccountInfo
}
// NewService creates a new object service
func NewService(mw service.ClientCommandsServer) *ObjectService {
return &ObjectService{mw: mw}
}
func (s *ObjectService) ListObjects() ([]Object, error) {
// TODO
return nil, nil
// ListObjects retrieves a 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", "type", "layout", "iconEmoji", "iconImage"},
})
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
}
// TODO: layout is not correctly returned in ObjectShow; therefore we need to resolve it here
object.Type = model.ObjectTypeLayout_name[int32(record.Fields["layout"].GetNumberValue())]
objects = append(objects, object)
}
return objects, total, hasMore, nil
}
func (s *ObjectService) GetObject(id string) (Object, error) {
// TODO
return Object{}, 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,
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
}
func (s *ObjectService) CreateObject(obj Object) (Object, error) {
// TODO
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) {
resp := s.mw.ObjectCreate(ctx, &pb.RpcObjectCreateRequest{
Details: &types.Struct{
Fields: map[string]*types.Value{
"name": pbtypes.String(request.Name),
"iconEmoji": pbtypes.String(request.Icon),
},
},
TemplateId: request.TemplateId,
SpaceId: spaceId,
ObjectTypeUniqueKey: request.ObjectTypeUniqueKey,
WithChat: request.WithChat,
})
if resp.Error.Code != pb.RpcObjectCreateResponseError_NULL {
return Object{}, ErrFailedCreateObject
}
objShowResp := s.mw.ObjectShow(ctx, &pb.RpcObjectShowRequest{
SpaceId: spaceId,
ObjectId: resp.ObjectId,
})
if objShowResp.Error.Code != pb.RpcObjectShowResponseError_NULL {
return Object{}, ErrFailedRetrieveObject
}
icon2 := util.GetIconFromEmojiOrImage(s.AccountInfo, objShowResp.ObjectView.Details[0].Details.Fields["iconEmoji"].GetStringValue(), objShowResp.ObjectView.Details[0].Details.Fields["iconImage"].GetStringValue())
objectTypeName, err := util.ResolveTypeToName(s.mw, spaceId, objShowResp.ObjectView.Details[0].Details.Fields["type"].GetStringValue())
if err != nil {
return Object{}, err
}
object := Object{
Type: "object",
Id: resp.ObjectId,
Name: objShowResp.ObjectView.Details[0].Details.Fields["name"].GetStringValue(),
Icon: icon2,
ObjectType: objectTypeName,
SpaceId: objShowResp.ObjectView.Details[0].Details.Fields["spaceId"].GetStringValue(),
RootId: objShowResp.ObjectView.RootId,
Blocks: s.GetBlocks(objShowResp),
Details: s.GetDetails(objShowResp),
}
return object, nil
}
func (s *ObjectService) UpdateObject(obj Object) (Object, error) {
// TODO
return Object{}, nil
// 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
}
func (s *ObjectService) ListTypes() ([]ObjectType, error) {
// TODO
return nil, nil
// ListTypes returns the 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"},
})
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(),
})
}
return objectTypes, total, hasMore, nil
}
func (s *ObjectService) ListTemplates() ([]ObjectTemplate, error) {
// TODO
return nil, nil
// ListTemplates returns the 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 details of the object
// GetDetails returns the list of details from the ObjectShowResponse
func (s *ObjectService) GetDetails(resp *pb.RpcObjectShowResponse) []Detail {
return []Detail{
{
@ -87,7 +354,7 @@ func (s *ObjectService) GetDetails(resp *pb.RpcObjectShowResponse) []Detail {
}
}
// getTags returns the list of tags from the object details
// getTags returns the list of tags from the ObjectShowResponse
func (s *ObjectService) getTags(resp *pb.RpcObjectShowResponse) []Tag {
tags := []Tag{}
@ -112,7 +379,7 @@ func (s *ObjectService) getTags(resp *pb.RpcObjectShowResponse) []Tag {
return tags
}
// GetBlocks returns the blocks of the object
// GetBlocks returns the list of blocks from the ObjectShowResponse
func (s *ObjectService) GetBlocks(resp *pb.RpcObjectShowResponse) []Block {
blocks := []Block{}
@ -158,6 +425,7 @@ func (s *ObjectService) GetBlocks(resp *pb.RpcObjectShowResponse) []Block {
return blocks
}
// mapAlign maps the protobuf BlockAlign to a string
func mapAlign(align model.BlockAlign) string {
switch align {
case model.Block_AlignLeft:
@ -173,6 +441,7 @@ func mapAlign(align model.BlockAlign) string {
}
}
// mapVerticalAlign maps the protobuf BlockVerticalAlign to a string
func mapVerticalAlign(align model.BlockVerticalAlign) string {
switch align {
case model.Block_VerticalAlignTop:

View file

@ -0,0 +1,568 @@
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", "type", "layout", "iconEmoji", "iconImage"},
}).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

@ -46,8 +46,8 @@ func (s *Server) NewRouter() *gin.Engine {
readOnly.GET("/spaces/:space_id/members", paginator, space.GetMembersHandler(s.spaceService))
readOnly.GET("/spaces/:space_id/objects", paginator, object.GetObjectsHandler(s.objectService))
readOnly.GET("/spaces/:space_id/objects/:object_id", object.GetObjectHandler(s.objectService))
readOnly.GET("/spaces/:space_id/objectTypes", paginator, object.GetObjectTypesHandler(s.objectService))
readOnly.GET("/spaces/:space_id/objectTypes/:typeId/templates", paginator, object.GetObjectTypeTemplatesHandler(s.objectService))
readOnly.GET("/spaces/:space_id/objectTypes", paginator, object.GetTypesHandler(s.objectService))
readOnly.GET("/spaces/:space_id/objectTypes/:typeId/templates", paginator, object.GetTemplatesHandler(s.objectService))
readOnly.GET("/search", paginator, search.SearchHandler(s.searchService))
}

View file

@ -58,6 +58,7 @@ func ResolveTypeToName(mw service.ClientCommandsServer, spaceId string, typeId s
Value: pbtypes.String(typeId),
},
},
Keys: []string{"name"},
})
if resp.Error.Code != pb.RpcObjectSearchResponseError_NULL {