From 1a9097d0f038cbceee102b76c09987c2b43ff7bf Mon Sep 17 00:00:00 2001 From: "Michael B. Klein" Date: Wed, 5 Jul 2023 21:11:33 +0000 Subject: [PATCH] Implment IIIFv3 and Lambda response streaming - Convert Lambda to streaming function url - Use IIIF v3 branch of node-iiif - Add IIIF v3 tests and other supports - Add service discovery endpoint at `/` - Create Terraform module - Create CloudFormation and Terraform examples - Enhance docs --- .gitignore | 23 +- README.md | 65 ++- bin/build | 2 +- bin/deploy | 4 +- bin/setup.js | 7 +- bin/update_version | 15 +- dependencies/package-lock.json | 18 +- dependencies/package.json | 4 +- examples/cloudformation/README.md | 10 + examples/cloudformation/custom_hostname.yml | 107 +++++ examples/terraform/README.md | 8 + examples/terraform/main.tf | 112 ++++++ examples/terraform/outputs.tf | 10 + examples/terraform/variables.tf | 20 + extras/terraform/README.md | 74 ++++ extras/terraform/main.tf | 36 ++ extras/terraform/outputs.tf | 9 + extras/terraform/variables.tf | 106 +++++ package-lock.json | 286 ++++++++++---- package.json | 7 +- sam/cloudfront/template.yml | 412 -------------------- sam/{standalone => }/template.yml | 80 ++-- src/cache.js | 55 --- src/index.js | 79 ++-- src/package-lock.json | 270 +++++++++---- src/package.json | 5 +- src/streamify.js | 20 + tests/cache.test.js | 65 --- tests/{index.test.js => index.v2.test.js} | 110 +----- tests/index.v3.test.js | 172 ++++++++ tests/service-discovery.test.js | 27 ++ tests/stream-handler.js | 11 + 32 files changed, 1301 insertions(+), 928 deletions(-) create mode 100644 examples/cloudformation/README.md create mode 100644 examples/cloudformation/custom_hostname.yml create mode 100644 examples/terraform/README.md create mode 100644 examples/terraform/main.tf create mode 100644 examples/terraform/outputs.tf create mode 100644 examples/terraform/variables.tf create mode 100644 extras/terraform/README.md create mode 100644 extras/terraform/main.tf create mode 100644 extras/terraform/outputs.tf create mode 100644 extras/terraform/variables.tf delete mode 100644 sam/cloudfront/template.yml rename sam/{standalone => }/template.yml (75%) delete mode 100644 src/cache.js create mode 100644 src/streamify.js delete mode 100644 tests/cache.test.js rename tests/{index.test.js => index.v2.test.js} (55%) create mode 100644 tests/index.v3.test.js create mode 100644 tests/service-discovery.test.js create mode 100644 tests/stream-handler.js diff --git a/.gitignore b/.gitignore index bcc31f5..4e2d435 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,26 @@ +# AWS SAM .aws .aws-sam -coverage deploy.yaml -node_modules -samconfig.toml package.yml +samconfig.toml + +# NodeJS Build +coverage +node_modules + +# Secrets +.env +env.json + +# Terraform +.terraform* +*.tfstate* +*.tfvars + +# Misc +.vscode *~undo-tree~ .DS_Store -.env sharp-lambda-layer.* +*.log \ No newline at end of file diff --git a/README.md b/README.md index a473565..e28ecab 100644 --- a/README.md +++ b/README.md @@ -4,16 +4,21 @@ [![Maintainability](https://api.codeclimate.com/v1/badges/4ac80b539190cb5b082f/maintainability)](https://codeclimate.com/github/samvera/serverless-iiif/maintainability) [![Test Coverage](https://coveralls.io/repos/github/samvera/serverless-iiif/badge.svg)](https://coveralls.io/github/samvera/serverless-iiif) +## Upgrade Note + +Previous versions of this application featured an optional [CloudFront](https://aws.amazon.com/cloudfront/) distribution that provided caching, custom domain/hostname mapping, and request/response pre/post-processing. However, the primary motivation for including this feature in the past was that it provided a complicated but effective way to skirt the hard 6 megabyte limit for Lambda function response payloads. Since then, AWS has introduced [AWS Lambda response streaming](https://aws.amazon.com/blogs/compute/introducing-aws-lambda-response-streaming/), which uses chunked responses to bypass the 6 megabyte limit. As this is a much more elegant solution to the problem, there's nothing about the CloudFront template that's specific to this project any more. Ongoing development and maintenance will therefore focus on the IIIF Lambda itself rather than the large, complicated template required for a flexible, customizable CloudFront deployment. + +While the CloudFront-enabled version of the application is no longer available in the Serverless Application Repository, the newly created [`examples`](./examples/README.md) directory includes sample [CloudFormation](https://aws.amazon.com/cloudformation/) templates and [Terraform](https://terraform.io/) manifests showing how to deploy the IIIF service as part of a larger application/infrastructure stack. + ## Description -A [IIIF 2.1 Image API](https://iiif.io/api/image/2.1/) compliant server written as an [AWS Serverless Application](https://aws.amazon.com/serverless/sam/). +A IIIF [2.1](https://iiif.io/api/image/2.1/) and [3.0](https://iiif.io/api/image/3.0/) Image API compliant server written as an [AWS Serverless Application](https://aws.amazon.com/serverless/sam/). ## Components * A simple [Lambda Function](https://aws.amazon.com/lambda/) wrapper for the [iiif-processor](https://www.npmjs.com/package/iiif-processor) module. * A [Lambda Function URL](https://docs.aws.amazon.com/lambda/latest/dg/lambda-urls.html) that is used to invoke the IIIF API via HTTPS. * A [Lambda Layer](https://docs.aws.amazon.com/lambda/latest/dg/configuration-layers.html) containing all the dependencies for the Lambda Function. -* An optional [CloudFormation](https://aws.amazon.com/cloudformation/) template describing the resources needed to deploy the application. ## Prerequisites @@ -24,39 +29,33 @@ A [IIIF 2.1 Image API](https://iiif.io/api/image/2.1/) compliant server written ## Quick Start -`serverless-iiif` comes in two flavors: *Standalone (Lambda-only)* and *Caching (CloudFront-enabled)*. The Standalone version is much simpler, but lacks the following features: - -- Custom Domain Name - - Standalone URLs are in the `lambda-url.AWS_REGION.on.aws` domain (e.g., `https://fu90293j0pj902j902c32j902.lambda-url.us-east-1.on.aws/iiif/2/`) - - Caching URLs *without* Custom Domains are in the `cloudfront.net` domain (e.g., `https://d3kmjdzzy1l5t3.cloudfront.net/iiif/2/`) -- Responses larger than ~6MB -- CloudFront function support (for pre/post-processing requests and responses) +`serverless-iiif` is deployed as a Lambda Function URL, in the `lambda-url.AWS_REGION.on.aws` domain (e.g., `https://fu90293j0pj902j902c32j902.lambda-url.us-east-1.on.aws/iiif/2/`). In order to use a custom domain name, or other features like caching and pre/post-processing functions, you'll have to set up a [CloudFront distribution](https://aws.amazon.com/cloudfront/). ### Deploying via the AWS Serverless Application Repository `serverless-iiif` is distributed and deployed via the [AWS Serverless Application Repository](https://aws.amazon.com/serverless/serverlessrepo/). To deploy it using the AWS Console: -1. Click one of the following links to deploy the desired application from the AWS Console: - - [Standalone (Lambda-Only) Version](https://console.aws.amazon.com/lambda/home#/create/app?applicationId=arn:aws:serverlessrepo:us-east-1:625046682746:applications/serverless-iiif-standalone) - - [Caching (CloudFront-Enabled) Version](https://console.aws.amazon.com/lambda/home#/create/app?applicationId=arn:aws:serverlessrepo:us-east-1:625046682746:applications/serverless-iiif-cloudfront) +1. Find the [serverless-iiif application](https://console.aws.amazon.com/lambda/home#/create/app?applicationId=arn:aws:serverlessrepo:us-east-1:625046682746:applications/serverless-iiif) in the AWS Serverless Application Repository. 2. Make sure your currently selected region (in the console's top navigation bar) is the one you want to deploy to. 3. Scroll down to the **Application settings** section. 4. Configure the deploy template: - Give your stack a unique **Application name** - Enter the name of the **SourceBucket** the service will serve images from - - Check the box acknowledging that the app will create a custom IAM roles and resource policies (and if deploying the Caching version, that it will also deploy a nested application) - - *Optional*: Enter or change any other parameters that apply to your desired configuration. + - Check the box acknowledging that the app will create a custom IAM roles and resource policies + - *Optional*: Enter or change any other parameters that apply to your desired configuration 5. Click **Deploy**. -6. When all the resources are properly created and configured, the new stack should be in the **CREATE_COMPLETE** stage. If there's an error, it will delete all the resources it created, roll back any changes it made, and eventually reach the **ROLLBACK_COMPLETE** stage. +6. When all the resources are properly created and configured, the new stack should be in the **CREATE_COMPLETE** stage. If there's an error, + it will delete all the resources it created, roll back any changes it made, and eventually reach the **ROLLBACK_COMPLETE** stage. 7. Click the **CloudFormation stack** link. -8. Click the **Outputs** tab to see (and copy) the IIIF Endpoint URL. +8. Click the **Outputs** tab to see (and copy) the IIIF Endpoint URLs. ### Deploying via the Command Line 1. Make sure you have the [SAM CLI](https://aws.amazon.com/serverless/sam/) and [AWS CLI](https://aws.amazon.com/cli/) installed. -2. Make sure the AWS CLI is [properly configured](https://docs.aws.amazon.com/cli/latest/userguide/cli-chap-configure.html) with credentials that have sufficient access to manage IAM, S3, Lambda, and (optionally) CloudFront resources. +2. Make sure the AWS CLI is [properly configured](https://docs.aws.amazon.com/cli/latest/userguide/cli-chap-configure.html) with + credentials that have sufficient access to manage IAM, S3, and Lambda resources. 3. Clone this repository. -4. Copy .env.example to .env. Update the various values within. +4. Copy `.env.example` to `.env`. Update the various values within. 5. Build the application: ```shell $ npm run build @@ -66,7 +65,7 @@ A [IIIF 2.1 Image API](https://iiif.io/api/image/2.1/) compliant server written $ npm run deploy ``` - You'll be prompted for various configuration parameters, confirmations, and acknowledgments of specific issues (particularly the creation of IAM resources and the deployment of an open/unauthenticated Lambda Function URL). + You'll be prompted for various configuration parameters, confirmations, and acknowledgments of specific issues (particularly the creation of IAM resources and the deployment of an open/unauthenticated Lambda Function URL). 7. Follow the prompts to complete the deployment process and get the resulting endpoint. ### Deleting the application @@ -123,16 +122,16 @@ The default values will work in most circumstances, but if you need the IIIF ser ### Request/Response Functions -The SAM deploy template takes several optional parameters to enable the association of [CloudFront Functions](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/cloudfront-functions.html) or [Lambda@Edge Functions](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/lambda-at-the-edge.html) with the CloudFront distribution. These functions can perform authentication and authorization functions, change how the S3 file and/or image dimensions are resolved, or alter the response from the lambda or cache. These parameters are: +The IIIF service can be heavily customized through the use of [CloudFront Functions](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/cloudfront-functions.html) or [Lambda@Edge Functions](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/lambda-at-the-edge.html) attached to a CloudFront distribution in front of the service. It's important to understand the four stages of CloudFront processing in order to know where a given type of customization belongs. -* `OriginRequestARN`: ARN of the Lambda@Edge Function to use at the origin-request stage -* `OriginResponseARN`: ARN of the Lambda@Edge Function to use at the origin-response stage -* `ViewerRequestARN`: ARN of the CloudFront or Lambda@Edge Function to use at the viewer-request stage -* `ViewerRequestType`: Type of viewer-request Function to use (`CloudWatch Function` or `Lambda@Edge`) -* `ViewerResponseARN`: ARN of the CloudFront or Lambda@Edge Function to use at the viewer-response stage -* `ViewerResponseType`: Type of viewer-response Function to use (`CloudWatch Function` or `Lambda@Edge`) - -These functions, if used, must be created, configured, and published before the serverless application is deployed. +- A `viewer-request` function will be called on every request, cached or not. This is the appropriate place to attach + a function that performs authorization, authentication, or anything else whose result should *not* be cached. +- An `origin-request` function will only be called when CloudFront refreshes the content from the origin (e.g., the IIIF server). + It's the appropriate place to attach a function that *should* be cached, such as S3 file resolution or the retrieval of + image dimensions. +- Similarly, the `origin-response` and `viewer-response` functions are called after the IIIF server returns its response + and before CloudFront passes it on to the viewer, respectively. They can be used to alter the response in a way that is + either cached or ephemeral. #### Examples @@ -178,13 +177,7 @@ For example, the following dimension values would all describe the same pyramida The `limit` calculator will keep going until both dimensions are _less than_ the limit, not _less than or equal to_. So a `limit: 512` on the third example above would generate a fourth page at `{ width: 256, height: 192 }`. -*Note:* The SAM deploy template adds a `preflight=true` environment variable to the main IIIF Lambda if a preflight function is provided. The function will _only_ look for the preflight headers if this environment variable is `true`. This prevents requests from including those headers directly if no preflight function is present. If you do use a preflight function, make sure it strips out any `x-preflight-location` and `x-preflight-dimensions` headers that it doesn't set itself. - -## Notes - -Lambda Function URLs have a payload (request/response body) size limit of approximately 6MB in both directions. To overcome this limitation, the Lambda URL is configured behind an AWS CloudFront distribution with two origins - the API and a cache bucket. Responses larger than 6MB are saved to the cache bucket at the same relative path as the request, and the Lambda returns a `404 Not Found` response to CloudFront. CloudFront then fails over to the second origin (the cache bucket), where it finds the actual response and returns it. - -The cache bucket uses an S3 lifecycle rule to expire cached responses in 1 day. +*Note:* If you plan to use CloudFront functions to add either of the above `x-preflight-` headers to incoming requests, you *must* set the value of the `Preflight` parameter to `true` when deploying `serverless-iiif`. The function will _only_ look for the preflight headers if this environment variable is `true`. This prevents requests from including those headers directly if no preflight function is present. If you do use a preflight function, make sure it strips out any `x-preflight-location` and `x-preflight-dimensions` headers that it doesn't set itself. ## License @@ -197,7 +190,7 @@ The cache bucket uses an S3 lifecycle rule to expire cached responses in 1 day. * [Rob Kaufman](https://github.com/orangewolf) * [Edward Silverton](https://github.com/edsilv) * [Trey Pendragon](https://github.com/tpendragon) -* [Dan Wolfe](https://github.com/danthewolfe) +* [Theia Wolfe](https://github.com/theiawolfe) ## Contributing diff --git a/bin/build b/bin/build index c642758..7847174 100755 --- a/bin/build +++ b/bin/build @@ -5,7 +5,7 @@ const Setup = require('./setup.js') var setup = new Setup(); -var cmd = `cd sam/${setup.type} && sam build --use-container` +var cmd = `cd sam && sam build --use-container` console.log(cmd) diff --git a/bin/deploy b/bin/deploy index fbfbadd..1774815 100755 --- a/bin/deploy +++ b/bin/deploy @@ -25,12 +25,12 @@ var keys = [ let parameter_overrides = "" keys.forEach(element => { - if(process.env[element] !== undefined) { + if (process.env[element] !== undefined) { parameter_overrides = parameter_overrides + ` ${element}="${process.env[element]}"` } }) -var cmd = `cd sam/${setup.type} && sam deploy --capabilities CAPABILITY_IAM CAPABILITY_AUTO_EXPAND --region ${process.env['AWS_REGION']} --profile ${process.env['AWS_PROFILE']} --stack-name ${process.env['AWS_STACK_NAME']} --resolve-s3 --parameter-overrides ${parameter_overrides}` +var cmd = `sam deploy --capabilities CAPABILITY_IAM CAPABILITY_AUTO_EXPAND --region ${process.env['AWS_REGION']} --profile ${process.env['AWS_PROFILE']} --stack-name ${process.env['AWS_STACK_NAME']} --resolve-s3 --parameter-overrides ${parameter_overrides}` console.log(cmd) exec(cmd, (error, stdout, stderr) => { diff --git a/bin/setup.js b/bin/setup.js index 9a8fead..e974bb2 100644 --- a/bin/setup.js +++ b/bin/setup.js @@ -1,11 +1,10 @@ class Setup { constructor() { - this.type = process.env.DEPLOY_TYPE || "cloudfront" - if(process.env.LOCAL_SHARP){ + if (process.env.LOCAL_SHARP) { var fs = require('fs'); var path = require('path'); - var file_path = path.resolve(__dirname, "..", "sam/standalone/template.yml"); - fs.readFile(file_path, {encoding: 'utf8'}, function (err,data) { + var file_path = path.resolve(__dirname, "..", "sam/template.yml"); + fs.readFile(file_path, {encoding: 'utf8'}, function (err, data) { var formatted = data.replace(/^\s+ContentUri: s3:\/\/bucket_name\/sharp-lambda-layer.*/g, " ContentUri: ../../sharp-lambda-layer.x86_64.zip"); fs.writeFile(file_path, formatted, 'utf8', function (err) { if (err) return console.log(err); diff --git a/bin/update_version b/bin/update_version index cc6f90e..05b4534 100755 --- a/bin/update_version +++ b/bin/update_version @@ -12,10 +12,11 @@ for f in $root/package.json $root/src/package.json $root/dependencies/package.js jq --arg v "$version" '.version = $v' <<< $content > $f done -for f in $root/sam/standalone/template.yml $root/sam/cloudfront/template.yml; do - if [[ "$OSTYPE" = "darwin*" ]]; then - sed -i '' -E "s/SemanticVersion: .+/SemanticVersion: $version/" $f - else - sed -i'' -E "s/SemanticVersion: .+/SemanticVersion: $version/" $f - fi -done +if [[ "$OSTYPE" = "darwin*" ]]; then + sedreplace="sed -i ''" +else + sedreplace="sed -i''" +fi + +$sedreplace -E "s/SemanticVersion: .+/SemanticVersion: $version/" $root/sam/template.yml +$sedreplace -E "s/ serverless_iiif_app_version =.+/ serverless_iiif_app_version = \"$version\"" $root/extras/terraform/main.tf diff --git a/dependencies/package-lock.json b/dependencies/package-lock.json index 74f90ac..92eab51 100644 --- a/dependencies/package-lock.json +++ b/dependencies/package-lock.json @@ -1,16 +1,16 @@ { "name": "serverless-iiif-dependencies", - "version": "4.3.1", + "version": "5.0.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "serverless-iiif-dependencies", - "version": "4.3.1", + "version": "5.0.0", "license": "Apache-2.0", "dependencies": { "aws-sdk": "^2.368", - "iiif-processor": "^3.2.3" + "iiif-processor": "^4.0.0" } }, "node_modules/available-typed-arrays": { @@ -319,9 +319,9 @@ "integrity": "sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg==" }, "node_modules/iiif-processor": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/iiif-processor/-/iiif-processor-3.2.3.tgz", - "integrity": "sha512-EOlmdGR3xSo8+x0X/82K9jJgsYWzekpE0OgXIYrfHg5ulnlAuKH+bSmv9GvN7gvIXk+Ww9NxrRRkujYlt49Efg==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/iiif-processor/-/iiif-processor-4.0.2.tgz", + "integrity": "sha512-aFghhFzc626hjEIp+VyNJ27rtvpCQ1JKyhR4GQ2vClwzA26UGHoXn28MNigIxiCFsBWESZycLTZTd3ENxCIQfw==", "dependencies": { "debug": "^4.3.4", "mime-types": "2.x", @@ -1038,9 +1038,9 @@ "integrity": "sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg==" }, "iiif-processor": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/iiif-processor/-/iiif-processor-3.2.3.tgz", - "integrity": "sha512-EOlmdGR3xSo8+x0X/82K9jJgsYWzekpE0OgXIYrfHg5ulnlAuKH+bSmv9GvN7gvIXk+Ww9NxrRRkujYlt49Efg==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/iiif-processor/-/iiif-processor-4.0.2.tgz", + "integrity": "sha512-aFghhFzc626hjEIp+VyNJ27rtvpCQ1JKyhR4GQ2vClwzA26UGHoXn28MNigIxiCFsBWESZycLTZTd3ENxCIQfw==", "requires": { "debug": "^4.3.4", "mime-types": "2.x", diff --git a/dependencies/package.json b/dependencies/package.json index 258e5d2..3606585 100644 --- a/dependencies/package.json +++ b/dependencies/package.json @@ -1,11 +1,11 @@ { "name": "serverless-iiif-dependencies", - "version": "4.3.2", + "version": "5.0.0", "description": "Dependencies for serverless IIIF", "author": "Michael B. Klein", "license": "Apache-2.0", "dependencies": { "aws-sdk": "^2.368", - "iiif-processor": "^3.2.3" + "iiif-processor": "^4.0.0" } } diff --git a/examples/cloudformation/README.md b/examples/cloudformation/README.md new file mode 100644 index 0000000..7b0c4fe --- /dev/null +++ b/examples/cloudformation/README.md @@ -0,0 +1,10 @@ +# serverless-iiif CloudFormation Examples + +This directory contains examples of how to use [CloudFormation](https://aws.amazon.com/cloudformation/) to deploy a serverless-iiif image server as part of a larger application stack. Please refer to the [CloudFormation documentation](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/Welcome.html) for more information on how to tailor these templates to your own needs and deploy them to AWS. + +## `custom_hostname.yml` + +The `custom_hostname` template will deploy a full application stack consisting of: + +- A serverless-iiif image server +- A CloudFront distribution with a custom hostname and SSL certificate diff --git a/examples/cloudformation/custom_hostname.yml b/examples/cloudformation/custom_hostname.yml new file mode 100644 index 0000000..7477da8 --- /dev/null +++ b/examples/cloudformation/custom_hostname.yml @@ -0,0 +1,107 @@ +AWSTemplateFormatVersion: 2010-09-09 +Description: IIIF Image server w/CloudFront Caching & Custom Hostname +Parameters: + CacheDomainName: + Type: String + Description: Custom Domain Name for the CloudFront Cache + CacheHostName: + Type: String + Description: Custom Hostname for the CloudFront Cache + CacheSSLCertificate: + Type: String + Description: >- + ARN of the ACM SSL Certification to use for the API Gateway Endpoint or + CloudFront Cache + IiifSourceBucket: + Type: String + Description: Name of bucket containing source images +Resources: + ServerlessIiif: + Type: 'AWS::Serverless::Application' + Properties: + Location: + ApplicationId: arn:aws:serverlessrepo:us-east-1:625046682746:applications/serverless-iiif-standalone-dev + SemanticVersion: 5.0.0 + Parameters: + SourceBucket: !Ref IiifSourceBucket + ResponseHeaderPolicy: + Type: 'AWS::CloudFront::ResponseHeadersPolicy' + Properties: + ResponseHeadersPolicyConfig: + Name: !Sub '${AWS::StackName}-allow-cors-response-headers' + Comment: Allows IIIF CORS response headers + CorsConfig: + AccessControlAllowCredentials: false + AccessControlAllowHeaders: + Items: + - '*' + AccessControlAllowMethods: + Items: + - GET + - OPTIONS + AccessControlAllowOrigins: + Items: + - '*' + AccessControlExposeHeaders: + Items: + - cache-control + - content-language + - content-length + - content-type + - date + - expires + - last-modified + - pragma + AccessControlMaxAgeSec: 3600 + OriginOverride: false + CachingEndpoint: + Type: 'AWS::CloudFront::Distribution' + Properties: + DistributionConfig: + Enabled: true + PriceClass: PriceClass_100 + Aliases: + - !Sub '${CacheHostName}.${CacheDomainName}' + ViewerCertificate: + AcmCertificateArn: !Ref CacheSSLCertificate + MinimumProtocolVersion: TLSv1 + SslSupportMethod: sni-only + Origins: + - Id: IiifLambda + CustomOriginConfig: + OriginProtocolPolicy: https-only + DomainName: !GetAtt ServerlessIiif.Outputs.FunctionDomain + DefaultCacheBehavior: + TargetOriginId: IiifLambda + ViewerProtocolPolicy: https-only + AllowedMethods: + - GET + - HEAD + - OPTIONS + CachedMethods: + - GET + - HEAD + CachePolicyId: 658327ea-f89d-4fab-a63d-7e88639e58f6 + ResponseHeadersPolicyId: !Ref ResponseHeaderPolicy + Route53Record: + Type: 'AWS::Route53::RecordSet' + Properties: + Name: !Ref '${CacheHostName}.${CacheDomainName}' + HostedZoneName: !Sub '${CacheDomainName}.' + Type: A + AliasTarget: + DNSName: !GetAtt CachingEndpoint.DomainName + HostedZoneId: Z2FDTNDATAQYW2 +Outputs: + EndpointV2: + Description: IIIFv2 Endpoint URL + Value: !Sub 'https://${CacheHostName}.${CacheDomainName}/iiif/2' + EndpointV3: + Description: IIIFv3 Endpoint URL + Value: !Sub 'https://${CacheHostName}.${CacheDomainName}/iiif/3' + DistributionId: + Description: Caching Distribution ID + Value: + Ref: CachingEndpoint + Export: + Name: !Sub '${AWS::StackName}:DistributionId' diff --git a/examples/terraform/README.md b/examples/terraform/README.md new file mode 100644 index 0000000..2cdf60c --- /dev/null +++ b/examples/terraform/README.md @@ -0,0 +1,8 @@ +# serverless-iiif Terraform Example + +This directory contains an example of how to use [Terraform](https://terraform.io/) and the [`serverless-iiif` Terraform module](../../extras/terraform/README.md) to deploy a full application stack consisting of: + +- A serverless-iiif image server +- A CloudFront distribution with a custom hostname and SSL certificate + +Please refer to the [Terraform documentation](https://developer.hashicorp.com/terraform/docs) for more information on how to tailor this manifest to your own needs. \ No newline at end of file diff --git a/examples/terraform/main.tf b/examples/terraform/main.tf new file mode 100644 index 0000000..65ba641 --- /dev/null +++ b/examples/terraform/main.tf @@ -0,0 +1,112 @@ +terraform { + required_providers { + aws = "~> 4.0" + } + required_version = ">= 1.3.0" +} + +provider "aws" { + default_tags { + tags = var.tags + } +} + +locals { + hostname_parts = split(".", var.cache_hostname) + hosted_zone_name = join(".", slice(local.hostname_parts, 1, length(local.hostname_parts))) +} + +resource "random_string" "resource_name" { + length = 8 + lower = true + upper = true + numeric = true + special = false +} + +resource "aws_cloudfront_response_headers_policy" "response_header_policy" { + comment = "Allows IIIF CORS response headers" + name = "allow-cors-response-headers-${random_string.resource_name.result}" + + cors_config { + access_control_allow_credentials = false + access_control_allow_headers { + items = ["*"] + } + access_control_allow_methods { + items = ["GET", "OPTIONS"] + } + access_control_allow_origins { + items = ["*"] + } + access_control_expose_headers { + items = [ + "cache-control", "content-language", "content-length", "content-type", + "date", "expires", "last-modified", "pragma" + ] + } + access_control_max_age_sec = 3600 + origin_override = false + } +} + +module "serverless_iiif" { + source = "../../extras/terraform" + source_bucket = var.iiif_source_bucket + stack_name = "serverless-iiif-${random_string.resource_name.result}" + force_host = var.cache_hostname +} + +resource "aws_cloudfront_distribution" "caching_distribution" { + enabled = true + price_class = "PriceClass_100" + aliases = [var.cache_hostname] + + origin { + origin_id = "iiif_lambda" + custom_origin_config { + http_port = 80 + https_port = 443 + origin_ssl_protocols = ["TLSv1.2"] + origin_protocol_policy = "https-only" + } + domain_name = module.serverless_iiif.outputs.FunctionDomain + } + + default_cache_behavior { + target_origin_id = "iiif_lambda" + viewer_protocol_policy = "https-only" + allowed_methods = ["GET", "HEAD", "OPTIONS"] + cached_methods = ["GET", "HEAD", "OPTIONS"] + cache_policy_id = "658327ea-f89d-4fab-a63d-7e88639e58f6" + response_headers_policy_id = aws_cloudfront_response_headers_policy.response_header_policy.id + } + + restrictions { + geo_restriction { + restriction_type = "none" + locations = [] + } + } + + viewer_certificate { + acm_certificate_arn = var.cache_ssl_certificate_arn + minimum_protocol_version = "TLSv1" + ssl_support_method = "sni-only" + } +} + +data "aws_route53_zone" "cache_hosted_zone" { + name = "${local.hosted_zone_name}." +} + +resource "aws_route53_record" "dns_record" { + name = var.cache_hostname + type = "A" + zone_id = data.aws_route53_zone.cache_hosted_zone.id + alias { + name = aws_cloudfront_distribution.caching_distribution.domain_name + zone_id = aws_cloudfront_distribution.caching_distribution.hosted_zone_id + evaluate_target_health = true + } +} \ No newline at end of file diff --git a/examples/terraform/outputs.tf b/examples/terraform/outputs.tf new file mode 100644 index 0000000..64f3dee --- /dev/null +++ b/examples/terraform/outputs.tf @@ -0,0 +1,10 @@ +output "endpoints" { + value = { + v2 = "https://${var.cache_hostname}/iiif/2" + v3 = "https://${var.cache_hostname}/iiif/3" + } +} + +output "distribution_id" { + value = aws_cloudfront_distribution.caching_distribution.id +} diff --git a/examples/terraform/variables.tf b/examples/terraform/variables.tf new file mode 100644 index 0000000..4a53acd --- /dev/null +++ b/examples/terraform/variables.tf @@ -0,0 +1,20 @@ +variable "cache_hostname" { + description = "Hostname to use as an alias for the CloudFront distribution" + type = string +} + +variable "cache_ssl_certificate_arn" { + description = "SSL certificate to use for the CloudFront distribution" + type = string +} + +variable "iiif_source_bucket" { + type = string + description = "Name of bucket containing source images" +} + +variable "tags" { + type = map(string) + description = "Tags to apply to all deployed resources" + default = {} +} diff --git a/extras/terraform/README.md b/extras/terraform/README.md new file mode 100644 index 0000000..4f475b1 --- /dev/null +++ b/extras/terraform/README.md @@ -0,0 +1,74 @@ +# serverless-iiif Terraform module + +Terraform module which deploys a [serverless-iiif](https://github.com/samvera/serverless-iiif) image service on AWS. + +## Usage + +### Minimal Example + +``` +module "serverless_iiif" { + source = "github.com/samvera/serverless-iiif//extras/terraform" + source_bucket = "iiif-images" + stack_name = "my-iiif-service" +} +``` + +### (Almost) Full Example + +``` +module "serverless_iiif" { + source = "github.com/samvera/serverless-iiif//extras/terraform" + source_bucket = "iiif-images" + stack_name = "my-iiif-service" + cors_allow_credentials = true + cors_allow_headers = "X-Custom-Header,Upgrade-Insecure-Requests" + cors_allow_origin = "REFLECT_ORIGIN" + cors_expose_headers = "Content-Encoding" + cors_max_age = 600 + force_host = "iiif.my-domain.edu" + iiif_lambda_memory = 2048 + iiif_lambda_timeout = 120 + pixel_density = 600 + preflight = true + resolver_template = "iiif/%s.tif" + + tags = { + Project = "my-image-service" + } +} +``` + +## Inputs + +| Name | Description | Type | Default | Required | +|---------------------------|-------------|------|---------|:--------:| +| `cors_allow_credentials` | Value of the CORS `Access-Control-Allow-Credentials` response header. Must be `true` to allow requests with `Authorization` and/or `Cookie` headers. | `bool` | `false` | no | +| `cors_allow_headers` | Value of the CORS `Access-Control-Allow-Headers` response header | `string` | `"*"` | no | +| `cors_allow_origin` | Value of the CORS `Access-Control-Allow-Origin` response header. Use the special value `REFLECT_ORIGIN` to copy the value from the `Origin` request header (required to emulate `*` for XHR requests using `Authorization` and/or `Cookie` headers). | `string` | `"*"` | no | +| `cors_expose_headers` | Value of the CORS `Access-Control-Expose-Headers` response header | `string` | `"cache-control,content-language,content-length,content-type,date,expires,last-modified,pragma"` | no | +| `cors_max_age` | Value of the CORS `Access-Control-MaxAge` response header | `number` | `3600` | no | +| `force_host` | Forced hostname to use in responses | `string` | `""` | no | +| `iiif_lambda_memory` | The memory provisioned for the lambda. | `number` | `3008` | no | +| `iiif_lambda_timeout` | The timeout for the lambda | `number` | `10` | no | +| `pixel_density` | Hardcoded DPI/Pixel Density/Resolution to encode in output images | `number` | `0` | no | +| `preflight` | Indicates whether the function should expect preflight headers | `bool` | `false` | no | +| `resolver_template` | A printf-style format string that determines the location of source image within the bucket given the image ID | `string` | `"%s.tif"` | no | +| `sharp_layer` | ARN of a custom AWS Lambda Layer containing the sharp and libvips dependencies | `string` | `""` | no | +| `source_bucket` | Name of the S3 bucket containing source images | `string` | `""` | yes | +| `stack_name` | The stack name for the deployed serverless-iiif application | `string` | `""` | yes | + +## Outputs + +| Name | Description | +|---------------------------|-------------------------------------------------------| +| `stack_id` | The ID of the serverless-iiif application stack | +| `outputs` | A map of outputs from the serverless-iiif application | +| `outputs.EndpointV2` | IIIF Image API v2 Endpoint | +| `outputs.EndpointV3` | IIIF Image API v3 Endpoint | +| `outputs.FunctionDomain` | IIIF Function Domain Name | +| `outputs.FunctionUrl` | IIIF Function URL | + +## License + +`serverless-iiif` is available under [the Apache 2.0 license](../../LICENSE). diff --git a/extras/terraform/main.tf b/extras/terraform/main.tf new file mode 100644 index 0000000..2dfb94f --- /dev/null +++ b/extras/terraform/main.tf @@ -0,0 +1,36 @@ +terraform { + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 4.0" + } + } + required_version = ">= 1.3.0" +} + +locals { + serverless_iiif_app_id = "arn:aws:serverlessrepo:us-east-1:625046682746:applications/serverless-iiif" + serverless_iiif_app_version = "5.0.0" +} + +resource "aws_serverlessapplicationrepository_cloudformation_stack" "serverless_iiif" { + name = var.stack_name + application_id = local.serverless_iiif_app_id + semantic_version = local.serverless_iiif_app_version + capabilities = ["CAPABILITY_IAM"] + parameters = { + CorsAllowCredentials = var.cors_allow_credentials + CorsAllowHeaders = var.cors_allow_headers + CorsAllowOrigin = var.cors_allow_origin + CorsExposeHeaders = var.cors_expose_headers + CorsMaxAge = var.cors_max_age + ForceHost = var.force_host + IiifLambdaMemory = var.iiif_lambda_memory + IiifLambdaTimeout = var.iiif_lambda_timeout + PixelDensity = var.pixel_density + Preflight = var.preflight + ResolverTemplate = var.resolver_template + SharpLayer = var.sharp_layer + SourceBucket = var.source_bucket + } +} diff --git a/extras/terraform/outputs.tf b/extras/terraform/outputs.tf new file mode 100644 index 0000000..b455942 --- /dev/null +++ b/extras/terraform/outputs.tf @@ -0,0 +1,9 @@ +output "stack_id" { + value = aws_serverlessapplicationrepository_cloudformation_stack.serverless_iiif.id + description = "The ID of the serverless-iiif application stack" +} + +output "outputs" { + value = aws_serverlessapplicationrepository_cloudformation_stack.serverless_iiif.outputs + description = "A map of outputs from the serverless-iiif application" +} diff --git a/extras/terraform/variables.tf b/extras/terraform/variables.tf new file mode 100644 index 0000000..0acdee1 --- /dev/null +++ b/extras/terraform/variables.tf @@ -0,0 +1,106 @@ +variable "stack_name" { + type = string + description = "The stack name for the deployed serverless-iiif application" +} + +variable "cors_allow_credentials" { + type = bool + description = <<-END + Value of the CORS `Access-Control-Allow-Credentials` response header. + Must be `true` to allow requests with `Authorization` and/or + `Cookie` headers. + END + default = false +} + +variable "cors_allow_headers" { + type = string + description = "Value of the CORS `Access-Control-Allow-Headers` response header" + default = "*" +} + +variable "cors_allow_origin" { + type = string + description = <<-END + Value of the CORS `Access-Control-Allow-Origin` response header. + Use the special value `REFLECT_ORIGIN` to copy the value from the + `Origin` request header (required to emulate `*` for XHR requests + using `Authorization` and/or `Cookie` headers). + END + default = "*" +} + +variable "cors_expose_headers" { + type = string + description = "Value of the CORS `Access-Control-Expose-Headers` response header" + default = "cache-control,content-language,content-length,content-type,date,expires,last-modified,pragma" +} + +variable "cors_max_age" { + type = number + description = "Value of the CORS `Access-Control-MaxAge` response header" + default = 3600 +} + +variable "force_host" { + type = string + description = "Forced hostname to use in responses" + default = "" +} + +variable "iiif_lambda_memory" { + type = number + description = "The memory provisioned for the lambda" + default = 3008 + + validation { + condition = var.iiif_lambda_memory >= 128 && var.iiif_lambda_memory <= 10240 + error_message = "iiif_lambda_memory must be between 128 and 10240" + } +} + +variable "iiif_lambda_timeout" { + type = number + description = "The timeout for the lambda" + default = 10 +} + +variable "pixel_density" { + type = number + description = "Hardcoded DPI/Pixel Density/Resolution to encode in output images" + default = 0 + + validation { + condition = var.pixel_density >= 0 + error_message = "pixel_density must be >= 0" + } +} + +variable "preflight" { + type = string + description = "Indicates whether the function should expect preflight headers" + default = "false" +} + +variable "resolver_template" { + type = string + description = "A printf-style format string that determines the location of source image within the bucket given the image ID" + default = "%s.tif" +} + +variable "sharp_layer" { + type = string + description = "ARN of a custom AWS Lambda Layer containing the sharp and libvips dependencies" + default = "" +} + +variable "source_bucket" { + type = string + description = "Name of the S3 bucket containing source images" +} + +variable "tags" { + type = map(string) + description = "Tags to apply to all deployed resources" + default = {} +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 269aef9..ab4da99 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "serverless-iiif", - "version": "4.3.1", + "version": "5.0.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "serverless-iiif", - "version": "4.3.1", + "version": "5.0.0", "license": "Apache-2.0", "dependencies": { "dotenv": "^16.0.3", @@ -22,8 +22,30 @@ "eslint-plugin-node": "^11.1.0", "eslint-plugin-promise": "^4.2.1", "eslint-plugin-standard": "^4.0.1", - "iiif-processor": "^3.2.0", - "jest": "^26.4.2" + "iiif-processor": "^4.0.0", + "jest": "^26.4.2", + "lambda-stream": "^0.4.0" + } + }, + "../node-iiif": { + "name": "iiif-processor", + "version": "4.0.0", + "extraneous": true, + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.3.4", + "mime-types": "2.x", + "sharp": ">=0.25.2 <1.0.0" + }, + "devDependencies": { + "coveralls": "^3.1.1", + "eslint": "^8.34.0", + "eslint-config-standard": "^17.0.0", + "eslint-plugin-import": "^2.27.5", + "eslint-plugin-jest": "^27.2.1", + "eslint-plugin-node": "^11.1.0", + "eslint-plugin-promise": "^6.1.1", + "jest": "^29.4.3" } }, "node_modules/@ampproject/remapping": { @@ -1661,6 +1683,12 @@ "integrity": "sha512-NmWvPnx0F1SfrQbYwOi7OeaNGokp9XhzNioJ/CSBs8Qa4vxug81mhJEAVZwxXuBmYB5KDRfMq/F3RR0BIU7sWg==", "dev": true }, + "node_modules/b4a": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.4.tgz", + "integrity": "sha512-fpWrvyVHEKyeEvbKZTVOeZF3VSKKWtJxFIxX/jaVPf+cLbGUSitjb49pHLqPV2BUNNZ0LcoeEGfE/YCpyDYHIw==", + "dev": true + }, "node_modules/babel-jest": { "version": "26.6.3", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-26.6.3.tgz", @@ -3570,6 +3598,12 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "dev": true }, + "node_modules/fast-fifo": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.0.tgz", + "integrity": "sha512-IgfweLvEpwyA4WgiQe9Nx6VV2QkML2NkvZnk1oKnIzXgXdWxuhF7zw4DvLTPZJn6PIUneiAXPF24QmoEqHTjyw==", + "dev": true + }, "node_modules/fast-glob": { "version": "3.2.12", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.12.tgz", @@ -4258,9 +4292,9 @@ } }, "node_modules/iiif-processor": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/iiif-processor/-/iiif-processor-3.2.3.tgz", - "integrity": "sha512-EOlmdGR3xSo8+x0X/82K9jJgsYWzekpE0OgXIYrfHg5ulnlAuKH+bSmv9GvN7gvIXk+Ww9NxrRRkujYlt49Efg==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/iiif-processor/-/iiif-processor-4.0.2.tgz", + "integrity": "sha512-aFghhFzc626hjEIp+VyNJ27rtvpCQ1JKyhR4GQ2vClwzA26UGHoXn28MNigIxiCFsBWESZycLTZTd3ENxCIQfw==", "dev": true, "dependencies": { "debug": "^4.3.4", @@ -5600,6 +5634,12 @@ "node": ">=6" } }, + "node_modules/lambda-stream": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/lambda-stream/-/lambda-stream-0.4.0.tgz", + "integrity": "sha512-l51Mxm5CDKJqVmRb77pN04AiWOdHjfbYE+ic9o/nE8oa/XOcfALvwQ8xtCwDi8S2pAUqoDCXJ/nXm10wD/B+ZA==", + "dev": true + }, "node_modules/lcov-parse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/lcov-parse/-/lcov-parse-1.0.0.tgz", @@ -5896,9 +5936,9 @@ "dev": true }, "node_modules/node-abi": { - "version": "3.33.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.33.0.tgz", - "integrity": "sha512-7GGVawqyHF4pfd0YFybhv/eM9JwTtPqx0mAanQ146O3FlSh3pA24zf9IRQTOsfTSqXTNzPSP5iagAJ94jjuVog==", + "version": "3.45.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.45.0.tgz", + "integrity": "sha512-iwXuFrMAcFVi/ZoZiqq8BzAdsLw9kxDfTC0HMyjXfSL/6CSDAGD5UmR7azrAgWV1zKYq7dUUMj4owusBWKLsiQ==", "dev": true, "dependencies": { "semver": "^7.3.5" @@ -5908,9 +5948,9 @@ } }, "node_modules/node-addon-api": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz", - "integrity": "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz", + "integrity": "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==", "dev": true }, "node_modules/node-int64": { @@ -6434,6 +6474,34 @@ "node": ">=10" } }, + "node_modules/prebuild-install/node_modules/tar-fs": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.1.tgz", + "integrity": "sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng==", + "dev": true, + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/prebuild-install/node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "dev": true, + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -6549,6 +6617,12 @@ } ] }, + "node_modules/queue-tick": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/queue-tick/-/queue-tick-1.0.1.tgz", + "integrity": "sha512-kJt5qhMxoszgU/62PLP1CJytzd2NKetjSRnyuj31fDd3Rlcz3fzlFdFLD1SItunPwyqEOkca6GbV612BWfaBag==", + "dev": true + }, "node_modules/rc": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", @@ -6630,9 +6704,9 @@ } }, "node_modules/readable-stream": { - "version": "3.6.1", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.1.tgz", - "integrity": "sha512-+rQmrWMYGA90yenhTYsLWAsLsqVC8osOw6PKE1HDYiO0gdPeKe/xDHNzIAIn4C91YQ6oenEhfYqqc1883qHbjQ==", + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", "dev": true, "dependencies": { "inherits": "^2.0.3", @@ -7290,9 +7364,9 @@ } }, "node_modules/semver": { - "version": "7.3.8", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz", - "integrity": "sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==", + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", "dev": true, "dependencies": { "lru-cache": "^6.0.0" @@ -7365,19 +7439,19 @@ } }, "node_modules/sharp": { - "version": "0.31.3", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.31.3.tgz", - "integrity": "sha512-XcR4+FCLBFKw1bdB+GEhnUNXNXvnt0tDo4WsBsraKymuo/IAuPuCBVAL2wIkUw2r/dwFW5Q5+g66Kwl2dgDFVg==", + "version": "0.32.2", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.32.2.tgz", + "integrity": "sha512-e4hyWKtU7ks/rLmoPys476zhpQnLqPJopz4+5b6OPeyJfvCTInAp0pe5tGhstjsNdUvDLrUVGEwevewYdZM8Eg==", "dev": true, "hasInstallScript": true, "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.1", - "node-addon-api": "^5.0.0", + "node-addon-api": "^6.1.0", "prebuild-install": "^7.1.1", - "semver": "^7.3.8", + "semver": "^7.5.4", "simple-get": "^4.0.1", - "tar-fs": "^2.1.1", + "tar-fs": "^3.0.4", "tunnel-agent": "^0.6.0" }, "engines": { @@ -7956,6 +8030,16 @@ "node": ">=0.10.0" } }, + "node_modules/streamx": { + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.15.0.tgz", + "integrity": "sha512-HcxY6ncGjjklGs1xsP1aR71INYcsXFJet5CU1CHqihQ2J5nOsbd4OjgjHO42w/4QNv9gZb3BueV+Vxok5pLEXg==", + "dev": true, + "dependencies": { + "fast-fifo": "^1.1.0", + "queue-tick": "^1.0.1" + } + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -8153,31 +8237,25 @@ "dev": true }, "node_modules/tar-fs": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.1.tgz", - "integrity": "sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.0.4.tgz", + "integrity": "sha512-5AFQU8b9qLfZCX9zp2duONhPmZv0hGYiBPJsyUdqMjzq/mqVpy/rEUSeHk1+YitmxugaptgBh5oDGU3VsAJq4w==", "dev": true, "dependencies": { - "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", - "tar-stream": "^2.1.4" + "tar-stream": "^3.1.5" } }, "node_modules/tar-stream": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", - "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.6.tgz", + "integrity": "sha512-B/UyjYwPpMBv+PaFSWAmtYjwdrlEaZQEhMIBFNC5oEG8lpiW8XjcSdmEaClj28ArfKScKHs2nshz3k2le6crsg==", "dev": true, "dependencies": { - "bl": "^4.0.3", - "end-of-stream": "^1.4.1", - "fs-constants": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^3.1.1" - }, - "engines": { - "node": ">=6" + "b4a": "^1.6.4", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" } }, "node_modules/terminal-link": { @@ -10249,6 +10327,12 @@ "integrity": "sha512-NmWvPnx0F1SfrQbYwOi7OeaNGokp9XhzNioJ/CSBs8Qa4vxug81mhJEAVZwxXuBmYB5KDRfMq/F3RR0BIU7sWg==", "dev": true }, + "b4a": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.4.tgz", + "integrity": "sha512-fpWrvyVHEKyeEvbKZTVOeZF3VSKKWtJxFIxX/jaVPf+cLbGUSitjb49pHLqPV2BUNNZ0LcoeEGfE/YCpyDYHIw==", + "dev": true + }, "babel-jest": { "version": "26.6.3", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-26.6.3.tgz", @@ -11707,6 +11791,12 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "dev": true }, + "fast-fifo": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.0.tgz", + "integrity": "sha512-IgfweLvEpwyA4WgiQe9Nx6VV2QkML2NkvZnk1oKnIzXgXdWxuhF7zw4DvLTPZJn6PIUneiAXPF24QmoEqHTjyw==", + "dev": true + }, "fast-glob": { "version": "3.2.12", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.12.tgz", @@ -12231,9 +12321,9 @@ "dev": true }, "iiif-processor": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/iiif-processor/-/iiif-processor-3.2.3.tgz", - "integrity": "sha512-EOlmdGR3xSo8+x0X/82K9jJgsYWzekpE0OgXIYrfHg5ulnlAuKH+bSmv9GvN7gvIXk+Ww9NxrRRkujYlt49Efg==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/iiif-processor/-/iiif-processor-4.0.2.tgz", + "integrity": "sha512-aFghhFzc626hjEIp+VyNJ27rtvpCQ1JKyhR4GQ2vClwzA26UGHoXn28MNigIxiCFsBWESZycLTZTd3ENxCIQfw==", "dev": true, "requires": { "debug": "^4.3.4", @@ -13242,6 +13332,12 @@ "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", "dev": true }, + "lambda-stream": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/lambda-stream/-/lambda-stream-0.4.0.tgz", + "integrity": "sha512-l51Mxm5CDKJqVmRb77pN04AiWOdHjfbYE+ic9o/nE8oa/XOcfALvwQ8xtCwDi8S2pAUqoDCXJ/nXm10wD/B+ZA==", + "dev": true + }, "lcov-parse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/lcov-parse/-/lcov-parse-1.0.0.tgz", @@ -13477,18 +13573,18 @@ "dev": true }, "node-abi": { - "version": "3.33.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.33.0.tgz", - "integrity": "sha512-7GGVawqyHF4pfd0YFybhv/eM9JwTtPqx0mAanQ146O3FlSh3pA24zf9IRQTOsfTSqXTNzPSP5iagAJ94jjuVog==", + "version": "3.45.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.45.0.tgz", + "integrity": "sha512-iwXuFrMAcFVi/ZoZiqq8BzAdsLw9kxDfTC0HMyjXfSL/6CSDAGD5UmR7azrAgWV1zKYq7dUUMj4owusBWKLsiQ==", "dev": true, "requires": { "semver": "^7.3.5" } }, "node-addon-api": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz", - "integrity": "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz", + "integrity": "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==", "dev": true }, "node-int64": { @@ -13883,6 +13979,33 @@ "simple-get": "^4.0.0", "tar-fs": "^2.0.0", "tunnel-agent": "^0.6.0" + }, + "dependencies": { + "tar-fs": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.1.tgz", + "integrity": "sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng==", + "dev": true, + "requires": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "dev": true, + "requires": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + } + } } }, "prelude-ls": { @@ -13964,6 +14087,12 @@ "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", "dev": true }, + "queue-tick": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/queue-tick/-/queue-tick-1.0.1.tgz", + "integrity": "sha512-kJt5qhMxoszgU/62PLP1CJytzd2NKetjSRnyuj31fDd3Rlcz3fzlFdFLD1SItunPwyqEOkca6GbV612BWfaBag==", + "dev": true + }, "rc": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", @@ -14030,9 +14159,9 @@ } }, "readable-stream": { - "version": "3.6.1", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.1.tgz", - "integrity": "sha512-+rQmrWMYGA90yenhTYsLWAsLsqVC8osOw6PKE1HDYiO0gdPeKe/xDHNzIAIn4C91YQ6oenEhfYqqc1883qHbjQ==", + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", "dev": true, "requires": { "inherits": "^2.0.3", @@ -14525,9 +14654,9 @@ } }, "semver": { - "version": "7.3.8", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz", - "integrity": "sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==", + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", "dev": true, "requires": { "lru-cache": "^6.0.0" @@ -14586,18 +14715,18 @@ } }, "sharp": { - "version": "0.31.3", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.31.3.tgz", - "integrity": "sha512-XcR4+FCLBFKw1bdB+GEhnUNXNXvnt0tDo4WsBsraKymuo/IAuPuCBVAL2wIkUw2r/dwFW5Q5+g66Kwl2dgDFVg==", + "version": "0.32.2", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.32.2.tgz", + "integrity": "sha512-e4hyWKtU7ks/rLmoPys476zhpQnLqPJopz4+5b6OPeyJfvCTInAp0pe5tGhstjsNdUvDLrUVGEwevewYdZM8Eg==", "dev": true, "requires": { "color": "^4.2.3", "detect-libc": "^2.0.1", - "node-addon-api": "^5.0.0", + "node-addon-api": "^6.1.0", "prebuild-install": "^7.1.1", - "semver": "^7.3.8", + "semver": "^7.5.4", "simple-get": "^4.0.1", - "tar-fs": "^2.1.1", + "tar-fs": "^3.0.4", "tunnel-agent": "^0.6.0" } }, @@ -15053,6 +15182,16 @@ } } }, + "streamx": { + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.15.0.tgz", + "integrity": "sha512-HcxY6ncGjjklGs1xsP1aR71INYcsXFJet5CU1CHqihQ2J5nOsbd4OjgjHO42w/4QNv9gZb3BueV+Vxok5pLEXg==", + "dev": true, + "requires": { + "fast-fifo": "^1.1.0", + "queue-tick": "^1.0.1" + } + }, "string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -15203,28 +15342,25 @@ } }, "tar-fs": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.1.tgz", - "integrity": "sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.0.4.tgz", + "integrity": "sha512-5AFQU8b9qLfZCX9zp2duONhPmZv0hGYiBPJsyUdqMjzq/mqVpy/rEUSeHk1+YitmxugaptgBh5oDGU3VsAJq4w==", "dev": true, "requires": { - "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", - "tar-stream": "^2.1.4" + "tar-stream": "^3.1.5" } }, "tar-stream": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", - "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.6.tgz", + "integrity": "sha512-B/UyjYwPpMBv+PaFSWAmtYjwdrlEaZQEhMIBFNC5oEG8lpiW8XjcSdmEaClj28ArfKScKHs2nshz3k2le6crsg==", "dev": true, "requires": { - "bl": "^4.0.3", - "end-of-stream": "^1.4.1", - "fs-constants": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^3.1.1" + "b4a": "^1.6.4", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" } }, "terminal-link": { diff --git a/package.json b/package.json index a7989b0..8c0409e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "serverless-iiif", - "version": "4.3.2", + "version": "5.0.0", "description": "Lambda wrapper for iiif-processor", "author": "Michael B. Klein", "license": "Apache-2.0", @@ -18,8 +18,9 @@ "eslint-plugin-node": "^11.1.0", "eslint-plugin-promise": "^4.2.1", "eslint-plugin-standard": "^4.0.1", - "iiif-processor": "^3.2.0", - "jest": "^26.4.2" + "iiif-processor": "^4.0.0", + "jest": "^26.4.2", + "lambda-stream": "^0.4.0" }, "jest": { "collectCoverageFrom": [ diff --git a/sam/cloudfront/template.yml b/sam/cloudfront/template.yml deleted file mode 100644 index 4eff9b6..0000000 --- a/sam/cloudfront/template.yml +++ /dev/null @@ -1,412 +0,0 @@ -Transform: "AWS::Serverless-2016-10-31" -Metadata: - AWS::ServerlessRepo::Application: - Name: serverless-iiif-cloudfront - Description: IIIF Image API 2.1 server in an AWS Serverless Application (w/CloudFront Caching) - Author: Samvera - SpdxLicenseId: Apache-2.0 - LicenseUrl: ../../LICENSE.txt - ReadmeUrl: ../../README.md - Labels: ["iiif", "image-processing"] - HomePageUrl: https://github.com/samvera/serverless-iiif - SemanticVersion: 4.3.2 - SourceCodeUrl: https://github.com/samvera/serverless-iiif - AWS::CloudFormation::Interface: - ParameterGroups: - - Label: - default: "General Configuration" - Parameters: - - SourceBucket - - IiifLambdaMemory - - IiifLambdaTimeout - - PixelDensity - - ResolverTemplate - - SharpLayer - - Label: - default: "Cache Configuration" - Parameters: - - CachePolicyID - - CachePriceClass - - Label: - default: "CORS Configuration" - Parameters: - - CorsAllowCredentials - - CorsAllowHeaders - - CorsAllowOrigin - - CorsExposeHeaders - - CorsMaxAge - - Label: - default: "Hostname Configuration" - Parameters: - - CacheDomainName - - CacheSSLCertificate - - Label: - default: "Function Configuration" - Parameters: - - OriginRequestARN - - OriginResponseARN - - ViewerRequestARN - - ViewerRequestType - - ViewerResponseARN - - ViewerResponseType -Parameters: - CachePolicyID: - Type: String - Description: The ID of a managed or custom CloudFront Cache Policy to use - Default: 658327ea-f89d-4fab-a63d-7e88639e58f6 - CachePriceClass: - Type: String - Description: Price Class for the CloudFront Cache - Default: PriceClass_100 - AllowedValues: - - PriceClass_100 - - PriceClass_200 - - PriceClass_All - CacheDomainName: - Type: String - Description: Custom Domain Name for the API Gateway Endpoint or CloudFront Cache - Default: "" - CacheSSLCertificate: - Type: String - Description: ARN of the ACM SSL Certification to use for the API Gateway Endpoint or CloudFront Cache - Default: "" - CorsAllowCredentials: - Type: String - Description: | - Value of the CORS `Access-Control-Allow-Credentials` response header. - Must be `true` to allow requests with `Authorization` and/or - `Cookie` headers. - AllowedValues: - - false - - true - Default: false - CorsAllowHeaders: - Type: String - Description: Value of the CORS `Access-Control-Allow-Headers` response header - Default: "*" - CorsAllowOrigin: - Type: String - Description: | - Value of the CORS `Access-Control-Allow-Origin` response header. - Use the special value `REFLECT_ORIGIN` to copy the value from the - `Origin` request header (required to emulate `*` for XHR requests - using `Authorization` and/or `Cookie` headers). - Default: "*" - CorsExposeHeaders: - Type: String - Description: Value of the CORS `Access-Control-Expose-Headers` response header - Default: cache-control,content-language,content-length,content-type,date,expires,last-modified,pragma - CorsMaxAge: - Type: Number - Description: Value of the CORS `Access-Control-MaxAge` response header - Default: 3600 - SourceBucket: - Type: String - Description: Name of bucket containing source images - IiifLambdaMemory: - Type: Number - Description: The memory provisioned for the lambda. - MinValue: 128 - MaxValue: 10240 - Default: 3008 - IiifLambdaTimeout: - Type: Number - Description: The timeout for the lambda. - Default: 10 - OriginRequestARN: - Type: String - Description: ARN of the Lambda@Edge Function to use at the origin-request stage - Default: "" - OriginResponseARN: - Type: String - Description: ARN of the Lambda@Edge Function to use at the origin-response stage - Default: "" - PixelDensity: - Type: Number - Description: Hardcoded DPI/Pixel Density/Resolution to encode in output images - Default: 0 - MinValue: 0 - ResolverTemplate: - Type: String - Description: A printf-style format string that determines the location of source image within the bucket given the image ID - Default: "%s.tif" - SharpLayer: - Type: String - Description: ARN of an AWS Lambda Layer containing the sharp and libvips dependencies (Leave blank for default) - Default: "" - ViewerRequestARN: - Type: String - Description: ARN of the CloudFront or Lambda@Edge Function to use at the viewer-request stage - Default: "" - ViewerRequestType: - Type: String - Description: Type of viewer-request Function to use (CloudWatch Function or Lambda@Edge) - Default: "None" - AllowedValues: - - CloudWatch Function - - Lambda@Edge - - None - ViewerResponseARN: - Type: String - Description: ARN of the CloudFront or Lambda@Edge Function to use at the viewer-response stage - Default: "" - ViewerResponseType: - Type: String - Description: Type of viewer-response Function to use (CloudWatch Function or Lambda@Edge) - Default: "None" - AllowedValues: - - CloudWatch Function - - Lambda@Edge - - None -Conditions: - DistributionCustomDomain: - Fn::And: - - Fn::Not: - - Fn::Equals: [!Ref CacheDomainName, ""] - - Fn::Not: - - Fn::Equals: [!Ref CacheSSLCertificate, ""] - UseOriginRequest: - Fn::Not: - - Fn::Equals: [!Ref OriginRequestARN, ""] - UseOriginResponse: - Fn::Not: - - Fn::Equals: [!Ref OriginResponseARN, ""] - UseViewerRequest: - Fn::And: - - Fn::Not: - - Fn::Equals: [!Ref ViewerRequestType, "None"] - - Fn::Not: - - Fn::Equals: [!Ref ViewerRequestARN, ""] - UseViewerResponse: - Fn::And: - - Fn::Not: - - Fn::Equals: [!Ref ViewerResponseType, "None"] - - Fn::Not: - - Fn::Equals: [!Ref ViewerResponseARN, ""] - ViewerRequestCloudWatchFunction: - Fn::And: - - Condition: UseViewerRequest - - Fn::Equals: [!Ref ViewerRequestType, "CloudWatch Function"] - ViewerRequestLambda: - Fn::And: - - Condition: UseViewerRequest - - Fn::Equals: [!Ref ViewerRequestType, "Lambda@Edge"] - ViewerResponseCloudWatchFunction: - Fn::And: - - Condition: UseViewerResponse - - Fn::Equals: [!Ref ViewerResponseType, "CloudWatch Function"] - ViewerResponseLambda: - Fn::And: - - Condition: UseViewerResponse - - Fn::Equals: [!Ref ViewerResponseType, "Lambda@Edge"] -Resources: - CacheBucket: - Type: "AWS::S3::Bucket" - Properties: - BucketName: - Fn::Sub: "${AWS::StackName}-cache" - LifecycleConfiguration: - Rules: - - Status: Enabled - ExpirationInDays: 1 - PublicAccessBlockConfiguration: - BlockPublicAcls : true - BlockPublicPolicy : true - IgnorePublicAcls : true - RestrictPublicBuckets : true - CacheBucketPolicy: - Type: "AWS::S3::BucketPolicy" - Properties: - Bucket: !Ref CacheBucket - PolicyDocument: - Version: "2012-10-17" - Statement: - - Effect: Allow - Action: - - s3:GetObject - Resource: - - Fn::Sub: "arn:aws:s3:::${CacheBucket}/*" - Principal: - CanonicalUser: - Fn::GetAtt: CachingIdentity.S3CanonicalUserId - CachingIdentity: - Type: "AWS::CloudFront::CloudFrontOriginAccessIdentity" - Properties: - CloudFrontOriginAccessIdentityConfig: - Comment: "Caching Distribution Identity" - OriginRequestPolicy: - Type: "AWS::CloudFront::OriginRequestPolicy" - Properties: - OriginRequestPolicyConfig: - Name: !Sub "${AWS::StackName}-allow-preflight-headers" - Comment: Allows IIIF preflight headers - CookiesConfig: - CookieBehavior: none - HeadersConfig: - HeaderBehavior: whitelist - Headers: - - x-preflight-location - - x-preflight-dimensions - QueryStringsConfig: - QueryStringBehavior: none - ResponseHeaderPolicy: - Type: "AWS::CloudFront::ResponseHeadersPolicy" - Properties: - ResponseHeadersPolicyConfig: - Name: !Sub "${AWS::StackName}-allow-cors-response-headers" - Comment: Allows IIIF CORS response headers - CorsConfig: - AccessControlAllowCredentials: false - AccessControlAllowHeaders: - Items: ["*"] - AccessControlAllowMethods: - Items: ["GET", "OPTIONS"] - AccessControlAllowOrigins: - Items: ["*"] - AccessControlExposeHeaders: - Items: ["cache-control", "content-language", "content-length", "content-type", "date", "expires", "last-modified", "pragma"] - AccessControlMaxAgeSec: 3600 - OriginOverride: false - CachingEndpoint: - Type: "AWS::CloudFront::Distribution" - Properties: - DistributionConfig: - Enabled: true - PriceClass: !Ref CachePriceClass - Aliases: - Fn::If: - - DistributionCustomDomain - - !Split [",", !Ref CacheDomainName] - - !Ref AWS::NoValue - ViewerCertificate: - Fn::If: - - DistributionCustomDomain - - AcmCertificateArn: !Ref CacheSSLCertificate - MinimumProtocolVersion: 'TLSv1' - SslSupportMethod: 'sni-only' - - CloudFrontDefaultCertificate: true - Origins: - - Id: IiifLambda - CustomOriginConfig: - OriginProtocolPolicy: https-only - DomainName: - Fn::GetAtt: IiifFunction.Outputs.FunctionDomain - - Id: IiifCache - S3OriginConfig: - OriginAccessIdentity: - Fn::Join: - - '' - - - 'origin-access-identity/cloudfront/' - - !Ref CachingIdentity - DomainName: - Fn::Sub: "${CacheBucket}.s3.${AWS::Region}.amazonaws.com" - OriginGroups: - Quantity: 1 - Items: - - Id: IiifOrigins - Members: - Quantity: 2 - Items: - - OriginId: IiifLambda - - OriginId: IiifCache - FailoverCriteria: - StatusCodes: - Items: [404] - Quantity: 1 - DefaultCacheBehavior: - TargetOriginId: IiifOrigins - ViewerProtocolPolicy: https-only - AllowedMethods: ["GET", "HEAD", "OPTIONS"] - CachedMethods: ["GET", "HEAD"] - CachePolicyId: !Ref CachePolicyID - OriginRequestPolicyId: !Ref OriginRequestPolicy - ResponseHeadersPolicyId: !Ref ResponseHeaderPolicy - FunctionAssociations: - - Fn::If: - - ViewerRequestCloudWatchFunction - - EventType: viewer-request - FunctionARN: !Ref ViewerRequestARN - - !Ref AWS::NoValue - - Fn::If: - - ViewerResponseCloudWatchFunction - - EventType: viewer-response - FunctionARN: !Ref ViewerResponseARN - - !Ref AWS::NoValue - LambdaFunctionAssociations: - - Fn::If: - - ViewerRequestLambda - - EventType: viewer-request - LambdaFunctionARN: !Ref ViewerRequestARN - IncludeBody: false - - !Ref AWS::NoValue - - Fn::If: - - UseOriginRequest - - EventType: origin-request - LambdaFunctionARN: !Ref OriginRequestARN - IncludeBody: false - - !Ref AWS::NoValue - - Fn::If: - - UseOriginResponse - - EventType: origin-response - LambdaFunctionARN: !Ref OriginResponseARN - IncludeBody: false - - !Ref AWS::NoValue - - Fn::If: - - ViewerResponseLambda - - EventType: viewer-response - LambdaFunctionARN: !Ref ViewerResponseARN - IncludeBody: false - - !Ref AWS::NoValue - IiifFunction: - Type: AWS::Serverless::Application - Properties: - # Swap the comment characters on the two Location properties and update SemanticVersion - # when publishing to SAR - Location: ../standalone/template.yml - # Location: - # ApplicationId: arn:aws:serverlessrepo:us-east-1:625046682746:applications/serverless-iiif-standalone - # SemanticVersion: 4.3.2 - Parameters: - CacheBucket: !Ref CacheBucket - CorsAllowCredentials: !Ref CorsAllowCredentials - CorsAllowOrigin: !Ref CorsAllowOrigin - CorsAllowHeaders: !Ref CorsAllowHeaders - CorsExposeHeaders: !Ref CorsExposeHeaders - CorsMaxAge: !Ref CorsMaxAge - ForceHost: - Fn::If: - - DistributionCustomDomain - - !Select [0, !Split [",", !Ref CacheDomainName]] - - !Ref AWS::NoValue - IiifLambdaMemory: !Ref IiifLambdaMemory - IiifLambdaTimeout: !Ref IiifLambdaTimeout - PixelDensity: !Ref PixelDensity - Preflight: - Fn::If: - - UseViewerRequest - - true - - false - ResolverTemplate: !Ref ResolverTemplate - SharpLayer: !Ref SharpLayer - SourceBucket: !Ref SourceBucket -Outputs: - Endpoint: - Description: IIIF Endpoint URL - Value: - Fn::Join: - - "" - - - "https://" - - Fn::If: - - DistributionCustomDomain - - !Select [0, !Split [",", !Ref CacheDomainName]] - - !GetAtt CachingEndpoint.DomainName - - "/iiif/2" - DistributionId: - Description: Caching Distribution ID - Value: - Ref: CachingEndpoint - Export: - Name: !Sub "${AWS::StackName}:DistributionId" - LambdaFunction: - Description: IIIF Lambda Function Name - Value: !Ref IiifFunction diff --git a/sam/standalone/template.yml b/sam/template.yml similarity index 75% rename from sam/standalone/template.yml rename to sam/template.yml index c62ed16..c773fc9 100644 --- a/sam/standalone/template.yml +++ b/sam/template.yml @@ -1,15 +1,15 @@ Transform: "AWS::Serverless-2016-10-31" Metadata: AWS::ServerlessRepo::Application: - Name: serverless-iiif-standalone + Name: serverless-iiif Description: IIIF Image API 2.1 server backend Lambda function Author: Samvera SpdxLicenseId: Apache-2.0 - LicenseUrl: ../../LICENSE.txt - ReadmeUrl: ../../README.md + LicenseUrl: ../LICENSE.txt + ReadmeUrl: ../README.md Labels: ["iiif", "image-processing"] HomePageUrl: https://github.com/samvera/serverless-iiif - SemanticVersion: 4.3.2 + SemanticVersion: 5.0.0 SourceCodeUrl: https://github.com/samvera/serverless-iiif AWS::CloudFormation::Interface: ParameterGroups: @@ -30,16 +30,11 @@ Metadata: - CorsExposeHeaders - CorsMaxAge - Label: - default: "Internal Use Only – Do Not Change" + default: "Advanced Options" Parameters: - - CacheBucket - ForceHost - Preflight Parameters: - CacheBucket: - Type: String - Description: Bucket to use for caching results larger than 6MB - Default: "" CorsAllowCredentials: Type: String Description: | @@ -99,15 +94,12 @@ Parameters: Default: "%s.tif" SharpLayer: Type: String - Description: ARN of an AWS Lambda Layer containing the sharp and libvips dependencies (Leave blank for default) + Description: ARN of a custom AWS Lambda Layer containing the sharp and libvips dependencies Default: "" SourceBucket: Type: String Description: Name of bucket containing source images Conditions: - UseCacheBucket: - Fn::Not: - - Fn::Equals: [!Ref CacheBucket, ""] UseDefaultSharpLayer: Fn::Equals: [!Ref SharpLayer, ""] UseForceHost: @@ -123,29 +115,30 @@ Resources: LayerName: Fn::Sub: "${AWS::StackName}-dependencies" Description: Dependencies for IIIF app - ContentUri: ../../dependencies + ContentUri: ../dependencies CompatibleRuntimes: - - nodejs16.x + - nodejs18.x LicenseInfo: "Apache-2.0" Metadata: - BuildMethod: nodejs16.x + BuildMethod: nodejs18.x IiifFunction: Type: "AWS::Serverless::Function" Properties: - Runtime: nodejs16.x + Runtime: nodejs18.x Handler: index.handler MemorySize: Ref: IiifLambdaMemory FunctionUrlConfig: AuthType: NONE + InvokeMode: RESPONSE_STREAM Timeout: Ref: IiifLambdaTimeout - CodeUri: ../../src + CodeUri: ../src Layers: - Ref: Dependencies - Fn::If: - UseDefaultSharpLayer - - !Sub "arn:aws:lambda:${AWS::Region}:625046682746:layer:libvips-sharp-jp2:3" + - !Sub "arn:aws:lambda:${AWS::Region}:625046682746:layer:libvips-sharp-jp2:4" - Ref: SharpLayer Policies: - AWSLambdaExecute @@ -161,39 +154,16 @@ Resources: - s3:GetObjectACL Resource: Fn::Sub: "arn:aws:s3:::${SourceBucket}/*" - - Fn::If: - - UseCacheBucket - - Version: "2012-10-17" - Statement: - - Effect: Allow - Action: - - s3:ListBucket - - s3:GetBucketLocation - Resource: - - Fn::Sub: "arn:aws:s3:::${SourceBucket}" - - Fn::Sub: "arn:aws:s3:::${CacheBucket}" - - Effect: Allow - Action: - - s3:GetObject - - s3:PutObject - - s3:DeleteObject - Resource: - - Fn::Sub: "arn:aws:s3:::${CacheBucket}/*" - - Version: "2012-10-17" - Statement: - - Effect: Allow - Action: - - s3:ListBucket - - s3:GetBucketLocation - Resource: - Fn::Sub: "arn:aws:s3:::${SourceBucket}" + - Version: "2012-10-17" + Statement: + - Effect: Allow + Action: + - s3:ListBucket + - s3:GetBucketLocation + Resource: + Fn::Sub: "arn:aws:s3:::${SourceBucket}" Environment: Variables: - cacheBucket: - Fn::If: - - UseCacheBucket - - !Ref CacheBucket - - !Ref AWS::NoValue corsAllowCredentials: !Ref CorsAllowCredentials corsAllowOrigin: !Ref CorsAllowOrigin corsAllowHeaders: !Ref CorsAllowHeaders @@ -214,10 +184,14 @@ Resources: tiffBucket: !Ref SourceBucket Outputs: - Endpoint: + EndpointV2: Description: IIIF Image API v2 Endpoint Value: - Fn::Sub: "${IiifFunctionUrl.FunctionUrl}iiif/2/" + Fn::Sub: "${IiifFunctionUrl.FunctionUrl}iiif/2" + EndpointV3: + Description: IIIF Image API v3 Endpoint + Value: + Fn::Sub: "${IiifFunctionUrl.FunctionUrl}iiif/3" FunctionDomain: Description: IIIF Function Domain Name Value: diff --git a/src/cache.js b/src/cache.js deleted file mode 100644 index a074e87..0000000 --- a/src/cache.js +++ /dev/null @@ -1,55 +0,0 @@ -const AWS = require('aws-sdk'); - -const cacheConfigured = () => { - return (typeof process.env.cacheBucket === 'string') && process.env.cacheBucket.length > 0; -}; - -const getCacheBucket = () => process.env.cacheBucket; - -const getCached = (key) => { - return new Promise((resolve) => { - if (!cacheConfigured()) { - resolve(false); - } - - const s3 = new AWS.S3(); - s3.headObject({ Bucket: getCacheBucket(), Key: key }, (err) => { - if (err) { - resolve(false); - } else { - resolve(true); - } - }); - }); -}; - -const makeCache = (key, image) => { - const cacheBucket = getCacheBucket(); - - return new Promise((resolve, reject) => { - if (!cacheConfigured()) { - reject(new Error(`Content size (${image.length.toString()}) exceeds API gateway maximum`)); - } - - const s3 = new AWS.S3(); - const uploadParams = { - Bucket: cacheBucket, - Key: key, - Body: image.body, - ContentType: image.contentType - }; - - s3.upload(uploadParams, (err) => { - if (err) { - reject(err); - } else { - resolve(true); - } - }); - }); -}; - -module.exports = { - getCached, - makeCache -}; diff --git a/src/index.js b/src/index.js index e6e880b..e9e6aec 100644 --- a/src/index.js +++ b/src/index.js @@ -1,11 +1,11 @@ const AWS = require('aws-sdk'); const IIIF = require('iiif-processor'); -const cache = require('./cache'); const helpers = require('./helpers'); const resolvers = require('./resolvers'); const { errorHandler } = require('./error'); +const { streamifyResponse } = require('./streamify'); -const handleRequestFunc = async (event, context) => { +const handleRequestFunc = streamifyResponse(async (event, context) => { const { addCorsHeaders, eventPath, fileMissing, getRegion } = helpers; AWS.config.region = getRegion(context); @@ -15,6 +15,8 @@ const handleRequestFunc = async (event, context) => { if (event.requestContext?.http?.method === 'OPTIONS') { // OPTIONS REQUEST response = { statusCode: 204, body: null }; + } else if (event?.requestContext?.http?.path === '/') { + response = handleServiceDiscoveryRequestFunc(); } else if (fileMissing(event)) { // INFO.JSON REQUEST const location = eventPath(event) + '/info.json'; @@ -24,6 +26,28 @@ const handleRequestFunc = async (event, context) => { response = await handleResourceRequestFunc(event, context); } return addCorsHeaders(event, response); +}); + +const handleServiceDiscoveryRequestFunc = () => { + return { + statusCode: 200, + headers: { + 'Content-Type': 'application/json' + }, + isBase64Encoded: false, + body: JSON.stringify({ + links: [ + { + href: '/iiif/2/{:id}', + name: 'IIIF Image API v2 endpoint' + }, + { + href: '/iiif/3/{:id}', + name: 'IIIF Image API v3 endpoint' + } + ] + }) + }; }; const handleResourceRequestFunc = async (event, context) => { @@ -46,57 +70,30 @@ const handleResourceRequestFunc = async (event, context) => { body: 'OK' }; } else { - return await handleImageRequestFunc(uri, resource); + const result = await resource.execute(); + return makeResponse(result); } } catch (err) { return errorHandler(err, event, context, resource); } }; -const handleImageRequestFunc = async (uri, resource) => { - const { isTooLarge } = helpers; - const { getCached, makeCache } = cache; - - const key = new URL(uri).pathname.replace(/^\//, ''); - const cached = resource.filename === 'info.json' ? false : await getCached(key); - - let response; - if (cached) { - response = forceFailover(); - } else { - const result = await resource.execute(); - - if (isTooLarge(result.body)) { - await makeCache(key, result); - response = forceFailover(); - } else { - response = makeResponse(result); - } - } - return response; -}; - -const forceFailover = () => { - return { - statusCode: 404, // Use 404 to force CloudFront to fail over to the cache - isBase64Encoded: false, - body: '' - }; -}; - const makeResponse = (result) => { - const { isBase64 } = helpers; - - const base64 = isBase64(result); - const content = base64 ? result.body.toString('base64') : result.body; + const linkHeaders = ['canonical', 'profile'] + .map((rel) => { + return { rel, property: `${rel}Link` }; + }) + .filter(({ property }) => result[property]) + .map(({ rel, property }) => `<${result[property]}>; rel=${rel}`); return { statusCode: 200, headers: { - 'Content-Type': result.contentType + 'Content-Type': result.contentType, + Link: linkHeaders.length > 0 ? linkHeaders.join(',') : undefined }, - isBase64Encoded: base64, - body: content + isBase64Encoded: false, + body: result.body }; }; diff --git a/src/package-lock.json b/src/package-lock.json index 0d00010..c4e71be 100644 --- a/src/package-lock.json +++ b/src/package-lock.json @@ -1,19 +1,41 @@ { "name": "serverless-iiif", - "version": "4.3.1", + "version": "5.0.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "serverless-iiif", - "version": "4.3.1", + "version": "5.0.0", "license": "Apache-2.0", "dependencies": { + "lambda-stream": "^0.4.0", "uri-js": "^4.4.1" }, "devDependencies": { "aws-sdk": "^2.368.0", - "iiif-processor": "^3.2.3" + "iiif-processor": "^4.0.0" + } + }, + "../../node-iiif": { + "name": "iiif-processor", + "version": "4.0.0", + "extraneous": true, + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.3.4", + "mime-types": "2.x", + "sharp": ">=0.25.2 <1.0.0" + }, + "devDependencies": { + "coveralls": "^3.1.1", + "eslint": "^8.34.0", + "eslint-config-standard": "^17.0.0", + "eslint-plugin-import": "^2.27.5", + "eslint-plugin-jest": "^27.2.1", + "eslint-plugin-node": "^11.1.0", + "eslint-plugin-promise": "^6.1.1", + "jest": "^29.4.3" } }, "node_modules/available-typed-arrays": { @@ -49,6 +71,12 @@ "node": ">= 10.0.0" } }, + "node_modules/b4a": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.4.tgz", + "integrity": "sha512-fpWrvyVHEKyeEvbKZTVOeZF3VSKKWtJxFIxX/jaVPf+cLbGUSitjb49pHLqPV2BUNNZ0LcoeEGfE/YCpyDYHIw==", + "dev": true + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -252,6 +280,12 @@ "node": ">=6" } }, + "node_modules/fast-fifo": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.0.tgz", + "integrity": "sha512-IgfweLvEpwyA4WgiQe9Nx6VV2QkML2NkvZnk1oKnIzXgXdWxuhF7zw4DvLTPZJn6PIUneiAXPF24QmoEqHTjyw==", + "dev": true + }, "node_modules/for-each": { "version": "0.3.3", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", @@ -351,9 +385,9 @@ "dev": true }, "node_modules/iiif-processor": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/iiif-processor/-/iiif-processor-3.2.3.tgz", - "integrity": "sha512-EOlmdGR3xSo8+x0X/82K9jJgsYWzekpE0OgXIYrfHg5ulnlAuKH+bSmv9GvN7gvIXk+Ww9NxrRRkujYlt49Efg==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/iiif-processor/-/iiif-processor-4.0.2.tgz", + "integrity": "sha512-aFghhFzc626hjEIp+VyNJ27rtvpCQ1JKyhR4GQ2vClwzA26UGHoXn28MNigIxiCFsBWESZycLTZTd3ENxCIQfw==", "dev": true, "dependencies": { "debug": "^4.3.4", @@ -456,6 +490,11 @@ "node": ">= 0.6.0" } }, + "node_modules/lambda-stream": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/lambda-stream/-/lambda-stream-0.4.0.tgz", + "integrity": "sha512-l51Mxm5CDKJqVmRb77pN04AiWOdHjfbYE+ic9o/nE8oa/XOcfALvwQ8xtCwDi8S2pAUqoDCXJ/nXm10wD/B+ZA==" + }, "node_modules/lru-cache": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", @@ -529,9 +568,9 @@ "dev": true }, "node_modules/node-abi": { - "version": "3.33.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.33.0.tgz", - "integrity": "sha512-7GGVawqyHF4pfd0YFybhv/eM9JwTtPqx0mAanQ146O3FlSh3pA24zf9IRQTOsfTSqXTNzPSP5iagAJ94jjuVog==", + "version": "3.45.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.45.0.tgz", + "integrity": "sha512-iwXuFrMAcFVi/ZoZiqq8BzAdsLw9kxDfTC0HMyjXfSL/6CSDAGD5UmR7azrAgWV1zKYq7dUUMj4owusBWKLsiQ==", "dev": true, "dependencies": { "semver": "^7.3.5" @@ -541,9 +580,9 @@ } }, "node_modules/node-addon-api": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.0.0.tgz", - "integrity": "sha512-GyHvgPvUXBvAkXa0YvYnhilSB1A+FRYMpIVggKzPZqdaZfevZOuzfWzyvgzOwRLHBeo/MMswmJFsrNF4Nw1pmA==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz", + "integrity": "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==", "dev": true }, "node_modules/once": { @@ -581,6 +620,34 @@ "node": ">=10" } }, + "node_modules/prebuild-install/node_modules/tar-fs": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.1.tgz", + "integrity": "sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng==", + "dev": true, + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/prebuild-install/node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "dev": true, + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/pump": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", @@ -609,6 +676,12 @@ "node": ">=0.4.x" } }, + "node_modules/queue-tick": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/queue-tick/-/queue-tick-1.0.1.tgz", + "integrity": "sha512-kJt5qhMxoszgU/62PLP1CJytzd2NKetjSRnyuj31fDd3Rlcz3fzlFdFLD1SItunPwyqEOkca6GbV612BWfaBag==", + "dev": true + }, "node_modules/rc": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", @@ -665,9 +738,9 @@ "dev": true }, "node_modules/semver": { - "version": "7.3.8", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz", - "integrity": "sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==", + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", "dev": true, "dependencies": { "lru-cache": "^6.0.0" @@ -680,19 +753,19 @@ } }, "node_modules/sharp": { - "version": "0.32.0", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.32.0.tgz", - "integrity": "sha512-yLAypVcqj1toSAqRSwbs86nEzfyZVDYqjuUX8grhFpeij0DDNagKJXELS/auegDBRDg1XBtELdOGfo2X1cCpeA==", + "version": "0.32.2", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.32.2.tgz", + "integrity": "sha512-e4hyWKtU7ks/rLmoPys476zhpQnLqPJopz4+5b6OPeyJfvCTInAp0pe5tGhstjsNdUvDLrUVGEwevewYdZM8Eg==", "dev": true, "hasInstallScript": true, "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.1", - "node-addon-api": "^6.0.0", + "node-addon-api": "^6.1.0", "prebuild-install": "^7.1.1", - "semver": "^7.3.8", + "semver": "^7.5.4", "simple-get": "^4.0.1", - "tar-fs": "^2.1.1", + "tar-fs": "^3.0.4", "tunnel-agent": "^0.6.0" }, "engines": { @@ -756,6 +829,16 @@ "is-arrayish": "^0.3.1" } }, + "node_modules/streamx": { + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.15.0.tgz", + "integrity": "sha512-HcxY6ncGjjklGs1xsP1aR71INYcsXFJet5CU1CHqihQ2J5nOsbd4OjgjHO42w/4QNv9gZb3BueV+Vxok5pLEXg==", + "dev": true, + "dependencies": { + "fast-fifo": "^1.1.0", + "queue-tick": "^1.0.1" + } + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -775,31 +858,25 @@ } }, "node_modules/tar-fs": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.1.tgz", - "integrity": "sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.0.4.tgz", + "integrity": "sha512-5AFQU8b9qLfZCX9zp2duONhPmZv0hGYiBPJsyUdqMjzq/mqVpy/rEUSeHk1+YitmxugaptgBh5oDGU3VsAJq4w==", "dev": true, "dependencies": { - "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", - "tar-stream": "^2.1.4" + "tar-stream": "^3.1.5" } }, "node_modules/tar-stream": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", - "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.6.tgz", + "integrity": "sha512-B/UyjYwPpMBv+PaFSWAmtYjwdrlEaZQEhMIBFNC5oEG8lpiW8XjcSdmEaClj28ArfKScKHs2nshz3k2le6crsg==", "dev": true, "dependencies": { - "bl": "^4.0.3", - "end-of-stream": "^1.4.1", - "fs-constants": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^3.1.1" - }, - "engines": { - "node": ">=6" + "b4a": "^1.6.4", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" } }, "node_modules/tunnel-agent": { @@ -946,6 +1023,12 @@ "xml2js": "0.5.0" } }, + "b4a": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.4.tgz", + "integrity": "sha512-fpWrvyVHEKyeEvbKZTVOeZF3VSKKWtJxFIxX/jaVPf+cLbGUSitjb49pHLqPV2BUNNZ0LcoeEGfE/YCpyDYHIw==", + "dev": true + }, "base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -1088,6 +1171,12 @@ "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", "dev": true }, + "fast-fifo": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.0.tgz", + "integrity": "sha512-IgfweLvEpwyA4WgiQe9Nx6VV2QkML2NkvZnk1oKnIzXgXdWxuhF7zw4DvLTPZJn6PIUneiAXPF24QmoEqHTjyw==", + "dev": true + }, "for-each": { "version": "0.3.3", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", @@ -1166,9 +1255,9 @@ "dev": true }, "iiif-processor": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/iiif-processor/-/iiif-processor-3.2.3.tgz", - "integrity": "sha512-EOlmdGR3xSo8+x0X/82K9jJgsYWzekpE0OgXIYrfHg5ulnlAuKH+bSmv9GvN7gvIXk+Ww9NxrRRkujYlt49Efg==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/iiif-processor/-/iiif-processor-4.0.2.tgz", + "integrity": "sha512-aFghhFzc626hjEIp+VyNJ27rtvpCQ1JKyhR4GQ2vClwzA26UGHoXn28MNigIxiCFsBWESZycLTZTd3ENxCIQfw==", "dev": true, "requires": { "debug": "^4.3.4", @@ -1244,6 +1333,11 @@ "integrity": "sha512-9FzQjJ7MATs1tSpnco1K6ayiYE3figslrXA72G2HQ/n76RzvYlofyi5QM+iX4YRs/pu3yzxlVQSST23+dMDknw==", "dev": true }, + "lambda-stream": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/lambda-stream/-/lambda-stream-0.4.0.tgz", + "integrity": "sha512-l51Mxm5CDKJqVmRb77pN04AiWOdHjfbYE+ic9o/nE8oa/XOcfALvwQ8xtCwDi8S2pAUqoDCXJ/nXm10wD/B+ZA==" + }, "lru-cache": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", @@ -1299,18 +1393,18 @@ "dev": true }, "node-abi": { - "version": "3.33.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.33.0.tgz", - "integrity": "sha512-7GGVawqyHF4pfd0YFybhv/eM9JwTtPqx0mAanQ146O3FlSh3pA24zf9IRQTOsfTSqXTNzPSP5iagAJ94jjuVog==", + "version": "3.45.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.45.0.tgz", + "integrity": "sha512-iwXuFrMAcFVi/ZoZiqq8BzAdsLw9kxDfTC0HMyjXfSL/6CSDAGD5UmR7azrAgWV1zKYq7dUUMj4owusBWKLsiQ==", "dev": true, "requires": { "semver": "^7.3.5" } }, "node-addon-api": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.0.0.tgz", - "integrity": "sha512-GyHvgPvUXBvAkXa0YvYnhilSB1A+FRYMpIVggKzPZqdaZfevZOuzfWzyvgzOwRLHBeo/MMswmJFsrNF4Nw1pmA==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz", + "integrity": "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==", "dev": true }, "once": { @@ -1340,6 +1434,33 @@ "simple-get": "^4.0.0", "tar-fs": "^2.0.0", "tunnel-agent": "^0.6.0" + }, + "dependencies": { + "tar-fs": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.1.tgz", + "integrity": "sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng==", + "dev": true, + "requires": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "dev": true, + "requires": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + } + } } }, "pump": { @@ -1363,6 +1484,12 @@ "integrity": "sha512-X/xY82scca2tau62i9mDyU9K+I+djTMUsvwf7xnUX5GLvVzgJybOJf4Y6o9Zx3oJK/LSXg5tTZBjwzqVPaPO2g==", "dev": true }, + "queue-tick": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/queue-tick/-/queue-tick-1.0.1.tgz", + "integrity": "sha512-kJt5qhMxoszgU/62PLP1CJytzd2NKetjSRnyuj31fDd3Rlcz3fzlFdFLD1SItunPwyqEOkca6GbV612BWfaBag==", + "dev": true + }, "rc": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", @@ -1399,27 +1526,27 @@ "dev": true }, "semver": { - "version": "7.3.8", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz", - "integrity": "sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==", + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", "dev": true, "requires": { "lru-cache": "^6.0.0" } }, "sharp": { - "version": "0.32.0", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.32.0.tgz", - "integrity": "sha512-yLAypVcqj1toSAqRSwbs86nEzfyZVDYqjuUX8grhFpeij0DDNagKJXELS/auegDBRDg1XBtELdOGfo2X1cCpeA==", + "version": "0.32.2", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.32.2.tgz", + "integrity": "sha512-e4hyWKtU7ks/rLmoPys476zhpQnLqPJopz4+5b6OPeyJfvCTInAp0pe5tGhstjsNdUvDLrUVGEwevewYdZM8Eg==", "dev": true, "requires": { "color": "^4.2.3", "detect-libc": "^2.0.1", - "node-addon-api": "^6.0.0", + "node-addon-api": "^6.1.0", "prebuild-install": "^7.1.1", - "semver": "^7.3.8", + "semver": "^7.5.4", "simple-get": "^4.0.1", - "tar-fs": "^2.1.1", + "tar-fs": "^3.0.4", "tunnel-agent": "^0.6.0" } }, @@ -1449,6 +1576,16 @@ "is-arrayish": "^0.3.1" } }, + "streamx": { + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.15.0.tgz", + "integrity": "sha512-HcxY6ncGjjklGs1xsP1aR71INYcsXFJet5CU1CHqihQ2J5nOsbd4OjgjHO42w/4QNv9gZb3BueV+Vxok5pLEXg==", + "dev": true, + "requires": { + "fast-fifo": "^1.1.0", + "queue-tick": "^1.0.1" + } + }, "string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -1465,28 +1602,25 @@ "dev": true }, "tar-fs": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.1.tgz", - "integrity": "sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.0.4.tgz", + "integrity": "sha512-5AFQU8b9qLfZCX9zp2duONhPmZv0hGYiBPJsyUdqMjzq/mqVpy/rEUSeHk1+YitmxugaptgBh5oDGU3VsAJq4w==", "dev": true, "requires": { - "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", - "tar-stream": "^2.1.4" + "tar-stream": "^3.1.5" } }, "tar-stream": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", - "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.6.tgz", + "integrity": "sha512-B/UyjYwPpMBv+PaFSWAmtYjwdrlEaZQEhMIBFNC5oEG8lpiW8XjcSdmEaClj28ArfKScKHs2nshz3k2le6crsg==", "dev": true, "requires": { - "bl": "^4.0.3", - "end-of-stream": "^1.4.1", - "fs-constants": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^3.1.1" + "b4a": "^1.6.4", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" } }, "tunnel-agent": { diff --git a/src/package.json b/src/package.json index db98b70..177dcba 100644 --- a/src/package.json +++ b/src/package.json @@ -1,14 +1,15 @@ { "name": "serverless-iiif", - "version": "4.3.2", + "version": "5.0.0", "description": "Lambda wrapper for iiif-processor", "author": "Michael B. Klein", "license": "Apache-2.0", "dependencies": { + "lambda-stream": "^0.4.0", "uri-js": "^4.4.1" }, "devDependencies": { "aws-sdk": "^2.368.0", - "iiif-processor": "^3.2.3" + "iiif-processor": "^4.0.0" } } diff --git a/src/streamify.js b/src/streamify.js new file mode 100644 index 0000000..b9c296b --- /dev/null +++ b/src/streamify.js @@ -0,0 +1,20 @@ +const { streamifyResponse } = require('lambda-stream'); + +const streamableHandler = (handler) => { + return streamifyResponse(async (event, responseStream, context) => { + const { body, ...prelude } = await handler(event, context); + /* istanbul ignore next */ + if (responseStream.constructor.from) { + /* istanbul ignore next */ + responseStream.constructor.from(responseStream, prelude); + } else { + responseStream.setContentType('application/vnd.awslambda.http-integration-response'); + responseStream.write(JSON.stringify(prelude)); + responseStream.write(new Uint8Array(8)); + } + responseStream.write(body || ''); + responseStream.end(); + }); +}; + +module.exports = { streamifyResponse: streamableHandler }; diff --git a/tests/cache.test.js b/tests/cache.test.js deleted file mode 100644 index d5ebea9..0000000 --- a/tests/cache.test.js +++ /dev/null @@ -1,65 +0,0 @@ -/* eslint-env jest */ -const AWS = require('aws-sdk'); -const { getCached, makeCache } = require('../src/cache'); -const AWSMockS3 = require('./__mocks/mockS3'); - -describe('cache functions', () => { - const OLD_ENV = process.env; - - beforeEach(() => { - jest.resetModules() // Most important - it clears the cache - process.env = { ...OLD_ENV }; // Make a copy - AWS.S3 = AWSMockS3.S3Cache; - }); - - afterAll(() => { - process.env = OLD_ENV; // Restore old environment - }); - - describe('getCached', () => { - it('returns true on a cache hit', async () => { - process.env.cacheBucket = 'cache-bucket'; - const result = await getCached('cache_hit/default.jpg'); - expect(result).toEqual(true); - }); - - it('returns false on a cache miss', async () => { - const result = await getCached('cache_miss/default.jpg'); - expect(result).toEqual(false); - }); - - it('returns false if no cache bucket is configured', async () => { - const result = await getCached('cache_miss/default.jpg'); - expect(result).toEqual(false); - }); - }); - - describe('makeCache', () => { - it('caches the result', async () => { - process.env.cacheBucket = 'cache-bucket'; - const result = await makeCache('new_cache_key/default.jpg', '[DATA TO CACHE]'); - expect(AWSMockS3.upload).toHaveBeenCalled(); - expect(result).toEqual(true); - }); - - it('throws an error when no cache bucket is configured', async() => { - try { - await makeCache('new_cache_key/default.jpg', '[DATA TO CACHE]'); - } catch(err) { - expect(err.message).toEqual('Content size (15) exceeds API gateway maximum'); - } - }); - - it('handles errors', async () => { - process.env.cacheBucket = 'cache-bucket'; - let caught; - try { - await makeCache('', '[DATA TO CACHE]') - } catch (err) { - caught = err; - } - expect(caught).toEqual('unknown cache key') - }); - }); -}); - diff --git a/tests/index.test.js b/tests/index.v2.test.js similarity index 55% rename from tests/index.test.js rename to tests/index.v2.test.js index bc67029..52149e0 100644 --- a/tests/index.test.js +++ b/tests/index.v2.test.js @@ -1,18 +1,15 @@ /* eslint-env jest */ const IIIF = require('iiif-processor'); const { handler } = require('../src/index'); -const cache = require('../src/cache'); const helpers = require('../src/helpers'); -const error = require('../src/error'); +const callHandler = require("./stream-handler"); -describe('index.handler', () => { +describe('index.handler /iiif/2', () => { const context = {}; beforeEach(() => { jest.mock('../src/helpers'); - beforeEach(() => { - jest.spyOn(console, 'error').mockImplementation(() => {}); - }); + jest.spyOn(console, 'error').mockImplementation(() => {}); helpers.getRegion = jest.fn().mockImplementation(() => { return 'AWS REGION'; @@ -30,8 +27,8 @@ describe('index.handler', () => { } }; - const expected = { statusCode: 204, body: null }; - const result = await handler(event, context); + const expected = { statusCode: 204, body: '' }; + const result = await callHandler(handler, event, context); expect(result).toMatchObject(expected); }); @@ -59,7 +56,7 @@ describe('index.handler', () => { } }; - const { body } = await handler(event, context); + const { body } = await callHandler(handler, event, context); const info = JSON.parse(body); expect(info['@id']).toEqual('http://iiif.example.edu/iiif/2/image_id'); expect(info.width).toEqual(1280); @@ -84,7 +81,7 @@ describe('index.handler', () => { } }; - const { body } = await handler(event, context); + const { body } = await callHandler(handler, event, context); const info = JSON.parse(body); expect(info['@id']).toEqual('https://iiif.example.edu/iiif/2/image_id'); expect(info.width).toEqual(1280); @@ -99,7 +96,7 @@ describe('index.handler', () => { const event = {}; const expected = { statusCode: 302, headers: { Location: '/iiif/2/image_id/info.json' }, body: 'Redirecting to info.json' }; - const result = await handler(event, context); + const result = await callHandler(handler, event, context); expect(result).toMatchObject(expected); }); }); @@ -113,96 +110,31 @@ describe('index.handler', () => { helpers.getUri = jest.fn().mockImplementationOnce(() => 'https://iiif.example.edu/iiif/2/image_id/full/full/0/default.jpg'); }); - it('works with base64 image.', async () => { - cache.getCached = jest.fn().mockImplementationOnce(async () => null); - helpers.isBase64 = jest.fn().mockImplementationOnce(() => true); - helpers.isTooLarge = jest.fn().mockImplementationOnce(() => false); - + it('does not use base64 encoding when streaming', async () => { IIIF.Processor = jest.fn().mockImplementationOnce(() => { return { id: 'image_id', execute: async function () { - return { body: Buffer.from(body) }; + return { + body: body, + canonicalLink: 'https://iiif.example.edu/iiif/2/image_id/full/full/0/default.jpg', + profileLink: 'http://iiif.io/api/image/2/level2.json' + }; } }; }); - - const expected = { - statusCode: 200, - headers: { 'Content-Type': undefined }, - isBase64Encoded: true, - body: Buffer.from(body).toString('base64') - }; - const result = await handler(event, context); - expect(result).toMatchObject(expected); - }); - - it('works with nonbase64 image.', async () => { - IIIF.Processor = jest.fn().mockImplementationOnce(() => { - return { - id: 'image_id', - execute: async function () { - return { body: body }; - } - }; - }); - cache.getCached = jest.fn().mockImplementationOnce(async () => null); helpers.isBase64 = jest.fn().mockImplementationOnce(() => false); helpers.isTooLarge = jest.fn().mockImplementationOnce(() => false); const expected = { statusCode: 200, - headers: { 'Content-Type': undefined }, - isBase64Encoded: false, - body: body - }; - const result = await handler(event, context); - expect(result).toMatchObject(expected); - }); - - it('returns 404 to force failover when cached file exists', async () => { - cache.getCached = jest.fn().mockImplementationOnce(async () => '[PRESIGNED CACHE URL]'); - - IIIF.Processor = jest.fn().mockImplementationOnce(() => { - return { - id: 'image_id', - execute: async function () { - return { body: body }; - } - }; - }); - - const expected = { - statusCode: 404, isBase64Encoded: false, - body: '' - }; - const result = await handler(event, context); - expect(result).toMatchObject(expected); - }); - - it('caches file and returns 404 to force failover when result is too large to return directly', async () => { - cache.getCached = jest.fn().mockImplementationOnce(async () => null); - cache.makeCache = jest.fn().mockImplementationOnce(async () => '[PRESIGNED CACHE URL]'); - helpers.isBase64 = jest.fn().mockImplementationOnce(() => false); - helpers.isTooLarge = jest.fn().mockImplementationOnce(() => true); - error.errorHandler = jest.fn().mockImplementationOnce(() => null); - IIIF.Processor = jest.fn().mockImplementationOnce(() => { - return { - id: 'image_id', - execute: async function () { - return { body: body }; - } - }; - }); - - const expected = { - statusCode: 404, - isBase64Encoded: false, - body: '' + body: body, + headers: { + Link: '; rel=canonical,; rel=profile' + } }; - const result = await handler(event, context); - expect(cache.makeCache).toHaveBeenCalled(); + const result = await callHandler(handler, event, context); expect(result).toMatchObject(expected); }); @@ -213,7 +145,7 @@ describe('index.handler', () => { execute: async function () { throw new Error('ERROR'); }, - errorClass: IIIF.IIIFError + errorClass: IIIF.Error }; }); const expected = { @@ -223,7 +155,7 @@ describe('index.handler', () => { }, statusCode: 500, }; - result = await handler(event, context); + result = await callHandler(handler, event, context); expect(result).toMatchObject(expected); }); }); diff --git a/tests/index.v3.test.js b/tests/index.v3.test.js new file mode 100644 index 0000000..c674b79 --- /dev/null +++ b/tests/index.v3.test.js @@ -0,0 +1,172 @@ +/* eslint-env jest */ +const IIIF = require('iiif-processor'); +const { handler } = require('../src/index'); +const helpers = require('../src/helpers'); +const callHandler = require('./stream-handler'); + +describe("index.handler /iiif/3", () => { + const context = {}; + + beforeEach(() => { + jest.mock("../src/helpers"); + jest.spyOn(console, "error").mockImplementation(() => {}); + + helpers.getRegion = jest.fn().mockImplementation(() => { + return "AWS REGION"; + }); + + helpers.eventPath = jest.fn().mockImplementation(() => "[EVENT PATH]"); + }); + + it("responds to OPTIONS REQUEST", async () => { + const event = { + requestContext: { + http: { + method: "OPTIONS", + }, + }, + }; + + const expected = { statusCode: 204, body: "" }; + const result = await callHandler(handler, event, context); + expect(result).toMatchObject(expected); + }); + + describe("INFO.JSON request", () => { + beforeEach(() => { + process.env.preflight = "true"; + }); + + afterEach(() => { + delete process.env.preflight; + }); + + it("responds to INFO.JSON REQUEST", async () => { + helpers.fileMissing = jest.fn().mockImplementationOnce(() => false); + + const event = { + headers: { + host: "iiif.example.edu", + "x-preflight-dimensions": '{"width": 1280, "height": 720}', + }, + requestContext: { + http: { + path: "/iiif/3/image_id/info.json", + }, + }, + }; + + const { body } = await callHandler(handler, event, context); + const info = JSON.parse(body); + expect(info["id"]).toEqual("http://iiif.example.edu/iiif/3/image_id"); + expect(info.width).toEqual(1280); + expect(info.height).toEqual(720); + expect(info.sizes.length).toEqual(4); + }); + + it("respects the x-forwarded-host header", async () => { + helpers.fileMissing = jest.fn().mockImplementationOnce(() => false); + + const event = { + headers: { + host: "handler.behind.proxy", + "x-forwarded-host": "iiif.example.edu", + "x-forwarded-proto": "https", + "x-preflight-dimensions": '{"width": 1280, "height": 720}', + }, + requestContext: { + http: { + path: "/iiif/3/image_id/info.json", + }, + }, + }; + + const { body } = await callHandler(handler, event, context); + const info = JSON.parse(body); + expect(info["id"]).toEqual("https://iiif.example.edu/iiif/3/image_id"); + expect(info.width).toEqual(1280); + expect(info.height).toEqual(720); + expect(info.sizes.length).toEqual(4); + }); + + it("redirects to INFO.JSON if filename missing", async () => { + helpers.eventPath = jest + .fn() + .mockImplementationOnce(() => "/iiif/3/image_id"); + helpers.fileMissing = jest.fn().mockImplementationOnce(() => true); + + const event = {}; + + const expected = { + statusCode: 302, + headers: { Location: "/iiif/3/image_id/info.json" }, + body: "Redirecting to info.json", + }; + const result = await callHandler(handler, event, context); + expect(result).toMatchObject(expected); + }); + }); + + // IMAGE REQUEST + describe("responds to IMAGE REQUEST", () => { + const body = "[CONTENT BODY]"; + const event = {}; + beforeEach(() => { + helpers.fileMissing = jest.fn().mockImplementationOnce(() => false); + helpers.getUri = jest + .fn() + .mockImplementationOnce( + () => + "https://iiif.example.edu/iiif/3/image_id/full/max/0/default.jpg" + ); + }); + + it('does not use base64 encoding when streaming', async () => { + IIIF.Processor = jest.fn().mockImplementationOnce(() => { + return { + id: "image_id", + execute: async function () { + return { + body: body, + canonicalLink: 'https://iiif.example.edu/iiif/3/image_id/full/full/0/default.jpg', + profileLink: 'http://iiif.io/api/image/3/level2.json' }; + }, + }; + }); + helpers.isBase64 = jest.fn().mockImplementationOnce(() => false); + helpers.isTooLarge = jest.fn().mockImplementationOnce(() => false); + + const expected = { + statusCode: 200, + isBase64Encoded: false, + body: body, + headers: { + Link: '; rel=canonical,; rel=profile' + } + }; + const result = await callHandler(handler, event, context); + expect(result).toMatchObject(expected); + }); + + it("handles errors that arise during processing", async () => { + IIIF.Processor = jest.fn().mockImplementationOnce(() => { + return { + id: "image_id", + execute: async function () { + throw new Error("ERROR"); + }, + errorClass: IIIF.Error, + }; + }); + const expected = { + body: "Error: ERROR", + headers: { + "Content-Type": "text.plain", + }, + statusCode: 500, + }; + result = await callHandler(handler, event, context); + expect(result).toMatchObject(expected); + }); + }); +}); diff --git a/tests/service-discovery.test.js b/tests/service-discovery.test.js new file mode 100644 index 0000000..aae6b3c --- /dev/null +++ b/tests/service-discovery.test.js @@ -0,0 +1,27 @@ +/* eslint-env jest */ +const { handler } = require('../src/index'); +const callHandler = require('./stream-handler'); +const helpers = require('../src/helpers'); + +describe('service discovery document', () => { + beforeEach(() => { + jest.mock('../src/helpers'); + helpers.getRegion = jest.fn().mockImplementation(() => { + return 'AWS REGION'; + }); + }); + + it('provides a links document at the root', async () => { + const event = { + requestContext: { + http: { + path: '/', + }, + }, + }; + + const { body } = await callHandler(handler, event, {}); + const info = JSON.parse(body); + expect(info.links.length).toEqual(2); + }); +}); \ No newline at end of file diff --git a/tests/stream-handler.js b/tests/stream-handler.js new file mode 100644 index 0000000..970caca --- /dev/null +++ b/tests/stream-handler.js @@ -0,0 +1,11 @@ +const { ResponseStream } = require('lambda-stream'); + +module.exports = async (handler, event, context) => { + const responseStream = new ResponseStream(); + const result = await handler(event, responseStream, context); + expect(result.statusCode).toEqual(200); + expect(result.headers['content-type']).toEqual('application/vnd.awslambda.http-integration-response'); + const payload = responseStream.getBufferedData().toString('UTF-8'); + const [prelude, body] = payload.split(/\u0000{8}/); + return { ...JSON.parse(prelude), body }; +};