From 6b4071ef7686ab8ad3a0d0dde1b5b05fed9dec06 Mon Sep 17 00:00:00 2001 From: Ronaldo Macapobre Date: Thu, 13 Feb 2025 17:56:27 +0000 Subject: [PATCH] - Update apiService to handle binary requests - Adjust unit tests - Added application/octet-stream binary media type support for API Gateway --- aws/services/apiService.ts | 34 +++++++++-- aws/services/httpService.ts | 57 +++++++++++-------- aws/tests/services/apiService.test.ts | 41 +++++++++++-- aws/tests/services/httpService.test.ts | 39 +++++++++---- aws/tests/util.test.ts | 10 ++-- aws/util.ts | 6 -- .../cloud/modules/APIGateway/main.tf | 7 ++- 7 files changed, 135 insertions(+), 59 deletions(-) diff --git a/aws/services/apiService.ts b/aws/services/apiService.ts index 179423b4..7c0967f8 100644 --- a/aws/services/apiService.ts +++ b/aws/services/apiService.ts @@ -1,4 +1,5 @@ import { APIGatewayEvent, APIGatewayProxyResult } from "aws-lambda"; +import { AxiosRequestConfig, AxiosResponse } from "axios"; import { sanitizeHeaders, sanitizeQueryStringParams } from "../util"; import { HttpService, IHttpService } from "./httpService"; import { SecretsManagerService } from "./secretsManagerService"; @@ -39,21 +40,35 @@ export class ApiService { const url = `${event.path}?${queryString}`; - console.log(`Sending ${method} request to ${url}`); console.log(`Headers: ${JSON.stringify(headers, null, 2)}`); + console.log(`Body: ${JSON.stringify(body, null, 2)}`); - let data; + // Determine if request expects a binary response + const isBinary = + headers && headers["Accept"]?.startsWith("application/octet-stream"); + + const axiosConfig: AxiosRequestConfig = { + headers: { + ...headers, + "Content-Type": isBinary + ? "application/octet-stream" + : "application/json", + }, + responseType: isBinary ? "arraybuffer" : "json", + }; + + let response: AxiosResponse; switch (method) { case "GET": - data = await this.httpService.get(url, headers); + response = await this.httpService.get(url, axiosConfig); break; case "POST": - data = await this.httpService.post(url, body, headers); + response = await this.httpService.post(url, body, axiosConfig); break; case "PUT": - data = await this.httpService.put(url, body, headers); + response = await this.httpService.put(url, body, axiosConfig); break; default: return { @@ -62,9 +77,16 @@ export class ApiService { }; } + console.log("Response:", response); + return { statusCode: 200, - body: JSON.stringify(data), + body: isBinary + ? Buffer.from(new Uint8Array(response.data as ArrayBuffer)).toString( + "base64" + ) + : JSON.stringify(response.data), + isBase64Encoded: !!isBinary, }; } catch (error) { console.error("Error:", error); diff --git a/aws/services/httpService.ts b/aws/services/httpService.ts index a32f6ce4..ba0b4d9b 100644 --- a/aws/services/httpService.ts +++ b/aws/services/httpService.ts @@ -1,19 +1,19 @@ -import axios, { AxiosInstance, AxiosResponse } from "axios"; +import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from "axios"; import * as https from "https"; export interface IHttpService { init(credentialsSecret: string, mtlsSecret: string): Promise; - get(url: string, headers?: Record): Promise; + get(url: string, config: AxiosRequestConfig): Promise>; post( url: string, - data?: Record, - headers?: Record - ): Promise; + data: Record, + config: AxiosRequestConfig + ): Promise>; put( url: string, - data?: Record, - headers?: Record - ): Promise; + data: Record, + config: AxiosRequestConfig + ): Promise>; } export class HttpService implements IHttpService { @@ -41,10 +41,13 @@ export class HttpService implements IHttpService { }); } - async get(url: string, headers?: Record): Promise { + async get( + url: string, + config: AxiosRequestConfig + ): Promise> { try { - const response: AxiosResponse = await this.axios.get(url, { headers }); - return response.data; + const response: AxiosResponse = await this.axios.get(url, config); + return response; } catch (error) { this.handleError(error); } @@ -52,14 +55,16 @@ export class HttpService implements IHttpService { async post( url: string, - data?: Record, - headers?: Record - ): Promise { + data: Record, + config: AxiosRequestConfig + ): Promise> { try { - const response: AxiosResponse = await this.axios.post(url, data, { - headers, - }); - return response.data; + const response: AxiosResponse = await this.axios.post( + url, + data, + config + ); + return response; } catch (error) { this.handleError(error); } @@ -67,14 +72,16 @@ export class HttpService implements IHttpService { async put( url: string, - data?: Record, - headers?: Record - ): Promise { + data: Record, + config: AxiosRequestConfig + ): Promise> { try { - const response: AxiosResponse = await this.axios.put(url, data, { - headers, - }); - return response.data; + const response: AxiosResponse = await this.axios.put( + url, + data, + config + ); + return response; } catch (error) { this.handleError(error); } diff --git a/aws/tests/services/apiService.test.ts b/aws/tests/services/apiService.test.ts index 599becb5..9d5f21dd 100644 --- a/aws/tests/services/apiService.test.ts +++ b/aws/tests/services/apiService.test.ts @@ -68,11 +68,16 @@ describe("ApiService", () => { const response = await apiService.handleRequest(event as APIGatewayEvent); expect(mockHttpService.get).toHaveBeenCalledWith("/test?key=value", { - Authorization: "Bearer token", + headers: { + Authorization: "Bearer token", + "Content-Type": "application/json", + }, + responseType: "json", }); expect(response).toEqual({ statusCode: 200, - body: JSON.stringify({ data: "get response" }), + body: JSON.stringify("get response"), + isBase64Encoded: false, }); }); @@ -90,11 +95,12 @@ describe("ApiService", () => { expect(mockHttpService.post).toHaveBeenCalledWith( "/test?", { name: "test" }, - { "Content-Type": "application/json" } + { headers: { "Content-Type": "application/json" }, responseType: "json" } ); expect(response).toEqual({ statusCode: 200, - body: JSON.stringify({ data: "post response" }), + body: JSON.stringify("post response"), + isBase64Encoded: false, }); }); @@ -122,4 +128,31 @@ describe("ApiService", () => { body: JSON.stringify({ message: "Internal Server Error" }), }); }); + + it("should handle a GET binary request", async () => { + const event: Partial = { + httpMethod: "GET", + path: "/test", + queryStringParameters: { key: "value" }, + headers: { Accept: "application/octet-stream" }, + body: null, + }; + + const response = await apiService.handleRequest(event as APIGatewayEvent); + + expect(mockHttpService.get).toHaveBeenCalledWith("/test?key=value", { + headers: { + Accept: "application/octet-stream", + "Content-Type": "application/octet-stream", + }, + responseType: "arraybuffer", + }); + expect(response).toEqual({ + statusCode: 200, + body: Buffer.from( + new Uint8Array("get response" as unknown as ArrayBuffer) + ).toString("base64"), + isBase64Encoded: true, + }); + }); }); diff --git a/aws/tests/services/httpService.test.ts b/aws/tests/services/httpService.test.ts index 9ef829c1..6cd06165 100644 --- a/aws/tests/services/httpService.test.ts +++ b/aws/tests/services/httpService.test.ts @@ -1,3 +1,4 @@ +import { AxiosRequestConfig } from "axios"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { default as axiosMock } from "../../mocks/axios"; import HttpService from "../../services/httpService"; @@ -15,6 +16,12 @@ describe("HttpService", () => { const cert = "mock-cert"; const key = "mock-key"; + const mockAxiosConfig: AxiosRequestConfig = { + headers: { + "Content-Type": "application/json", + }, + }; + beforeEach(() => { httpService = new HttpService(); credentialsSecret = JSON.stringify({ @@ -47,23 +54,27 @@ describe("HttpService", () => { axiosMock.get.mockResolvedValue({ data: { message: "success" } }); await httpService.init(credentialsSecret, mtlsSecret); - const result = await httpService.get("/test"); + const response = await httpService.get("/test", mockAxiosConfig); - expect(result).toEqual({ message: "success" }); - expect(axiosMock.get).toHaveBeenCalledWith("/test", { headers: undefined }); + expect(response.data).toEqual({ message: "success" }); + expect(axiosMock.get).toHaveBeenCalledWith("/test", mockAxiosConfig); }); it("should perform POST request", async () => { axiosMock.post.mockResolvedValue({ data: { id: 1 } }); await httpService.init(credentialsSecret, mtlsSecret); - const result = await httpService.post("/test", { name: "example" }); + const response = await httpService.post( + "/test", + { name: "example" }, + mockAxiosConfig + ); - expect(result).toEqual({ id: 1 }); + expect(response.data).toEqual({ id: 1 }); expect(axiosMock.post).toHaveBeenCalledWith( "/test", { name: "example" }, - { headers: undefined } + mockAxiosConfig ); }); @@ -71,13 +82,17 @@ describe("HttpService", () => { axiosMock.put.mockResolvedValue({ data: { updated: true } }); await httpService.init(credentialsSecret, mtlsSecret); - const result = await httpService.put("/test", { name: "updated" }); + const response = await httpService.put( + "/test", + { name: "updated" }, + mockAxiosConfig + ); - expect(result).toEqual({ updated: true }); + expect(response.data).toEqual({ updated: true }); expect(axiosMock.put).toHaveBeenCalledWith( "/test", { name: "updated" }, - { headers: undefined } + mockAxiosConfig ); }); @@ -86,8 +101,8 @@ describe("HttpService", () => { axiosMock.isAxiosError.mockReturnValue(true); await httpService.init(credentialsSecret, mtlsSecret); - await expect(httpService.get("/not-found")).rejects.toThrow( - "HTTP Error: 404" - ); + await expect( + httpService.get("/not-found", mockAxiosConfig) + ).rejects.toThrow("HTTP Error: 404"); }); }); diff --git a/aws/tests/util.test.ts b/aws/tests/util.test.ts index 74dfcfa5..4da8add0 100644 --- a/aws/tests/util.test.ts +++ b/aws/tests/util.test.ts @@ -23,15 +23,17 @@ describe("sanitizeHeaders", () => { applicationCd: "app123", correlationId: "12345", Accept: "application/octet-stream", - "Content-Type": "application/octet-stream", }); }); it("should return an empty object if no allowed headers are present", () => { const headers = { unauthorizedHeader: "shouldBeRemoved" }; - expect(sanitizeHeaders(headers)).toEqual({ - "Content-Type": "application/json", - }); + expect(sanitizeHeaders(headers)).toEqual({}); + }); + + it("should return an empty object when Authorization header is present", () => { + const headers = { Authorization: "Bearer 123" }; + expect(sanitizeHeaders(headers)).toEqual({}); }); }); diff --git a/aws/util.ts b/aws/util.ts index 1919575a..dc705fac 100644 --- a/aws/util.ts +++ b/aws/util.ts @@ -29,12 +29,6 @@ export const sanitizeHeaders = ( } } - // Specify Content-Type as application/json unless specified in Accept - filteredHeaders["Content-Type"] = - filteredHeaders["Accept"] === "application/octet-stream" - ? "application/octet-stream" - : "application/json"; - return filteredHeaders; }; diff --git a/infrastructure/cloud/modules/APIGateway/main.tf b/infrastructure/cloud/modules/APIGateway/main.tf index b910e7d8..d2c3206f 100644 --- a/infrastructure/cloud/modules/APIGateway/main.tf +++ b/infrastructure/cloud/modules/APIGateway/main.tf @@ -1,6 +1,7 @@ resource "aws_api_gateway_rest_api" "apigw" { name = "${var.app_name}-api-gateway-${var.environment}" + binary_media_types = ["application/octet-stream"] } resource "aws_api_gateway_deployment" "apigw_deployment" { @@ -10,9 +11,11 @@ resource "aws_api_gateway_deployment" "apigw_deployment" { rest_api_id = aws_api_gateway_rest_api.apigw.id triggers = { - redeployment = sha1(jsonencode(aws_api_gateway_rest_api.apigw)) + redeployment = sha1(jsonencode({ + binary_media_types = aws_api_gateway_rest_api.apigw.binary_media_types + body = aws_api_gateway_rest_api.apigw.body + })) } - lifecycle { create_before_destroy = true }