diff --git a/electron/main.ts b/electron/main.ts index 0cdeb74..a7ab62a 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1,5 +1,6 @@ -import { app, BrowserWindow } from 'electron' +import { app, BrowserWindow, ipcMain } from 'electron' import path from 'node:path' +import { ApiResponse } from '../src/api/types' // The built directory structure // @@ -13,17 +14,18 @@ import path from 'node:path' process.env.DIST = path.join(__dirname, '../dist') process.env.VITE_PUBLIC = app.isPackaged ? process.env.DIST : path.join(process.env.DIST, '../public') - -let win: BrowserWindow | null // 🚧 Use ['ENV_NAME'] avoid vite:define plugin - Vite@2.x const VITE_DEV_SERVER_URL = process.env['VITE_DEV_SERVER_URL'] function createWindow() { - win = new BrowserWindow({ + const win = new BrowserWindow({ icon: path.join(process.env.VITE_PUBLIC, 'reqio.svg'), webPreferences: { preload: path.join(__dirname, 'preload.js'), + nodeIntegration: false, + contextIsolation: true }, + width: 1450, height: 900, }) @@ -47,7 +49,6 @@ function createWindow() { app.on('window-all-closed', () => { if (process.platform !== 'darwin') { app.quit() - win = null } }) @@ -57,6 +58,49 @@ app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) { createWindow() } -}) +}); + +ipcMain.handle("rest", async (_event: any, url: string, options?: RequestInit): Promise => { + try { + const response = await fetch(url, options); + + const headers: Record = {}; + response.headers.forEach((value, key) => { + headers[key] = value; + }); + + const contentType = response.headers.get('content-type') || ''; + let data: any; + + if (contentType.includes('application/json')) { + data = await response.json(); + } else if (contentType.includes('application/xml') || contentType.includes('text/xml')) { + data = await response.text(); + } else if (contentType.includes('text/html')) { + data = await response.text(); + } else if (contentType.includes('text/')) { + data = await response.text(); + } else if (contentType.includes('application/octet-stream') || + contentType.includes('image/') || + contentType.includes('application/pdf')) { + const buffer = await response.arrayBuffer(); + data = Buffer.from(buffer).toString('base64'); + } else { + data = await response.text(); + } + + return { + ok: response.ok, + data, + status: response.status, + statusText: response.statusText, + headers + }; + } catch (err) { + const error = err instanceof Error ? err.message : String(err); + console.error('IPC Handler - Error:', error); + return { ok: false, error }; + } +}); app.whenReady().then(createWindow) diff --git a/electron/preload.ts b/electron/preload.ts index ec4ff00..126d4e6 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -3,6 +3,18 @@ import { contextBridge, ipcRenderer } from 'electron' // --------- Expose some API to the Renderer process --------- contextBridge.exposeInMainWorld('ipcRenderer', withPrototype(ipcRenderer)) +contextBridge.exposeInMainWorld("api", { + fetch: (url: string, options?: RequestInit) => + ipcRenderer.invoke("rest", url, options) as Promise<{ + ok: boolean; + data?: T; + error?: string; + status?: number; + statusText?: string; + headers?: Record; + }>, +}); + // `exposeInMainWorld` can't detect attributes and methods of `prototype`, manually patching it. function withPrototype(obj: Record) { const protos = Object.getPrototypeOf(obj) diff --git a/src/api/rest.ts b/src/api/rest.ts index b7b4c4b..422f471 100644 --- a/src/api/rest.ts +++ b/src/api/rest.ts @@ -1,4 +1,3 @@ -import axios from 'axios'; import { Header } from '../components/RequestPanel/types.ts'; import { HeadersMapping, JSONValue } from './types.ts'; @@ -10,31 +9,63 @@ const restructureHeaders = (headers: Header[]) => { return formattedHeaders; }; +declare global { + interface Window { + api: { + fetch( + url: string, + options?: RequestInit + ): Promise<{ + ok: boolean; + data?: T; + error?: string; + status?: number; + statusText?: string; + headers?: Record; + }>; + }; + } +} + export const get = async (url: string, headers: Header[] = []) => { - return axios.get(url, { + const options: RequestInit = { headers: restructureHeaders(headers), - }); + method: 'GET', + }; + return window.api.fetch(url, options); }; export const post = async (url: string, body: JSONValue, headers: Header[] = []) => { - return axios.post(url, body, { + const options: RequestInit = { headers: restructureHeaders(headers), - }); + method: 'POST', + body: JSON.stringify(body), + }; + return window.api.fetch(url, options); }; export const patch = async (url: string, body: JSONValue, headers: Header[] = []) => { - return axios.patch(url, body, { + const options: RequestInit = { headers: restructureHeaders(headers), - }); + method: 'PATCH', + body: JSON.stringify(body), + }; + return window.api.fetch(url, options); }; export const put = async (url: string, body: JSONValue, headers: Header[] = []) => { - return axios.put(url, body, { + const options: RequestInit = { headers: restructureHeaders(headers), - }); + method: 'PUT', + body: JSON.stringify(body), + }; + return window.api.fetch(url, options); }; + export const delete_req = async (url: string, headers: Header[] = []) => { - return axios.delete(url, { + const options: RequestInit = { headers: restructureHeaders(headers), - }); + method: 'DELETE', + }; + return window.api.fetch(url, options); }; diff --git a/src/api/types.ts b/src/api/types.ts index 839322d..02934c6 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -14,6 +14,16 @@ export type HttpStatusCodes = { [key: number]: string; }; +export interface ApiResponse { + ok: boolean; + data?: T; + error?: string; + status?: number; + statusText?: string; + headers?: Record; +} + +// Deprecated - no longer using axios export type AxiosErrorCodes = { [key: string]: string; }; diff --git a/src/components/AppBody/index.tsx b/src/components/AppBody/index.tsx index 6b2b7fd..8af565a 100644 --- a/src/components/AppBody/index.tsx +++ b/src/components/AppBody/index.tsx @@ -2,8 +2,9 @@ import './index.scss'; import { Method } from './types.ts'; import { delete_req, get, patch, post, put } from '../../api/rest.ts'; -import { getErrorCode, getHttpStatusText } from '../../api/statusCodes.ts'; +import { getHttpStatusText } from '../../api/statusCodes.ts'; import { Header, QueryParam } from '../RequestPanel/types.ts'; +import { ApiResponse } from '../../api/types.ts'; import RequestPanel from '../RequestPanel'; import ResponsePanel from '../ResponsePanel'; @@ -11,7 +12,6 @@ import PaneSplitter from '../PaneSplitter'; import UrlPanel from '../UrlPanel'; import { useState } from 'react'; -import { AxiosError, AxiosResponse, AxiosResponseHeaders, RawAxiosResponseHeaders } from 'axios'; import { AuthType, Credentials } from '../RequestAuthPanel/types.ts'; const AppBody = () => { @@ -31,14 +31,14 @@ const AppBody = () => { const [statusText, setStatusText] = useState(''); const [timeTaken, setTimeTaken] = useState(0); - function parseResponseHeader(header: RawAxiosResponseHeaders | AxiosResponseHeaders) { - return Object.entries(header || {}).map(([key, value]) => ({ + function parseResponseHeader(header: Record = {}) { + return Object.entries(header).map(([key, value]) => ({ key, - value: Array.isArray(value) ? value.join(', ') : value, + value: Array.isArray(value) ? value.join(', ') : String(value), })); } - const onSuccessResponse = (response: AxiosResponse, startTime: number) => { + const onSuccessResponse = (response: ApiResponse, startTime: number) => { const endTime = performance.now(); const duration = endTime - startTime; setTimeTaken(duration); @@ -46,20 +46,27 @@ const AppBody = () => { setIsLoading(false); setResponse(JSON.stringify(response.data, null, 2)); setResponseHeaders(parseResponseHeader(response.headers)); - setStatusCode(response.status); - setStatusText(response.statusText || getHttpStatusText(response.status)); + setStatusCode(response.status || 0); + setStatusText(response.statusText || getHttpStatusText(response.status || 0)); }; - const onFailureResponse = (error: AxiosError) => { + const onFailureResponse = (error: ApiResponse | Error) => { setIsLoading(false); - if (error.response) { - setResponse(JSON.stringify(error.response?.data || {}, null, 2)); - setResponseHeaders(parseResponseHeader(error.response.headers)); - setStatusCode(error.response?.status || 0); - setStatusText(error.response?.statusText || getHttpStatusText(statusCode)); - } else { + if ('status' in error && error.status) { + // ApiResponse with error + setResponse(error.error || JSON.stringify(error.data || {}, null, 2)); + setResponseHeaders(parseResponseHeader(error.headers)); + setStatusCode(error.status); + setStatusText(error.statusText || getHttpStatusText(error.status)); + } else if (error instanceof Error) { + // JavaScript Error (network error, etc.) setResponse(`Error: ${error.message}`); - setStatusText(`ERROR: ${getErrorCode(error?.code || '')}`); + setStatusText('ERROR: NETWORK_FAILURE'); + setStatusCode(0); + } else { + // Generic error + setResponse('Unknown error occurred'); + setStatusText('ERROR: UNKNOWN'); setStatusCode(0); } }; diff --git a/tests/api/integration.test.ts b/tests/api/integration.test.ts new file mode 100644 index 0000000..2409b5d --- /dev/null +++ b/tests/api/integration.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { get, post } from '../../src/api/rest'; + +describe('API Integration Tests', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('Full Request Flow', () => { + it('should handle a complete GET request with headers and response', async () => { + const mockResponse = { + ok: true, + data: { users: [{ id: 1, name: 'John' }] }, + status: 200, + statusText: 'OK', + headers: { + 'content-type': 'application/json', + 'x-ratelimit-remaining': '99', + }, + }; + + global.window = { + ...global.window, + api: { + fetch: vi.fn().mockResolvedValue(mockResponse), + }, + } as any; + + const result = await get('https://api.example.com/users', [ + { key: 'Authorization', value: 'Bearer token' }, + ]); + + expect(result.ok).toBe(true); + expect(result.status).toBe(200); + expect(result.data).toEqual({ users: [{ id: 1, name: 'John' }] }); + expect(result.headers).toBeDefined(); + expect(result.headers?.['content-type']).toBe('application/json'); + }); + + it('should handle a complete POST request with body', async () => { + const mockResponse = { + ok: true, + data: { id: 123, created: true }, + status: 201, + statusText: 'Created', + headers: { + 'content-type': 'application/json', + location: '/api/users/123', + }, + }; + + global.window = { + ...global.window, + api: { + fetch: vi.fn().mockResolvedValue(mockResponse), + }, + } as any; + + const body = { name: 'Jane Doe', email: 'jane@example.com' }; + const result = await post('https://api.example.com/users', body, [ + { key: 'Content-Type', value: 'application/json' }, + ]); + + expect(result.ok).toBe(true); + expect(result.status).toBe(201); + expect(result.data).toEqual({ id: 123, created: true }); + expect(result.headers?.location).toBe('/api/users/123'); + }); + + it('should handle error responses', async () => { + const mockErrorResponse = { + ok: false, + error: 'HTTP 404: Not Found', + status: 404, + statusText: 'Not Found', + headers: { + 'content-type': 'application/json', + }, + }; + + global.window = { + ...global.window, + api: { + fetch: vi.fn().mockResolvedValue(mockErrorResponse), + }, + } as any; + + const result = await get('https://api.example.com/notfound'); + + expect(result.ok).toBe(false); + expect(result.status).toBe(404); + expect(result.error).toBe('HTTP 404: Not Found'); + }); + }); +}); diff --git a/tests/api/rest.test.ts b/tests/api/rest.test.ts new file mode 100644 index 0000000..d5131d9 --- /dev/null +++ b/tests/api/rest.test.ts @@ -0,0 +1,156 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { get, post, patch, put, delete_req } from '../../src/api/rest'; +import { Header } from '../../src/components/RequestPanel/types'; + +// Mock window.api +const mockFetch = vi.fn(); + +beforeEach(() => { + vi.clearAllMocks(); + global.window = { + ...global.window, + api: { + fetch: mockFetch, + }, + } as any; +}); + +describe('REST API Functions', () => { + const mockUrl = 'https://api.example.com/data'; + const mockHeaders: Header[] = [ + { key: 'Content-Type', value: 'application/json' }, + { key: 'Authorization', value: 'Bearer token123' }, + ]; + + describe('get', () => { + it('should call window.api.fetch with GET method', async () => { + const mockResponse = { + ok: true, + data: { message: 'success' }, + status: 200, + statusText: 'OK', + headers: {}, + }; + mockFetch.mockResolvedValue(mockResponse); + + const result = await get(mockUrl, mockHeaders); + + expect(mockFetch).toHaveBeenCalledWith(mockUrl, { + headers: { + 'Content-Type': 'application/json', + Authorization: 'Bearer token123', + }, + method: 'GET', + }); + expect(result).toEqual(mockResponse); + }); + + it('should work with empty headers', async () => { + const mockResponse = { ok: true, data: {} }; + mockFetch.mockResolvedValue(mockResponse); + + await get(mockUrl); + + expect(mockFetch).toHaveBeenCalledWith(mockUrl, { + headers: {}, + method: 'GET', + }); + }); + }); + + describe('post', () => { + it('should call window.api.fetch with POST method and body', async () => { + const body = { name: 'test', value: 123 }; + const mockResponse = { ok: true, data: { id: 1 } }; + mockFetch.mockResolvedValue(mockResponse); + + await post(mockUrl, body, mockHeaders); + + expect(mockFetch).toHaveBeenCalledWith(mockUrl, { + headers: { + 'Content-Type': 'application/json', + Authorization: 'Bearer token123', + }, + method: 'POST', + body: JSON.stringify(body), + }); + }); + + it('should handle array body', async () => { + const body = [1, 2, 3]; + mockFetch.mockResolvedValue({ ok: true }); + + await post(mockUrl, body); + + expect(mockFetch).toHaveBeenCalledWith( + mockUrl, + expect.objectContaining({ + body: JSON.stringify(body), + }) + ); + }); + }); + + describe('patch', () => { + it('should call window.api.fetch with PATCH method', async () => { + const body = { status: 'updated' }; + mockFetch.mockResolvedValue({ ok: true }); + + await patch(mockUrl, body, mockHeaders); + + expect(mockFetch).toHaveBeenCalledWith(mockUrl, { + headers: { + 'Content-Type': 'application/json', + Authorization: 'Bearer token123', + }, + method: 'PATCH', + body: JSON.stringify(body), + }); + }); + }); + + describe('put', () => { + it('should call window.api.fetch with PUT method', async () => { + const body = { name: 'updated', value: 456 }; + mockFetch.mockResolvedValue({ ok: true }); + + await put(mockUrl, body, mockHeaders); + + expect(mockFetch).toHaveBeenCalledWith(mockUrl, { + headers: { + 'Content-Type': 'application/json', + Authorization: 'Bearer token123', + }, + method: 'PUT', + body: JSON.stringify(body), + }); + }); + }); + + describe('delete_req', () => { + it('should call window.api.fetch with DELETE method', async () => { + mockFetch.mockResolvedValue({ ok: true, status: 204 }); + + await delete_req(mockUrl, mockHeaders); + + expect(mockFetch).toHaveBeenCalledWith(mockUrl, { + headers: { + 'Content-Type': 'application/json', + Authorization: 'Bearer token123', + }, + method: 'DELETE', + }); + }); + + it('should work without headers', async () => { + mockFetch.mockResolvedValue({ ok: true }); + + await delete_req(mockUrl); + + expect(mockFetch).toHaveBeenCalledWith(mockUrl, { + headers: {}, + method: 'DELETE', + }); + }); + }); +}); diff --git a/tests/components/AppBody.test.tsx b/tests/components/AppBody.test.tsx index 4dea37a..4405422 100644 --- a/tests/components/AppBody.test.tsx +++ b/tests/components/AppBody.test.tsx @@ -233,12 +233,12 @@ describe(`AppBody`, () => { it(`should update ResponsePanel props on API error`, async () => { const { get } = await import('../../src/api/rest.ts'); - (get as ReturnType).mockRejectedValueOnce({ - response: { - status: 404, - statusText: 'Not Found', - data: { message: 'Resource not found' }, - }, + (get as ReturnType).mockResolvedValueOnce({ + ok: false, + status: 404, + statusText: 'Not Found', + data: { message: 'Resource not found' }, + headers: {}, }); act(() => { @@ -261,11 +261,7 @@ describe(`AppBody`, () => { it(`should handle missing statusText, body and statusCode in API error response`, async () => { const { get } = await import('../../src/api/rest.ts'); - (get as ReturnType).mockRejectedValueOnce({ - response: { - //response missing statusText, data and statusCode - }, - }); + (get as ReturnType).mockRejectedValueOnce(new Error('Unknown error')); act(() => { mockedComponents.urlPanel?.onMethodChange('GET'); mockedComponents.urlPanel?.onUrlChange('https://example.com/error'); @@ -277,16 +273,13 @@ describe(`AppBody`, () => { expect(mockedComponents.responsePanel?.isLoading).toBe(false); }); expect(mockedComponents.responsePanel?.statusCode).toBe(0); - expect(mockedComponents.responsePanel?.statusText).toBe('Unknown'); - expect(mockedComponents.responsePanel?.response).toBe(JSON.stringify({}, null, 2)); + expect(mockedComponents.responsePanel?.statusText).toBe('ERROR: NETWORK_FAILURE'); + expect(mockedComponents.responsePanel?.response).toBe('Error: Unknown error'); }); it(`should update ResponsePanel props on network error`, async () => { const { get } = await import('../../src/api/rest.ts'); - (get as ReturnType).mockRejectedValueOnce({ - message: 'Network Error', - code: 'ERR_NETWORK', - }); + (get as ReturnType).mockRejectedValueOnce(new Error('Network Error')); act(() => { mockedComponents.urlPanel?.onMethodChange('GET'); @@ -308,10 +301,7 @@ describe(`AppBody`, () => { it(`should handle missing error.code in network error`, async () => { const { get } = await import('../../src/api/rest.ts'); - (get as ReturnType).mockRejectedValueOnce({ - message: 'Some random error', - //code missing - }); + (get as ReturnType).mockRejectedValueOnce(new Error('Some random error')); act(() => { mockedComponents.urlPanel?.onMethodChange('GET'); mockedComponents.urlPanel?.onUrlChange('https://example.com'); @@ -323,7 +313,7 @@ describe(`AppBody`, () => { expect(mockedComponents.responsePanel?.isLoading).toBe(false); }); expect(mockedComponents.responsePanel?.statusCode).toBe(0); - expect(mockedComponents.responsePanel?.statusText).toBe('ERROR: UNKNOWN'); + expect(mockedComponents.responsePanel?.statusText).toBe('ERROR: NETWORK_FAILURE'); expect(mockedComponents.responsePanel?.response).toBe('Error: Some random error'); }); diff --git a/tests/electron/ipc-handler.test.ts b/tests/electron/ipc-handler.test.ts new file mode 100644 index 0000000..199e8b3 --- /dev/null +++ b/tests/electron/ipc-handler.test.ts @@ -0,0 +1,167 @@ +/** + * @vitest-environment node + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock fetch globally +const mockFetch = vi.fn(); +global.fetch = mockFetch; + +describe('Electron IPC Handler - rest', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('Content-Type Handling', () => { + it('should parse JSON responses', async () => { + const mockData = { message: 'success', id: 123 }; + const mockResponse = { + ok: true, + status: 200, + statusText: 'OK', + headers: new Map([['content-type', 'application/json']]), + json: vi.fn().mockResolvedValue(mockData), + }; + + mockFetch.mockResolvedValue(mockResponse); + + // Simulate the IPC handler logic + const response = await fetch('https://api.example.com/data'); + const headers: Record = {}; + response.headers.forEach((value: string, key: string) => { + headers[key] = value; + }); + + const contentType = response.headers.get('content-type') || ''; + expect(contentType).toContain('application/json'); + }); + + it('should handle HTML responses as text', async () => { + const mockHtml = 'Hello'; + const mockResponse = { + ok: true, + status: 200, + statusText: 'OK', + headers: new Map([['content-type', 'text/html']]), + text: vi.fn().mockResolvedValue(mockHtml), + }; + + mockFetch.mockResolvedValue(mockResponse); + + const response = await fetch('https://example.com'); + const contentType = response.headers.get('content-type') || ''; + expect(contentType).toContain('text/html'); + }); + + it('should handle XML responses as text', async () => { + const mockXml = 'test'; + const mockResponse = { + ok: true, + status: 200, + statusText: 'OK', + headers: new Map([['content-type', 'application/xml']]), + text: vi.fn().mockResolvedValue(mockXml), + }; + + mockFetch.mockResolvedValue(mockResponse); + + const response = await fetch('https://api.example.com/data.xml'); + const contentType = response.headers.get('content-type') || ''; + expect(contentType).toContain('application/xml'); + }); + }); + + describe('Response Status Handling', () => { + it('should return status and statusText for successful requests', async () => { + const mockResponse = { + ok: true, + status: 200, + statusText: 'OK', + headers: new Map([['content-type', 'application/json']]), + json: vi.fn().mockResolvedValue({ data: 'test' }), + }; + + mockFetch.mockResolvedValue(mockResponse); + + const response = await fetch('https://api.example.com/data'); + + expect(response.status).toBe(200); + expect(response.statusText).toBe('OK'); + expect(response.ok).toBe(true); + }); + + it('should handle 404 errors', async () => { + const mockResponse = { + ok: false, + status: 404, + statusText: 'Not Found', + headers: new Map([['content-type', 'application/json']]), + }; + + mockFetch.mockResolvedValue(mockResponse); + + const response = await fetch('https://api.example.com/notfound'); + + expect(response.status).toBe(404); + expect(response.statusText).toBe('Not Found'); + expect(response.ok).toBe(false); + }); + + it('should handle 500 errors', async () => { + const mockResponse = { + ok: false, + status: 500, + statusText: 'Internal Server Error', + headers: new Map([['content-type', 'application/json']]), + }; + + mockFetch.mockResolvedValue(mockResponse); + + const response = await fetch('https://api.example.com/error'); + + expect(response.status).toBe(500); + expect(response.ok).toBe(false); + }); + }); + + describe('Headers Conversion', () => { + it('should convert Headers to plain object', () => { + const headers = new Map([ + ['content-type', 'application/json'], + ['authorization', 'Bearer token123'], + ['x-custom-header', 'custom-value'], + ]); + + const headersObj: Record = {}; + headers.forEach((value, key) => { + headersObj[key] = value; + }); + + expect(headersObj).toEqual({ + 'content-type': 'application/json', + 'authorization': 'Bearer token123', + 'x-custom-header': 'custom-value', + }); + }); + }); + + describe('Error Handling', () => { + it('should handle network errors', async () => { + const networkError = new Error('Failed to fetch'); + mockFetch.mockRejectedValue(networkError); + + await expect(fetch('https://api.example.com/data')).rejects.toThrow( + 'Failed to fetch' + ); + }); + + it('should handle timeout errors', async () => { + const timeoutError = new Error('Request timeout'); + mockFetch.mockRejectedValue(timeoutError); + + await expect(fetch('https://api.example.com/data')).rejects.toThrow( + 'Request timeout' + ); + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index 00bf87c..d056bf6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -238,9 +238,9 @@ optionalDependencies: global-agent "^3.0.0" -"@electron/node-gyp@git+https://github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2": +"@electron/node-gyp@https://github.com/electron/node-gyp#06b29aafb7708acef8b3669835c8a7857ebc92d2": version "10.2.0-electron.1" - resolved "git+https://github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2" + resolved "https://github.com/electron/node-gyp#06b29aafb7708acef8b3669835c8a7857ebc92d2" dependencies: env-paths "^2.2.0" exponential-backoff "^3.1.1"