-
Notifications
You must be signed in to change notification settings - Fork 1.2k
Description
Description:
I'm currently facing two issue's when sending multipart request to AWS SAM LOCAL.
confirm-invoice:1 Access to fetch at 'http://localhost:3000/invoices' from origin 'http://localhost:5173' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.
and
[20/Aug/2025 08:39:46] "OPTIONS /invoices HTTP/1.1" 200 -
2025-08-20 08:39:46,263 | UnicodeDecodeError while processing HTTP request: 'utf-8' codec can't decode byte 0xa1 in position 693: invalid start
byte
Steps to reproduce:
Observed result:
This is my template.yaml
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: >
Invoice management serverless API with separate Lambdas per operation
plus WorkMail-based OTP login.
Globals:
Function:
Timeout: 50
Runtime: python3.13
Tracing: Active
LoggingConfig:
LogFormat: JSON
Environment:
Variables:
# === Replace in production ===
WORKMAIL_ORGANIZATION_ID: "local-dev" # Prod: actual AWS WorkMail Org ID
OTP_TABLE_NAME: !Ref OtpTable
EMAIL_SOURCE: "[email protected]" # Prod: verified SES email
JWT_SECRET: "local-secret" # Prod: store in Secrets Manager
INVOICES_TABLE_NAME: !Ref InvoicesTable
EMPLOYEES_TABLE_NAME: !Ref EmployeesTable
ACCOUNTS_TABLE_NAME: !Ref AccountsTable
REFRESH_TOKENS_TABLE_NAME: !Ref RefreshTokensTable # 🆕 Added refresh tokens table name
Api:
Cors:
AllowMethods: "'GET,POST,PUT,PATCH,DELETE,OPTIONS'"
AllowHeaders: "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token'"
AllowOrigin: "'*'"
Auth:
# 🆕 Define the authorizer at the API level
Authorizers:
ApiAuthorizer:
FunctionPayloadType: TOKEN
FunctionArn: !GetAtt JwtAuthorizerFunction.Arn
Identity:
Headers:
- Authorization
Resources:
InvoiceApi:
Type: AWS::Serverless::Api
Properties:
StageName: Prod
DefinitionBody:
swagger: '2.0'
info:
title: !Ref 'AWS::StackName'
schemes:
- 'https'
x-amazon-apigateway-binary-media-types:
- 'multipart/form-data'
- 'application/octet-stream'
paths:
/invoices:
options:
x-amazon-apigateway-integration:
type: 'mock'
requestTemplates:
application/json: '{"statusCode": 200}'
passthroughBehavior: 'when_no_match'
responses:
'200':
headers:
Access-Control-Allow-Origin:
type: 'string'
Access-Control-Allow-Methods:
type: 'string'
Access-Control-Allow-Headers:
type: 'string'
schema: {}
post:
security:
- ApiAuthorizer: []
x-amazon-apigateway-integration:
type: 'aws_proxy'
httpMethod: 'POST'
uri: !Sub 'arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${CreateInvoiceFunction.Arn}/invocations'
passthroughBehavior: 'when_no_match'
responses: {}
get:
security:
- ApiAuthorizer: []
x-amazon-apigateway-integration:
type: 'aws_proxy'
httpMethod: 'POST'
uri: !Sub 'arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${ListInvoicesFunction.Arn}/invocations'
passthroughBehavior: 'when_no_match'
responses: {}
/invoices/{reference_id}:
get:
security:
- ApiAuthorizer: []
parameters:
- name: 'reference_id'
in: 'path'
required: true
type: 'string'
x-amazon-apigateway-integration:
type: 'aws_proxy'
httpMethod: 'POST'
uri: !Sub 'arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${GetInvoiceFunction.Arn}/invocations'
passthroughBehavior: 'when_no_match'
responses: {}
put:
security:
- ApiAuthorizer: []
parameters:
- name: 'reference_id'
in: 'path'
required: true
type: 'string'
x-amazon-apigateway-integration:
type: 'aws_proxy'
httpMethod: 'POST'
uri: !Sub 'arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${UpdateInvoiceFunction.Arn}/invocations'
passthroughBehavior: 'when_no_match'
responses: {}
delete:
security:
- ApiAuthorizer: []
parameters:
- name: 'reference_id'
in: 'path'
required: true
type: 'string'
x-amazon-apigateway-integration:
type: 'aws_proxy'
httpMethod: 'POST'
uri: !Sub 'arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${DeleteInvoiceFunction.Arn}/invocations'
passthroughBehavior: 'when_no_match'
responses: {}
/invoices/{reference_id}/items:
post:
security:
- ApiAuthorizer: []
parameters:
- name: 'reference_id'
in: 'path'
required: true
type: 'string'
x-amazon-apigateway-integration:
type: 'aws_proxy'
httpMethod: 'POST'
uri: !Sub 'arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${AddItemFunction.Arn}/invocations'
passthroughBehavior: 'when_no_match'
responses: {}
/invoices/{reference_id}/items/{item_id}:
delete:
security:
- ApiAuthorizer: []
parameters:
- name: 'reference_id'
in: 'path'
required: true
type: 'string'
- name: 'item_id'
in: 'path'
required: true
type: 'string'
x-amazon-apigateway-integration:
type: 'aws_proxy'
httpMethod: 'POST'
uri: !Sub 'arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${DeleteItemFunction.Arn}/invocations'
passthroughBehavior: 'when_no_match'
responses: {}
/auth/request_otp:
post:
x-amazon-apigateway-integration:
type: 'aws_proxy'
httpMethod: 'POST'
uri: !Sub 'arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${RequestOtpFunction.Arn}/invocations'
passthroughBehavior: 'when_no_match'
responses: {}
/auth/verify_otp:
post:
x-amazon-apigateway-integration:
type: 'aws_proxy'
httpMethod: 'POST'
uri: !Sub 'arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${VerifyOtpFunction.Arn}/invocations'
passthroughBehavior: 'when_no_match'
responses: {}
/employees:
get:
security:
- ApiAuthorizer: []
x-amazon-apigateway-integration:
type: aws_proxy
httpMethod: post
uri:
!Sub "arn:${AWS::Partition}:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${ListEmployeesFunction.Arn}/invocations"
passthroughBehavior: when_no_match
responses: {}
/accounts:
options:
x-amazon-apigateway-integration:
type: 'mock'
requestTemplates:
application/json: '{"statusCode": 200}'
passthroughBehavior: 'when_no_match'
responses:
'200':
headers:
Access-Control-Allow-Origin:
type: 'string'
Access-Control-Allow-Methods:
type: 'string'
Access-Control-Allow-Headers:
type: 'string'
schema: {}
get:
security:
- ApiAuthorizer: []
x-amazon-apigateway-integration:
type: 'aws_proxy'
httpMethod: 'POST'
uri: !Sub 'arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${GetAccountsFunction.Arn}/invocations'
passthroughBehavior: 'when_no_match'
responses: {}
================= Invoice Lambdas =================
CreateInvoiceFunction:
Type: AWS::Serverless::Function
Properties:
Handler: create_invoice.lambda_handler
CodeUri: lambda/
Policies:
- DynamoDBCrudPolicy:
TableName: !Ref InvoicesTable
- DynamoDBReadPolicy:
TableName: !Ref EmployeesTable # 🆕 Read access to get employee details
- DynamoDBReadPolicy:
TableName: !Ref AccountsTable # 🆕 Read access for accounts validation
- S3ReadWritePolicy:
BucketName: !Ref InvoicesBucket # 🆕 S3 permissions for file uploads
Environment:
Variables:
BUCKET_NAME: !Ref InvoicesBucket # 🆕 Pass bucket name to lambda
InvoicesBucket: # 🆕 Added S3 bucket for invoices
Type: AWS::S3::Bucket
Properties:
AccessControl: Private
CorsConfiguration:
CorsRules:
- AllowedOrigins:
- ""
AllowedHeaders:
- ""
AllowedMethods:
- GET
- PUT
- POST
- DELETE
- HEAD
MaxAge: 3000
my parser
def parse_multipart(event):
"""Parse multipart/form-data requests (file uploads)."""
form_data = {}
files_data = [] # 🆕 This will now be a list to store multiple files
headers = {k.lower(): v for k, v in (event.get("headers") or {}).items()}
content_type = headers.get("content-type")
if not content_type or not content_type.startswith("multipart/form-data"):
return form_data, files_data
body_bytes = base64.b64decode(event["body"]) if event.get("isBase64Encoded") else (event.get("body") or "").encode("utf-8")
multipart_data = decoder.MultipartDecoder(body_bytes, content_type)
for part in multipart_data.parts:
content_disposition = part.headers.get(b"Content-Disposition", b"")
if b"filename" in content_disposition:
filename_bytes = content_disposition.split(b"filename=")[1].strip(b'"')
file_data = {
"filename": filename_bytes.decode("utf-8", "ignore"),
"content": part.content,
}
files_data.append(file_data) # 🆕 Append each file to the list
else:
name_bytes = content_disposition.split(b"name=")[1].strip(b'"')
try:
form_data[name_bytes.decode("utf-8")] = part.content.decode("utf-8")
except UnicodeDecodeError:
form_data[name_bytes.decode("utf-8")] = part.content.decode("latin-1")
return form_data, files_data # 🆕 Return the list of files
My Payload

Expected result:
Additional environment details (Ex: Windows, Mac, Amazon Linux etc)
- OS:
sam --version
:- AWS region:
# Paste the output of `sam --info` here
{
"version": "1.142.1",
"system": {
"python": "3.13.5",
"os": "macOS-15.6-arm64-arm-64bit-Mach-O"
},
"additional_dependencies": {
"docker_engine": "28.3.2",
"aws_cdk": "Not available",
"terraform": "Not available"
},
"available_beta_feature_env_vars": [
"SAM_CLI_BETA_FEATURES",
"SAM_CLI_BETA_BUILD_PERFORMANCE",
"SAM_CLI_BETA_TERRAFORM_SUPPORT",
"SAM_CLI_BETA_PACKAGE_PERFORMANCE",
"SAM_CLI_BETA_RUST_CARGO_LAMBDA"
]
}
Add --debug flag to command you are running