diff --git a/dataapiv1/spec.yaml b/dataapiv1/spec.yaml index 7e5976f..7f5e2f6 100644 --- a/dataapiv1/spec.yaml +++ b/dataapiv1/spec.yaml @@ -171,6 +171,340 @@ paths: description: The alpha API is enabled '404': description: The alpha API is not enabled + '/v1.alpha/buckets/{bucketName}/scopes/{scopeName}/collections/{collectionName}/documents/{documentKey}/append': + parameters: + - $ref: '#/components/parameters/AuthorizationHeader' + - $ref: '#/components/parameters/BucketName' + - $ref: '#/components/parameters/ScopeName' + - $ref: '#/components/parameters/CollectionName' + - $ref: '#/components/parameters/DocumentKey' + post: + operationId: appendToDocument + tags: + - Binary Operations + parameters: + - $ref: '#/components/parameters/IfMatchHeader' + - $ref: '#/components/parameters/DurabilityLevelHeader' + requestBody: + required: true + content: + '*': + schema: {} + responses: + '200': + description: Successful appended contents to the document. + headers: + ETag: + $ref: "#/components/headers/ETag" + X-CB-MutationToken: + $ref: "#/components/headers/MutationToken" + '400': + $ref: '#/components/responses/BadRequest' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' + '500': + $ref: '#/components/responses/InternalServerError' + '503': + $ref: '#/components/responses/ServiceUnavailable' + '504': + $ref: '#/components/responses/GatewayTimeout' + '/v1.alpha/buckets/{bucketName}/scopes/{scopeName}/collections/{collectionName}/documents/{documentKey}/prepend': + parameters: + - $ref: '#/components/parameters/AuthorizationHeader' + - $ref: '#/components/parameters/BucketName' + - $ref: '#/components/parameters/ScopeName' + - $ref: '#/components/parameters/CollectionName' + - $ref: '#/components/parameters/DocumentKey' + post: + operationId: prependToDocument + tags: + - Binary Operations + parameters: + - $ref: '#/components/parameters/IfMatchHeader' + - $ref: '#/components/parameters/DurabilityLevelHeader' + requestBody: + required: true + content: + '*': + schema: {} + responses: + '200': + description: Successful prepended contents to the document. + headers: + ETag: + $ref: "#/components/headers/ETag" + X-CB-MutationToken: + $ref: "#/components/headers/MutationToken" + '400': + $ref: '#/components/responses/BadRequest' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' + '500': + $ref: '#/components/responses/InternalServerError' + '503': + $ref: '#/components/responses/ServiceUnavailable' + '504': + $ref: '#/components/responses/GatewayTimeout' + '/v1.alpha/buckets/{bucketName}/scopes/{scopeName}/collections/{collectionName}/documents/{documentKey}/increment': + parameters: + - $ref: '#/components/parameters/AuthorizationHeader' + - $ref: '#/components/parameters/BucketName' + - $ref: '#/components/parameters/ScopeName' + - $ref: '#/components/parameters/CollectionName' + - $ref: '#/components/parameters/DocumentKey' + post: + operationId: incrementDocument + tags: + - Binary Operations + parameters: + - $ref: '#/components/parameters/ExpiresHeader' + - $ref: '#/components/parameters/DurabilityLevelHeader' + requestBody: + required: true + content: + 'application/json': + schema: + type: object + properties: + initial: + description: The value to set the document to if the document does not exist. + type: integer + format: uint64 + delta: + description: The value to increment the document by if it exists. + type: integer + format: uint64 + responses: + '200': + description: Successful incremented the document. + headers: + ETag: + $ref: "#/components/headers/ETag" + X-CB-MutationToken: + $ref: "#/components/headers/MutationToken" + content: + application/json: + schema: + type: integer + format: uint64 + '400': + $ref: '#/components/responses/BadRequest' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' + '503': + $ref: '#/components/responses/ServiceUnavailable' + '504': + $ref: '#/components/responses/GatewayTimeout' + '/v1.alpha/buckets/{bucketName}/scopes/{scopeName}/collections/{collectionName}/documents/{documentKey}/decrement': + parameters: + - $ref: '#/components/parameters/AuthorizationHeader' + - $ref: '#/components/parameters/BucketName' + - $ref: '#/components/parameters/ScopeName' + - $ref: '#/components/parameters/CollectionName' + - $ref: '#/components/parameters/DocumentKey' + post: + operationId: decrementDocument + tags: + - Binary Operations + parameters: + - $ref: '#/components/parameters/ExpiresHeader' + - $ref: '#/components/parameters/DurabilityLevelHeader' + requestBody: + required: true + content: + 'application/json': + schema: + type: object + properties: + initial: + type: integer + format: uint64 + description: The value to set the document to if the document does not exist. + delta: + type: integer + format: uint64 + description: The value to increment the document by if it exists. + responses: + '200': + description: Successful incremented the document. + headers: + ETag: + $ref: "#/components/headers/ETag" + X-CB-MutationToken: + $ref: "#/components/headers/MutationToken" + content: + application/json: + schema: + type: integer + format: uint64 + '400': + $ref: '#/components/responses/BadRequest' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' + '503': + $ref: '#/components/responses/ServiceUnavailable' + '504': + $ref: '#/components/responses/GatewayTimeout' + '/v1.alpha/buckets/{bucketName}/scopes/{scopeName}/collections/{collectionName}/documents/{documentKey}/lock': + parameters: + - $ref: '#/components/parameters/AuthorizationHeader' + - $ref: '#/components/parameters/BucketName' + - $ref: '#/components/parameters/ScopeName' + - $ref: '#/components/parameters/CollectionName' + - $ref: '#/components/parameters/DocumentKey' + post: + operationId: lockDocument + tags: + - Locking Operations + parameters: + - $ref: '#/components/parameters/AcceptEncodingHeader' + requestBody: + required: true + content: + 'application/json': + schema: + type: object + properties: + lockTime: + description: The maximum period of time the document should remain locked. + type: integer + format: uint32 + responses: + '200': + description: Successful locked the document + headers: + Content-Encoding: + $ref: "#/components/headers/ContentEncoding" + ETag: + $ref: "#/components/headers/ETag" + X-CB-Flags: + $ref: "#/components/headers/DocumentFlags" + content: + '*': + schema: + type: string + format: binary + '400': + $ref: '#/components/responses/BadRequest' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' + '500': + $ref: '#/components/responses/InternalServerError' + '503': + $ref: '#/components/responses/ServiceUnavailable' + '504': + $ref: '#/components/responses/GatewayTimeout' + '/v1.alpha/buckets/{bucketName}/scopes/{scopeName}/collections/{collectionName}/documents/{documentKey}/unlock': + parameters: + - $ref: '#/components/parameters/AuthorizationHeader' + - $ref: '#/components/parameters/BucketName' + - $ref: '#/components/parameters/ScopeName' + - $ref: '#/components/parameters/CollectionName' + - $ref: '#/components/parameters/DocumentKey' + post: + operationId: unlockDocument + tags: + - Binary Operations + parameters: + - $ref: '#/components/parameters/IfMatchHeader' + responses: + '200': + description: Successful unlocked the document. + headers: + X-CB-MutationToken: + $ref: "#/components/headers/MutationToken" + '400': + $ref: '#/components/responses/BadRequest' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' + '500': + $ref: '#/components/responses/InternalServerError' + '503': + $ref: '#/components/responses/ServiceUnavailable' + '504': + $ref: '#/components/responses/GatewayTimeout' + '/v1.alpha/buckets/{bucketName}/scopes/{scopeName}/collections/{collectionName}/documents/{documentKey}/touch': + parameters: + - $ref: '#/components/parameters/AuthorizationHeader' + - $ref: '#/components/parameters/BucketName' + - $ref: '#/components/parameters/ScopeName' + - $ref: '#/components/parameters/CollectionName' + - $ref: '#/components/parameters/DocumentKey' + post: + operationId: touchDocument + tags: + - Expiry Operations + parameters: + - $ref: '#/components/parameters/AcceptEncodingHeader' + requestBody: + required: true + content: + 'application/json': + schema: + type: object + properties: + expiry: + description: The new expiry to set for the document, specified as an ISO8601 string. + type: string + returnContent: + description: Specifies whether the documents contents should be returned in the response. + type: boolean + responses: + '200': + description: Successful updated the expiry of the document and is returning the content of the document. + headers: + Content-Encoding: + $ref: "#/components/headers/ContentEncoding" + ETag: + $ref: "#/components/headers/ETag" + X-CB-Flags: + $ref: "#/components/headers/DocumentFlags" + content: + '*': + schema: + type: string + format: binary + '202': + description: Successful updated the expiry of the document but is not returning the content of the document. + headers: + ETag: + $ref: "#/components/headers/ETag" + '400': + $ref: '#/components/responses/BadRequest' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' + '503': + $ref: '#/components/responses/ServiceUnavailable' + '504': + $ref: '#/components/responses/GatewayTimeout' components: securitySchemes: BasicAuth: @@ -271,6 +605,7 @@ components: - DocumentExists - CasMismatch - DocumentLocked + - DocumentNotLocked - ValueTooLarge - DocumentNotJson - PathMismatch @@ -291,6 +626,7 @@ components: - ErrorCodeDocumentExists - ErrorCodeCasMismatch - ErrorCodeDocumentLocked + - ErrorCodeDocumentNotLocked - ErrorCodeValueTooLarge - ErrorCodeDocumentNotJson - ErrorCodePathMismatch diff --git a/gateway/dapiimpl/server_v1/dataapi_binary.go b/gateway/dapiimpl/server_v1/dataapi_binary.go new file mode 100644 index 0000000..72d6041 --- /dev/null +++ b/gateway/dapiimpl/server_v1/dataapi_binary.go @@ -0,0 +1,152 @@ +package server_v1 + +import ( + "context" + "errors" + + "github.com/couchbase/gocbcorex" + "github.com/couchbase/gocbcorex/memdx" + "github.com/couchbase/stellar-gateway/dataapiv1" +) + +func (s *DataApiServer) AppendToDocument( + ctx context.Context, in dataapiv1.AppendToDocumentRequestObject, +) (dataapiv1.AppendToDocumentResponseObject, error) { + bucketAgent, oboUser, errSt := s.authHandler.GetMemdOboAgent(ctx, in.Params.Authorization, in.BucketName) + if errSt != nil { + return nil, errSt.Err() + } + + key, errSt := s.parseKey(in.DocumentKey) + if errSt != nil { + return nil, errSt.Err() + } + + cas, errSt := s.parseCAS(in.Params.IfMatch) + if errSt != nil { + return nil, errSt.Err() + } + + docValue, err := readDocFromHttpBody(in.Body) + if err != nil { + if errors.Is(err, ErrSizeLimitExceeded) { + return nil, s.errorHandler.NewContentTooLargeStatus().Err() + } else { + return nil, s.errorHandler.NewGenericStatus(err).Err() + } + } + + var opts gocbcorex.AppendOptions + opts.OnBehalfOf = oboUser + opts.ScopeName = in.ScopeName + opts.CollectionName = in.CollectionName + opts.Key = key + opts.Cas = cas + opts.Value = docValue + + if in.Params.XCBDurabilityLevel != nil { + dl, errSt := durabilityLevelToMemdx(*in.Params.XCBDurabilityLevel) + if errSt != nil { + return nil, errSt.Err() + } + opts.DurabilityLevel = dl + } + + result, err := bucketAgent.Append(ctx, &opts) + if err != nil { + if errors.Is(err, memdx.ErrCasMismatch) { + return nil, s.errorHandler.NewDocCasMismatchStatus(err, in.BucketName, in.ScopeName, in.CollectionName, in.DocumentKey).Err() + } else if errors.Is(err, memdx.ErrDocLocked) { + return nil, s.errorHandler.NewDocLockedStatus(err, in.BucketName, in.ScopeName, in.CollectionName, in.DocumentKey).Err() + } else if errors.Is(err, memdx.ErrDocNotFound) { + return nil, s.errorHandler.NewDocMissingStatus(err, in.BucketName, in.ScopeName, in.CollectionName, in.DocumentKey).Err() + } else if errors.Is(err, memdx.ErrUnknownCollectionName) { + return nil, s.errorHandler.NewCollectionMissingStatus(err, in.BucketName, in.ScopeName, in.CollectionName).Err() + } else if errors.Is(err, memdx.ErrUnknownScopeName) { + return nil, s.errorHandler.NewScopeMissingStatus(err, in.BucketName, in.ScopeName).Err() + } else if errors.Is(err, memdx.ErrAccessError) { + return nil, s.errorHandler.NewCollectionNoWriteAccessStatus(err, in.BucketName, in.ScopeName, in.CollectionName).Err() + } else if errors.Is(err, memdx.ErrValueTooLarge) { + return nil, s.errorHandler.NewValueTooLargeStatus(err, in.BucketName, in.ScopeName, in.CollectionName, in.DocumentKey, true).Err() + } + return nil, s.errorHandler.NewGenericStatus(err).Err() + } + + return dataapiv1.AppendToDocument200Response{ + Headers: dataapiv1.AppendToDocument200ResponseHeaders{ + ETag: casToHttpEtag(result.Cas), + XCBMutationToken: tokenFromGocbcorex(in.BucketName, result.MutationToken), + }, + }, nil +} + +func (s *DataApiServer) PrependToDocument( + ctx context.Context, in dataapiv1.PrependToDocumentRequestObject, +) (dataapiv1.PrependToDocumentResponseObject, error) { + bucketAgent, oboUser, errSt := s.authHandler.GetMemdOboAgent(ctx, in.Params.Authorization, in.BucketName) + if errSt != nil { + return nil, errSt.Err() + } + + key, errSt := s.parseKey(in.DocumentKey) + if errSt != nil { + return nil, errSt.Err() + } + + cas, errSt := s.parseCAS(in.Params.IfMatch) + if errSt != nil { + return nil, errSt.Err() + } + + docValue, err := readDocFromHttpBody(in.Body) + if err != nil { + if errors.Is(err, ErrSizeLimitExceeded) { + return nil, s.errorHandler.NewContentTooLargeStatus().Err() + } else { + return nil, s.errorHandler.NewGenericStatus(err).Err() + } + } + + var opts gocbcorex.PrependOptions + opts.OnBehalfOf = oboUser + opts.ScopeName = in.ScopeName + opts.CollectionName = in.CollectionName + opts.Key = key + opts.Cas = cas + opts.Value = docValue + + if in.Params.XCBDurabilityLevel != nil { + dl, errSt := durabilityLevelToMemdx(*in.Params.XCBDurabilityLevel) + if errSt != nil { + return nil, errSt.Err() + } + opts.DurabilityLevel = dl + } + + result, err := bucketAgent.Prepend(ctx, &opts) + if err != nil { + if errors.Is(err, memdx.ErrCasMismatch) { + return nil, s.errorHandler.NewDocCasMismatchStatus(err, in.BucketName, in.ScopeName, in.CollectionName, in.DocumentKey).Err() + } else if errors.Is(err, memdx.ErrDocLocked) { + return nil, s.errorHandler.NewDocLockedStatus(err, in.BucketName, in.ScopeName, in.CollectionName, in.DocumentKey).Err() + } else if errors.Is(err, memdx.ErrDocNotFound) { + return nil, s.errorHandler.NewDocMissingStatus(err, in.BucketName, in.ScopeName, in.CollectionName, in.DocumentKey).Err() + } else if errors.Is(err, memdx.ErrUnknownCollectionName) { + return nil, s.errorHandler.NewCollectionMissingStatus(err, in.BucketName, in.ScopeName, in.CollectionName).Err() + } else if errors.Is(err, memdx.ErrUnknownScopeName) { + return nil, s.errorHandler.NewScopeMissingStatus(err, in.BucketName, in.ScopeName).Err() + } else if errors.Is(err, memdx.ErrAccessError) { + return nil, s.errorHandler.NewCollectionNoWriteAccessStatus(err, in.BucketName, in.ScopeName, in.CollectionName).Err() + } else if errors.Is(err, memdx.ErrValueTooLarge) { + return nil, s.errorHandler.NewValueTooLargeStatus(err, in.BucketName, in.ScopeName, in.CollectionName, in.DocumentKey, true).Err() + } + return nil, s.errorHandler.NewGenericStatus(err).Err() + } + + return dataapiv1.PrependToDocument200Response{ + Headers: dataapiv1.PrependToDocument200ResponseHeaders{ + ETag: casToHttpEtag(result.Cas), + XCBMutationToken: tokenFromGocbcorex(in.BucketName, result.MutationToken), + }, + }, nil +} diff --git a/gateway/dapiimpl/server_v1/dataapi_counter.go b/gateway/dapiimpl/server_v1/dataapi_counter.go new file mode 100644 index 0000000..b2e7f90 --- /dev/null +++ b/gateway/dapiimpl/server_v1/dataapi_counter.go @@ -0,0 +1,148 @@ +package server_v1 + +import ( + "context" + "errors" + + "github.com/couchbase/gocbcorex" + "github.com/couchbase/gocbcorex/memdx" + "github.com/couchbase/stellar-gateway/dataapiv1" +) + +func (s *DataApiServer) IncrementDocument( + ctx context.Context, in dataapiv1.IncrementDocumentRequestObject, +) (dataapiv1.IncrementDocumentResponseObject, error) { + bucketAgent, oboUser, errSt := s.authHandler.GetMemdOboAgent(ctx, in.Params.Authorization, in.BucketName) + if errSt != nil { + return nil, errSt.Err() + } + + key, errSt := s.parseKey(in.DocumentKey) + if errSt != nil { + return nil, errSt.Err() + } + + var opts gocbcorex.IncrementOptions + opts.OnBehalfOf = oboUser + opts.ScopeName = in.ScopeName + opts.CollectionName = in.CollectionName + opts.Key = key + if in.Body.Delta != nil { + opts.Delta = uint64(*in.Body.Delta) + } + if in.Body.Initial != nil { + opts.Initial = uint64(*in.Body.Initial) + } + + if in.Params.Expires != nil { + expiry, errSt := httpTimeToGocbcorexExpiry(*in.Params.Expires) + if errSt != nil { + return nil, errSt.Err() + } + + opts.Expiry = expiry + } + + if in.Params.XCBDurabilityLevel != nil { + dl, errSt := durabilityLevelToMemdx(*in.Params.XCBDurabilityLevel) + if errSt != nil { + return nil, errSt.Err() + } + opts.DurabilityLevel = dl + } + + result, err := bucketAgent.Increment(ctx, &opts) + if err != nil { + if errors.Is(err, memdx.ErrCasMismatch) { + return nil, s.errorHandler.NewDocCasMismatchStatus(err, in.BucketName, in.ScopeName, in.CollectionName, in.DocumentKey).Err() + } else if errors.Is(err, memdx.ErrDocLocked) { + return nil, s.errorHandler.NewDocLockedStatus(err, in.BucketName, in.ScopeName, in.CollectionName, in.DocumentKey).Err() + } else if errors.Is(err, memdx.ErrDocNotFound) { + return nil, s.errorHandler.NewDocMissingStatus(err, in.BucketName, in.ScopeName, in.CollectionName, in.DocumentKey).Err() + } else if errors.Is(err, memdx.ErrUnknownCollectionName) { + return nil, s.errorHandler.NewCollectionMissingStatus(err, in.BucketName, in.ScopeName, in.CollectionName).Err() + } else if errors.Is(err, memdx.ErrUnknownScopeName) { + return nil, s.errorHandler.NewScopeMissingStatus(err, in.BucketName, in.ScopeName).Err() + } else if errors.Is(err, memdx.ErrAccessError) { + return nil, s.errorHandler.NewCollectionNoWriteAccessStatus(err, in.BucketName, in.ScopeName, in.CollectionName).Err() + } + return nil, s.errorHandler.NewGenericStatus(err).Err() + } + + return dataapiv1.IncrementDocument200JSONResponse{ + Headers: dataapiv1.IncrementDocument200ResponseHeaders{ + ETag: casToHttpEtag(result.Cas), + XCBMutationToken: tokenFromGocbcorex(in.BucketName, result.MutationToken), + }, + Body: result.Value, + }, nil +} + +func (s *DataApiServer) DecrementDocument( + ctx context.Context, in dataapiv1.DecrementDocumentRequestObject, +) (dataapiv1.DecrementDocumentResponseObject, error) { + bucketAgent, oboUser, errSt := s.authHandler.GetMemdOboAgent(ctx, in.Params.Authorization, in.BucketName) + if errSt != nil { + return nil, errSt.Err() + } + + key, errSt := s.parseKey(in.DocumentKey) + if errSt != nil { + return nil, errSt.Err() + } + + var opts gocbcorex.DecrementOptions + opts.OnBehalfOf = oboUser + opts.ScopeName = in.ScopeName + opts.CollectionName = in.CollectionName + opts.Key = key + if in.Body.Delta != nil { + opts.Delta = uint64(*in.Body.Delta) + } + if in.Body.Initial != nil { + opts.Initial = uint64(*in.Body.Initial) + } + + if in.Params.Expires != nil { + expiry, errSt := httpTimeToGocbcorexExpiry(*in.Params.Expires) + if errSt != nil { + return nil, errSt.Err() + } + + opts.Expiry = expiry + } + + if in.Params.XCBDurabilityLevel != nil { + dl, errSt := durabilityLevelToMemdx(*in.Params.XCBDurabilityLevel) + if errSt != nil { + return nil, errSt.Err() + } + opts.DurabilityLevel = dl + } + + result, err := bucketAgent.Decrement(ctx, &opts) + if err != nil { + if errors.Is(err, memdx.ErrCasMismatch) { + return nil, s.errorHandler.NewDocCasMismatchStatus(err, in.BucketName, in.ScopeName, in.CollectionName, in.DocumentKey).Err() + } else if errors.Is(err, memdx.ErrDocLocked) { + return nil, s.errorHandler.NewDocLockedStatus(err, in.BucketName, in.ScopeName, in.CollectionName, in.DocumentKey).Err() + } else if errors.Is(err, memdx.ErrDocNotFound) { + return nil, s.errorHandler.NewDocMissingStatus(err, in.BucketName, in.ScopeName, in.CollectionName, in.DocumentKey).Err() + } else if errors.Is(err, memdx.ErrUnknownCollectionName) { + return nil, s.errorHandler.NewCollectionMissingStatus(err, in.BucketName, in.ScopeName, in.CollectionName).Err() + } else if errors.Is(err, memdx.ErrUnknownScopeName) { + return nil, s.errorHandler.NewScopeMissingStatus(err, in.BucketName, in.ScopeName).Err() + } else if errors.Is(err, memdx.ErrAccessError) { + return nil, s.errorHandler.NewCollectionNoWriteAccessStatus(err, in.BucketName, in.ScopeName, in.CollectionName).Err() + } + return nil, s.errorHandler.NewGenericStatus(err).Err() + } + + return dataapiv1.DecrementDocument200JSONResponse{ + Headers: dataapiv1.DecrementDocument200ResponseHeaders{ + ETag: casToHttpEtag(result.Cas), + XCBMutationToken: tokenFromGocbcorex(in.BucketName, result.MutationToken), + }, + Body: result.Value, + }, nil +} diff --git a/gateway/dapiimpl/server_v1/dataapi_locking.go b/gateway/dapiimpl/server_v1/dataapi_locking.go new file mode 100644 index 0000000..27d019b --- /dev/null +++ b/gateway/dapiimpl/server_v1/dataapi_locking.go @@ -0,0 +1,123 @@ +package server_v1 + +import ( + "bytes" + "context" + "errors" + + "github.com/couchbase/gocbcorex" + "github.com/couchbase/gocbcorex/memdx" + "github.com/couchbase/stellar-gateway/dataapiv1" +) + +func (s *DataApiServer) LockDocument( + ctx context.Context, in dataapiv1.LockDocumentRequestObject, +) (dataapiv1.LockDocumentResponseObject, error) { + bucketAgent, oboUser, errSt := s.authHandler.GetMemdOboAgent(ctx, in.Params.Authorization, in.BucketName) + if errSt != nil { + return nil, errSt.Err() + } + + key, errSt := s.parseKey(in.DocumentKey) + if errSt != nil { + return nil, errSt.Err() + } + + var opts gocbcorex.GetAndLockOptions + opts.OnBehalfOf = oboUser + opts.ScopeName = in.ScopeName + opts.CollectionName = in.CollectionName + opts.Key = key + + if in.Body.LockTime != nil { + opts.LockTime = *in.Body.LockTime + } + + result, err := bucketAgent.GetAndLock(ctx, &opts) + if err != nil { + if errors.Is(err, memdx.ErrDocLocked) { + return nil, s.errorHandler.NewDocLockedStatus(err, in.BucketName, in.ScopeName, in.CollectionName, in.DocumentKey).Err() + } else if errors.Is(err, memdx.ErrDocNotFound) { + return nil, s.errorHandler.NewDocMissingStatus(err, in.BucketName, in.ScopeName, in.CollectionName, in.DocumentKey).Err() + } else if errors.Is(err, memdx.ErrUnknownCollectionName) { + return nil, s.errorHandler.NewCollectionMissingStatus(err, in.BucketName, in.ScopeName, in.CollectionName).Err() + } else if errors.Is(err, memdx.ErrUnknownScopeName) { + return nil, s.errorHandler.NewScopeMissingStatus(err, in.BucketName, in.ScopeName).Err() + } else if errors.Is(err, memdx.ErrAccessError) { + return nil, s.errorHandler.NewCollectionNoWriteAccessStatus(err, in.BucketName, in.ScopeName, in.CollectionName).Err() + } + return nil, s.errorHandler.NewGenericStatus(err).Err() + } + + resp := dataapiv1.LockDocument200AsteriskResponse{ + Headers: dataapiv1.LockDocument200ResponseHeaders{ + ETag: casToHttpEtag(result.Cas), + XCBFlags: uint32(result.Flags), + }, + } + + contentType := flagsToHttpContentType(result.Flags) + + contentEncoding, respValue, errSt := + CompressHandler{}.MaybeCompressContent(result.Value, 0, in.Params.AcceptEncoding) + if errSt != nil { + return nil, errSt.Err() + } + + resp.ContentType = contentType + resp.Headers.ContentEncoding = contentEncoding + resp.Body = bytes.NewReader(respValue) + resp.ContentLength = int64(len(respValue)) + + return resp, nil +} + +func (s *DataApiServer) UnlockDocument( + ctx context.Context, in dataapiv1.UnlockDocumentRequestObject, +) (dataapiv1.UnlockDocumentResponseObject, error) { + bucketAgent, oboUser, errSt := s.authHandler.GetMemdOboAgent(ctx, in.Params.Authorization, in.BucketName) + if errSt != nil { + return nil, errSt.Err() + } + + key, errSt := s.parseKey(in.DocumentKey) + if errSt != nil { + return nil, errSt.Err() + } + + cas, errSt := s.parseCAS(in.Params.IfMatch) + if errSt != nil { + return nil, errSt.Err() + } + + var opts gocbcorex.UnlockOptions + opts.OnBehalfOf = oboUser + opts.ScopeName = in.ScopeName + opts.CollectionName = in.CollectionName + opts.Key = key + opts.Cas = cas + + result, err := bucketAgent.Unlock(ctx, &opts) + if err != nil { + if errors.Is(err, memdx.ErrCasMismatch) { + return nil, s.errorHandler.NewDocCasMismatchStatus(err, in.BucketName, in.ScopeName, in.CollectionName, in.DocumentKey).Err() + } else if errors.Is(err, memdx.ErrDocNotLocked) { + return nil, s.errorHandler.NewDocNotLockedStatus(err, in.BucketName, in.ScopeName, in.CollectionName, in.DocumentKey).Err() + } else if errors.Is(err, memdx.ErrDocNotFound) { + return nil, s.errorHandler.NewDocMissingStatus(err, in.BucketName, in.ScopeName, in.CollectionName, in.DocumentKey).Err() + } else if errors.Is(err, memdx.ErrUnknownCollectionName) { + return nil, s.errorHandler.NewCollectionMissingStatus(err, in.BucketName, in.ScopeName, in.CollectionName).Err() + } else if errors.Is(err, memdx.ErrUnknownScopeName) { + return nil, s.errorHandler.NewScopeMissingStatus(err, in.BucketName, in.ScopeName).Err() + } else if errors.Is(err, memdx.ErrAccessError) { + return nil, s.errorHandler.NewCollectionNoWriteAccessStatus(err, in.BucketName, in.ScopeName, in.CollectionName).Err() + } + return nil, s.errorHandler.NewGenericStatus(err).Err() + } + + return dataapiv1.UnlockDocument200Response{ + Headers: dataapiv1.UnlockDocument200ResponseHeaders{ + XCBMutationToken: tokenFromGocbcorex(in.BucketName, result.MutationToken), + }, + }, nil +} diff --git a/gateway/dapiimpl/server_v1/dataapi_touch.go b/gateway/dapiimpl/server_v1/dataapi_touch.go new file mode 100644 index 0000000..470a1d0 --- /dev/null +++ b/gateway/dapiimpl/server_v1/dataapi_touch.go @@ -0,0 +1,111 @@ +package server_v1 + +import ( + "bytes" + "context" + "errors" + + "github.com/couchbase/gocbcorex" + "github.com/couchbase/gocbcorex/memdx" + "github.com/couchbase/stellar-gateway/dataapiv1" +) + +func (s *DataApiServer) TouchDocument( + ctx context.Context, in dataapiv1.TouchDocumentRequestObject, +) (dataapiv1.TouchDocumentResponseObject, error) { + bucketAgent, oboUser, errSt := s.authHandler.GetMemdOboAgent(ctx, in.Params.Authorization, in.BucketName) + if errSt != nil { + return nil, errSt.Err() + } + + key, errSt := s.parseKey(in.DocumentKey) + if errSt != nil { + return nil, errSt.Err() + } + + var newExpiry uint32 + if in.Body.Expiry != nil { + expiry, errSt := httpTimeToGocbcorexExpiry(*in.Body.Expiry) + if errSt != nil { + return nil, errSt.Err() + } + + newExpiry = expiry + } + + if in.Body.ReturnContent == nil || !*in.Body.ReturnContent { + var opts gocbcorex.TouchOptions + opts.OnBehalfOf = oboUser + opts.ScopeName = in.ScopeName + opts.CollectionName = in.CollectionName + opts.Key = key + opts.Expiry = newExpiry + + result, err := bucketAgent.Touch(ctx, &opts) + if err != nil { + if errors.Is(err, memdx.ErrDocLocked) { + return nil, s.errorHandler.NewDocLockedStatus(err, in.BucketName, in.ScopeName, in.CollectionName, in.DocumentKey).Err() + } else if errors.Is(err, memdx.ErrDocNotFound) { + return nil, s.errorHandler.NewDocMissingStatus(err, in.BucketName, in.ScopeName, in.CollectionName, in.DocumentKey).Err() + } else if errors.Is(err, memdx.ErrUnknownCollectionName) { + return nil, s.errorHandler.NewCollectionMissingStatus(err, in.BucketName, in.ScopeName, in.CollectionName).Err() + } else if errors.Is(err, memdx.ErrUnknownScopeName) { + return nil, s.errorHandler.NewScopeMissingStatus(err, in.BucketName, in.ScopeName).Err() + } else if errors.Is(err, memdx.ErrAccessError) { + return nil, s.errorHandler.NewCollectionNoWriteAccessStatus(err, in.BucketName, in.ScopeName, in.CollectionName).Err() + } + return nil, s.errorHandler.NewGenericStatus(err).Err() + } + + return dataapiv1.TouchDocument202Response{ + Headers: dataapiv1.TouchDocument202ResponseHeaders{ + ETag: casToHttpEtag(result.Cas), + }, + }, nil + } else { + var opts gocbcorex.GetAndTouchOptions + opts.OnBehalfOf = oboUser + opts.ScopeName = in.ScopeName + opts.CollectionName = in.CollectionName + opts.Key = key + opts.Expiry = newExpiry + + result, err := bucketAgent.GetAndTouch(ctx, &opts) + if err != nil { + if errors.Is(err, memdx.ErrDocLocked) { + return nil, s.errorHandler.NewDocLockedStatus(err, in.BucketName, in.ScopeName, in.CollectionName, in.DocumentKey).Err() + } else if errors.Is(err, memdx.ErrDocNotFound) { + return nil, s.errorHandler.NewDocMissingStatus(err, in.BucketName, in.ScopeName, in.CollectionName, in.DocumentKey).Err() + } else if errors.Is(err, memdx.ErrUnknownCollectionName) { + return nil, s.errorHandler.NewCollectionMissingStatus(err, in.BucketName, in.ScopeName, in.CollectionName).Err() + } else if errors.Is(err, memdx.ErrUnknownScopeName) { + return nil, s.errorHandler.NewScopeMissingStatus(err, in.BucketName, in.ScopeName).Err() + } else if errors.Is(err, memdx.ErrAccessError) { + return nil, s.errorHandler.NewCollectionNoWriteAccessStatus(err, in.BucketName, in.ScopeName, in.CollectionName).Err() + } + return nil, s.errorHandler.NewGenericStatus(err).Err() + } + + resp := dataapiv1.TouchDocument200AsteriskResponse{ + Headers: dataapiv1.TouchDocument200ResponseHeaders{ + ETag: casToHttpEtag(result.Cas), + XCBFlags: uint32(result.Flags), + }, + } + + contentType := flagsToHttpContentType(result.Flags) + + contentEncoding, respValue, errSt := + CompressHandler{}.MaybeCompressContent(result.Value, 0, in.Params.AcceptEncoding) + if errSt != nil { + return nil, errSt.Err() + } + + resp.ContentType = contentType + resp.Headers.ContentEncoding = contentEncoding + resp.Body = bytes.NewReader(respValue) + resp.ContentLength = int64(len(respValue)) + + return resp, nil + } +} diff --git a/gateway/dapiimpl/server_v1/errorhandler.go b/gateway/dapiimpl/server_v1/errorhandler.go index 02a3998..791ef81 100644 --- a/gateway/dapiimpl/server_v1/errorhandler.go +++ b/gateway/dapiimpl/server_v1/errorhandler.go @@ -251,6 +251,19 @@ func (e ErrorHandler) NewDocLockedStatus(baseErr error, bucketName, scopeName, c return st } +func (e ErrorHandler) NewDocNotLockedStatus(baseErr error, bucketName, scopeName, collectionName, docId string) *Status { + st := &Status{ + StatusCode: http.StatusBadRequest, + Code: dataapiv1.ErrorCodeDocumentNotLocked, + Message: fmt.Sprintf("Cannot unlock an unlocked document '%s' in '%s/%s/%s'.", + docId, bucketName, scopeName, collectionName), + Resource: fmt.Sprintf("/buckets/%s/scopes/%s/collections/%s/documents/%s", + bucketName, scopeName, collectionName, docId), + } + st = e.tryAttachExtraContext(st, baseErr) + return st +} + func (e ErrorHandler) NewDocExistsStatus(baseErr error, bucketName, scopeName, collectionName, docId string) *Status { st := &Status{ StatusCode: http.StatusConflict,