From 8cd5bc26206bb2b47ba8310685f5271c60f2b3f0 Mon Sep 17 00:00:00 2001 From: Timo van Veenendaal Date: Thu, 25 Apr 2024 10:47:30 -0700 Subject: [PATCH] [core] New multipart/form-data primitive in core-client-rest (#29047) ### Packages impacted by this PR - `@azure/core-client-rest` ### Issues associated with this PR - Resolves #28971 ### Describe the problem that is addressed by this PR - Major bump of `@azure-rest/core-client` to 2.0.0 due to introduced behavioral breaking change. - The expected body shape for `multipart/form-data` is now an array of `PartDescriptor`, which has the following fields: - `headers` and `body` representing the multipart request headers and body - Convenience fields for MIME header values: - `contentType`, for the `Content-Type` MIME header - `name`, `filename`, `dispositionType`, for constructing the `Content-Disposition` MIME header - These convenience values take precedence over any existing MIME information (name and filename) present in the request body (i.e. the `name` property on `File` and the `type` property on both `File` and `Blob`) - If the headers are set explicitly in the `headers` bag, the headers bag value takes precedence above all. - Implemented part serialization flowchart more or less as described in the Loop, with a couple of notable differences (and other less notable ones): - `string` values are put directly on the wire regardless of content type; this allows for customers to pass pre-serialized JSON to the service - If no content type is specified, and we cannot infer the content type, we default to `application/json` (i.e. there is no situation where we would throw a "cannot encode type" error) - Added support for `FormData` objects. If a `FormData` object is encountered, it is passed directly to `core-rest-pipeline` for it to handle. ### Are there test cases added in this PR? _(If not, why?)_ Yes ### To do - [ ] Port Core change to ts-http-runtime before merging --- common/config/rush/pnpm-lock.yaml | 139 +++++++--- sdk/core/core-client-rest/CHANGELOG.md | 4 +- sdk/core/core-client-rest/package.json | 2 +- .../src/helpers/isBinaryBody.ts | 22 ++ sdk/core/core-client-rest/src/multipart.ts | 204 ++++++++++++++ sdk/core/core-client-rest/src/sendRequest.ts | 77 +----- .../core-client-rest/test/multipart.spec.ts | 250 ++++++++++++++++++ .../core-client-rest/test/sendRequest.spec.ts | 189 +++++++++---- 8 files changed, 734 insertions(+), 153 deletions(-) create mode 100644 sdk/core/core-client-rest/src/helpers/isBinaryBody.ts create mode 100644 sdk/core/core-client-rest/src/multipart.ts create mode 100644 sdk/core/core-client-rest/test/multipart.spec.ts diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index b7de442d3b31..2cba976502cd 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -1182,6 +1182,20 @@ packages: '@jridgewell/trace-mapping': 0.3.25 dev: false + /@azure-rest/core-client@1.4.0: + resolution: {integrity: sha512-ozTDPBVUDR5eOnMIwhggbnVmOrka4fXCs8n8mvUo4WLLc38kki6bAOByDoVZZPz/pZy2jMt2kwfpvy/UjALj6w==} + engines: {node: '>=18.0.0'} + dependencies: + '@azure/abort-controller': 2.1.2 + '@azure/core-auth': 1.7.2 + '@azure/core-rest-pipeline': 1.15.2 + '@azure/core-tracing': 1.1.2 + '@azure/core-util': 1.9.0 + tslib: 2.6.2 + transitivePeerDependencies: + - supports-color + dev: false + /@azure-tools/test-credential@1.0.4: resolution: {integrity: sha512-O5wyYiI6bILqO9MOeQ1WhSIcKH6c3DvbpsjMPKYJ+yekmFhFUb/zU/pSKsLvyqZhqUWSACFMNri4cZd4tuW0rw==} engines: {node: '>=18.0.0'} @@ -10789,10 +10803,11 @@ packages: dev: false file:projects/agrifood-farming.tgz: - resolution: {integrity: sha512-6BGK38/gwkYwQFj7rsFPzI1CffcBJJMczbMzPxc+aJKpVhChR8OwcWXHIRcESWlcLICvzHGuifuu6Tt8U9C/Hw==, tarball: file:projects/agrifood-farming.tgz} + resolution: {integrity: sha512-YU3YqO3bFhD5ziyDmXO5wfNWMYvh07lRy3t8HtSRFy0nlzRKakp3QBy5SW53RauwOy+zA3ZwJGXPBo4++7CjKQ==, tarball: file:projects/agrifood-farming.tgz} name: '@rush-temp/agrifood-farming' version: 0.0.0 dependencies: + '@azure-rest/core-client': 1.4.0 '@azure-tools/test-credential': 1.0.4 '@azure-tools/test-recorder': 3.2.0 '@microsoft/api-extractor': 7.43.1(@types/node@18.19.31) @@ -10833,10 +10848,11 @@ packages: dev: false file:projects/ai-anomaly-detector.tgz: - resolution: {integrity: sha512-8lBes67KNhcamu3JLbKs4Qwwp5fCORV9gDUQuTxZ5qLIN9Aeqw+bIeF8Jv8BWCDP4V4mPF/q8iHYEaeJjh3B0w==, tarball: file:projects/ai-anomaly-detector.tgz} + resolution: {integrity: sha512-UpPrG7hL1wkwN0o5Xb0nLoPwbt/+ve7qwDUM428jcCU4D+a1e1vfWRbaFVfM/jxW1ZW0yJLavuEcdMLlfAMJkw==, tarball: file:projects/ai-anomaly-detector.tgz} name: '@rush-temp/ai-anomaly-detector' version: 0.0.0 dependencies: + '@azure-rest/core-client': 1.4.0 '@azure-tools/test-credential': 1.0.4 '@azure-tools/test-recorder': 3.2.0 '@microsoft/api-extractor': 7.43.1(@types/node@18.19.31) @@ -10877,10 +10893,11 @@ packages: dev: false file:projects/ai-content-safety.tgz: - resolution: {integrity: sha512-clNuLvKo7Gf3IOVmkIkhLvtxz6k4jSCMooe5ooj4qaReQlO6s+AaOALy2n5Y01Is2kaB846Dhe9aJvN6hxq1tA==, tarball: file:projects/ai-content-safety.tgz} + resolution: {integrity: sha512-II2H4blSlKnTT32+3IwVTK7lOlesqbXppBJf0nZFQW6T46t3rUtg+vJRUTk6TjZyEtWYsGV6iy/Bvdwn2l/ZZw==, tarball: file:projects/ai-content-safety.tgz} name: '@rush-temp/ai-content-safety' version: 0.0.0 dependencies: + '@azure-rest/core-client': 1.4.0 '@azure-tools/test-credential': 1.0.4 '@azure-tools/test-recorder': 3.2.0 '@microsoft/api-extractor': 7.43.1(@types/node@18.19.31) @@ -10920,10 +10937,11 @@ packages: dev: false file:projects/ai-document-intelligence.tgz: - resolution: {integrity: sha512-vgAJaGcUIIoPgXGSwH5JoPo79hDGN14Tvhj+Ev3GFIplDNlwihmdkYGjOHWIE00JqmBGP/xSAiHc5SSqUOqGbQ==, tarball: file:projects/ai-document-intelligence.tgz} + resolution: {integrity: sha512-82ISwcjBPwxy84UZ1YPGICh7mfP8oCNcd22J3xnuNpInMQdxVJ/gYXuRonk1DnME3UtxKPAImdPRaFTjCVwjDg==, tarball: file:projects/ai-document-intelligence.tgz} name: '@rush-temp/ai-document-intelligence' version: 0.0.0 dependencies: + '@azure-rest/core-client': 1.4.0 '@azure-tools/test-credential': 1.0.4 '@azure-tools/test-recorder': 3.2.0 '@azure/abort-controller': 1.1.0 @@ -10964,10 +10982,11 @@ packages: dev: false file:projects/ai-document-translator.tgz: - resolution: {integrity: sha512-yoYiezbQH+mSyE94/SlLH2aJoYkkHXbUkehPIeAX7didep1q+2o1VBZByxsLYF4VJRtdWpgWun+QZ2TI0oQopg==, tarball: file:projects/ai-document-translator.tgz} + resolution: {integrity: sha512-I6MG+g3mSJ4usdAePDujdgdxUYtdABT1QpbLGup7MXe5IA0045NyrW51B0La8XBflfjTK3XNUwTwzADELOTOWw==, tarball: file:projects/ai-document-translator.tgz} name: '@rush-temp/ai-document-translator' version: 0.0.0 dependencies: + '@azure-rest/core-client': 1.4.0 '@azure-tools/test-credential': 1.0.4 '@azure-tools/test-recorder': 3.2.0 '@microsoft/api-extractor': 7.43.1(@types/node@18.19.31) @@ -11149,10 +11168,11 @@ packages: dev: false file:projects/ai-language-textauthoring.tgz: - resolution: {integrity: sha512-Ick55epSZNDqWlKV3D2bkzrQeva5llsCsKT3wqxJM2OqTDVWvRrkSoM2T7JZNBZoqY2RnSfSgL+1zbXKwINI/g==, tarball: file:projects/ai-language-textauthoring.tgz} + resolution: {integrity: sha512-/gf8/UVuuQAuzK3fUcA8+bvzrSaVJ4hqumAc0DBkmy0w6SXAsgu2dDEBRAno4vsB2XK7OeajKFfOSVPbv1Y0Og==, tarball: file:projects/ai-language-textauthoring.tgz} name: '@rush-temp/ai-language-textauthoring' version: 0.0.0 dependencies: + '@azure-rest/core-client': 1.4.0 '@microsoft/api-extractor': 7.43.1(@types/node@18.19.31) '@types/mocha': 10.0.6 '@types/node': 18.19.31 @@ -11263,10 +11283,11 @@ packages: dev: false file:projects/ai-translation-text.tgz: - resolution: {integrity: sha512-zWsMjqx1NRkvvq6FyRIQNAzY3FD2x8gaPy45G2O4xGxVPxKXEdZxMVmh3bsHMkItWMRun1Z9WZTgHfPItX9A3g==, tarball: file:projects/ai-translation-text.tgz} + resolution: {integrity: sha512-7ClXHXhOWPPFPGFcbkSDxl2ZNA53QNO2n9M49DWJChZBziXvVlnuuYft9PB58atVK3gnjiuOutQznl7QrnF0hA==, tarball: file:projects/ai-translation-text.tgz} name: '@rush-temp/ai-translation-text' version: 0.0.0 dependencies: + '@azure-rest/core-client': 1.4.0 '@azure-tools/test-credential': 1.0.4 '@azure-tools/test-recorder': 3.2.0 '@microsoft/api-extractor': 7.43.1(@types/node@18.19.31) @@ -11306,10 +11327,11 @@ packages: dev: false file:projects/ai-vision-image-analysis.tgz: - resolution: {integrity: sha512-YYaqGHsc7V8u3Nlca2mSTbvm/nnU/W4GeUy6BwmUXUV1orOaWLFV4ha1Oto0w8OiVcjWS8nuP64j8txQYkbwLg==, tarball: file:projects/ai-vision-image-analysis.tgz} + resolution: {integrity: sha512-BqrDyqYI0Wmf1nvDq027Bs4nKWQSg+39YKjzNUULj6eP3lWuCldFTat1IY9paLd1RTMqXLHHx+QSV7QWCx8xBA==, tarball: file:projects/ai-vision-image-analysis.tgz} name: '@rush-temp/ai-vision-image-analysis' version: 0.0.0 dependencies: + '@azure-rest/core-client': 1.4.0 '@azure-tools/test-credential': 1.0.4 '@azure-tools/test-recorder': 3.2.0 '@microsoft/api-extractor': 7.39.5(@types/node@20.10.8) @@ -11391,10 +11413,11 @@ packages: dev: false file:projects/api-management-custom-widgets-tools.tgz: - resolution: {integrity: sha512-HIbogMftv1mSQAn2Lb24K79DiYVtOeUXwqPMbANi0TrZRiUFYblvejH70uTd9pATQUQ34dd2/tJXXPtBF259Fg==, tarball: file:projects/api-management-custom-widgets-tools.tgz} + resolution: {integrity: sha512-cElNU+kU/8pKQ0MpoMAMvpA/uPMCQGBpI1o1oHI0HOA9VAmYfa0L8DAhFCr/5k7CscPtndASt0esZol+2nQPqA==, tarball: file:projects/api-management-custom-widgets-tools.tgz} name: '@rush-temp/api-management-custom-widgets-tools' version: 0.0.0 dependencies: + '@azure-rest/core-client': 1.4.0 '@azure/storage-blob': 12.17.0 '@microsoft/api-extractor': 7.43.1(@types/node@18.19.31) '@types/node': 18.19.31 @@ -11796,10 +11819,11 @@ packages: dev: false file:projects/arm-appservice.tgz: - resolution: {integrity: sha512-iCuTsffjZnrCH+2cY3p0gP2VEAveJ7qXDVb0fVDuIuoX3StbWjyj3vy9qA2F5tzp7nVeTCOXPxzQ7s2TDfD2lg==, tarball: file:projects/arm-appservice.tgz} + resolution: {integrity: sha512-PMUwMGTVt2vPo12IJmE2NcRBBR12AsCd8yCzJqahAUCbg6kNwH/3gzecOPZWfFH8yZHhe7IgzOkIT+ZcCvqFgw==, tarball: file:projects/arm-appservice.tgz} name: '@rush-temp/arm-appservice' version: 0.0.0 dependencies: + '@azure-rest/core-client': 1.4.0 '@azure-tools/test-credential': 1.0.4 '@azure-tools/test-recorder': 3.2.0 '@microsoft/api-extractor': 7.43.1(@types/node@18.19.31) @@ -12551,10 +12575,11 @@ packages: dev: false file:projects/arm-compute.tgz: - resolution: {integrity: sha512-s5IhxKceFpMrw+OdG14bujPfUNyIIb6N9OiUe7A+8OPblUN+76unp9SpK2iQqskINpmGsGm7UuXCjkhGSdmpPA==, tarball: file:projects/arm-compute.tgz} + resolution: {integrity: sha512-e8qORBHLs611//YWQR6aqLwl4U4F2bvUCgawAwBre+znaO2IwW47Qx45gxpkuLZFVkzp8QwlUTeDex3oNFt70Q==, tarball: file:projects/arm-compute.tgz} name: '@rush-temp/arm-compute' version: 0.0.0 dependencies: + '@azure-rest/core-client': 1.4.0 '@azure-tools/test-credential': 1.0.4 '@azure-tools/test-recorder': 3.2.0 '@azure/arm-network': 32.2.0 @@ -12791,10 +12816,11 @@ packages: dev: false file:projects/arm-containerservice.tgz: - resolution: {integrity: sha512-fCEK8sMUTP4HUMR5APU4nLHa+byYmPX4ClgxQ9p7MY1C3ar18HBmpTO61hHOCzaNPA5NRKN9IdzOU4bX0jBpGA==, tarball: file:projects/arm-containerservice.tgz} + resolution: {integrity: sha512-X9RgOTSSNl6qP9LUtomJ+mZJV0kIrr1FC0xwOXNYaGSO2yPdCE2RszD02dUszxHrmlVC1WzJ6EPMyNVylnaeKQ==, tarball: file:projects/arm-containerservice.tgz} name: '@rush-temp/arm-containerservice' version: 0.0.0 dependencies: + '@azure-rest/core-client': 1.4.0 '@azure-tools/test-credential': 1.0.4 '@azure-tools/test-recorder': 3.2.0 '@microsoft/api-extractor': 7.43.1(@types/node@18.19.31) @@ -15394,10 +15420,11 @@ packages: dev: false file:projects/arm-network.tgz: - resolution: {integrity: sha512-EzCXricZ1HQ0zCnM+5Jb1IKDWgcYFfV55IbTIXNLZEirsKeU8p9aeVsvIC8cga91Sryc5jNB6rdqgTeJNelnmQ==, tarball: file:projects/arm-network.tgz} + resolution: {integrity: sha512-w/nDD+STjW8t+UcqBUte4K4p2gAgIO5rTc5GdoS3KHnPBdi7pboseb5LZHgY/kixCbhmiG7zgXahGvvrnXcrjg==, tarball: file:projects/arm-network.tgz} name: '@rush-temp/arm-network' version: 0.0.0 dependencies: + '@azure-rest/core-client': 1.4.0 '@azure-tools/test-credential': 1.0.4 '@azure-tools/test-recorder': 3.2.0 '@microsoft/api-extractor': 7.43.1(@types/node@18.19.31) @@ -16849,10 +16876,11 @@ packages: dev: false file:projects/arm-servicefabric.tgz: - resolution: {integrity: sha512-HrPgMeapIfK7BWL0Nd5yecMEx4jOkWzyYx8xbVUuA3KMkVN1SLiUk3oEyJ7HPb8WFMnnO4TCh6x3V4eu8Jy4FA==, tarball: file:projects/arm-servicefabric.tgz} + resolution: {integrity: sha512-y4llv5+mZjh+xg3sQo5BBb4WUA41RRIjHivHyb/80y0nvLdtjaqHg4JwLsQgelpvL6ASn5EaZ9Stic/4rmnITA==, tarball: file:projects/arm-servicefabric.tgz} name: '@rush-temp/arm-servicefabric' version: 0.0.0 dependencies: + '@azure-rest/core-client': 1.4.0 '@azure-tools/test-credential': 1.0.4 '@azure-tools/test-recorder': 3.2.0 '@microsoft/api-extractor': 7.43.1(@types/node@18.19.31) @@ -18228,10 +18256,11 @@ packages: dev: false file:projects/communication-job-router.tgz: - resolution: {integrity: sha512-ghJUQ+lWN6aAkr2wTq+9MbJKgx3e9q+1Fka9/MdbJAIa0iMs4og/U+wt0Y+CoUw4hDUCvGjvk617ll4J4ZAj9A==, tarball: file:projects/communication-job-router.tgz} + resolution: {integrity: sha512-ivEusWiLnpMyvODtrDKzAS9MFion+5TWzsQF6GN9GR+w6PyB8V6HGGuIQN333PBUaSEiv6msN9FuEDcXGJmpvQ==, tarball: file:projects/communication-job-router.tgz} name: '@rush-temp/communication-job-router' version: 0.0.0 dependencies: + '@azure-rest/core-client': 1.4.0 '@azure-tools/test-credential': 1.0.4 '@azure-tools/test-recorder': 3.2.0 '@microsoft/api-extractor': 7.43.1(@types/node@18.19.31) @@ -18271,10 +18300,11 @@ packages: dev: false file:projects/communication-messages.tgz: - resolution: {integrity: sha512-IQSUrk63aCPP8ws5ok4nZfQNns/QavJkNXUTiB+oKqJrO8G5/I/i+Smh1txrjjKyJCBGYyq+0e5rMKF/2j+7qg==, tarball: file:projects/communication-messages.tgz} + resolution: {integrity: sha512-bkn+U65q9nFSnYE8eLlstXs2qP2MaBaj4gWnQugCP3v+yqFgruH49Qn11wSQbXp4wPQfHZQK27w72xsYimr+ig==, tarball: file:projects/communication-messages.tgz} name: '@rush-temp/communication-messages' version: 0.0.0 dependencies: + '@azure-rest/core-client': 1.4.0 '@azure-tools/test-credential': 1.0.4 '@azure-tools/test-recorder': 3.2.0 '@azure/identity': 3.4.2 @@ -18616,10 +18646,11 @@ packages: dev: false file:projects/confidential-ledger.tgz: - resolution: {integrity: sha512-VDCRWmch58A0tcMF+DOEB7+QomrjZmTSLjiZ5VnZE4INbxB/T7ZgVBfOtdWEKfajDyv+oxAoC8jihoowFiVO9Q==, tarball: file:projects/confidential-ledger.tgz} + resolution: {integrity: sha512-s0Uyboxy0H9MAbe2d+zLR+HTlvYSzs5IYMU08ddj6911SmIiJGUC1Y7vk4E25bZr3FmbEn1HFPs79kzp8UsPDw==, tarball: file:projects/confidential-ledger.tgz} name: '@rush-temp/confidential-ledger' version: 0.0.0 dependencies: + '@azure-rest/core-client': 1.4.0 '@azure-tools/test-credential': 1.0.4 '@azure-tools/test-recorder': 3.2.0 '@microsoft/api-extractor': 7.43.1(@types/node@18.19.31) @@ -19169,10 +19200,11 @@ packages: dev: false file:projects/defender-easm.tgz: - resolution: {integrity: sha512-mwphRJHZXN68Oh7PI1ZDQjGrprhx78XpcXgKR5XpcW81qdDTVc/myaLRFMsy1XNJNO4terLPZpAOkrpCKwrROA==, tarball: file:projects/defender-easm.tgz} + resolution: {integrity: sha512-b4QY37P8yvjQsphSIMDOVYyIhU53X0GrDC3wkX8GbzqNXCaHuB6w195WhWXBs98edq7n4ySRjZwzxcoQ3YNPCA==, tarball: file:projects/defender-easm.tgz} name: '@rush-temp/defender-easm' version: 0.0.0 dependencies: + '@azure-rest/core-client': 1.4.0 '@azure-tools/test-credential': 1.0.4 '@azure-tools/test-recorder': 3.2.0 '@microsoft/api-extractor': 7.43.1(@types/node@18.19.31) @@ -19276,10 +19308,11 @@ packages: dev: false file:projects/developer-devcenter.tgz: - resolution: {integrity: sha512-WDiUmv2dTqJRiWuYqWBldOGZQ/C0XJUNnZhvJmK2BQ5KEKwtaOxvja+sLDO2iORNa7e2FnQoUT0MozAlO2mDNw==, tarball: file:projects/developer-devcenter.tgz} + resolution: {integrity: sha512-dQtk4GFZuTJll6Q6ByrWLBgHs2uh4HF4LOfmcLK37n68gJ54j3Z+3ft5kDS/29pqWI5j7VDSMJ/23VmCwToXXA==, tarball: file:projects/developer-devcenter.tgz} name: '@rush-temp/developer-devcenter' version: 0.0.0 dependencies: + '@azure-rest/core-client': 1.4.0 '@azure-tools/test-credential': 1.0.4 '@azure-tools/test-recorder': 3.2.0 '@azure/abort-controller': 1.1.0 @@ -19490,10 +19523,11 @@ packages: dev: false file:projects/eventgrid-namespaces.tgz: - resolution: {integrity: sha512-zlgDLY2/atr0SEvFXbq0wzSIY49exfSfibktCPBtxYPIF4JrprNAnk7Ly3ytPM9rRWYHGwMKQzDeafNownfp3g==, tarball: file:projects/eventgrid-namespaces.tgz} + resolution: {integrity: sha512-ISX2HAkVQ38oFHYkBv7TCfCl511ZNdK9V0qNdlzuE6uTBlqx7HBANns2QkwR8hXwtZ07JEdhdfIK8GFBa5Os6g==, tarball: file:projects/eventgrid-namespaces.tgz} name: '@rush-temp/eventgrid-namespaces' version: 0.0.0 dependencies: + '@azure-rest/core-client': 1.4.0 '@azure-tools/test-recorder': 3.2.0 '@microsoft/api-extractor': 7.43.1(@types/node@18.19.31) '@types/chai': 4.3.14 @@ -19717,10 +19751,11 @@ packages: dev: false file:projects/health-insights-cancerprofiling.tgz: - resolution: {integrity: sha512-WasQNfrqnYqvOotvsseyfiTxeFHWQ+WUDauCMNdoV6+1TP1ne9+ZleD6cWlUAFBd1nodKXC+XhSPsdti0+tBEA==, tarball: file:projects/health-insights-cancerprofiling.tgz} + resolution: {integrity: sha512-Q5vEroLYjL6QMY3cpOAGe4pRfL0a1kSknVkgbqVkr7ULHOpt+lCE9w3r41H9kyuR23YDj4s84eZWIetSrnF7uQ==, tarball: file:projects/health-insights-cancerprofiling.tgz} name: '@rush-temp/health-insights-cancerprofiling' version: 0.0.0 dependencies: + '@azure-rest/core-client': 1.4.0 '@azure-tools/test-credential': 1.0.4 '@azure-tools/test-recorder': 3.2.0 '@azure/abort-controller': 1.1.0 @@ -19761,10 +19796,11 @@ packages: dev: false file:projects/health-insights-clinicalmatching.tgz: - resolution: {integrity: sha512-UG5umW6ZU+/dPMZU8w4iMQUvEbchYWFYfxZ3fh6NNjuqK9PRieJa0BjInMqFMPhQKruZhE3LO4DLBONmZusi5w==, tarball: file:projects/health-insights-clinicalmatching.tgz} + resolution: {integrity: sha512-9MP0CnGT9TyjmzX13VtNJgQOB5eR1b6T+pxH6xBGdFZcJFOYn3DF3G/sgqFnJOnqm5laaJ0BKmoqGfLWaQ+27g==, tarball: file:projects/health-insights-clinicalmatching.tgz} name: '@rush-temp/health-insights-clinicalmatching' version: 0.0.0 dependencies: + '@azure-rest/core-client': 1.4.0 '@azure-tools/test-credential': 1.0.4 '@azure-tools/test-recorder': 3.2.0 '@azure/abort-controller': 1.1.0 @@ -19805,10 +19841,11 @@ packages: dev: false file:projects/health-insights-radiologyinsights.tgz: - resolution: {integrity: sha512-SWAxn9/n3re6Wv+aGXQHizDC3YRVHZEP3zUR+DzjlRvQW35Wi2ESmKW0VhHgU6KPFS8ZcSykOyiC9hMuaEKVjA==, tarball: file:projects/health-insights-radiologyinsights.tgz} + resolution: {integrity: sha512-SxoiuxTjHLYT74+n7UWnk1icSHytquniSLS5NJh5Nf9z2xKOtvxOLUQHuSBqi6tdnWVZT8wz1fuV6AQVFIKs6Q==, tarball: file:projects/health-insights-radiologyinsights.tgz} name: '@rush-temp/health-insights-radiologyinsights' version: 0.0.0 dependencies: + '@azure-rest/core-client': 1.4.0 '@azure-tools/test-credential': 1.0.4 '@azure-tools/test-recorder': 3.2.0 '@azure/abort-controller': 1.1.0 @@ -20002,10 +20039,11 @@ packages: dev: false file:projects/iot-device-update.tgz: - resolution: {integrity: sha512-z2H4PXhQw+cP8/5gZa8YqNjbGvj0lHkxkEuL+grZb2EEIx3E0IBqh1yF3tDlUebR0PjoUWfVkfSaZwlho1nCbA==, tarball: file:projects/iot-device-update.tgz} + resolution: {integrity: sha512-wyJMIwH3I2g3KjQZ72qQ0rNJpbpgIyVKMwdrOPJ6WfG/WgpRHXk7B/ri0Sm9qBC5r52zUHHloKo2rgjF9fZg2g==, tarball: file:projects/iot-device-update.tgz} name: '@rush-temp/iot-device-update' version: 0.0.0 dependencies: + '@azure-rest/core-client': 1.4.0 '@azure-tools/test-credential': 1.0.4 '@azure-tools/test-recorder': 3.2.0 '@microsoft/api-extractor': 7.43.1(@types/node@18.19.31) @@ -20282,10 +20320,11 @@ packages: dev: false file:projects/load-testing.tgz: - resolution: {integrity: sha512-B6V59q8zcKEGvE0t8knlTyRMwu8cz5jl+LzWHQy0C3aP0x51sz9vWKMn72YZ6mbZhlAV8MOVGbSiZRYvbESosw==, tarball: file:projects/load-testing.tgz} + resolution: {integrity: sha512-vhqc5S3Tcjd3665DGFbX8uQwbC6DEHvLEvvyHGVVPIY2PE6gXtxKWboNv5bYLJmhaaCa+vdflL1P7hkWe4tDEw==, tarball: file:projects/load-testing.tgz} name: '@rush-temp/load-testing' version: 0.0.0 dependencies: + '@azure-rest/core-client': 1.4.0 '@azure-tools/test-credential': 1.0.4 '@azure-tools/test-recorder': 3.2.0 '@azure/abort-controller': 1.1.0 @@ -20379,10 +20418,11 @@ packages: dev: false file:projects/maps-geolocation.tgz: - resolution: {integrity: sha512-dw9U8GEHEvQmyP6B74rXyLJMsdz8W2L5QjmX1BiWVEJnXlyMAeOUY0BBWIEl+2RE9ayWIhixlk6c6h1rzGfgvw==, tarball: file:projects/maps-geolocation.tgz} + resolution: {integrity: sha512-WFOsl/ClPrKOOofujPmRASHax4cz+r84hJvb5qpkEBPZJTY4ihhAzXYpm3ZzqGkv+9AbPt4t9P/7pLZ9pgbsGg==, tarball: file:projects/maps-geolocation.tgz} name: '@rush-temp/maps-geolocation' version: 0.0.0 dependencies: + '@azure-rest/core-client': 1.4.0 '@azure-tools/test-credential': 1.0.4 '@azure-tools/test-recorder': 3.2.0 '@azure/maps-common': 1.0.0-beta.2 @@ -20423,10 +20463,11 @@ packages: dev: false file:projects/maps-render.tgz: - resolution: {integrity: sha512-qYc6fPZ15FNnWlkoQ1H/a1p/vdExMoyKomhhYaGCOXT12wFxjZmuxkJofPTKx2ge3qNzWU9vFuWoNtdLVUKoWQ==, tarball: file:projects/maps-render.tgz} + resolution: {integrity: sha512-mG5sGORPKNgf2yCnIP0Fw/0ysuIXqx+PuEGpR9SDH9RZZ2M2m/w1ajKqda4R+upKBCGln42HaLrfHoZGvkcn9Q==, tarball: file:projects/maps-render.tgz} name: '@rush-temp/maps-render' version: 0.0.0 dependencies: + '@azure-rest/core-client': 1.4.0 '@azure-tools/test-credential': 1.0.4 '@azure-tools/test-recorder': 3.2.0 '@azure/maps-common': 1.0.0-beta.2 @@ -20467,10 +20508,11 @@ packages: dev: false file:projects/maps-route.tgz: - resolution: {integrity: sha512-5vEkGd2V0lq1vZ/7MJjFdbWcmv0wwkjei5akXaxBa2htjmFyY77y7ZpVAinNV5vqiI0SrKnZPHCiITKzZn9KCg==, tarball: file:projects/maps-route.tgz} + resolution: {integrity: sha512-IOEZ+7y0e6XhsjuwnUfa9uYeurrhxzK8vHIzJvWOp7sluLEn4oXyisIoi8IcHPPzfrb7c2FEkhhpxP6yDTk28g==, tarball: file:projects/maps-route.tgz} name: '@rush-temp/maps-route' version: 0.0.0 dependencies: + '@azure-rest/core-client': 1.4.0 '@azure-tools/test-credential': 1.0.4 '@azure-tools/test-recorder': 3.2.0 '@azure/maps-common': 1.0.0-beta.2 @@ -20511,10 +20553,11 @@ packages: dev: false file:projects/maps-search.tgz: - resolution: {integrity: sha512-rIreVVKjpgxa5lGOGFVMPEz5nWlPIgpoH89JyWBjfM3qjUrdmcRi6BCmZze729cQTmEgX4vHdZ9gYq0CE8fMiA==, tarball: file:projects/maps-search.tgz} + resolution: {integrity: sha512-CuryLCuK/0hRH8p5CCxbkHv/GMlVIsPUl+axOAcHO3jKaFCro8hkoQL62wPfAm5vRm5dxGYC7W02hEPrtkACcg==, tarball: file:projects/maps-search.tgz} name: '@rush-temp/maps-search' version: 0.0.0 dependencies: + '@azure-rest/core-client': 1.4.0 '@azure-tools/test-credential': 1.0.4 '@azure-tools/test-recorder': 3.2.0 '@azure/maps-common': 1.0.0-beta.2 @@ -20837,10 +20880,11 @@ packages: dev: false file:projects/notification-hubs.tgz: - resolution: {integrity: sha512-IuTJot7puVmZJWEU6bao9U6/W22NXEdFG3Ho72I5yGzz6igZNK1rh9UHuf5Ijyp1fQLGWoYv6s2wEScUtnMnqA==, tarball: file:projects/notification-hubs.tgz} + resolution: {integrity: sha512-7os4zqqGI5GAQ5zSIhxI4/UJc23DjQvXVT6c+3FIPtX5NHzQgsIvDQaFGXdap2zM+eQIwuOO10Ai1W0gq0PX+g==, tarball: file:projects/notification-hubs.tgz} name: '@rush-temp/notification-hubs' version: 0.0.0 dependencies: + '@azure-rest/core-client': 1.4.0 '@microsoft/api-extractor': 7.43.1(@types/node@18.19.31) '@types/node': 18.19.31 '@vitest/browser': 1.5.0(playwright@1.43.1)(vitest@1.5.0) @@ -20870,10 +20914,11 @@ packages: dev: false file:projects/openai-1.tgz: - resolution: {integrity: sha512-izpP1I7t1lwtB7MenA5PrdSSvI7vJXYncYtILfrecCEbv3DhcFEfWfv1ml/G8DbU+r/2G03w2yhu2Qg95axEAA==, tarball: file:projects/openai-1.tgz} + resolution: {integrity: sha512-uH9goL81523v25MwZsG21p+FZ3STQ92W05oOCfwF2mofjYDNAwTnvvfEwcylZm1a1znlOSosuUvIRSGIbpMUMA==, tarball: file:projects/openai-1.tgz} name: '@rush-temp/openai-1' version: 0.0.0 dependencies: + '@azure-rest/core-client': 1.4.0 '@azure-tools/test-credential': 1.0.4 '@azure-tools/test-recorder': 3.2.0 '@azure/abort-controller': 1.1.0 @@ -20913,10 +20958,11 @@ packages: dev: false file:projects/openai-assistants.tgz: - resolution: {integrity: sha512-2d+8e7mfrwjpdQJNRHbErXypRwyfwymOyTfmjaUut92mqaJddRv4DhZ8ONkqoKXrWUcRy26tNdtir8rCEgWpUw==, tarball: file:projects/openai-assistants.tgz} + resolution: {integrity: sha512-jAqH1b7EuTPDFwJob/5vJp8+KL/1mSj3aCSnshiLJ7lhdQoFLNh7a3iJVbwKAzuMz4+zpdoFi6kndomPAir1Ig==, tarball: file:projects/openai-assistants.tgz} name: '@rush-temp/openai-assistants' version: 0.0.0 dependencies: + '@azure-rest/core-client': 1.4.0 '@azure-tools/test-credential': 1.0.4 '@azure-tools/test-recorder': 3.2.0 '@microsoft/api-extractor': 7.43.1(@types/node@18.19.31) @@ -20954,10 +21000,11 @@ packages: dev: false file:projects/openai.tgz: - resolution: {integrity: sha512-YCl9X0UlCUKuPLNUPXV4yew2s4mBVPAXkcPwLuDS8GId2QGI6mqwdbHuUaRIq3CAxJG3TVMXQ/B9aRttwcxRTQ==, tarball: file:projects/openai.tgz} + resolution: {integrity: sha512-PkpUH/G0OwQ4thtMdzw8+ejEu9/uvaD9zCkNFWx/I7B71/Izxu+f/y9BYBgJaSgnsRdPhSuoW/fglojTUpIcqQ==, tarball: file:projects/openai.tgz} name: '@rush-temp/openai' version: 0.0.0 dependencies: + '@azure-rest/core-client': 1.4.0 '@azure/abort-controller': 1.1.0 '@microsoft/api-extractor': 7.43.1(@types/node@18.19.31) '@types/node': 18.19.31 @@ -20969,6 +21016,8 @@ packages: source-map-support: 0.5.21 tslib: 2.6.2 typescript: 5.4.5 + transitivePeerDependencies: + - supports-color dev: false file:projects/opentelemetry-instrumentation-azure-sdk.tgz: @@ -21471,10 +21520,11 @@ packages: dev: false file:projects/purview-administration.tgz: - resolution: {integrity: sha512-vlpKfFIUEiY+8k7jIH4iTqjMYwCTrDvJtsRhXyjQUs/m5HJLb3ETZJZrFpwTkMUFwWFg+OCY9kiE/BfpspDXzg==, tarball: file:projects/purview-administration.tgz} + resolution: {integrity: sha512-uB6Fq2i8vJKnDzYMSeRTxrNvR1wjmdrvEmRENX7G66eC8lySZ/JOZmUl30O7LMiiesyjC7PE2SRJLJ1Cv/1SxQ==, tarball: file:projects/purview-administration.tgz} name: '@rush-temp/purview-administration' version: 0.0.0 dependencies: + '@azure-rest/core-client': 1.4.0 '@azure-tools/test-credential': 1.0.4 '@azure-tools/test-recorder': 3.2.0 '@microsoft/api-extractor': 7.43.1(@types/node@18.19.31) @@ -21513,10 +21563,11 @@ packages: dev: false file:projects/purview-catalog.tgz: - resolution: {integrity: sha512-ECYUf1cSbC1okgd94qbVmQe7ONYTCdPZK9bkQ46thBumNoT4qOYhs4s1xba+0w2D1qO2KTuC9ramppKBPc4HOQ==, tarball: file:projects/purview-catalog.tgz} + resolution: {integrity: sha512-MYD60ja+WB3Gmamn9Vl2HA9fEngcji2BZbhc/tfizWDDwnskM0Unpey8c6uvsbH2f1WQbGoulYCL9ZJqZQ8zug==, tarball: file:projects/purview-catalog.tgz} name: '@rush-temp/purview-catalog' version: 0.0.0 dependencies: + '@azure-rest/core-client': 1.4.0 '@azure-tools/test-credential': 1.0.4 '@azure-tools/test-recorder': 3.2.0 '@microsoft/api-extractor': 7.43.1(@types/node@18.19.31) @@ -21555,10 +21606,11 @@ packages: dev: false file:projects/purview-datamap.tgz: - resolution: {integrity: sha512-wyojIgYGfilwobQKLSGat5SobZ3R/J5s492rXCWPKTK4Vz0Cj5vssEJcKdlvEdjnqTp90UON/W1OvZyj+DAT2Q==, tarball: file:projects/purview-datamap.tgz} + resolution: {integrity: sha512-2IaN8JyBJ12CxhJrG8aJNXOlIvryQPzuosufVMdbxh7ledrmVsuTJ8KV/EwZbiy/hTytcEUPuhCgVd0Qxn534A==, tarball: file:projects/purview-datamap.tgz} name: '@rush-temp/purview-datamap' version: 0.0.0 dependencies: + '@azure-rest/core-client': 1.4.0 '@azure-tools/test-credential': 1.0.4 '@azure-tools/test-recorder': 3.2.0 '@microsoft/api-extractor': 7.43.1(@types/node@18.19.31) @@ -21598,10 +21650,11 @@ packages: dev: false file:projects/purview-scanning.tgz: - resolution: {integrity: sha512-evTlMLTdLO9q2UhCi8Neul7e4hLH5CY0ixfXn4SJi/QRKmatnhchDVWVUgPfLiNmKrTiK258/WIYw/soj5rngA==, tarball: file:projects/purview-scanning.tgz} + resolution: {integrity: sha512-dRGR2v1IzbHRvLeKCWOPS1+nT5r63t60ranyRTakCqetv35h/gn6u1MqKU8aO7gvIfNvJHrrGusS0cwZ2wEcew==, tarball: file:projects/purview-scanning.tgz} name: '@rush-temp/purview-scanning' version: 0.0.0 dependencies: + '@azure-rest/core-client': 1.4.0 '@azure-tools/test-credential': 1.0.4 '@azure-tools/test-recorder': 3.2.0 '@microsoft/api-extractor': 7.43.1(@types/node@18.19.31) @@ -21640,10 +21693,11 @@ packages: dev: false file:projects/purview-sharing.tgz: - resolution: {integrity: sha512-7IY6MkwFRb1GjWNDjPN44FtwY+VB0Ak46zYC5fV5bwQmniAQlkmk0ktRy/Dcb1RKgAlciJ3tkm5TRafS0eV28g==, tarball: file:projects/purview-sharing.tgz} + resolution: {integrity: sha512-ReCN6K3/jsiNnabBlBWeJavj9Ez2znyjRjHjILLt0AwOOfr1c4oDsurlzMr7FwGhD9P3qrcqseTFcci87qPRRg==, tarball: file:projects/purview-sharing.tgz} name: '@rush-temp/purview-sharing' version: 0.0.0 dependencies: + '@azure-rest/core-client': 1.4.0 '@azure-tools/test-credential': 1.0.4 '@azure-tools/test-recorder': 3.2.0 '@azure/abort-controller': 1.1.0 @@ -21684,10 +21738,11 @@ packages: dev: false file:projects/purview-workflow.tgz: - resolution: {integrity: sha512-dTYT0PnjHmJpgWG6cLBpJmn5Lt7+8t4grgdPa4hR8udWTfYkbzbLhE8tiMmcy6KIeXPLOhe1OOBg/GuZK9i5cA==, tarball: file:projects/purview-workflow.tgz} + resolution: {integrity: sha512-hhs/lS+MRi4F4QhvnW/NR7eflUK6AUiYZZ5qU79BHQb63G7FvXVtLKq0fKWJsD8MGfF5Xer9ye8HD3lMPt7mpQ==, tarball: file:projects/purview-workflow.tgz} name: '@rush-temp/purview-workflow' version: 0.0.0 dependencies: + '@azure-rest/core-client': 1.4.0 '@azure-tools/test-credential': 1.0.4 '@azure-tools/test-recorder': 3.2.0 '@microsoft/api-extractor': 7.43.1(@types/node@18.19.31) @@ -22336,10 +22391,11 @@ packages: dev: false file:projects/synapse-access-control.tgz: - resolution: {integrity: sha512-0eKmliIBl0+tOHF8Wk0SNhnN+TqQFi3RVDAJ05JEVkFti8ELm8Fb64YzsQ3DWfOFLFgFp+e4xHc2wn8sPI3xdg==, tarball: file:projects/synapse-access-control.tgz} + resolution: {integrity: sha512-YhwR7at6DXe0Lor8l4/jztijmEAfzEFm2gmfyosa15kKNECyALiLSREFLI2XiCoZc+F3Ut0e21zWzZbm5RxjDQ==, tarball: file:projects/synapse-access-control.tgz} name: '@rush-temp/synapse-access-control' version: 0.0.0 dependencies: + '@azure-rest/core-client': 1.4.0 '@azure-tools/test-credential': 1.0.4 '@azure-tools/test-recorder': 3.2.0 '@microsoft/api-extractor': 7.43.1(@types/node@18.19.31) @@ -22555,10 +22611,11 @@ packages: dev: false file:projects/template-dpg.tgz: - resolution: {integrity: sha512-uA5DX3EkzXe7eBBynoO9XTfPJdgelyY8mmPZ7SesCLIWTtl1LMk6f+SaFn7hEmJLJ+xKAloAxoBztYwAV5b67g==, tarball: file:projects/template-dpg.tgz} + resolution: {integrity: sha512-wVhCq0Sk8PSQ9epard4APluKg3piAJLeuDZghfNGIi0gOshN+75Su1ZswaTa3JL5hTUREE60ABX8tlUuOjwYIA==, tarball: file:projects/template-dpg.tgz} name: '@rush-temp/template-dpg' version: 0.0.0 dependencies: + '@azure-rest/core-client': 1.4.0 '@azure-tools/test-credential': 1.0.4 '@azure-tools/test-recorder': 3.2.0 '@microsoft/api-extractor': 7.43.1(@types/node@18.19.31) diff --git a/sdk/core/core-client-rest/CHANGELOG.md b/sdk/core/core-client-rest/CHANGELOG.md index b73af1ce37f2..f19eea266c8e 100644 --- a/sdk/core/core-client-rest/CHANGELOG.md +++ b/sdk/core/core-client-rest/CHANGELOG.md @@ -1,11 +1,13 @@ # Release History -## 1.4.1 (Unreleased) +## 2.0.0 (Unreleased) ### Features Added ### Breaking Changes +- Changed the format accepted for `multipart/form-data` requests. + ### Bugs Fixed ### Other Changes diff --git a/sdk/core/core-client-rest/package.json b/sdk/core/core-client-rest/package.json index cbce6bebad4f..c9ae03edf902 100644 --- a/sdk/core/core-client-rest/package.json +++ b/sdk/core/core-client-rest/package.json @@ -1,6 +1,6 @@ { "name": "@azure-rest/core-client", - "version": "1.4.1", + "version": "2.0.0", "description": "Core library for interfacing with Azure Rest Clients", "sdk-type": "client", "type": "module", diff --git a/sdk/core/core-client-rest/src/helpers/isBinaryBody.ts b/sdk/core/core-client-rest/src/helpers/isBinaryBody.ts new file mode 100644 index 000000000000..8434d754a477 --- /dev/null +++ b/sdk/core/core-client-rest/src/helpers/isBinaryBody.ts @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { isReadableStream } from "./isReadableStream.js"; + +export function isBinaryBody( + body: unknown, +): body is + | Uint8Array + | NodeJS.ReadableStream + | ReadableStream + | (() => NodeJS.ReadableStream) + | (() => ReadableStream) + | Blob { + return ( + body !== undefined && + (body instanceof Uint8Array || + isReadableStream(body) || + typeof body === "function" || + body instanceof Blob) + ); +} diff --git a/sdk/core/core-client-rest/src/multipart.ts b/sdk/core/core-client-rest/src/multipart.ts new file mode 100644 index 000000000000..64403fd2e1ba --- /dev/null +++ b/sdk/core/core-client-rest/src/multipart.ts @@ -0,0 +1,204 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { + BodyPart, + MultipartRequestBody, + RawHttpHeadersInput, + RestError, + createHttpHeaders, +} from "@azure/core-rest-pipeline"; +import { stringToUint8Array } from "@azure/core-util"; +import { isBinaryBody } from "./helpers/isBinaryBody.js"; + +/** + * Describes a single part in a multipart body. + */ +export interface PartDescriptor { + /** + * Content type of this part. If set, this value will be used to set the Content-Type MIME header for this part, although explicitly + * setting the Content-Type header in the headers bag will override this value. If set to `null`, no content type will be inferred from + * the body field. Otherwise, the value of the Content-Type MIME header will be inferred based on the type of the body. + */ + contentType?: string | null; + + /** + * The disposition type of this part (for example, "form-data" for parts making up a multipart/form-data request). If set, this value + * will be used to set the Content-Disposition MIME header for this part, in addition to the `name` and `filename` properties. + * If the `name` or `filename` properties are set while `dispositionType` is left undefined, `dispositionType` will default to "form-data". + * + * Explicitly setting the Content-Disposition header in the headers bag will override this value. + */ + dispositionType?: string; + + /** + * The field name associated with this part. This value will be used to construct the Content-Disposition header, + * along with the `dispositionType` and `filename` properties, if the header has not been set in the `headers` bag. + */ + name?: string; + + /** + * The file name of the content if it is a file. This value will be used to construct the Content-Disposition header, + * along with the `dispositionType` and `name` properties, if the header has not been set in the `headers` bag. + */ + filename?: string; + + /** + * The multipart headers for this part of the multipart body. Values of the Content-Type and Content-Disposition headers set in the headers bag + * will take precedence over those computed from the request body or the contentType, dispositionType, name, and filename fields on this object. + */ + headers?: RawHttpHeadersInput; + + /** + * The body of this part of the multipart request. + */ + body?: unknown; +} + +type MultipartBodyType = BodyPart["body"]; + +type HeaderValue = RawHttpHeadersInput[string]; + +/** + * Get value of a header in the part descriptor ignoring case + */ +function getHeaderValue(descriptor: PartDescriptor, headerName: string): HeaderValue | undefined { + if (descriptor.headers) { + const actualHeaderName = Object.keys(descriptor.headers).find( + (x) => x.toLowerCase() === headerName.toLowerCase(), + ); + if (actualHeaderName) { + return descriptor.headers[actualHeaderName]; + } + } + + return undefined; +} + +function getPartContentType(descriptor: PartDescriptor): HeaderValue | undefined { + const contentTypeHeader = getHeaderValue(descriptor, "content-type"); + if (contentTypeHeader) { + return contentTypeHeader; + } + + // Special value of null means content type is to be omitted + if (descriptor.contentType === null) { + return undefined; + } + + if (descriptor.contentType) { + return descriptor.contentType; + } + + const { body } = descriptor; + + if (body === null || body === undefined) { + return undefined; + } + + if (typeof body === "string" || typeof body === "number" || typeof body === "boolean") { + return "text/plain; charset=UTF-8"; + } + + if (body instanceof Blob) { + return body.type || "application/octet-stream"; + } + + if (isBinaryBody(body)) { + return "application/octet-stream"; + } + + // arbitrary non-text object -> generic JSON content type by default. We will try to JSON.stringify the body. + return "application/json; charset=UTF-8"; +} + +/** + * Enclose value in quotes and escape special characters, for use in the Content-Disposition header + */ +function escapeDispositionField(value: string): string { + return JSON.stringify(value); +} + +function getContentDisposition(descriptor: PartDescriptor): HeaderValue | undefined { + const contentDispositionHeader = getHeaderValue(descriptor, "content-disposition"); + if (contentDispositionHeader) { + return contentDispositionHeader; + } + + if ( + descriptor.dispositionType === undefined && + descriptor.name === undefined && + descriptor.filename === undefined + ) { + return undefined; + } + + const dispositionType = descriptor.dispositionType ?? "form-data"; + + let disposition = dispositionType; + if (descriptor.name) { + disposition += `; name=${escapeDispositionField(descriptor.name)}`; + } + + let filename: string | undefined = undefined; + if (descriptor.filename) { + filename = descriptor.filename; + } else if (typeof File !== "undefined" && descriptor.body instanceof File) { + const filenameFromFile = (descriptor.body as File).name; + if (filenameFromFile !== "") { + filename = filenameFromFile; + } + } + + if (filename) { + disposition += `; filename=${escapeDispositionField(filename)}`; + } + + return disposition; +} + +function normalizeBody(body?: unknown, contentType?: HeaderValue): MultipartBodyType { + if (body === undefined) { + // zero-length body + return new Uint8Array([]); + } + + // binary and primitives should go straight on the wire regardless of content type + if (isBinaryBody(body)) { + return body; + } + if (typeof body === "string" || typeof body === "number" || typeof body === "boolean") { + return stringToUint8Array(String(body), "utf-8"); + } + + // stringify objects for JSON-ish content types e.g. application/json, application/merge-patch+json, application/vnd.oci.manifest.v1+json, application.json; charset=UTF-8 + if (contentType && /application\/(.+\+)?json(;.+)?/i.test(String(contentType))) { + return stringToUint8Array(JSON.stringify(body), "utf-8"); + } + + throw new RestError(`Unsupported body/content-type combination: ${body}, ${contentType}`); +} + +export function buildBodyPart(descriptor: PartDescriptor): BodyPart { + const contentType = getPartContentType(descriptor); + const contentDisposition = getContentDisposition(descriptor); + const headers = createHttpHeaders(descriptor.headers ?? {}); + + if (contentType) { + headers.set("content-type", contentType); + } + if (contentDisposition) { + headers.set("content-disposition", contentDisposition); + } + + const body = normalizeBody(descriptor.body, contentType); + + return { + headers, + body, + }; +} + +export function buildMultipartBody(parts: PartDescriptor[]): MultipartRequestBody { + return { parts: parts.map(buildBodyPart) }; +} diff --git a/sdk/core/core-client-rest/src/sendRequest.ts b/sdk/core/core-client-rest/src/sendRequest.ts index 7bc6bfea4135..37f328870ff7 100644 --- a/sdk/core/core-client-rest/src/sendRequest.ts +++ b/sdk/core/core-client-rest/src/sendRequest.ts @@ -2,22 +2,21 @@ // Licensed under the MIT license. import { - FormDataMap, - FormDataValue, HttpClient, HttpMethods, + MultipartRequestBody, Pipeline, PipelineRequest, PipelineResponse, RequestBodyType, RestError, - createFile, createHttpHeaders, createPipelineRequest, } from "@azure/core-rest-pipeline"; import { getCachedDefaultHttpsClient } from "./clientHelpers.js"; import { isReadableStream } from "./helpers/isReadableStream.js"; import { HttpResponse, RequestParameters } from "./common.js"; +import { PartDescriptor, buildMultipartBody } from "./multipart.js"; /** * Helper function to send request used by the client @@ -103,8 +102,8 @@ function buildPipelineRequest( options: InternalRequestParameters = {}, ): PipelineRequest { const requestContentType = getRequestContentType(options); - const { body, formData } = getRequestBody(options.body, requestContentType); - const hasContent = body !== undefined || formData !== undefined; + const { body, multipartBody } = getRequestBody(options.body, requestContentType); + const hasContent = body !== undefined || multipartBody !== undefined; const headers = createHttpHeaders({ ...(options.headers ? options.headers : {}), @@ -119,7 +118,7 @@ function buildPipelineRequest( url, method, body, - formData, + multipartBody, headers, allowInsecureConnection: options.allowInsecureConnection, tracingOptions: options.tracingOptions, @@ -136,7 +135,7 @@ function buildPipelineRequest( interface RequestBody { body?: RequestBodyType; - formData?: FormDataMap; + multipartBody?: MultipartRequestBody; } /** @@ -147,6 +146,10 @@ function getRequestBody(body?: unknown, contentType: string = ""): RequestBody { return { body: undefined }; } + if (typeof FormData !== "undefined" && body instanceof FormData) { + return { body }; + } + if (isReadableStream(body)) { return { body }; } @@ -163,9 +166,10 @@ function getRequestBody(body?: unknown, contentType: string = ""): RequestBody { switch (firstType) { case "multipart/form-data": - return isRLCFormDataInput(body) - ? { formData: processFormData(body) } - : { body: JSON.stringify(body) }; + if (Array.isArray(body)) { + return { multipartBody: buildMultipartBody(body as PartDescriptor[]) }; + } + return { body: JSON.stringify(body) }; case "text/plain": return { body: String(body) }; default: @@ -176,59 +180,6 @@ function getRequestBody(body?: unknown, contentType: string = ""): RequestBody { } } -/** - * Union of possible input types for multipart/form-data values that are accepted by RLCs. - * This extends the default FormDataValue type to include Uint8Array, which is accepted as an input by RLCs. - */ -type RLCFormDataValue = FormDataValue | Uint8Array; - -/** - * Input shape for a form data body type as generated by an RLC - */ -type RLCFormDataInput = Record; - -function isRLCFormDataValue(value: unknown): value is RLCFormDataValue { - return ( - typeof value === "string" || - value instanceof Uint8Array || - // We don't do `instanceof Blob` since we should also accept polyfills of e.g. File in Node. - typeof (value as Blob).stream === "function" - ); -} - -function isRLCFormDataInput(body: unknown): body is RLCFormDataInput { - return ( - body !== undefined && - body instanceof Object && - Object.values(body).every( - (value) => - isRLCFormDataValue(value) || (Array.isArray(value) && value.every(isRLCFormDataValue)), - ) - ); -} - -function processFormDataValue(value: RLCFormDataValue): FormDataValue { - return value instanceof Uint8Array ? createFile(value, "blob") : value; -} - -/** - * Checks if binary data is in Uint8Array format, if so wrap it in a Blob - * to send over the wire - */ -function processFormData(formData: RLCFormDataInput): FormDataMap { - const processedFormData: FormDataMap = {}; - - for (const element in formData) { - const value = formData[element]; - - processedFormData[element] = Array.isArray(value) - ? value.map(processFormDataValue) - : processFormDataValue(value); - } - - return processedFormData; -} - /** * Prepares the response body */ diff --git a/sdk/core/core-client-rest/test/multipart.spec.ts b/sdk/core/core-client-rest/test/multipart.spec.ts new file mode 100644 index 000000000000..a15beecd7d28 --- /dev/null +++ b/sdk/core/core-client-rest/test/multipart.spec.ts @@ -0,0 +1,250 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { describe, it, assert } from "vitest"; +import { PartDescriptor, buildBodyPart } from "../src/multipart.js"; +import { stringToUint8Array } from "../../core-util/dist/commonjs/bytesEncoding.js"; + +describe("multipart buildBodyPart", () => { + describe("content-type calculation", () => { + it.each([ + { + description: "content type header set to null results in header being unset", + descriptor: { + contentType: null, + body: "even though the body is a string, the content type should still not be set", + }, + expected: undefined, + }, + { + description: "content-type header set in part headers takes precedence over contentType", + descriptor: { + contentType: "dont-pick-me", + headers: { + "content-type": "pick-me-instead", + }, + }, + expected: "pick-me-instead", + }, + { + description: "contentType value takes precedence over natural content type", + descriptor: { + contentType: "application/json", + body: `{ "aaa": "bbb" }`, // would otherwise expect this to give text/plain; charset=UTF-8 + }, + expected: "application/json", + }, + { + description: "content type is taken from Blob if available", + descriptor: { + body: new Blob([], { type: "content-type-from-blob" }), + }, + expected: "content-type-from-blob", + }, + { + description: "contentType value takes precedence over Blob content type", + descriptor: { + contentType: "pick-me", + body: new Blob([], { type: "content-type-from-blob" }), + }, + expected: "pick-me", + }, + { + description: "content type for Blob defaults to application/octet-stream", + descriptor: { + body: new Blob([]), + }, + expected: "application/octet-stream", + }, + { + description: `content type for Uint8Array defaults to application/octet-stream`, + descriptor: { + body: new Uint8Array([]), + }, + expected: "application/octet-stream", + }, + ])("$description", ({ descriptor, expected }) => { + const result = buildBodyPart(descriptor); + assert.equal(result.headers.get("content-type"), expected); + }); + }); + + describe("content-disposition calculation", () => { + it.each([ + { + description: "explicitly setting header value in headers bag takes precedence", + descriptor: { + dispositionType: "ignore-me", + name: "ignore-me-too", + filename: "also-ignore-me", + headers: { + "content-disposition": "pick-me", + }, + }, + expected: "pick-me", + }, + { + description: "sets disposition correctly without name or filename", + descriptor: { + dispositionType: "disposition-type", + }, + expected: "disposition-type", + }, + { + description: "does not set header if unset", + descriptor: {}, + expected: undefined, + }, + { + description: "sets name without filename", + descriptor: { + dispositionType: "form-data", + name: "fieldName", + }, + expected: `form-data; name="fieldName"`, + }, + { + description: "escapes wacky characters in name and filename", + descriptor: { + dispositionType: "form-data", + name: 'hello"\r\nworld', + filename: 'aaa\r\n".txt', + }, + expected: `form-data; name="hello\\"\\r\\nworld"; filename="aaa\\r\\n\\".txt"`, + }, + { + description: "correctly sets both name and filename", + descriptor: { + dispositionType: "form-data", + filename: "file.txt", + name: "fieldName", + }, + expected: `form-data; name="fieldName"; filename="file.txt"`, + }, + { + description: "disposition type defaults to form-data", + descriptor: { + filename: "file.txt", + name: "fieldName", + }, + expected: `form-data; name="fieldName"; filename="file.txt"`, + }, + { + description: "content-disposition header takes precedence", + descriptor: { + filename: "aaa.txt", + name: "bbb", + headers: { + "content-disposition": "pick-me", + }, + }, + expected: "pick-me", + }, + { + description: "disposition type can be customized", + descriptor: { + filename: "file.txt", + name: "fieldName", + dispositionType: "disposition", + }, + expected: `disposition; name="fieldName"; filename="file.txt"`, + }, + ])("$description", ({ descriptor, expected }) => { + const result = buildBodyPart(descriptor); + assert.equal(result.headers.get("content-disposition"), expected); + }); + + it.skipIf(typeof File === "undefined")("sets filename from file object", () => { + const result = buildBodyPart({ + name: "aaa", + body: new File([], "aaa.txt"), + }); + + assert.equal( + result.headers.get("content-disposition"), + `form-data; name="aaa"; filename="aaa.txt"`, + ); + }); + + it.skipIf(typeof File === "undefined")( + "filename parameter overrides File object filename", + () => { + const result = buildBodyPart({ + name: "aaa", + filename: "override", + body: new File([], "aaa.txt"), + }); + + assert.equal( + result.headers.get("content-disposition"), + `form-data; name="aaa"; filename="override"`, + ); + }, + ); + }); + + describe("body normalization", () => { + it.each([ + { + description: "empty body gives empty uint8array", + descriptor: { + contentType: "application/vnd.unknown.contenttype", + }, + expected: new Uint8Array([]), + }, + { + description: "binary content gets passed through, regardless of content type", + descriptor: { + contentType: "application/json; charset=UTF-8", + body: new Uint8Array([1, 2, 3]), + }, + expected: new Uint8Array([1, 2, 3]), + }, + { + description: "stringifies JSON", + descriptor: { + body: { key: "value" }, + }, + expected: stringToUint8Array(JSON.stringify({ key: "value" }), "utf-8"), + }, + { + description: "stringifies more complicated JSON content type", + descriptor: { + body: { key: "value" }, + contentType: "application/merge-patch+JSON", + }, + expected: stringToUint8Array(JSON.stringify({ key: "value" }), "utf-8"), + }, + { + description: "is case-insensitive when checking JSON content type", + descriptor: { + body: { key: "value" }, + contentType: "application/merge-patch+JSON", + }, + expected: stringToUint8Array(JSON.stringify({ key: "value" }), "utf-8"), + }, + { + description: "strings are passed though", + descriptor: { + body: "invalidJson", + contentType: "application/json", + }, + expected: stringToUint8Array("invalidJson", "utf-8"), + }, + ])("$description", ({ descriptor, expected }) => { + const result = buildBodyPart(descriptor); + assert.deepEqual(result.body, expected); + }); + + it("throws when passing an object when specifying a non-JSON content-type", () => { + const descriptor: PartDescriptor = { + body: { a: "b" }, + contentType: "application/xml", + }; + assert.throws( + () => buildBodyPart(descriptor), + /Unsupported body\/content-type combination.*/, + ); + }); + }); +}); diff --git a/sdk/core/core-client-rest/test/sendRequest.spec.ts b/sdk/core/core-client-rest/test/sendRequest.spec.ts index e489003a75ce..2eada65eb142 100644 --- a/sdk/core/core-client-rest/test/sendRequest.spec.ts +++ b/sdk/core/core-client-rest/test/sendRequest.spec.ts @@ -4,15 +4,15 @@ import { describe, it, assert } from "vitest"; import { sendRequest } from "../src/sendRequest.js"; import { - FormDataValue, + MultipartRequestBody, Pipeline, PipelineResponse, RestError, createEmptyPipeline, - createFile, createHttpHeaders, } from "@azure/core-rest-pipeline"; import { stringToUint8Array } from "@azure/core-util"; +import { PartDescriptor } from "../src/multipart.js"; describe("sendRequest", () => { const foo = new Uint8Array([0x66, 0x6f, 0x6f]); @@ -170,10 +170,34 @@ describe("sendRequest", () => { describe("FormData content", () => { it("should handle request body as FormData", async () => { - const expectedFormData = { fileName: "foo.txt", file: "bar" }; + const expectedFormData: PartDescriptor[] = [ + { name: "fileName", body: "foo.txt" }, + { name: "file", body: "bar" }, + ]; + + const expectedBody: MultipartRequestBody = { + parts: [ + { + headers: createHttpHeaders({ + "content-type": "text/plain; charset=UTF-8", + "content-disposition": `form-data; name="fileName"`, + }), + body: stringToUint8Array("foo.txt", "utf-8"), + }, + { + headers: createHttpHeaders({ + "content-type": "text/plain; charset=UTF-8", + "content-disposition": `form-data; name="file"`, + }), + body: stringToUint8Array("bar", "utf-8"), + }, + ], + }; + const mockPipeline: Pipeline = createEmptyPipeline(); mockPipeline.sendRequest = async (_client, request) => { - assert.deepEqual(request.formData, expectedFormData); + assert.deepEqual(request.multipartBody, expectedBody); + assert.equal(request.headers.get("content-type"), "multipart/form-data"); return { headers: createHttpHeaders() } as PipelineResponse; }; @@ -183,74 +207,143 @@ describe("sendRequest", () => { }); }); - it("should handle request body as FormData with binary", async () => { - const expectedFormData = { fileName: "foo.txt", file: "foo" }; + it("should handle multipart/form-data request body with binary", async () => { + const expectedFormData: PartDescriptor[] = [{ name: "file", filename: "foo.txt", body: foo }]; + const expectedBody: MultipartRequestBody = { + parts: [ + { + headers: createHttpHeaders({ + "content-type": "application/octet-stream", + "content-disposition": `form-data; name="file"; filename="foo.txt"`, + }), + body: foo, + }, + ], + }; const mockPipeline: Pipeline = createEmptyPipeline(); mockPipeline.sendRequest = async (_client, request) => { - assert.equal(request.formData?.fileName, "foo.txt"); - assert.sameOrderedMembers( - [...new Uint8Array(await (request.formData?.file as Blob).arrayBuffer())], - [...foo], - ); + assert.deepEqual(request.multipartBody, expectedBody); + assert.equal(request.headers.get("content-type"), "multipart/form-data"); + return { headers: createHttpHeaders() } as PipelineResponse; }; await sendRequest("POST", mockBaseUrl, mockPipeline, { - body: { ...expectedFormData, file: foo }, + body: expectedFormData, contentType: "multipart/form-data", }); }); - it("should handle request body as FormData with array of binary", async () => { - const expectedFormData = { fileName: "foo.txt" }; + it("should handle multipart/form-data request body with multiple files of the same field name", async () => { + const expectedFormData: PartDescriptor[] = [ + { name: "fileName", body: "foo.txt" }, + { name: "files", body: foo }, + { name: "files", body: foo }, + ]; + const expectedBody: MultipartRequestBody = { + parts: [ + { + headers: createHttpHeaders({ + "content-type": "text/plain; charset=UTF-8", + "content-disposition": `form-data; name="fileName"`, + }), + body: stringToUint8Array("foo.txt", "utf-8"), + }, + { + headers: createHttpHeaders({ + "content-type": "application/octet-stream", + "content-disposition": `form-data; name="files"`, + }), + body: foo, + }, + { + headers: createHttpHeaders({ + "content-type": "application/octet-stream", + "content-disposition": `form-data; name="files"`, + }), + body: foo, + }, + ], + }; + const mockPipeline: Pipeline = createEmptyPipeline(); mockPipeline.sendRequest = async (_client, request) => { - assert.equal(request.formData?.fileName, "foo.txt"); - assert.isArray(request.formData?.files); - const files = request.formData?.files as Blob[]; - assert.lengthOf(files, 2); - - assert.sameOrderedMembers([...new Uint8Array(await files[0].arrayBuffer())], [...foo]); - assert.sameOrderedMembers([...new Uint8Array(await files[1].arrayBuffer())], [...foo]); + assert.deepEqual(request.multipartBody, expectedBody); + assert.equal(request.headers.get("content-type"), "multipart/form-data"); return { headers: createHttpHeaders() } as PipelineResponse; }; await sendRequest("POST", mockBaseUrl, mockPipeline, { - body: { ...expectedFormData, files: [foo, foo] }, + body: expectedFormData, contentType: "multipart/form-data", }); }); - it("should handle request body as FormData with multiple file and text fields", async () => { - const file1 = createFile(stringToUint8Array("File 1", "utf-8"), "file1.txt", { - type: "text/plain", - }); - const file2 = createFile(new Uint8Array([1, 2, 3]), "file1.txt", { - type: "application/octet-stream", - }); - const file3 = new Blob([stringToUint8Array("{}", "utf-8")], { type: "application/json" }); - const text = "Hello"; + it("should handle request body as FormData with mixed fields including binary, text and JSON", async () => { + const input: PartDescriptor[] = [ + { + name: "fileArray1", + filename: "file1.txt", + contentType: "text/plain; charset=UTF-8", + body: "File 1", + }, + { name: "fileArray1", filename: "file2", body: new Uint8Array([1, 2, 3]) }, + { name: "fileArray2", body: new Uint8Array([4, 5, 6]), filename: "file3" }, + { name: "fileArray2", body: {} }, + { name: "textField", body: "Hello world!" }, + ]; + + const expectedBody: MultipartRequestBody = { + parts: [ + { + headers: createHttpHeaders({ + "content-type": "text/plain; charset=UTF-8", + "content-disposition": `form-data; name="fileArray1"; filename="file1.txt"`, + }), + body: stringToUint8Array("File 1", "utf-8"), + }, + { + headers: createHttpHeaders({ + "content-type": "application/octet-stream", + "content-disposition": `form-data; name="fileArray1"; filename="file2"`, + }), + body: new Uint8Array([1, 2, 3]), + }, + { + headers: createHttpHeaders({ + "content-type": "application/octet-stream", + "content-disposition": `form-data; name="fileArray2"; filename="file3"`, + }), + body: new Uint8Array([4, 5, 6]), + }, + { + headers: createHttpHeaders({ + "content-type": "application/json; charset=UTF-8", + "content-disposition": `form-data; name="fileArray2"`, + }), + body: stringToUint8Array("{}", "utf-8"), + }, + { + headers: createHttpHeaders({ + "content-type": "text/plain; charset=UTF-8", + "content-disposition": `form-data; name="textField"`, + }), + body: stringToUint8Array("Hello world!", "utf-8"), + }, + ], + }; const mockPipeline = createEmptyPipeline(); mockPipeline.sendRequest = async (_client, request) => { - assert.strictEqual((request.formData?.fileArray1 as FormDataValue[])[0], file1); - assert.strictEqual((request.formData?.fileArray1 as FormDataValue[])[1], file2); - assert.strictEqual((request.formData?.fileArray2 as FormDataValue[])[0], file2); - assert.strictEqual((request.formData?.fileArray2 as FormDataValue[])[1], file3); - assert.strictEqual(request.formData?.standaloneFile as FormDataValue, file3); - assert.strictEqual(request.formData?.text as string, "Hello"); + assert.deepEqual(request.multipartBody, expectedBody); + assert.equal(request.headers.get("content-type"), "multipart/form-data"); return { headers: createHttpHeaders() } as PipelineResponse; }; await sendRequest("POST", mockBaseUrl, mockPipeline, { - body: { - fileArray1: [file1, file2], - fileArray2: [file2, file3], - standaloneFile: file3, - text, - }, + body: input, contentType: "multipart/form-data", }); }); @@ -436,11 +529,13 @@ describe("sendRequest", () => { assert.equal(response.body, "test"); }); - it("should send formdata body", async () => { - const testForm = { foo: "test" }; + it.skipIf(typeof FormData === undefined)("should send FormData body", async () => { + const formData = new FormData(); + formData.append("foo", "test"); + const mockPipeline: Pipeline = createEmptyPipeline(); mockPipeline.sendRequest = async (_client, request) => { - assert.deepEqual(request.formData, testForm); + assert.deepEqual(request.body, formData); return { headers: createHttpHeaders(), } as PipelineResponse; @@ -448,7 +543,7 @@ describe("sendRequest", () => { await sendRequest("GET", mockBaseUrl, mockPipeline, { contentType: "multipart/form-data", - body: testForm, + body: formData, }); });