Working with Attachments
When working with ScriptRunner Connect's Managed API, you can easily work with attachments using supported methods. As a convention, when adding an attachment, you should be able to specify either a string
type (plain text) or ArrayBuffer
type (binary) as the content/body of the attachment.
For example, in Jira Cloud, the content is either a type of string
or an ArrayBuffer
:
Here is a code snippet that will create an attachment in Jira Cloud with a file named hello.txt
and with a plain text content of hello world
:
jsimport JiraCloud from './api/jira/cloud'; export default async function(event: any, context: Context): Promise<void> { await JiraCloud.Issue.Attachment.addAttachments({ issueIdOrKey: 'ISSUE-1', body: [{ fileName: 'hello.txt', content: 'hello world' }] }); }
The following code snippet demonstrates how to work with binary data. In this case, the script fetches a random kitten image and then uploads it to Jira as an attachment. Notice how the .arrayBuffer() method is used to extract the binary representation of the image and is then passed directly into the content
field:
jsimport JiraCloud from './api/jira/cloud'; export default async function(event: any, context: Context): Promise<void> { // Fetch a kitten image using Fetch API const response = await fetch('https://placekitten.com/300/400'); // Check if the response is OK if (!response.ok) { // If not then throw an error throw Error(`Unexpected response: ${response.status}`) } // Extract the kitten image as ArrayBuffer const kittenImage = await response.arrayBuffer(); // Add an attachment with the kitten image await JiraCloud.Issue.Attachment.addAttachments({ issueIdOrKey: 'ISSUE-1', body: [{ fileName: 'kitten.jpg', content: kittenImage }] }); }
Fetch API
If you don't want to use the Managed API directly, you can use the Fetch API instead as an escape hatch, but you'll probably have to construct the FormData
manually unless the service you're connecting to can accept the attachment in another format. You could use, for example, a base64 string along with the @stitch-it/convert package to convert ArrayBuffer
into a base64 string
.
The following code snippet does the same thing as the snippet above, but it uses low-level Managed Fetch API to upload the attachment. (Notice how Blob
and FormData
need to be constructed manually in this case.)
jsimport JiraCloud from './api/jira/cloud'; export default async function (event: any, context: Context): Promise<void> { const issueKey = 'ISSUE-1'; // Fetch a an image using Fetch API const response = await fetch('https://app.scriptrunnerconnect.com/js.png'); // Check if the response is OK if (!response.ok) { // If not then throw an error throw Error(`Unexpected response while fetching the image: ${response.status}`) } // Extract the the image as ArrayBuffer const image = await response.arrayBuffer(); // Construct the FormData that holds the attachment const blob = new Blob([image]); const formData = new FormData(); formData.append('file', blob, 'js.png'); // Add an attachment using Fetch API const attachmentResponse = await JiraCloud.fetch(`/rest/api/3/issue/${issueKey}/attachments`, { method: 'POST', body: formData, headers: { 'Accept': 'application/json', 'X-Atlassian-Token': 'no-check', } }); // Check if the attachment response is OK if (!attachmentResponse.ok) { // If not then throw an error throw Error(`Unexpected response while uploading attachment: ${attachmentResponse.status}`); } }
Content-type fix 🔧
In the example above, the Content-Type
header is not explicitly specified. When this happens and FormData
is being passed into the body, ScriptRunner Connect will automatically attach the appropriate multipart/form-data
content-type header.
Working with large attachments (<100MB)
When constructing a FormData
object together with Blob
, as shown in the example above, the underlying (attachment) data will be copied into memory multiple times. This is an inefficient use of CPU time and can cause runtime crashes due to excessive memory usage. To circumvent the issue, you can use the convertArrayBufferToFormDataBuffer
(or Convert.arrayBufferToFormDataBuffer
) utility function from the @sr-connect/convert library to achieve the same outcome more efficiently since data will only be copied once.
Here's an example that demonstrates this:
jsimport JiraCloud from './api/jira/cloud'; import { Convert } from "@sr-connect/convert"; export default async function(event: any, context: Context): Promise<void> { const issueKey = 'ISSUE-1'; // Fetch an image using Fetch API const response = await fetch('https://app.scriptrunnerconnect.com/js.png'); // Check if the response is OK if (!response.ok) { // If not then throw an error throw Error(`Unexpected response while fetching the image: ${response.status}`) } // Extract the the image as ArrayBuffer const image = await response.arrayBuffer(); // Convert the image to buffered form data const bufferedFormData = Convert.arrayBufferToFormDataBuffer({ fileName: 'js.png', value: image }); // Add an attachment using Fetch API const attachmentResponse = await JiraCloud.fetch(`/rest/api/3/issue/${issueKey}/attachments`, { method: 'POST', body: bufferedFormData.arrayBuffer, // Get and then set the encoded buffer headers: { 'Accept': 'application/json', 'X-Atlassian-Token': 'no-check', 'Content-Type': bufferedFormData.contentType // Get and then set the content type } }); // Check if the attachment response is OK if (!attachmentResponse.ok) { // If not then throw an error throw Error(`Unexpected response while uploading attachment: ${attachmentResponse.status}`); } }
Working with very large attachments (100MB+)
While the convertArrayBufferToFormDataBuffer
utility function helps you transmit larger attachments by being more memory efficient, you may still run out of memory at some point. As a rule of thumb, attachments larger than 100MB will fail to be processed in memory, even with the aforementioned memory optimization. To work around this limitation, ScriptRunner Connect offers a feature to circumvent the memory limitation by not loading the attachment into memory, thus allowing it to process very large files.
Theoretical limit 🤓
We've tested this feature with files as large as a few GBs and believe the theoretical limit attachment-wise is 5TB. The primary constraint remains the 15-minute limit for running your function, as you must fetch and upload the attachment within this timeframe.
In practice
This feature requires you to first let us know which file you would like us to temporarily store so you can upload it later. To do this, add a custom header called x-stitch-store-body: true
to indicate that the body received with this API call should be stored for future use. Instead of receiving the actual body in the response, you will receive the header x-stitch-stored-body-id
which contains a unique ID that references the body content that we stored for you.
When you are ready to make the API call and retrieve the stored attachment, pass the x-stitch-stored-body-id
header with the unique ID you received earlier.
And we'll handle the rest! We'll retrieve the stored body, transform it into multipart/form-data format, swap out the request body with the transformed content, and make the API call.
Since the actual body of the request will be replaced with the transformed content, there's no need to specify the body yourself, as it will be ignored.
Manipulating multipart/form-data fields
Use the following custom headers to control the transformation process into multipart/form-data format:
- x-stitch-stored-body-form-data-file-name
Use this header to specify the name of the file that is attached to the file entry. Defaults to:file
. - x-stitch-stored-body-form-data-additional-fields
Use this header to add additional key pairs to the form data content. Key pairs are delimited by;
and keys and pairs are delimited by:
For example,key:value;foo:bar;
- x-stitch-stored-body-form-data-file-identifier
Identifier for the file entry. Defaults to:file
. Overwrite it if the service you're working with requires a different identifier. For example, ServiceNow requires the following identifier:uploadFile
- x-stitch-drop-body: true
Although unrelated to uploading attachments, this header will drop the body from the request if you are only interested in the headers and the service does not support theHEAD
method, which otherwise should be used. This option is always enabled when thex-stitch-store-body
header is enabled.
An example
jsimport JiraCloud from './api/jira/cloud'; const ISSUE_KEY = 'ISSUE-1'; export default async function (event: any, context: Context): Promise<void> { // Get the issue const issue = await JiraCloud.Issue.getIssue({ issueIdOrKey: ISSUE_KEY }); // Get the first attachment const attachment = issue.fields.attachment[0]; // Fetch the attachment content const attachmentContentResponse = await JiraCloud.fetch(attachment.content, { headers: { 'x-stitch-store-body': 'true' // Ask this attachment to be stored } }); // Check if the attachment content response is OK if (!attachmentContentResponse.ok) { // If not, then throw an error throw Error(`Failed to download attachment: ${attachmentContentResponse.status}`); } // Since we asked the attachment to be stored we're expecting to receive the stored attachment body ID const storedBodyId = attachmentContentResponse.headers.get('x-stitch-stored-body-id'); // Check if the stored body ID was found if (!storedBodyId) { // If not, throw an error throw Error('No stored body id found'); } // Upload an attachment under the same issue with the same content but prepend `copy_of_` to the attachment name const uploadResponse = await JiraCloud.fetch(`/rest/api/3/issue/${ISSUE_KEY}/attachments`, { method: 'POST', headers: { 'X-Atlassian-Token': 'no-check', 'x-stitch-stored-body-id': storedBodyId, // Reference the stored attachment body ID 'x-stitch-stored-body-form-data-file-name': `copy_of_${attachment.filename}`, // Specify the file name to be used in multipart/form-data 'x-stitch-stored-body-form-data-additional-fields': 'key:value;foo:bar;', // Add additional fields to the multipart/form-data, although not required for Atlassian and thus gets ignored 'x-stitch-stored-body-form-data-file-identifier': 'file' // Specify the multipart/form-data file upload identifier, although not necessary for Atlassian since it defaults to 'file' anyway } }) // Check if the upload was successful if (!uploadResponse.ok) { // If it was not, throw an error throw Error(`Failed to upload attachment: ${uploadResponse.status}`) } // Log out the copied attachment name console.log(`Copied attachment: ${attachment.filename}`); }
Known limitations 👀
- Managed APIs do not currently support this feature. To use custom headers, you will need to use the Fetch API.
- At this time, we only support transforming the received body into the multipart/form-data format. We'll look to add other formats in the future. If you urgently need us to support other formats, please contact ScriptRunner Connect support.
Helpful templates 💡
Here are a few templates that might be beneficial to you:
- Keep two Jira Cloud projects in sync
In addition to other fields kept in sync between two Jira Cloud projects, this template syncs attachments, too. - Send back an image with the Generic HTTP Event
This template demonstrates how to convertArrayBuffer
into base64string
should you need to perform such a conversion.