diff --git a/sdk/storage/storage-blob/CHANGELOG.md b/sdk/storage/storage-blob/CHANGELOG.md index e41d10721778..1a946b8a0595 100644 --- a/sdk/storage/storage-blob/CHANGELOG.md +++ b/sdk/storage/storage-blob/CHANGELOG.md @@ -5,6 +5,7 @@ - Supported quick query. Added a new API `BlockBlobClient.query()`. - Increased the maximum block size for Block Blob from 100MiB to 4000MiB(~4GB). And thereby supporting ~200TB maximum size for Block Blob. - Added support for blob versioning. +- Supported blob tags. ## 12.1.2 (2020.05) diff --git a/sdk/storage/storage-blob/review/storage-blob.api.md b/sdk/storage/storage-blob/review/storage-blob.api.md index 76f5117189c7..fe0da7c71172 100644 --- a/sdk/storage/storage-blob/review/storage-blob.api.md +++ b/sdk/storage/storage-blob/review/storage-blob.api.md @@ -208,6 +208,7 @@ export interface AppendBlobCreateOptions extends CommonOptions { customerProvidedKey?: CpkInfo; encryptionScope?: string; metadata?: Metadata; + tags?: Tags; } // @public @@ -373,10 +374,12 @@ export class BlobClient extends StorageClient { getBlockBlobClient(): BlockBlobClient; getPageBlobClient(): PageBlobClient; getProperties(options?: BlobGetPropertiesOptions): Promise; + getTags(options?: BlobGetTagsOptions): Promise; get name(): string; setAccessTier(tier: BlockBlobTier | PremiumPageBlobTier | string, options?: BlobSetTierOptions): Promise; setHTTPHeaders(blobHTTPHeaders?: BlobHTTPHeaders, options?: BlobSetHTTPHeadersOptions): Promise; setMetadata(metadata?: Metadata, options?: BlobSetMetadataOptions): Promise; + setTags(tags: Tags, options?: BlobSetTagsOptions): Promise; syncCopyFromURL(copySource: string, options?: BlobSyncCopyFromURLOptions): Promise; undelete(options?: BlobUndeleteOptions): Promise; withSnapshot(snapshot: string): BlobClient; @@ -642,6 +645,32 @@ export type BlobGetPropertiesResponse = BlobGetPropertiesHeaders & { }; }; +// @public +export interface BlobGetTagsHeaders { + clientRequestId?: string; + date?: Date; + // (undocumented) + errorCode?: string; + requestId?: string; + version?: string; +} + +// @public +export interface BlobGetTagsOptions extends CommonOptions { + abortSignal?: AbortSignalLike; +} + +// @public +export type BlobGetTagsResponse = { + tags: Tags; +} & BlobGetTagsHeaders & { + _response: HttpResponse & { + parsedHeaders: BlobGetTagsHeaders; + bodyAsText: string; + parsedBody: BlobTags; + }; +}; + // @public export interface BlobHierarchyListSegment { // (undocumented) @@ -662,10 +691,6 @@ export interface BlobHTTPHeaders { // @public export interface BlobItem { - // Warning: (ae-forgotten-export) The symbol "BlobTags" needs to be exported by the entry point index.d.ts - // - // (undocumented) - blobTags?: BlobTags; // (undocumented) deleted: boolean; // (undocumented) @@ -685,6 +710,8 @@ export interface BlobItem { // (undocumented) snapshot: string; // (undocumented) + tags?: Tags; + // (undocumented) versionId?: string; } @@ -833,6 +860,7 @@ export class BlobSASPermissions { deleteVersion: boolean; static parse(permissions: string): BlobSASPermissions; read: boolean; + tag: boolean; toString(): string; write: boolean; } @@ -866,6 +894,7 @@ export class BlobServiceClient extends StorageClient { containerCreateResponse: ContainerCreateResponse; }>; deleteContainer(containerName: string, options?: ContainerDeleteMethodOptions): Promise; + findBlobsByTags(tagFilterSqlExpression: string, options?: ServiceFindBlobByTagsOptions): PagedAsyncIterableIterator; static fromConnectionString(connectionString: string, options?: StoragePipelineOptions): BlobServiceClient; getAccountInfo(options?: ServiceGetAccountInfoOptions): Promise; getBlobBatchClient(): BlobBatchClient; @@ -957,6 +986,28 @@ export type BlobSetMetadataResponse = BlobSetMetadataHeaders & { }; }; +// @public +export interface BlobSetTagsHeaders { + clientRequestId?: string; + date?: Date; + // (undocumented) + errorCode?: string; + requestId?: string; + version?: string; +} + +// @public +export interface BlobSetTagsOptions extends CommonOptions { + abortSignal?: AbortSignalLike; +} + +// @public +export type BlobSetTagsResponse = BlobSetTagsHeaders & { + _response: coreHttp.HttpResponse & { + parsedHeaders: BlobSetTagsHeaders; + }; +}; + // @public export interface BlobSetTierHeaders { clientRequestId?: string; @@ -1002,6 +1053,7 @@ export interface BlobStartCopyFromURLOptions extends CommonOptions { metadata?: Metadata; rehydratePriority?: RehydratePriority; sourceConditions?: ModifiedAccessConditions; + tags?: Tags; tier?: BlockBlobTier | PremiumPageBlobTier | string; } @@ -1019,6 +1071,21 @@ export interface BlobSyncCopyFromURLOptions extends CommonOptions { metadata?: Metadata; sourceConditions?: ModifiedAccessConditions; sourceContentMD5?: Uint8Array; + tags?: Tags; +} + +// @public +export interface BlobTag { + // (undocumented) + key: string; + // (undocumented) + value: string; +} + +// @public +export interface BlobTags { + // (undocumented) + blobTagSet: BlobTag[]; } // @public @@ -1101,6 +1168,7 @@ export interface BlockBlobCommitBlockListOptions extends CommonOptions { customerProvidedKey?: CpkInfo; encryptionScope?: string; metadata?: Metadata; + tags?: Tags; tier?: BlockBlobTier | string; } @@ -1153,6 +1221,7 @@ export interface BlockBlobParallelUploadOptions extends CommonOptions { [propertyName: string]: string; }; onProgress?: (progress: TransferProgressEvent) => void; + tags?: Tags; } // @public @@ -1265,6 +1334,7 @@ export interface BlockBlobUploadOptions extends CommonOptions { encryptionScope?: string; metadata?: Metadata; onProgress?: (progress: TransferProgressEvent) => void; + tags?: Tags; tier?: BlockBlobTier | string; } @@ -1285,6 +1355,7 @@ export interface BlockBlobUploadStreamOptions extends CommonOptions { [propertyName: string]: string; }; onProgress?: (progress: TransferProgressEvent) => void; + tags?: Tags; } // @public @@ -1522,10 +1593,10 @@ export interface ContainerListBlobFlatSegmentHeaders { // @public export type ContainerListBlobFlatSegmentResponse = ListBlobsFlatSegmentResponse & ContainerListBlobFlatSegmentHeaders & { - _response: coreHttp.HttpResponse & { + _response: HttpResponse & { parsedHeaders: ContainerListBlobFlatSegmentHeaders; bodyAsText: string; - parsedBody: ListBlobsFlatSegmentResponse; + parsedBody: ListBlobsFlatSegmentResponseModel; }; }; @@ -1542,10 +1613,10 @@ export interface ContainerListBlobHierarchySegmentHeaders { // @public export type ContainerListBlobHierarchySegmentResponse = ListBlobsHierarchySegmentResponse & ContainerListBlobHierarchySegmentHeaders & { - _response: coreHttp.HttpResponse & { + _response: HttpResponse & { parsedHeaders: ContainerListBlobHierarchySegmentHeaders; bodyAsText: string; - parsedBody: ListBlobsHierarchySegmentResponse; + parsedBody: ListBlobsHierarchySegmentResponseModel; }; }; @@ -1556,6 +1627,7 @@ export interface ContainerListBlobsOptions extends CommonOptions { includeDeleted?: boolean; includeMetadata?: boolean; includeSnapshots?: boolean; + includeTags?: boolean; includeUncommitedBlobs?: boolean; includeVersions?: boolean; prefix?: string; @@ -1605,6 +1677,7 @@ export class ContainerSASPermissions { list: boolean; static parse(permissions: string): ContainerSASPermissions; read: boolean; + tag: boolean; toString(): string; write: boolean; } @@ -1705,6 +1778,28 @@ export { deserializationPolicy } // @public export type EncryptionAlgorithmType = 'AES256'; +// @public +export interface FilterBlobItem { + // (undocumented) + containerName: string; + // (undocumented) + name: string; + // (undocumented) + tagValue: string; +} + +// @public +export interface FilterBlobSegment { + // (undocumented) + blobs: FilterBlobItem[]; + // (undocumented) + continuationToken?: string; + // (undocumented) + serviceEndpoint: string; + // (undocumented) + where: string; +} + // @public export function generateAccountSASQueryParameters(accountSASSignatureValues: AccountSASSignatureValues, sharedKeyCredential: StorageSharedKeyCredential): SASQueryParameters; @@ -1788,6 +1883,26 @@ export interface ListBlobsFlatSegmentResponse { serviceEndpoint: string; } +// @public +export interface ListBlobsFlatSegmentResponseModel { + // (undocumented) + containerName: string; + // (undocumented) + continuationToken?: string; + // (undocumented) + marker?: string; + // (undocumented) + maxPageSize?: number; + // (undocumented) + prefix?: string; + // Warning: (ae-forgotten-export) The symbol "BlobFlatListSegment" needs to be exported by the entry point index.d.ts + // + // (undocumented) + segment: BlobFlatListSegment_2; + // (undocumented) + serviceEndpoint: string; +} + // @public export interface ListBlobsHierarchySegmentResponse { // (undocumented) @@ -1808,6 +1923,28 @@ export interface ListBlobsHierarchySegmentResponse { serviceEndpoint: string; } +// @public +export interface ListBlobsHierarchySegmentResponseModel { + // (undocumented) + containerName: string; + // (undocumented) + continuationToken?: string; + // (undocumented) + delimiter?: string; + // (undocumented) + marker?: string; + // (undocumented) + maxPageSize?: number; + // (undocumented) + prefix?: string; + // Warning: (ae-forgotten-export) The symbol "BlobHierarchyListSegment" needs to be exported by the entry point index.d.ts + // + // (undocumented) + segment: BlobHierarchyListSegment_2; + // (undocumented) + serviceEndpoint: string; +} + // @public export type ListBlobsIncludeItem = 'copy' | 'deleted' | 'metadata' | 'snapshots' | 'uncommittedblobs' | 'versions' | 'tags'; @@ -1964,6 +2101,7 @@ export interface PageBlobCreateOptions extends CommonOptions { customerProvidedKey?: CpkInfo; encryptionScope?: string; metadata?: Metadata; + tags?: Tags; tier?: PremiumPageBlobTier | string; } @@ -2280,6 +2418,30 @@ export interface SequenceNumberAccessConditions { // @public export type SequenceNumberActionType = 'max' | 'update' | 'increment'; +// @public +export interface ServiceFilterBlobsHeaders { + clientRequestId?: string; + date?: Date; + // (undocumented) + errorCode?: string; + requestId?: string; + version?: string; +} + +// @public +export interface ServiceFindBlobByTagsOptions extends CommonOptions { + abortSignal?: AbortSignalLike; +} + +// @public +export type ServiceFindBlobsByTagsSegmentResponse = FilterBlobSegment & ServiceFilterBlobsHeaders & { + _response: coreHttp.HttpResponse & { + parsedHeaders: ServiceFilterBlobsHeaders; + bodyAsText: string; + parsedBody: FilterBlobSegment; + }; +}; + // @public export interface ServiceGetAccountInfoHeaders { accountKind?: AccountKind; @@ -2543,6 +2705,9 @@ export class StorageSharedKeyCredentialPolicy extends CredentialPolicy { // @public export type SyncCopyStatusType = 'success'; +// @public +export type Tags = Record; + // @public export interface UserDelegationKey { signedExpiresOn: Date; diff --git a/sdk/storage/storage-blob/src/BlobDownloadResponse.ts b/sdk/storage/storage-blob/src/BlobDownloadResponse.ts index 1f11f951f733..4bb14770c1aa 100644 --- a/sdk/storage/storage-blob/src/BlobDownloadResponse.ts +++ b/sdk/storage/storage-blob/src/BlobDownloadResponse.ts @@ -322,6 +322,17 @@ export class BlobDownloadResponse implements BlobDownloadResponseModel { return this.originalResponse.etag; } + /** + * The number of tags associated with the blob + * + * @readonly + * @type {(number | undefined)} + * @memberof BlobDownloadResponse + */ + public get tagCount(): number | undefined { + return this.originalResponse.tagCount; + } + /** * The error code. * diff --git a/sdk/storage/storage-blob/src/BlobSASPermissions.ts b/sdk/storage/storage-blob/src/BlobSASPermissions.ts index 792577c496e1..6c12d7cd7639 100644 --- a/sdk/storage/storage-blob/src/BlobSASPermissions.ts +++ b/sdk/storage/storage-blob/src/BlobSASPermissions.ts @@ -46,6 +46,9 @@ export class BlobSASPermissions { case "x": blobSASPermissions.deleteVersion = true; break; + case "t": + blobSASPermissions.tag = true; + break; default: throw new RangeError(`Invalid permission: ${char}`); } @@ -102,6 +105,14 @@ export class BlobSASPermissions { */ public deleteVersion: boolean = false; + /** + * Specfies Tag access granted. + * + * @type {boolean} + * @memberof BlobSASPermissions + */ + public tag: boolean = false; + /** * Converts the given permissions to a string. Using this method will guarantee the permissions are in an * order accepted by the service. @@ -129,6 +140,9 @@ export class BlobSASPermissions { if (this.deleteVersion) { permissions.push("x"); } + if (this.tag) { + permissions.push("t"); + } return permissions.join(""); } } diff --git a/sdk/storage/storage-blob/src/BlobSASSignatureValues.ts b/sdk/storage/storage-blob/src/BlobSASSignatureValues.ts index c9970f3be8b7..b3eff14da424 100644 --- a/sdk/storage/storage-blob/src/BlobSASSignatureValues.ts +++ b/sdk/storage/storage-blob/src/BlobSASSignatureValues.ts @@ -306,6 +306,7 @@ export function generateBlobSASQueryParameters( throw TypeError("Invalid sharedKeyCredential, userDelegationKey or accountName."); } + // Version 2019-12-12 adds support for the blob tags permission. // Version 2018-11-09 adds support for the signed resource and signed blob snapshot time fields. // https://docs.microsoft.com/en-us/rest/api/storageservices/constructing-a-service-sas#constructing-the-signature-string if (version >= "2018-11-09") { @@ -355,7 +356,8 @@ function generateBlobSASQueryParameters20150405( ): SASQueryParameters { if ( !blobSASSignatureValues.identifier && - !blobSASSignatureValues.permissions && !blobSASSignatureValues.expiresOn + !blobSASSignatureValues.permissions && + !blobSASSignatureValues.expiresOn ) { throw new RangeError( "Must provide 'permissions' and 'expiresOn' for Blob SAS generation when 'identifier' is not provided." @@ -374,6 +376,10 @@ function generateBlobSASQueryParameters20150405( throw RangeError("'version' must be >= '2019-10-10' when provided 'versionId'."); } + if (blobSASSignatureValues.permissions && blobSASSignatureValues.permissions.tag) { + throw RangeError("'version' must be >= '2019-12-12' when provided 't' permission."); + } + if (blobSASSignatureValues.blobName) { resource = "b"; } @@ -461,7 +467,8 @@ function generateBlobSASQueryParameters20181109( ): SASQueryParameters { if ( !blobSASSignatureValues.identifier && - !blobSASSignatureValues.permissions && !blobSASSignatureValues.expiresOn + !blobSASSignatureValues.permissions && + !blobSASSignatureValues.expiresOn ) { throw new RangeError( "Must provide 'permissions' and 'expiresOn' for Blob SAS generation when 'identifier' is not provided." @@ -472,6 +479,18 @@ function generateBlobSASQueryParameters20181109( let resource: string = "c"; let verifiedPermissions: string | undefined; + if (blobSASSignatureValues.versionId && version < "2019-10-10") { + throw RangeError("'version' must be >= '2019-10-10' when provided 'versionId'."); + } + + if ( + blobSASSignatureValues.permissions && + blobSASSignatureValues.permissions.tag && + version < "2019-12-12" + ) { + throw RangeError("'version' must be >= '2019-12-12' when provided 't' permission."); + } + if (blobSASSignatureValues.blobName === undefined && blobSASSignatureValues.snapshotTime) { throw RangeError("Must provide 'blobName' when provided 'snapshotTime'."); } @@ -578,6 +597,10 @@ function generateBlobSASQueryParametersUDK20181109( ); } + if (blobSASSignatureValues.permissions && blobSASSignatureValues.permissions.tag) { + throw RangeError("'version' must be >= '2019-12-12' when provided 't' permission."); + } + const version = blobSASSignatureValues.version ? blobSASSignatureValues.version : SERVICE_VERSION; let resource: string = "c"; let verifiedPermissions: string | undefined; diff --git a/sdk/storage/storage-blob/src/BlobServiceClient.ts b/sdk/storage/storage-blob/src/BlobServiceClient.ts index 4ad33f0eb288..fff24db33399 100644 --- a/sdk/storage/storage-blob/src/BlobServiceClient.ts +++ b/sdk/storage/storage-blob/src/BlobServiceClient.ts @@ -21,7 +21,9 @@ import { ServiceListContainersSegmentResponse, ContainerItem, ListContainersIncludeType, - UserDelegationKeyModel + UserDelegationKeyModel, + ServiceFindBlobsByTagsSegmentResponse, + FilterBlobItem } from "./generatedModels"; import { Service } from "./generated/src/operations"; import { newPipeline, StoragePipelineOptions, Pipeline } from "./Pipeline"; @@ -158,6 +160,34 @@ interface ServiceListContainersSegmentOptions extends CommonOptions { include?: ListContainersIncludeType | ListContainersIncludeType[]; } +/** + * Options to configure the {@link BlobServiceClient.findBlobsByTagsSegment} operation. + * + * @interface ServiceFindBlobsByTagsSegmentOptions + */ +interface ServiceFindBlobsByTagsSegmentOptions extends CommonOptions { + /** + * An implementation of the `AbortSignalLike` interface to signal the request to cancel the operation. + * For example, use the @azure/abort-controller to create an `AbortSignal`. + * + * @type {AbortSignalLike} + * @memberof ServiceFindBlobsByTagsSegmentOptions + */ + abortSignal?: AbortSignalLike; + /** + * Specifies the maximum number of blobs + * to return. If the request does not specify maxPageSize, or specifies a + * value greater than 5000, the server will return up to 5000 items. Note + * that if the listing operation crosses a partition boundary, then the + * service will return a continuation token for retrieving the remainder of + * the results. For this reason, it is possible that the service will return + * fewer results than specified by maxPageSize, or than the default of 5000. + * @type {number} + * @memberof ServiceFindBlobsByTagsSegmentOptions + */ + maxPageSize?: number; +} + /** * Options to configure the {@link BlobServiceClient.listContainers} operation. * @@ -185,6 +215,23 @@ export interface ServiceListContainersOptions extends CommonOptions { includeMetadata?: boolean; } +/** + * Options to configure the {@link BlobServiceClient.findBlobsByTags} operation. + * + * @export + * @interface ServiceFindBlobByTagsOptions + */ +export interface ServiceFindBlobByTagsOptions extends CommonOptions { + /** + * An implementation of the `AbortSignalLike` interface to signal the request to cancel the operation. + * For example, use the @azure/abort-controller to create an `AbortSignal`. + * + * @type {AbortSignalLike} + * @memberof ServiceListContainersOptions + */ + abortSignal?: AbortSignalLike; +} + /** * A user delegation key. */ @@ -669,6 +716,234 @@ export class BlobServiceClient extends StorageClient { } } + /** + * The Filter Blobs operation enables callers to list blobs across all containers whose tags + * match a given search expression. Filter blobs searches across all containers within a + * storage account but can be scoped within the expression to a single container. + * + * @private + * @param {string} tagFilterSqlExpression The where parameter enables the caller to query blobs whose tags match a given expression. + * The given expression must evaluate to true for a blob to be returned in the results. + * The[OData - ABNF] filter syntax rule defines the formal grammar for the value of the where query parameter; + * however, only a subset of the OData filter syntax is supported in the Blob service. + * @param {string} [marker] A string value that identifies the portion of + * the list of blobs to be returned with the next listing operation. The + * operation returns the NextMarker value within the response body if the + * listing operation did not return all blobs remaining to be listed + * with the current page. The NextMarker value can be used as the value for + * the marker parameter in a subsequent call to request the next page of list + * items. The marker value is opaque to the client. + * @param {ServiceFindBlobsByTagsSegmentOptions} [options={}] Options to find blobs by tags. + * @returns {Promise} + * @memberof BlobServiceClient + */ + private async findBlobsByTagsSegment( + tagFilterSqlExpression: string, + marker?: string, + options: ServiceFindBlobsByTagsSegmentOptions = {} + ): Promise { + // TODO: Rename response.blobs to response.blobItems? + const { span, spanOptions } = createSpan( + "BlobServiceClient-findBlobsByTagsSegment", + options.tracingOptions + ); + + try { + return await this.serviceContext.filterBlobs({ + abortSignal: options.abortSignal, + where: tagFilterSqlExpression, + marker, + maxPageSize: options.maxPageSize, + spanOptions + }); + } catch (e) { + span.setStatus({ + code: CanonicalCode.UNKNOWN, + message: e.message + }); + throw e; + } finally { + span.end(); + } + } + + /** + * Returns an AsyncIterableIterator for ServiceFindBlobsByTagsSegmentResponse. + * + * @private + * @param {string} tagFilterSqlExpression The where parameter enables the caller to query blobs whose tags match a given expression. + * The given expression must evaluate to true for a blob to be returned in the results. + * The[OData - ABNF] filter syntax rule defines the formal grammar for the value of the where query parameter; + * however, only a subset of the OData filter syntax is supported in the Blob service. + * @param {string} [marker] A string value that identifies the portion of + * the list of blobs to be returned with the next listing operation. The + * operation returns the NextMarker value within the response body if the + * listing operation did not return all blobs remaining to be listed + * with the current page. The NextMarker value can be used as the value for + * the marker parameter in a subsequent call to request the next page of list + * items. The marker value is opaque to the client. + * @param {ServiceFindBlobsByTagsSegmentOptions} [options={}] Options to find blobs by tags. + * @returns {AsyncIterableIterator} + * @memberof BlobServiceClient + */ + private async *findBlobsByTagsSegments( + tagFilterSqlExpression: string, + marker?: string, + options: ServiceFindBlobsByTagsSegmentOptions = {} + ): AsyncIterableIterator { + let response; + if (!!marker || marker === undefined) { + do { + response = await this.findBlobsByTagsSegment(tagFilterSqlExpression, marker, options); + response.blobs = response.blobs || []; + marker = response.continuationToken; + yield response; + } while (marker); + } + } + + /** + * Returns an AsyncIterableIterator for blobs. + * + * @private + * @param {string} tagFilterSqlExpression The where parameter enables the caller to query blobs whose tags match a given expression. + * The given expression must evaluate to true for a blob to be returned in the results. + * The[OData - ABNF] filter syntax rule defines the formal grammar for the value of the where query parameter; + * however, only a subset of the OData filter syntax is supported in the Blob service. + * @param {ServiceFindBlobsByTagsSegmentOptions} [options={}] Options to findBlobsByTagsItems. + * @returns {AsyncIterableIterator} + * @memberof BlobServiceClient + */ + private async *findBlobsByTagsItems( + tagFilterSqlExpression: string, + options: ServiceFindBlobsByTagsSegmentOptions = {} + ): AsyncIterableIterator { + let marker: string | undefined; + for await (const segment of this.findBlobsByTagsSegments( + tagFilterSqlExpression, + marker, + options + )) { + yield* segment.blobs; + } + } + + /** + * Returns an async iterable iterator to find all blobs with specified tag + * under the specified account. + * + * .byPage() returns an async iterable iterator to list the blobs in pages. + * + * @see https://docs.microsoft.com/en-us/rest/api/storageservices/get-blob-service-properties + * + * Example using `for await` syntax: + * + * ```js + * let i = 1; + * for await (const blob of blobServiceClient.findBlobsByTags("tagkey='tagvalue'")) { + * console.log(`Blob ${i++}: ${container.name}`); + * } + * ``` + * + * Example using `iter.next()`: + * + * ```js + * let i = 1; + * const iter = blobServiceClient.findBlobsByTags("tagkey='tagvalue'"); + * let blobItem = await iter.next(); + * while (!blobItem.done) { + * console.log(`Blob ${i++}: ${blobItem.value.name}`); + * blobItem = await iter.next(); + * } + * ``` + * + * Example using `byPage()`: + * + * ```js + * // passing optional maxPageSize in the page settings + * let i = 1; + * for await (const response of blobServiceClient.findBlobsByTags("tagkey='tagvalue'").byPage({ maxPageSize: 20 })) { + * if (response.blobs) { + * for (const blob of response.blobs) { + * console.log(`Blob ${i++}: ${blob.name}`); + * } + * } + * } + * ``` + * + * Example using paging with a marker: + * + * ```js + * let i = 1; + * let iterator = blobServiceClient.findBlobsByTags("tagkey='tagvalue'").byPage({ maxPageSize: 2 }); + * let response = (await iterator.next()).value; + * + * // Prints 2 blob names + * if (response.blobs) { + * for (const blob of response.blobs) { + * console.log(`Blob ${i++}: ${blob.name}`); + * } + * } + * + * // Gets next marker + * let marker = response.continuationToken; + * // Passing next marker as continuationToken + * iterator = blobServiceClient + * .findBlobsByTags("tagkey='tagvalue'") + * .byPage({ continuationToken: marker, maxPageSize: 10 }); + * response = (await iterator.next()).value; + * + * // Prints blob names + * if (response.blobs) { + * for (const blob of response.blobs) { + * console.log(`Blob ${i++}: ${blob.name}`); + * } + * } + * ``` + * + * @param {string} tagFilterSqlExpression The where parameter enables the caller to query blobs whose tags match a given expression. + * The given expression must evaluate to true for a blob to be returned in the results. + * The[OData - ABNF] filter syntax rule defines the formal grammar for the value of the where query parameter; + * however, only a subset of the OData filter syntax is supported in the Blob service. + * @param {ServiceFindBlobByTagsOptions} [options={}] Options to find blobs by tags. + * @returns {PagedAsyncIterableIterator} + * @memberof BlobServiceClient + */ + public findBlobsByTags( + tagFilterSqlExpression: string, + options: ServiceFindBlobByTagsOptions = {} + ): PagedAsyncIterableIterator { + // AsyncIterableIterator to iterate over blobs + const listSegmentOptions: ServiceFindBlobsByTagsSegmentOptions = { + ...options + }; + + const iter = this.findBlobsByTagsItems(tagFilterSqlExpression, listSegmentOptions); + return { + /** + * @member {Promise} [next] The next method, part of the iteration protocol + */ + next() { + return iter.next(); + }, + /** + * @member {Symbol} [asyncIterator] The connection to the async iterator, part of the iteration protocol + */ + [Symbol.asyncIterator]() { + return this; + }, + /** + * @member {Function} [byPage] Return an AsyncIterableIterator that works a page at a time + */ + byPage: (settings: PageSettings = {}) => { + return this.findBlobsByTagsSegments(tagFilterSqlExpression, settings.continuationToken, { + maxPageSize: settings.maxPageSize, + ...listSegmentOptions + }); + } + }; + } + /** * Returns an AsyncIterableIterator for ServiceListContainersSegmentResponses * diff --git a/sdk/storage/storage-blob/src/Clients.ts b/sdk/storage/storage-blob/src/Clients.ts index 0e3df04274d5..4fcc9565e5b6 100644 --- a/sdk/storage/storage-blob/src/Clients.ts +++ b/sdk/storage/storage-blob/src/Clients.ts @@ -1,142 +1,146 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. +import "@azure/core-paging"; + +import * as fs from "fs"; +import { Readable } from "stream"; + +import { AbortSignalLike } from "@azure/abort-controller"; import { + generateUuid, + getDefaultProxySettings, + HttpRequestBody, + HttpResponse, isNode, - TransferProgressEvent, - TokenCredential, isTokenCredential, - getDefaultProxySettings, + TokenCredential, + TransferProgressEvent, URLBuilder } from "@azure/core-http"; +import { PollerLike, PollOperationState } from "@azure/core-lro"; import { CanonicalCode } from "@opentelemetry/api"; + +import { BlobDownloadResponse } from "./BlobDownloadResponse"; +import { BlobQueryResponse } from "./BlobQueryResponse"; +import { AnonymousCredential } from "./credentials/AnonymousCredential"; +import { StorageSharedKeyCredential } from "./credentials/StorageSharedKeyCredential"; import { - BlobDownloadResponseModel, - CpkInfo, - DeleteSnapshotsOptionType, - ModifiedAccessConditions, - RehydratePriority, - LeaseAccessConditions, + AppendBlob, + Blob as StorageBlob, + BlockBlob, + Container, + PageBlob +} from "./generated/src/operations"; +import { StorageClientContext } from "./generated/src/storageClient"; +import { + AppendBlobAppendBlockFromUrlResponse, + AppendBlobAppendBlockResponse, + AppendBlobCreateResponse, + BlobAbortCopyFromURLResponse, + BlobCopyFromURLResponse, + BlobCreateSnapshotResponse, + BlobDeleteResponse, BlobDownloadOptionalParams, + BlobDownloadResponseModel, BlobGetPropertiesResponse, - BlobDeleteResponse, - BlobUndeleteResponse, BlobHTTPHeaders, + BlobPrefix, BlobSetHTTPHeadersResponse, BlobSetMetadataResponse, - BlobCreateSnapshotResponse, - BlobStartCopyFromURLResponse, - BlobAbortCopyFromURLResponse, - BlobCopyFromURLResponse, + BlobSetTagsResponse, BlobSetTierResponse, - ContainerEncryptionScope + BlobStartCopyFromURLResponse, + BlobUndeleteResponse, + BlockBlobCommitBlockListResponse, + BlockBlobGetBlockListResponse, + BlockBlobStageBlockFromURLResponse, + BlockBlobStageBlockResponse, + BlockBlobUploadHeaders, + BlockBlobUploadResponse, + BlockListType, + ContainerBreakLeaseOptionalParams, + ContainerCreateResponse, + ContainerDeleteResponse, + ContainerEncryptionScope, + ContainerGetAccessPolicyHeaders, + ContainerGetPropertiesResponse, + ContainerSetAccessPolicyResponse, + ContainerSetMetadataResponse, + CpkInfo, + DeleteSnapshotsOptionType, + LeaseAccessConditions, + ListBlobsIncludeItem, + ModifiedAccessConditions, + PageBlobClearPagesResponse, + PageBlobCopyIncrementalResponse, + PageBlobCreateResponse, + PageBlobResizeResponse, + PageBlobUpdateSequenceNumberResponse, + PageBlobUploadPagesFromURLResponse, + PageBlobUploadPagesResponse, + PublicAccessType, + RehydratePriority, + SequenceNumberActionType, + SignedIdentifierModel, + BlobGetTagsHeaders, + BlobTags, + ListBlobsFlatSegmentResponseModel, + ContainerListBlobFlatSegmentHeaders, + BlobProperties, + ContainerListBlobHierarchySegmentHeaders, + ListBlobsHierarchySegmentResponseModel } from "./generatedModels"; -import { AbortSignalLike } from "@azure/abort-controller"; -import { BlobDownloadResponse } from "./BlobDownloadResponse"; -import { Blob as StorageBlob } from "./generated/src/operations"; -import { rangeToString } from "./Range"; import { + AppendBlobRequestConditions, BlobRequestConditions, - Metadata, - ensureCpkIfSpecified, BlockBlobTier, + ensureCpkIfSpecified, + Metadata, + Tags, + PageBlobRequestConditions, PremiumPageBlobTier, toAccessTier } from "./models"; -import { newPipeline, StoragePipelineOptions, Pipeline } from "./Pipeline"; -import { - DEFAULT_MAX_DOWNLOAD_RETRY_REQUESTS, - URLConstants, - DEFAULT_BLOB_DOWNLOAD_BLOCK_BYTES, - DEFAULT_BLOCK_BUFFER_SIZE_BYTES -} from "./utils/constants"; -import { - setURLParameter, - extractConnectionStringParts, - appendToURLPath, - toQuerySerialization -} from "./utils/utils.common"; -import { fsStat, readStreamToLocalFile, streamToBuffer } from "./utils/utils.node"; -import { StorageSharedKeyCredential } from "./credentials/StorageSharedKeyCredential"; -import { AnonymousCredential } from "./credentials/AnonymousCredential"; -import { Batch } from "./utils/Batch"; -import { createSpan } from "./utils/tracing"; -import { HttpRequestBody } from "@azure/core-http"; -import { - AppendBlobCreateResponse, - AppendBlobAppendBlockFromUrlResponse, - AppendBlobAppendBlockResponse -} from "./generatedModels"; -import { AppendBlob } from "./generated/src/operations"; -import { AppendBlobRequestConditions } from "./models"; -import { CommonOptions, StorageClient } from "./StorageClient"; -import * as fs from "fs"; -import { generateUuid, HttpResponse } from "@azure/core-http"; -import { - BlockBlobUploadHeaders, - BlockBlobUploadResponse, - BlockBlobStageBlockResponse, - BlockBlobStageBlockFromURLResponse, - BlockBlobCommitBlockListResponse, - BlockBlobGetBlockListResponse, - BlockListType -} from "./generatedModels"; -import { BlockBlob } from "./generated/src/operations"; -import { Range } from "./Range"; -import { generateBlockID } from "./utils/utils.common"; -import { - BLOCK_BLOB_MAX_STAGE_BLOCK_BYTES, - BLOCK_BLOB_MAX_UPLOAD_BLOB_BYTES, - BLOCK_BLOB_MAX_BLOCKS -} from "./utils/constants"; -import { BufferScheduler } from "./utils/BufferScheduler"; -import { Readable } from "stream"; -import { - PageBlobCreateResponse, - PageBlobUploadPagesResponse, - PageBlobUploadPagesFromURLResponse, - PageBlobClearPagesResponse, - PageBlobResizeResponse, - SequenceNumberActionType, - PageBlobUpdateSequenceNumberResponse, - PageBlobCopyIncrementalResponse -} from "./generatedModels"; -import { PageBlob } from "./generated/src/operations"; -import { PageBlobRequestConditions } from "./models"; import { PageBlobGetPageRangesDiffResponse, PageBlobGetPageRangesResponse, rangeResponseFromModel } from "./PageBlobRangeResponse"; +import { newPipeline, Pipeline, StoragePipelineOptions } from "./Pipeline"; import { BlobBeginCopyFromUrlPoller, BlobBeginCopyFromUrlPollState, CopyPollerBlobClient } from "./pollers/BlobStartCopyFromUrlPoller"; -import { PollerLike, PollOperationState } from "@azure/core-lro"; -import { ContainerBreakLeaseOptionalParams } from "./generatedModels"; -import { StorageClientContext } from "./generated/src/storageClient"; +import { Range, rangeToString } from "./Range"; +import { CommonOptions, StorageClient } from "./StorageClient"; +import { Batch } from "./utils/Batch"; +import { BufferScheduler } from "./utils/BufferScheduler"; import { - ContainerGetAccessPolicyHeaders, - SignedIdentifierModel, - PublicAccessType, - ListBlobsIncludeItem, - ContainerCreateResponse, - ContainerGetPropertiesResponse, - ContainerDeleteResponse, - ContainerSetMetadataResponse, - ContainerSetAccessPolicyResponse, - ContainerListBlobFlatSegmentResponse, - ContainerListBlobHierarchySegmentResponse, - BlobItem, - BlobPrefix -} from "./generatedModels"; -import { Container } from "./generated/src/operations"; -import { ETagNone } from "./utils/constants"; -import { truncatedISO8061Date } from "./utils/utils.common"; -import "@azure/core-paging"; -import { PagedAsyncIterableIterator, PageSettings } from "@azure/core-paging"; -import { BlobQueryResponse } from "./BlobQueryResponse"; + BLOCK_BLOB_MAX_BLOCKS, + BLOCK_BLOB_MAX_STAGE_BLOCK_BYTES, + BLOCK_BLOB_MAX_UPLOAD_BLOB_BYTES, + DEFAULT_BLOB_DOWNLOAD_BLOCK_BYTES, + DEFAULT_BLOCK_BUFFER_SIZE_BYTES, + DEFAULT_MAX_DOWNLOAD_RETRY_REQUESTS, + ETagNone, + URLConstants +} from "./utils/constants"; +import { createSpan } from "./utils/tracing"; +import { + appendToURLPath, + extractConnectionStringParts, + generateBlockID, + setURLParameter, + toBlobTagsString, + toQuerySerialization, + truncatedISO8061Date, + toBlobTags, + toTags +} from "./utils/utils.common"; +import { fsStat, readStreamToLocalFile, streamToBuffer } from "./utils/utils.node"; +import { PageSettings, PagedAsyncIterableIterator } from "@azure/core-paging"; /** * Options to configure the {@link BlobClient.beginCopyFromURL} operation. @@ -461,6 +465,65 @@ export interface BlobSetMetadataOptions extends CommonOptions { encryptionScope?: string; } +/** + * Options to configure the {@link BlobClient.setTags} operation. + * + * @export + * @interface BlobSetTagsOptions + */ +export interface BlobSetTagsOptions extends CommonOptions { + /** + * An implementation of the `AbortSignalLike` interface to signal the request to cancel the operation. + * For example, use the @azure/abort-controller to create an `AbortSignal`. + * + * @type {AbortSignalLike} + * @memberof BlobSetTagsOptions + */ + abortSignal?: AbortSignalLike; +} + +/** + * Options to configure the {@link BlobClient.getTags} operation. + * + * @export + * @interface BlobGetTagsOptions + */ +export interface BlobGetTagsOptions extends CommonOptions { + /** + * An implementation of the `AbortSignalLike` interface to signal the request to cancel the operation. + * For example, use the @azure/abort-controller to create an `AbortSignal`. + * + * @type {AbortSignalLike} + * @memberof BlobGetTagsOptions + */ + abortSignal?: AbortSignalLike; +} + +/** + * Contains response data for the {@link ContainerClient.getTags} operation. + */ +export type BlobGetTagsResponse = { tags: Tags } & BlobGetTagsHeaders & { + /** + * The underlying HTTP response. + */ + _response: HttpResponse & { + /** + * The parsed HTTP response headers. + */ + parsedHeaders: BlobGetTagsHeaders; + + /** + * The response body as text (string format) + */ + bodyAsText: string; + + /** + * The response body as parsed JSON or XML + */ + parsedBody: BlobTags; + }; + }; + /** * Options to configure Blob - Acquire Lease operation. * @@ -681,6 +744,13 @@ export interface BlobStartCopyFromURLOptions extends CommonOptions { * @memberof BlobStartCopyFromURLOptions */ rehydratePriority?: RehydratePriority; + /** + * Blob tags. + * + * @type {Tags} + * @memberof BlobStartCopyFromURLOptions + */ + tags?: Tags; } /** @@ -751,6 +821,13 @@ export interface BlobSyncCopyFromURLOptions extends CommonOptions { * @memberof BlobSyncCopyFromURLOptions */ sourceContentMD5?: Uint8Array; + /** + * Blob tags. + * + * @type {Tags} + * @memberof BlobSyncCopyFromURLOptions + */ + tags?: Tags; } /** @@ -1497,6 +1574,66 @@ export class BlobClient extends StorageClient { } } + /** + * Sets tags on the underlying blob. + * A blob can have up to 10 tags. Tag keys must be between 1 and 128 characters. Tag values must be between 0 and 256 characters. + * Valid tag key and value characters include lower and upper case letters, digits (0-9), + * space (' '), plus ('+'), minus ('-'), period ('.'), foward slash ('/'), colon (':'), equals ('='), and underscore ('_'). + * + * @param {Tags} tags + * @param {BlobSetTagsOptions} [options={}] + * @returns {Promise} + * @memberof BlobClient + */ + public async setTags(tags: Tags, options: BlobSetTagsOptions = {}): Promise { + const { span, spanOptions } = createSpan("BlobClient-setTags", options.tracingOptions); + try { + return await this.blobContext.setTags({ + abortSignal: options.abortSignal, + spanOptions, + tags: toBlobTags(tags) + }); + } catch (e) { + span.setStatus({ + code: CanonicalCode.UNKNOWN, + message: e.message + }); + throw e; + } finally { + span.end(); + } + } + + /** + * Gets the tags associated with the underlying blob. + * + * @param {BlobGetTagsOptions} [options={}] + * @returns {Promise} + * @memberof BlobClient + */ + public async getTags(options: BlobGetTagsOptions = {}): Promise { + const { span, spanOptions } = createSpan("BlobClient-getTags", options.tracingOptions); + try { + const response = await this.blobContext.getTags({ + abortSignal: options.abortSignal, + spanOptions + }); + const wrappedResponse: BlobGetTagsResponse = { + ...response, + tags: toTags({ blobTagSet: response.blobTagSet }) || {} + }; + return wrappedResponse; + } catch (e) { + span.setStatus({ + code: CanonicalCode.UNKNOWN, + message: e.message + }); + throw e; + } finally { + span.end(); + } + } + /** * Get a {@link BlobLeaseClient} that manages leases on the blob. * @@ -1705,6 +1842,7 @@ export class BlobClient extends StorageClient { sourceIfUnmodifiedSince: options.sourceConditions.ifUnmodifiedSince }, sourceContentMD5: options.sourceContentMD5, + blobTagsString: toBlobTagsString(options.tags), spanOptions }); } catch (e) { @@ -2059,6 +2197,7 @@ export class BlobClient extends StorageClient { }, rehydratePriority: options.rehydratePriority, tier: toAccessTier(options.tier), + blobTagsString: toBlobTagsString(options.tags), spanOptions }); } catch (e) { @@ -2127,6 +2266,13 @@ export interface AppendBlobCreateOptions extends CommonOptions { * @memberof AppendBlobCreateOptions */ encryptionScope?: string; + /** + * Blob tags. + * + * @type {Tags} + * @memberof AppendBlobCreateOptions + */ + tags?: Tags; } /** @@ -2480,6 +2626,7 @@ export class AppendBlobClient extends BlobClient { modifiedAccessConditions: options.conditions, cpkInfo: options.customerProvidedKey, encryptionScope: options.encryptionScope, + blobTagsString: toBlobTagsString(options.tags), spanOptions }); } catch (e) { @@ -2683,6 +2830,13 @@ export interface BlockBlobUploadOptions extends CommonOptions { * @memberof BlockBlobUploadOptions */ tier?: BlockBlobTier | string; + /** + * Blob tags. + * + * @type {Tags} + * @memberof BlockBlobUploadOptions + */ + tags?: Tags; } /** @@ -3058,6 +3212,14 @@ export interface BlockBlobCommitBlockListOptions extends CommonOptions { * @memberof BlockBlobCommitBlockListOptions */ tier?: BlockBlobTier | string; + + /** + * Blob tags. + * + * @type {Tags} + * @memberof BlockBlobCommitBlockListOptions + */ + tags?: Tags; } /** @@ -3143,6 +3305,14 @@ export interface BlockBlobUploadStreamOptions extends CommonOptions { * @memberof BlockBlobUploadStreamOptions */ encryptionScope?: string; + + /** + * Blob tags. + * + * @type {Tags} + * @memberof BlockBlobUploadStreamOptions + */ + tags?: Tags; } /** * Option interface for {@link BlockBlobClient.uploadFile} and {@link BlockBlobClient.uploadSeekableStream}. @@ -3229,6 +3399,14 @@ export interface BlockBlobParallelUploadOptions extends CommonOptions { * @memberof BlockBlobParallelUploadOptions */ encryptionScope?: string; + + /** + * Blob tags. + * + * @type {Tags} + * @memberof BlockBlobParallelUploadOptions + */ + tags?: Tags; } /** @@ -3557,6 +3735,7 @@ export class BlockBlobClient extends BlobClient { cpkInfo: options.customerProvidedKey, encryptionScope: options.encryptionScope, tier: toAccessTier(options.tier), + blobTagsString: toBlobTagsString(options.tags), spanOptions }); } catch (e) { @@ -3703,6 +3882,7 @@ export class BlockBlobClient extends BlobClient { cpkInfo: options.customerProvidedKey, encryptionScope: options.encryptionScope, tier: toAccessTier(options.tier), + blobTagsString: toBlobTagsString(options.tags), spanOptions } ); @@ -4266,6 +4446,13 @@ export interface PageBlobCreateOptions extends CommonOptions { * @memberof PageBlobCreateOptions */ tier?: PremiumPageBlobTier | string; + /** + * Blob tags. + * + * @type {Tags} + * @memberof PageBlobCreateOptions + */ + tags?: Tags; } /** @@ -4788,6 +4975,7 @@ export class PageBlobClient extends BlobClient { cpkInfo: options.customerProvidedKey, encryptionScope: options.encryptionScope, tier: toAccessTier(options.tier), + blobTagsString: toBlobTagsString(options.tags), spanOptions }); } catch (e) { @@ -5934,6 +6122,115 @@ interface ContainerListBlobsSegmentOptions extends CommonOptions { include?: ListBlobsIncludeItem[]; } +/** + * An interface representing BlobHierarchyListSegment. + */ +export interface BlobHierarchyListSegment { + blobPrefixes?: BlobPrefix[]; + blobItems: BlobItem[]; +} + +/** + * An enumeration of blobs + */ +export interface ListBlobsHierarchySegmentResponse { + serviceEndpoint: string; + containerName: string; + prefix?: string; + marker?: string; + maxPageSize?: number; + delimiter?: string; + segment: BlobHierarchyListSegment; + continuationToken?: string; +} + +/** + * Contains response data for the listBlobHierarchySegment operation. + */ +export type ContainerListBlobHierarchySegmentResponse = ListBlobsHierarchySegmentResponse & + ContainerListBlobHierarchySegmentHeaders & { + /** + * The underlying HTTP response. + */ + _response: HttpResponse & { + /** + * The parsed HTTP response headers. + */ + parsedHeaders: ContainerListBlobHierarchySegmentHeaders; + + /** + * The response body as text (string format) + */ + bodyAsText: string; + + /** + * The response body as parsed JSON or XML + */ + parsedBody: ListBlobsHierarchySegmentResponseModel; + }; + }; + +/** + * An Azure Storage blob + */ +export interface BlobItem { + name: string; + deleted: boolean; + snapshot: string; + versionId?: string; + isCurrentVersion?: boolean; + properties: BlobProperties; + metadata?: { [propertyName: string]: string }; + tags?: Tags; + objectReplicationMetadata?: { [propertyName: string]: string }; +} + +/** + * An interface representing BlobFlatListSegment. + */ +export interface BlobFlatListSegment { + blobItems: BlobItem[]; +} + +/** + * An enumeration of blobs + */ +export interface ListBlobsFlatSegmentResponse { + serviceEndpoint: string; + containerName: string; + prefix?: string; + marker?: string; + maxPageSize?: number; + segment: BlobFlatListSegment; + continuationToken?: string; +} + +/** + * Contains response data for the listBlobFlatSegment operation. + */ +export type ContainerListBlobFlatSegmentResponse = ListBlobsFlatSegmentResponse & + ContainerListBlobFlatSegmentHeaders & { + /** + * The underlying HTTP response. + */ + _response: HttpResponse & { + /** + * The parsed HTTP response headers. + */ + parsedHeaders: ContainerListBlobFlatSegmentHeaders; + + /** + * The response body as text (string format) + */ + bodyAsText: string; + + /** + * The response body as parsed JSON or XML + */ + parsedBody: ListBlobsFlatSegmentResponseModel; + }; + }; + /** * Options to configure Container - List Blobs operations. * @@ -5983,6 +6280,10 @@ export interface ContainerListBlobsOptions extends CommonOptions { * Specifies whether blobs for which blocks have been uploaded, but which have not been committed using Put Block List, be included in the response. */ includeUncommitedBlobs?: boolean; + /** + * Specifies whether blob tags be returned in the response. + */ + includeTags?: boolean; } /** @@ -6684,11 +6985,25 @@ export class ContainerClient extends StorageClient { options.tracingOptions ); try { - return await this.containerContext.listBlobFlatSegment({ + const resposne = await this.containerContext.listBlobFlatSegment({ marker, ...options, spanOptions }); + const wrappedResponse: ContainerListBlobFlatSegmentResponse = { + ...resposne, + segment: { + ...resposne.segment, + blobItems: resposne.segment.blobItems.map((blobItemInteral) => { + const blobItem: BlobItem = { + ...blobItemInteral, + tags: toTags(blobItemInteral.blobTags) + }; + return blobItem; + }) + } + }; + return wrappedResponse; } catch (e) { span.setStatus({ code: CanonicalCode.UNKNOWN, @@ -6723,11 +7038,25 @@ export class ContainerClient extends StorageClient { options.tracingOptions ); try { - return await this.containerContext.listBlobHierarchySegment(delimiter, { + const resposne = await this.containerContext.listBlobHierarchySegment(delimiter, { marker, ...options, spanOptions }); + const wrappedResponse: ContainerListBlobHierarchySegmentResponse = { + ...resposne, + segment: { + ...resposne.segment, + blobItems: resposne.segment.blobItems.map((blobItemInteral) => { + const blobItem: BlobItem = { + ...blobItemInteral, + tags: toTags(blobItemInteral.blobTags) + }; + return blobItem; + }) + } + }; + return wrappedResponse; } catch (e) { span.setStatus({ code: CanonicalCode.UNKNOWN, @@ -6878,6 +7207,9 @@ export class ContainerClient extends StorageClient { if (options.includeUncommitedBlobs) { include.push("uncommittedblobs"); } + if (options.includeTags) { + include.push("tags"); + } if (options.prefix === "") { options.prefix = undefined; } @@ -6929,7 +7261,8 @@ export class ContainerClient extends StorageClient { * @param {ContainerListBlobsSegmentOptions} [options] Options to list blobs operation. * @returns {AsyncIterableIterator} * @memberof ContainerClient - */ private async *listHierarchySegments( + */ + private async *listHierarchySegments( delimiter: string, marker?: string, options: ContainerListBlobsSegmentOptions = {} @@ -7086,6 +7419,9 @@ export class ContainerClient extends StorageClient { if (options.includeUncommitedBlobs) { include.push("uncommittedblobs"); } + if (options.includeTags) { + include.push("tags"); + } if (options.prefix === "") { options.prefix = undefined; } diff --git a/sdk/storage/storage-blob/src/ContainerSASPermissions.ts b/sdk/storage/storage-blob/src/ContainerSASPermissions.ts index c4ea3fc04731..299ba0df129c 100644 --- a/sdk/storage/storage-blob/src/ContainerSASPermissions.ts +++ b/sdk/storage/storage-blob/src/ContainerSASPermissions.ts @@ -44,6 +44,9 @@ export class ContainerSASPermissions { case "l": containerSASPermissions.list = true; break; + case "t": + containerSASPermissions.tag = true; + break; default: throw new RangeError(`Invalid permission ${char}`); } @@ -100,6 +103,14 @@ export class ContainerSASPermissions { */ public list: boolean = false; + /** + * Specfies Tag access granted. + * + * @type {boolean} + * @memberof BlobSASPermissions + */ + public tag: boolean = false; + /** * Converts the given permissions to a string. Using this method will guarantee the permissions are in an * order accepted by the service. @@ -130,6 +141,9 @@ export class ContainerSASPermissions { if (this.list) { permissions.push("l"); } + if (this.tag) { + permissions.push("t"); + } return permissions.join(""); } } diff --git a/sdk/storage/storage-blob/src/generatedModels.ts b/sdk/storage/storage-blob/src/generatedModels.ts index ebd570f16948..309d24270976 100644 --- a/sdk/storage/storage-blob/src/generatedModels.ts +++ b/sdk/storage/storage-blob/src/generatedModels.ts @@ -12,7 +12,7 @@ export { AppendBlobAppendBlockHeaders, AppendBlobCreateHeaders, ArchiveStatus, - ListBlobsFlatSegmentResponse, + ListBlobsFlatSegmentResponse as ListBlobsFlatSegmentResponseModel, BlobAbortCopyFromURLHeaders, BlobCopyFromURLHeaders, BlobCreateSnapshotHeaders, @@ -26,8 +26,8 @@ export { BlobHTTPHeaders, BlobSetHTTPHeadersResponse, BlobSetMetadataResponse, + BlobSetTagsResponse, BlobCreateSnapshotResponse, - BlobFlatListSegment, BlobStartCopyFromURLHeaders, BlobStartCopyFromURLResponse, BlobAbortCopyFromURLResponse, @@ -36,13 +36,13 @@ export { BlobSetMetadataHeaders, BlobSetTierHeaders, BlobSetTierResponse, - BlobHierarchyListSegment, - BlobItemInternal as BlobItem, + BlobSetTagsHeaders, BlobPrefix, BlobDownloadHeaders, BlobQueryResponse as BlobQueryResponseModel, BlobDownloadResponse as BlobDownloadResponseModel, BlobType, + BlobTags, BlobUndeleteHeaders, Block, BlockBlobCommitBlockListHeaders, @@ -59,6 +59,8 @@ export { BlockBlobGetBlockListResponse, BlobServiceProperties, BlobServiceStatistics, + BlobGetTagsHeaders, + BlobTag, ContainerCreateHeaders, ContainerCreateResponse, ContainerDeleteHeaders, @@ -67,9 +69,7 @@ export { ContainerGetPropertiesHeaders, ContainerBreakLeaseOptionalParams, ContainerListBlobFlatSegmentHeaders, - ContainerListBlobFlatSegmentResponse, ContainerListBlobHierarchySegmentHeaders, - ContainerListBlobHierarchySegmentResponse, ContainerGetPropertiesResponse, ContainerProperties, ContainerSetMetadataResponse, @@ -87,10 +87,12 @@ export { LeaseDurationType, LeaseStateType, LeaseStatusType, - ListBlobsHierarchySegmentResponse, + ListBlobsHierarchySegmentResponse as ListBlobsHierarchySegmentResponseModel, ListBlobsIncludeItem, ListContainersIncludeType, ListContainersSegmentResponse, + FilterBlobSegment, + ServiceFilterBlobsHeaders, Logging, Metrics, ModifiedAccessConditions, @@ -138,5 +140,7 @@ export { ServiceSubmitBatchOptionalParams as ServiceSubmitBatchOptionalParamsModel, SignedIdentifier as SignedIdentifierModel, UserDelegationKey as UserDelegationKeyModel, - ContainerEncryptionScope + ContainerEncryptionScope, + ServiceFilterBlobsResponse as ServiceFindBlobsByTagsSegmentResponse, + FilterBlobItem } from "./generated/src/models"; diff --git a/sdk/storage/storage-blob/src/index.browser.ts b/sdk/storage/storage-blob/src/index.browser.ts index 57214f7b5115..ecb4ede68f38 100644 --- a/sdk/storage/storage-blob/src/index.browser.ts +++ b/sdk/storage/storage-blob/src/index.browser.ts @@ -13,7 +13,7 @@ export * from "./credentials/AnonymousCredential"; export * from "./credentials/Credential"; export { SasIPRange } from "./SasIPRange"; export { Range } from "./Range"; -export { BlockBlobTier, PremiumPageBlobTier } from "./models"; +export { BlockBlobTier, PremiumPageBlobTier, Tags } from "./models"; export * from "./Pipeline"; export * from "./policies/AnonymousCredentialPolicy"; export * from "./policies/CredentialPolicy"; diff --git a/sdk/storage/storage-blob/src/index.ts b/sdk/storage/storage-blob/src/index.ts index 6cb9eee5aa44..36220069c67d 100644 --- a/sdk/storage/storage-blob/src/index.ts +++ b/sdk/storage/storage-blob/src/index.ts @@ -22,7 +22,7 @@ export * from "./credentials/Credential"; export * from "./credentials/StorageSharedKeyCredential"; export { SasIPRange } from "./SasIPRange"; export { Range } from "./Range"; -export { BlockBlobTier, PremiumPageBlobTier } from "./models"; +export { BlockBlobTier, PremiumPageBlobTier, Tags } from "./models"; export * from "./Pipeline"; export * from "./policies/AnonymousCredentialPolicy"; export * from "./policies/CredentialPolicy"; diff --git a/sdk/storage/storage-blob/src/models.ts b/sdk/storage/storage-blob/src/models.ts index c253686c2dd7..dec8da7e987b 100644 --- a/sdk/storage/storage-blob/src/models.ts +++ b/sdk/storage/storage-blob/src/models.ts @@ -11,6 +11,11 @@ import { } from "./generatedModels"; import { EncryptionAlgorithmAES25 } from "./utils/constants"; +/** + * Blob tags. + */ +export type Tags = Record; + /** * A map of name-value pairs to associate with the resource. */ diff --git a/sdk/storage/storage-blob/src/utils/utils.common.ts b/sdk/storage/storage-blob/src/utils/utils.common.ts index 917424b68477..3889ffa0d62d 100644 --- a/sdk/storage/storage-blob/src/utils/utils.common.ts +++ b/sdk/storage/storage-blob/src/utils/utils.common.ts @@ -5,8 +5,9 @@ import { AbortSignalLike } from "@azure/abort-controller"; import { HttpHeaders, isNode, URLBuilder } from "@azure/core-http"; import { BlobQueryCsvTextConfiguration, BlobQueryJsonTextConfiguration } from "../Clients"; -import { QuerySerialization } from "../generated/src/models"; +import { QuerySerialization, BlobTags } from "../generated/src/models"; import { DevelopmentConnectionString, HeaderConstants, URLConstants } from "./constants"; +import { Tags } from "../models"; /** * Reserved URL characters must be properly escaped for Storage services like Blob or File. @@ -556,6 +557,76 @@ export function getAccountNameFromUrl(url: string): string { } } +/** + * Convert Tags to encoded string. + * + * @export + * @param {Tags} tags + * @returns {string | undefined} + */ +export function toBlobTagsString(tags?: Tags): string | undefined { + if (tags === undefined) { + return undefined; + } + + const tagPairs = []; + for (const key in tags) { + if (tags.hasOwnProperty(key)) { + const value = tags[key]; + tagPairs.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`); + } + } + + return tagPairs.join("&"); +} + +/** + * Convert Tags type to BlobTags. + * + * @export + * @param {Tags} [tags] + * @returns {(BlobTags | undefined)} + */ +export function toBlobTags(tags?: Tags): BlobTags | undefined { + if (tags === undefined) { + return undefined; + } + + const res: BlobTags = { + blobTagSet: [] + }; + + for (const key in tags) { + if (tags.hasOwnProperty(key)) { + const value = tags[key]; + res.blobTagSet.push({ + key, + value + }); + } + } + return res; +} + +/** + * Covert BlobTags to Tags type. + * + * @export + * @param {BlobTags} [tags] + * @returns {(Tags | undefined)} + */ +export function toTags(tags?: BlobTags): Tags | undefined { + if (tags === undefined) { + return undefined; + } + + const res: Tags = {}; + for (const blobTag of tags.blobTagSet) { + res[blobTag.key] = blobTag.value; + } + return res; +} + /** * Convert BlobQueryTextConfiguration to QuerySerialization type. * diff --git a/sdk/storage/storage-blob/test/blobclient.spec.ts b/sdk/storage/storage-blob/test/blobclient.spec.ts index faf6b862b9c6..f0ebdf745a7a 100644 --- a/sdk/storage/storage-blob/test/blobclient.spec.ts +++ b/sdk/storage/storage-blob/test/blobclient.spec.ts @@ -9,7 +9,7 @@ import { getBSU, getSASConnectionStringFromEnvironment, recorderEnvSetup, - isBlobVersioningDisabled, + isBlobVersioningDisabled } from "./utils"; import { record, delay } from "@azure/test-utils-recorder"; import { @@ -52,6 +52,88 @@ describe("BlobClient", () => { } }); + it("Set blob tags should work", async () => { + const tags = { + tag1: "val1", + tag2: "val2" + }; + await blockBlobClient.setTags(tags); + + const response = await blockBlobClient.getTags(); + assert.deepStrictEqual(response.tags, tags); + + const properties = await blockBlobClient.getProperties(); + assert.deepStrictEqual(properties.tagCount, 2); + + const download = await blockBlobClient.download(); + assert.deepStrictEqual(download.tagCount, 2); + + const listblob = containerClient.listBlobsFlat({ includeTags: true }); + + const iter = listblob.byPage(); + const segment = await iter.next(); + + // TODO: Make blob tag type consistency cross all request or response + assert.deepStrictEqual(segment.value.segment.blobItems[0].tags, tags); + }); + + it("Get blob tags should work with a snapshot", async () => { + const tags = { + tag1: "val1", + tag2: "val2" + }; + await blockBlobClient.setTags(tags); + + const snapshotResponse = await blockBlobClient.createSnapshot(); + const blockBlobClientSnapshot = blockBlobClient.withSnapshot(snapshotResponse.snapshot!); + + const response = await blockBlobClientSnapshot.getTags(); + assert.deepStrictEqual(response.tags, tags); + }); + + it("Create block blob blob should work with tags", async () => { + await blockBlobClient.delete(); + + const tags = { + tag1: "val1", + tag2: "val2" + }; + await blockBlobClient.upload("hello", 5, { tags }); + + const response = await blockBlobClient.getTags(); + assert.deepStrictEqual(response.tags, tags); + }); + + it("Create append blob should work with tags", async () => { + await blockBlobClient.delete(); + + const tags = { + tag1: "val1", + tag2: "val2" + }; + + const appendBlobClient = blobClient.getAppendBlobClient(); + await appendBlobClient.create({ tags }); + + const response = await appendBlobClient.getTags(); + assert.deepStrictEqual(response.tags, tags); + }); + + it("Create page blob should work with tags", async () => { + await blockBlobClient.delete(); + + const tags = { + tag1: "val1", + tag2: "val2" + }; + + const pageBlobClient = blobClient.getPageBlobClient(); + await pageBlobClient.create(512, { tags }); + + const response = await pageBlobClient.getTags(); + assert.deepStrictEqual(response.tags, tags); + }); + it("download with with default parameters", async () => { const result = await blobClient.download(); assert.deepStrictEqual(await bodyToString(result, content.length), content); @@ -254,7 +336,7 @@ describe("BlobClient", () => { const iter = containerClient .listBlobsFlat({ includeDeleted: true, - includeVersions: true, // Need this when blob versioning is turned on. + includeVersions: true // Need this when blob versioning is turned on. }) .byPage({ maxPageSize: 1 }); @@ -293,7 +375,10 @@ describe("BlobClient", () => { ); if (isBlobVersioningDisabled()) { - assert.ok(result.segment.blobItems![0].deleted, "Expect that the blob is marked for deletion"); + assert.ok( + result.segment.blobItems![0].deleted, + "Expect that the blob is marked for deletion" + ); } await blobClient.undelete(); @@ -301,7 +386,7 @@ describe("BlobClient", () => { const iter2 = containerClient .listBlobsFlat({ includeDeleted: true, - includeVersions: true, // Need this when blob versioning is turned on. + includeVersions: true // Need this when blob versioning is turned on. }) .byPage(); diff --git a/sdk/storage/storage-blob/test/blobserviceclient.spec.ts b/sdk/storage/storage-blob/test/blobserviceclient.spec.ts index 06fd1574700e..d0a541f2b1a3 100644 --- a/sdk/storage/storage-blob/test/blobserviceclient.spec.ts +++ b/sdk/storage/storage-blob/test/blobserviceclient.spec.ts @@ -7,9 +7,11 @@ import { getBSU, getSASConnectionStringFromEnvironment, getTokenBSU, - recorderEnvSetup + recorderEnvSetup, + sleep } from "./utils"; import { record, delay, Recorder } from "@azure/test-utils-recorder"; +import { Tags } from "../src/models"; dotenv.config(); describe("BlobServiceClient", () => { @@ -466,4 +468,71 @@ describe("BlobServiceClient", () => { assert.notDeepStrictEqual(response.signedObjectId, undefined); assert.notDeepStrictEqual(response.signedExpiresOn, undefined); }); + + it("Find blob by tags should work", async () => { + const blobServiceClient = getBSU(); + + const containerName = recorder.getUniqueName("container1"); + const containerClient = blobServiceClient.getContainerClient(containerName); + await containerClient.create(); + + const key1 = recorder.getUniqueName("key"); + const key2 = recorder.getUniqueName("key2"); + + const blobName1 = recorder.getUniqueName("blobname1"); + const appendBlobClient1 = containerClient.getAppendBlobClient(blobName1); + const tags1: Tags = {}; + tags1[key1] = recorder.getUniqueName("val1"); + tags1[key2] = "default"; + await appendBlobClient1.create({ tags: tags1 }); + + const blobName2 = recorder.getUniqueName("blobname2"); + const appendBlobClient2 = containerClient.getAppendBlobClient(blobName2); + const tags2: Tags = {}; + tags2[key1] = recorder.getUniqueName("val2"); + tags2[key2] = "default"; + await appendBlobClient2.create({ tags: tags2 }); + + const blobName3 = recorder.getUniqueName("blobname3"); + const appendBlobClient3 = containerClient.getAppendBlobClient(blobName3); + const tags3: Tags = {}; + tags3[key1] = recorder.getUniqueName("val3"); + tags3[key2] = "default"; + await appendBlobClient3.create({ tags: tags3 }); + + // Wait for indexing tags + await sleep(2); + + for await (const blob of blobServiceClient.findBlobsByTags(`${key1}='${tags1[key1]}'`)) { + assert.deepStrictEqual(blob.containerName, containerName); + assert.deepStrictEqual(blob.name, blobName1); + assert.deepStrictEqual(blob.tagValue, tags1[key1]); + } + + const blobs = []; + for await (const segment of blobServiceClient + .findBlobsByTags(`${key1}='${tags2[key1]}'`) + .byPage()) { + for (const blob of segment.blobs) { + blobs.push(blob); + } + } + assert.deepStrictEqual(blobs.length, 1); + assert.deepStrictEqual(blobs[0].containerName, containerName); + assert.deepStrictEqual(blobs[0].name, blobName2); + assert.deepStrictEqual(blobs[0].tagValue, tags2[key1]); + + const blobsWithTag2 = []; + for await (const segment of blobServiceClient.findBlobsByTags(`${key2}='default'`).byPage({ + maxPageSize: 1 + })) { + assert.ok(segment.blobs.length <= 1); + for (const blob of segment.blobs) { + blobsWithTag2.push(blob); + } + } + assert.deepStrictEqual(blobsWithTag2.length, 3); + + await containerClient.delete(); + }); }); diff --git a/sdk/storage/storage-blob/test/browser/highlevel.browser.spec.ts b/sdk/storage/storage-blob/test/browser/highlevel.browser.spec.ts index 73615d5d21fc..9c14a4736fa9 100644 --- a/sdk/storage/storage-blob/test/browser/highlevel.browser.spec.ts +++ b/sdk/storage/storage-blob/test/browser/highlevel.browser.spec.ts @@ -154,6 +154,24 @@ describe("Highlevel", () => { assert.equal(uploadedString, downloadedString); }); + it("uploadBrowserDataToBlockBlob should work with tags", async () => { + recorder.skip("browser", "Temp file - recorder doesn't support saving the file"); + + const tags = { + tag1: "val1", + tag2: "val2" + }; + + await blockBlobClient.uploadBrowserData(tempFile2, { + blockSize: 512 * 1024, + maxSingleShotSize: 0, + tags + }); + + const response = await blockBlobClient.getTags(); + assert.deepStrictEqual(response.tags, tags); + }); + it("uploadBrowserDataToBlockBlob should success when blob >= BLOCK_BLOB_MAX_UPLOAD_BLOB_BYTES", async function() { recorder.skip("browser", "Temp file - recorder doesn't support saving the file"); if (isIE()) { diff --git a/sdk/storage/storage-blob/test/node/highlevel.node.spec.ts b/sdk/storage/storage-blob/test/node/highlevel.node.spec.ts index e38b26a11c18..4dcec8d15235 100644 --- a/sdk/storage/storage-blob/test/node/highlevel.node.spec.ts +++ b/sdk/storage/storage-blob/test/node/highlevel.node.spec.ts @@ -4,7 +4,12 @@ import * as path from "path"; import { PassThrough } from "stream"; import { AbortController } from "@azure/abort-controller"; -import { createRandomLocalFile, getBSU, recorderEnvSetup, isBlobVersioningDisabled } from "../utils"; +import { + createRandomLocalFile, + getBSU, + recorderEnvSetup, + isBlobVersioningDisabled +} from "../utils"; import { RetriableReadableStreamOptions } from "../../src/utils/RetriableReadableStream"; import { record, Recorder } from "@azure/test-utils-recorder"; import { ContainerClient, BlobClient, BlockBlobClient, BlobServiceClient } from "../../src"; @@ -28,7 +33,7 @@ describe("Highlevel", () => { let recorder: Recorder; let blobServiceClient: BlobServiceClient; - beforeEach(async function () { + beforeEach(async function() { recorder = record(this, recorderEnvSetup); blobServiceClient = getBSU(); containerName = recorder.getUniqueName("container"); @@ -39,14 +44,14 @@ describe("Highlevel", () => { blockBlobClient = blobClient.getBlockBlobClient(); }); - afterEach(async function () { + afterEach(async function() { if (!this.currentTest?.isPending()) { await containerClient.delete(); recorder.stop(); } }); - before(async function () { + before(async function() { recorder = record(this, recorderEnvSetup); if (!fs.existsSync(tempFolderPath)) { fs.mkdirSync(tempFolderPath); @@ -58,7 +63,7 @@ describe("Highlevel", () => { recorder.stop(); }); - after(async function () { + after(async function() { recorder = record(this, recorderEnvSetup); fs.unlinkSync(tempFileLarge); fs.unlinkSync(tempFileSmall); @@ -67,7 +72,7 @@ describe("Highlevel", () => { it("put blob with maximum size", async () => { recorder.skip("node", "Temp file - recorder doesn't support saving the file"); - const MB = 1024 * 1024 + const MB = 1024 * 1024; const maxPutBlobSizeLimitInMB = 5000; const tempFile = await createRandomLocalFile(tempFolderPath, maxPutBlobSizeLimitInMB, MB); const inputStream = fs.createReadStream(tempFile); @@ -77,7 +82,7 @@ describe("Highlevel", () => { abortSignal: AbortController.timeout(20 * 1000) // takes too long to upload the file }); } catch (err) { - assert.equal(err.name, 'AbortError'); + assert.equal(err.name, "AbortError"); } }).timeout(timeoutForLargeFileUploadingTest); @@ -99,6 +104,24 @@ describe("Highlevel", () => { assert.ok(downloadedData.equals(uploadedData)); }).timeout(timeoutForLargeFileUploadingTest); + it("uploadFile should work with tags", async () => { + recorder.skip("node", "Temp file - recorder doesn't support saving the file"); + + const tags = { + tag1: "val1", + tag2: "val2" + }; + + await blockBlobClient.uploadFile(tempFileSmall, { + blockSize: 4 * 1024 * 1024, + concurrency: 20, + tags + }); + + const response = await blockBlobClient.getTags(); + assert.deepStrictEqual(response.tags, tags); + }); + it("uploadFile should success when blob < BLOCK_BLOB_MAX_UPLOAD_BLOB_BYTES", async () => { recorder.skip("node", "Temp file - recorder doesn't support saving the file"); await blockBlobClient.uploadFile(tempFileSmall, { @@ -183,7 +206,7 @@ describe("Highlevel", () => { aborter.abort(); } }); - } catch (err) { } + } catch (err) {} assert.ok(eventTriggered); }); @@ -206,20 +229,24 @@ describe("Highlevel", () => { aborter.abort(); } }); - } catch (err) { } + } catch (err) {} assert.ok(eventTriggered); }); it("uploadFile should succeed with blockSize = BLOCK_BLOB_MAX_STAGE_BLOCK_BYTES", async () => { recorder.skip("node", "Temp file - recorder doesn't support saving the file"); - const tempFile = await createRandomLocalFile(tempFolderPath, BLOCK_BLOB_MAX_STAGE_BLOCK_BYTES / (1024 * 1024) + 1, 1024 * 1024); + const tempFile = await createRandomLocalFile( + tempFolderPath, + BLOCK_BLOB_MAX_STAGE_BLOCK_BYTES / (1024 * 1024) + 1, + 1024 * 1024 + ); try { await blockBlobClient.uploadFile(tempFile, { blockSize: BLOCK_BLOB_MAX_STAGE_BLOCK_BYTES, abortSignal: AbortController.timeout(20 * 1000) // takes too long to upload the file }); } catch (err) { - assert.equal(err.name, 'AbortError'); + assert.equal(err.name, "AbortError"); } fs.unlinkSync(tempFile); @@ -262,6 +289,24 @@ describe("Highlevel", () => { fs.unlinkSync(downloadFilePath); }); + it("uploadStream should work with tags", async () => { + recorder.skip("node", "Temp file - recorder doesn't support saving the file"); + + const buf = Buffer.from([0x62, 0x75, 0x66, 0x66, 0x65, 0x72]); + const bufferStream = new PassThrough(); + bufferStream.end(buf); + + const tags = { + tag1: "val1", + tag2: "val2" + }; + + await blockBlobClient.uploadStream(bufferStream, 4 * 1024 * 1024, 20, { tags }); + + const response = await blockBlobClient.getTags(); + assert.deepStrictEqual(response.tags, tags); + }); + it("uploadStream should abort", async () => { recorder.skip("node", "Temp file - recorder doesn't support saving the file"); const rs = fs.createReadStream(tempFileLarge); @@ -406,7 +451,7 @@ describe("Highlevel", () => { aborter.abort(); } }); - } catch (err) { } + } catch (err) {} assert.ok(eventTriggered); }); diff --git a/sdk/storage/storage-blob/test/node/sas.spec.ts b/sdk/storage/storage-blob/test/node/sas.spec.ts index c9b9fb30ce46..d8c435f26cf2 100644 --- a/sdk/storage/storage-blob/test/node/sas.spec.ts +++ b/sdk/storage/storage-blob/test/node/sas.spec.ts @@ -324,6 +324,66 @@ describe("Shared Access Signature (SAS) generation Node.js only", () => { await containerClient.delete(); }); + it("generateBlobSASQueryParameters should work for blob tags", async () => { + const now = recorder.newDate("now"); + now.setMinutes(now.getMinutes() - 5); // Skip clock skew with server + + const tmr = recorder.newDate("tmr"); + tmr.setDate(tmr.getDate() + 1); + + // By default, credential is always the last element of pipeline factories + const factories = (blobServiceClient as any).pipeline.factories; + const sharedKeyCredential = factories[factories.length - 1]; + + const containerName = recorder.getUniqueName("container"); + const containerClient = blobServiceClient.getContainerClient(containerName); + await containerClient.create(); + + const blobName = recorder.getUniqueName("blob"); + const blobClient = containerClient.getPageBlobClient(blobName); + await blobClient.create(1024, { + blobHTTPHeaders: { + blobContentType: "content-type-original" + } + }); + + const blobSAS = generateBlobSASQueryParameters( + { + blobName: blobClient.name, + cacheControl: "cache-control-override", + containerName: blobClient.containerName, + contentDisposition: "content-disposition-override", + contentEncoding: "content-encoding-override", + contentLanguage: "content-language-override", + contentType: "content-type-override", + expiresOn: tmr, + ipRange: { start: "0.0.0.0", end: "255.255.255.255" }, + permissions: BlobSASPermissions.parse("racwdt"), + protocol: SASProtocol.HttpsAndHttp, + startsOn: now + }, + sharedKeyCredential as StorageSharedKeyCredential + ); + + const sasURL = `${blobClient.url}?${blobSAS}`; + const blobClientWithSAS = new PageBlobClient(sasURL, newPipeline(new AnonymousCredential())); + + const tags = { + tag1: "val1", + tag2: "val2" + }; + await blobClientWithSAS.setTags(tags); + + const properties = await blobClientWithSAS.getProperties(); + assert.equal(properties.cacheControl, "cache-control-override"); + assert.equal(properties.contentDisposition, "content-disposition-override"); + assert.equal(properties.contentEncoding, "content-encoding-override"); + assert.equal(properties.contentLanguage, "content-language-override"); + assert.equal(properties.contentType, "content-type-override"); + + await containerClient.delete(); + }); + it("generateBlobSASQueryParameters should work for blob snapshot", async () => { const now = recorder.newDate("now"); now.setMinutes(now.getMinutes() - 5); // Skip clock skew with server @@ -802,8 +862,10 @@ describe("Shared Access Signature (SAS) generation Node.js only", () => { await containerClient.delete(); }); - it("generateAccountSASQueryParameters should work for blob version delete", async function () { - if (isBlobVersioningDisabled()) { this.skip(); } + it("generateAccountSASQueryParameters should work for blob version delete", async function() { + if (isBlobVersioningDisabled()) { + this.skip(); + } // create versions const containerName = recorder.getUniqueName("container"); @@ -846,8 +908,10 @@ describe("Shared Access Signature (SAS) generation Node.js only", () => { await containerClientwithSAS.delete(); }); - it("generateBlobSASQueryParameters should work for blob version delete", async function () { - if (isBlobVersioningDisabled()) { this.skip(); } + it("generateBlobSASQueryParameters should work for blob version delete", async function() { + if (isBlobVersioningDisabled()) { + this.skip(); + } // create versions const containerName = recorder.getUniqueName("container"); @@ -879,7 +943,7 @@ describe("Shared Access Signature (SAS) generation Node.js only", () => { ipRange: { start: "0.0.0.0", end: "255.255.255.255" }, permissions: BlobSASPermissions.parse("racwdx"), protocol: SASProtocol.HttpsAndHttp, - versionId: uploadRes.versionId, + versionId: uploadRes.versionId }, sharedKeyCredential as StorageSharedKeyCredential ); @@ -893,15 +957,17 @@ describe("Shared Access Signature (SAS) generation Node.js only", () => { }); // TODO: prepare ACCOUNT_TOKEN for the test account - it.skip("GenerateUserDelegationSAS should work for blob version delete", async function () { - if (isBlobVersioningDisabled()) { this.skip(); } + it.skip("GenerateUserDelegationSAS should work for blob version delete", async function() { + if (isBlobVersioningDisabled()) { + this.skip(); + } // Try to get blobServiceClient object with TokenCredential // when ACCOUNT_TOKEN environment variable is set let blobServiceClientWithToken: BlobServiceClient | undefined; try { blobServiceClientWithToken = getTokenBSU(); - } catch { } + } catch {} // Requires bearer token for this case which cannot be generated in the runtime // Make sure this case passed in sanity test @@ -941,7 +1007,7 @@ describe("Shared Access Signature (SAS) generation Node.js only", () => { ipRange: { start: "0.0.0.0", end: "255.255.255.255" }, permissions: BlobSASPermissions.parse("racwdx"), protocol: SASProtocol.HttpsAndHttp, - versionId: uploadRes.versionId, + versionId: uploadRes.versionId }, userDelegationKey, accountName diff --git a/sdk/storage/storage-blob/test/utils/testutils.common.ts b/sdk/storage/storage-blob/test/utils/testutils.common.ts index 00c3739088e8..434695078fba 100644 --- a/sdk/storage/storage-blob/test/utils/testutils.common.ts +++ b/sdk/storage/storage-blob/test/utils/testutils.common.ts @@ -134,3 +134,16 @@ export function isSuperSet(m1?: BlobMetadata, m2?: BlobMetadata): boolean { return true; } + +/** + * Sleep for seconds. + * + * @export + * @param {number} seconds + * @returns {Promise} + */ +export function sleep(seconds: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, seconds * 1000); + }); +}