diff --git a/README.md b/README.md
index bcb0f52..d26026d 100644
--- a/README.md
+++ b/README.md
@@ -15,7 +15,11 @@ A simple RESTful API that allows you to manipulate files and directories.
The easiest way to run the File API is to use the Docker image. The image is available on [Docker Hub](https://hub.docker.com/r/xkonti/file-api):
```bash
-docker run -d -p 3210:3000 -e API_KEY=YOUR_API_KEY -v /your/directory/to/mount:/data xkonti/file-api:latest
+docker run -d \
+ -p 3210:3000 \
+ -e API_KEY=YOUR_API_KEY \
+ -v /your/directory/to/mount:/data:rw \
+ xkonti/file-api:latest
```
Available configuration options:
@@ -37,9 +41,9 @@ Each HTTP request will be first checked for API key. If the API key is not provi
### List files and directories in a directory
-You can list files and/or directories by using the `/list` endpoint with the following parameters:
+You can list files and/or directories by using the `GET /list` endpoint with the following parameters:
-- `path` - the path of the directory to list (remember to encode it)
+- `path` - the path of the directory to list (remember to url-encode it)
- `dirs` - whether to include directories or not (default: `false`). If directories are listed their contents will be nested.
- `depth` - how deep to list directories. The default `1` will list only files and directories within the specified directory - no contents of subdirectories will be listed.
@@ -181,9 +185,9 @@ You can list files and/or directories by using the `/list` endpoint with the fol
### Download a file
-You can download a specific file by using the `/file` endpoint with the following parameters:
+You can download a specific file by using the `GET /file` endpoint with the following parameters:
-- `path` - the path of the file to download (remember to encode it)
+- `path` - the path of the file to download (remember to url-encode it)
If the file does not exist or a path is pointing to a directory, a `404 Not Found` response will be returned.
@@ -193,29 +197,61 @@ If the file does not exist or a path is pointing to a directory, a `404 Not Foun
- `curl --request GET --url 'http://localhost:3000/file?path=%2FExpenses%202022.xlsx'` - download the `Expenses 2022.xlsx` file (`%2FExpenses%202022.xlsx` is the encoded `/Expenses 2022.xlsx`)
+### Upload a file
+
+You can upload a specific file by using the `POST /file` endpoint with the following parameters:
+
+- `path` - the destination path of the file to upload (remember to url-encode it)
+- `overwrite` - whether to overwrite the file if it already exists (default: `false`)
+
+All the parent directories of the destination path will be created automatically if they do not exist.
+
+The file contents should be sent in the request body in one of the following ways:
+
+- `multipart/form-data` - the file contents should be sent as a `file` field
+- `application/octet-stream` - the file contents should be sent as the request body
+
+Possible responses:
+
+- `201` - the file was uploaded successfully
+- `400` - the request was malformed in some way:
+ - the `path` parameter is missing
+ - the `path` parameter is pointing to a directory
+ - the file contents are missing
+- `415` - unsupported content type
+- `422` - unrecognized file contents format
+
+
+ Examples [click to expand]
+
+- `curl --request POST --url 'http://localhost:3000/file?path=%2FExpenses%202022.xlsx' --header 'Content-Type: application/octet-stream' --data-binary @/path/to/file` - upload the `Expenses 2022.xlsx` file (`%2FExpenses%202022.xlsx` is the encoded `/Expenses 2022.xlsx`)
+
+
## Roadmap
### Directories
-- [x] List files and directories in a directory
-- [ ] Check if a directory exists
-- [ ] Create a directory
-- [ ] Delete a directory
-- [ ] Rename a directory
-- [ ] Move a directory
-- [ ] Copy a directory
+- [x] List files and directories in a directory as a flat list: `GET /list`
+- [ ] List files and directories in a directory as a tree: `GET /tree`
+- [ ] Check if a directory exists: `GET /dir/exists`
+- [ ] Create a directory: `POST /dir`
+- [ ] Delete a directory: `DELETE /dir`
+- [ ] Rename a directory: : `POST /dir/rename`
+- [ ] Move a directory: `POST /dir/move`
+- [ ] Copy a directory: `POST /dir/copy`
### Files
-- [ ] Check if a file exists
-- [x] Download a file
-- [ ] Upload a file
-- [ ] Delete a file
-- [ ] Create an empty file
-- [ ] Rename a file
-- [ ] Move a file
-- [ ] Copy a file
-- [ ] Get file metadata
+- [ ] Check if a file exists: `GET /file/exists`
+- [x] Download a file: `GET /file`
+- [x] Upload a file: `POST /file`
+- [ ] Delete a file: `DELETE /file`
+- [ ] Create an empty file: `POST /file/touch`
+- [ ] Rename a file: `POST /file/rename`
+- [ ] Move a file: `POST /file/move`
+- [ ] Copy a file: `POST /file/copy`
+- [ ] Get file metadata: `GET /file/meta`
+- [ ] Get file size: `GET /file/size`
### Permissions
@@ -235,6 +271,5 @@ If the file does not exist or a path is pointing to a directory, a `404 Not Foun
## Stack
-- Runtime: Bun
-- Bundler: Bun
-- API framework: Elysia.js
+- Runtime and bundler: [Bun](https://bun.sh/)
+- API framework: [Elysia.js](https://elysiajs.com/)
diff --git a/bun.lockb b/bun.lockb
index a40f162..676dacb 100755
Binary files a/bun.lockb and b/bun.lockb differ
diff --git a/bunfig.toml b/bunfig.toml
index eb18f48..0d5c1ef 100644
--- a/bunfig.toml
+++ b/bunfig.toml
@@ -1,5 +1,3 @@
[test]
-
-# always enable coverage
coverage = true
-coverageThreshold = 0.8
\ No newline at end of file
+coverageThreshold = 0.7
\ No newline at end of file
diff --git a/package.json b/package.json
index d8fd50a..f927e83 100644
--- a/package.json
+++ b/package.json
@@ -1,16 +1,18 @@
{
"name": "file-api",
- "version": "0.2.0",
+ "version": "0.3.0",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"dev": "bun run --watch src/index.ts",
"prod": "bun run src/index.ts"
},
"dependencies": {
- "elysia": "latest"
+ "elysia": "^0.6.17",
+ "neverthrow": "^6.0.0",
+ "rimraf": "^5.0.1"
},
"devDependencies": {
- "bun-types": "^0.7.3"
+ "bun-types": "^0.8.1"
},
"module": "src/index.js"
}
diff --git a/src/app.ts b/src/app.ts
index 2e58fb9..2f9c246 100644
--- a/src/app.ts
+++ b/src/app.ts
@@ -3,6 +3,7 @@ import {addGetListEndpoint} from './endpoints/list/getList';
import {addApiKeyGuard} from './endpoints/guards';
import {addErrorHandling} from './endpoints/errors';
import {addGetFileEndpoint} from './endpoints/file/getFile';
+import {addUploadFileEndpoint} from './endpoints/file/uploadFile';
export function buildApp() {
let app = new Elysia();
@@ -14,5 +15,6 @@ export function buildApp() {
// Endpoints
addGetListEndpoint(app);
addGetFileEndpoint(app);
+ addUploadFileEndpoint(app);
return app;
}
diff --git a/src/constants/commonResponses.ts b/src/constants/commonResponses.ts
new file mode 100644
index 0000000..b61a821
--- /dev/null
+++ b/src/constants/commonResponses.ts
@@ -0,0 +1,20 @@
+// Success
+export const fileUploadSuccessMsg = 'File uploaded successfully';
+
+// Bad requests
+
+export const dirNotExistsMsg = 'The directory does not exist';
+export const fileAlreadyExistsMsg = 'The file already exists';
+export const fileEmptyMsg = 'The file is empty';
+export const fileMustNotEmptyMsg = 'File contents cannot be empty';
+export const provideValidPathMsg = 'You must provide a valid path to a file';
+export const unsupportedContentTypeMsg = 'Unsupported content type';
+export const unsupportedBinaryDataTypeMsg = 'Unsupported binary data type';
+export const invalidFileFormatInFormDataMsg = 'Invalid file format in form data';
+export const noFileFieldInFormDataMsg = 'No file field in form data';
+export const invalidFormDataStructure = 'Invalid structure for multipart/form-data';
+
+// Internal server errors
+export const dirCreateFailMsg = 'Could not create directory';
+export const unknownErrorMsg = 'An unknown error occurred';
+export const uploadErrorMsg = 'An error occurred while uploading the file';
diff --git a/src/endpoints/file/getFile.test.ts b/src/endpoints/file/getFile.test.ts
index 3d9f292..a33ab8f 100644
--- a/src/endpoints/file/getFile.test.ts
+++ b/src/endpoints/file/getFile.test.ts
@@ -1,17 +1,9 @@
-import {beforeEach, expect, test} from 'bun:test';
+import {afterEach, beforeEach, expect, test} from 'bun:test';
import {buildApp} from '../../app';
-import {setConfig} from '../../utils/config';
import {RequestBuilder} from '../../utils/requestBuilder';
import {UrlBuilder} from '../../utils/urlBuilder';
-import {
- dir1Name,
- emptyFileName,
- illegalPaths,
- loremFileContents,
- loremFileName,
- pathToNowhere,
- testingDirectoryRelativePath,
-} from '../../testing/testingUtils.test';
+import {fs1Files, fs1TestDirectoryContents, illegalPaths} from '../../testing/constants';
+import {createTestFileSystem, destroyTestFileSystem} from '../../testing/testFileSystem';
function buildFileRequest(path: string | null) {
let url = new UrlBuilder('http://localhost/file');
@@ -25,36 +17,38 @@ function buildFileRequest(path: string | null) {
// App instance
let app: ReturnType;
-beforeEach(() => {
- // Update config
- setConfig({
- apiKey: 'test',
- dataDir: './',
- });
+const files = fs1Files;
+const filesList = Object.values(files);
+const testDirectoryContents = fs1TestDirectoryContents;
- // Create app
+beforeEach(async () => {
+ await createTestFileSystem(testDirectoryContents);
app = buildApp();
});
-// TODO: Should return a 200 if the file is found
+afterEach(async () => {
+ await destroyTestFileSystem();
+});
+
test('should return file contents if the file is found', async () => {
- const filePath = `${testingDirectoryRelativePath}/${loremFileName}`;
- const request = buildFileRequest(filePath);
- const response = await app.handle(request);
- expect(response.status).toBe(200);
- const bodyAsText = await response.text();
- expect(bodyAsText).toBe(loremFileContents);
+ for (const fileDef of filesList) {
+ const request = buildFileRequest(fileDef.relativePath);
+ const response = await app.handle(request);
+ expect(response.status).toBe(200);
+ const bodyAsText = await response.text();
+ expect(bodyAsText).toBe(fileDef.contents);
+ }
});
test('should return a 404 if the file is not found', async () => {
- const filePath = `${testingDirectoryRelativePath}/${pathToNowhere}`;
+ const filePath = `/path/to/nowhere`;
const request = buildFileRequest(filePath);
const response = await app.handle(request);
expect(response.status).toBe(404);
});
test('should return a 404 if the path is not a file', async () => {
- const filePath = `${testingDirectoryRelativePath}/${dir1Name}`;
+ const filePath = `/TerryPratchett`;
const request = buildFileRequest(filePath);
const response = await app.handle(request);
expect(response.status).toBe(404);
@@ -80,10 +74,8 @@ test('should return a 400 when illegal path', async () => {
}
});
-// TODO: Should return a 200 if the file is empty
test('should return a 200 if the file is empty', async () => {
- const filePath = `${testingDirectoryRelativePath}/${emptyFileName}`;
- const request = buildFileRequest(filePath);
+ const request = buildFileRequest(files.emptiness.relativePath);
const response = await app.handle(request);
expect(response.status).toBe(200);
const bodyAsText = await response.text();
diff --git a/src/endpoints/file/uploadFile.test.ts b/src/endpoints/file/uploadFile.test.ts
new file mode 100644
index 0000000..6ccb2c5
--- /dev/null
+++ b/src/endpoints/file/uploadFile.test.ts
@@ -0,0 +1,272 @@
+import {afterEach, beforeEach, describe, expect, test} from 'bun:test';
+import {buildApp} from '../../app';
+import {RequestBuilder} from '../../utils/requestBuilder';
+import {UrlBuilder} from '../../utils/urlBuilder';
+import {createTestFileSystem, destroyTestFileSystem} from '../../testing/testFileSystem';
+import {illegalPaths, testDirectory} from '../../testing/constants';
+import {getPermutations} from '../../testing/testingUtils.test';
+import {getFile} from '../../utils/fileUtils';
+import {join} from 'path';
+
+const example1Content = 'This is an example file.';
+const example1ContentAsBlob = new Blob([example1Content], {type: 'text/plain'});
+
+// Example 2 is a bit longer. It's the same as example 1, but repeated 2000 times.
+const example2Content = example1Content.repeat(2000);
+const example2ContentAsBlob = new Blob([example2Content], {type: 'text/plain'});
+
+function buildUploadRequest(
+ path: string | null,
+ overwrite: boolean | null,
+ content: string | Blob | null,
+ transferMethod: 'form-data' | 'raw',
+ contentTypeOverride?: string,
+) {
+ let url = new UrlBuilder('http://localhost/file');
+ if (path != null) {
+ url.setParam('path', path);
+ }
+ if (overwrite != null) {
+ url.setParam('overwrite', overwrite ? 'true' : 'false');
+ }
+ const requestBuilder = new RequestBuilder(url).setMethod('POST');
+
+ // If transferMethod is 'form-data', send the content as a multipart/form-data request.
+ if (transferMethod === 'form-data') {
+ const formData = new FormData();
+ if (content != null) {
+ formData.append('file', content);
+ }
+ requestBuilder.setBody(formData);
+ }
+
+ // Otherwise, send the content as raw text.
+ else if (transferMethod === 'raw') {
+ if (content != null) {
+ requestBuilder.setBody(content);
+ }
+ requestBuilder.setHeader('Content-Type', 'application/octet-stream');
+ }
+
+ if (contentTypeOverride != null) {
+ requestBuilder.setHeader('Content-Type', contentTypeOverride);
+ }
+
+ return requestBuilder.build();
+}
+
+// App instance
+let app: ReturnType;
+
+const defaultContents = 'This is an example file.';
+
+beforeEach(async () => {
+ await createTestFileSystem({
+ '/existing.txt': defaultContents,
+ exdir1: {
+ 'anotherExisting.jpg': defaultContents,
+ },
+ exdir2: {
+ exists: defaultContents,
+ },
+ });
+ app = buildApp();
+});
+
+afterEach(async () => {
+ await destroyTestFileSystem();
+});
+
+describe('Upload file', () => {
+ test.each(getPermutations(['', null], [false, true], ['form-data', 'raw']))(
+ 'should return 400 when path is empty',
+ async (path, override, transferMethod) => {
+ let request = buildUploadRequest(
+ path,
+ override,
+ example1ContentAsBlob,
+ transferMethod as 'form-data' | 'raw',
+ );
+ let response = await app.handle(request);
+ expect(response.status).toBe(400);
+ },
+ );
+
+ test.each(
+ getPermutations(
+ illegalPaths as string[],
+ [false, true],
+ [example1ContentAsBlob, example2ContentAsBlob],
+ ['form-data', 'raw'],
+ ),
+ )(
+ 'should return 400 when path is invalid',
+ async (illegalPath, override, content, transferMethod) => {
+ let request = buildUploadRequest(
+ illegalPath,
+ override,
+ content,
+ transferMethod as 'form-data' | 'raw',
+ );
+ let response = await app.handle(request);
+ expect(response.status).toBe(400);
+ },
+ );
+
+ test.each(
+ getPermutations(
+ ['/file1.txt', '/dir1/file2.txt'],
+ [false, true],
+ ['', new Blob(undefined, {type: 'text/plain'})],
+ ['form-data', 'raw'],
+ ),
+ )(
+ 'should return 400 when trying to upload empty file',
+ async (path, override, content, transferMethod) => {
+ let request = buildUploadRequest(
+ path,
+ override,
+ content,
+ transferMethod as 'form-data' | 'raw',
+ );
+ let response = await app.handle(request);
+ if (transferMethod === 'form-data') {
+ expect(response.status == 400 || response.status == 422).toBeTrue();
+ } else {
+ console.log('Not form data:', transferMethod);
+ expect(response.status).toBe(400);
+ }
+ },
+ );
+
+ test.each(
+ getPermutations(
+ ['/existing.txt', 'exdir1/anotherExisting.jpg', '/exdir2/exists'],
+ [example1ContentAsBlob, example2ContentAsBlob],
+ ['form-data', 'raw'],
+ ),
+ )(
+ 'should return 409 when trying to upload a file that already exists and overwrite is false',
+ async (path, content, transferMethod) => {
+ let request = buildUploadRequest(path, false, content, transferMethod as 'form-data' | 'raw');
+ let response = await app.handle(request);
+ expect(response.status).toBe(409);
+ const absolutePath = join(testDirectory, path);
+ const file = await getFile(absolutePath);
+ expect(file).not.toBeNull();
+ expect(file?.size).toBe(defaultContents.length);
+ const fileContents = await file?.text();
+ expect(fileContents).toBe(defaultContents);
+ },
+ );
+
+ test.each(
+ getPermutations(
+ ['/existing.txt', 'exdir1/anotherExisting.jpg', '/exdir2/exists'],
+ [example1ContentAsBlob, example2ContentAsBlob],
+ ['form-data', 'raw'],
+ ),
+ )(
+ 'should return 201 when trying to upload a file that already exists and overwrite is true',
+ async (path, content, transferMethod) => {
+ let request = buildUploadRequest(path, true, content, transferMethod as 'form-data' | 'raw');
+ let response = await app.handle(request);
+ expect(response.status).toBe(201);
+ const absolutePath = join(testDirectory, path);
+ const file = await getFile(absolutePath);
+ expect(file).not.toBeNull();
+ const expectedTextContents = content instanceof Blob ? await content.text() : content;
+ expect(file?.size).toBe(expectedTextContents.length);
+ const fileContents = await file?.text();
+ expect(fileContents).toBe(expectedTextContents);
+ },
+ );
+
+ test.each(
+ getPermutations(
+ ['/file1.txt', '/file2.txt'],
+ [false, true],
+ [example1ContentAsBlob, example2ContentAsBlob],
+ ['form-data', 'raw'],
+ ),
+ )(
+ 'should return 201 when uploading a file that does not exist',
+ async (path, override, content, transferMethod) => {
+ let request = buildUploadRequest(
+ path,
+ override,
+ content,
+ transferMethod as 'form-data' | 'raw',
+ );
+ let response = await app.handle(request);
+ expect(response.status).toBe(201);
+ const absolutePath = join(testDirectory, path);
+ const file = await getFile(absolutePath);
+ expect(file).not.toBeNull();
+ const expectedTextContents = content instanceof Blob ? await content.text() : content;
+ expect(file?.size).toBe(expectedTextContents.length);
+ const fileContents = await file?.text();
+ expect(fileContents).toBe(expectedTextContents);
+ },
+ );
+
+ test.each(
+ getPermutations(
+ ['/newDirectory/file1.txt', '/some/other/directory/somewhere/file2.txt'],
+ [false, true],
+ [example1ContentAsBlob, example2ContentAsBlob],
+ ['form-data', 'raw'],
+ ),
+ )(
+ 'should return 201 when uploading a file to a path that does not exist',
+ async (path, override, content, transferMethod) => {
+ let request = buildUploadRequest(
+ path,
+ override,
+ content,
+ transferMethod as 'form-data' | 'raw',
+ );
+ let response = await app.handle(request);
+ expect(response.status).toBe(201);
+ const absolutePath = join(testDirectory, path);
+ const file = await getFile(absolutePath);
+ expect(file).not.toBeNull();
+ const expectedTextContents = content instanceof Blob ? await content.text() : content;
+ expect(file?.size).toBe(expectedTextContents.length);
+ const fileContents = await file?.text();
+ expect(fileContents).toBe(expectedTextContents);
+ },
+ );
+
+ test.each(
+ getPermutations(
+ ['/file1.txt', '/dir1/file2.txt'],
+ [false, true],
+ [example1ContentAsBlob, example2ContentAsBlob],
+ ['form-data', 'raw'],
+ [
+ 'text/plain',
+ 'application/json',
+ 'application/xml',
+ 'image/png',
+ 'image/jpeg',
+ 'image/gif',
+ '',
+ 'something/else',
+ ],
+ ),
+ )(
+ 'should return 415 when uploading a file with an unsupported content type',
+ async (path, override, content, transferMethod, contentType) => {
+ let request = buildUploadRequest(
+ path,
+ override,
+ content,
+ transferMethod as 'form-data' | 'raw',
+ contentType,
+ );
+ let response = await app.handle(request);
+ expect(response.status).toBe(415);
+ },
+ );
+});
diff --git a/src/endpoints/file/uploadFile.ts b/src/endpoints/file/uploadFile.ts
new file mode 100644
index 0000000..6dd8568
--- /dev/null
+++ b/src/endpoints/file/uploadFile.ts
@@ -0,0 +1,178 @@
+import Elysia from 'elysia';
+import {validateRelativePath} from '../../utils/pathUtils';
+import {getFile, writeFile} from '../../utils/fileUtils';
+import {
+ fileEmptyMsg,
+ fileAlreadyExistsMsg,
+ fileUploadSuccessMsg,
+ unsupportedContentTypeMsg,
+ uploadErrorMsg,
+ fileMustNotEmptyMsg,
+ unsupportedBinaryDataTypeMsg,
+ invalidFileFormatInFormDataMsg,
+ noFileFieldInFormDataMsg,
+ invalidFormDataStructure,
+} from '../../constants/commonResponses';
+import {Result, ok, err} from 'neverthrow';
+
+/**
+ * Add the endpoint for uploading a file.
+ */
+export function addUploadFileEndpoint(app: Elysia) {
+ return app.post(
+ 'file',
+ async ({body, headers, query, set}) => {
+ // Verify and process the relative path
+ const pathValidationResult = validateRelativePath(query.path);
+ if (pathValidationResult.isErr()) {
+ set.status = 400;
+ return pathValidationResult.error;
+ }
+ const {absolutePath} = pathValidationResult.value;
+
+ // Check if the file should be overwritten if one exists already
+ const overwrite = (query.overwrite ?? 'false') === 'true';
+
+ // Check if file exists
+ const existingFile = await getFile(absolutePath);
+ if (existingFile && !overwrite) {
+ set.status = 409;
+ return fileAlreadyExistsMsg;
+ }
+
+ // Get file contents
+ const contentType = getMediaType(headers);
+ const fileContentsResult = await getFileContents(contentType, body);
+ if (fileContentsResult.isErr()) {
+ switch (fileContentsResult.error) {
+ case unsupportedContentTypeMsg:
+ set.status = 415;
+ break;
+ case unsupportedBinaryDataTypeMsg:
+ case invalidFileFormatInFormDataMsg:
+ set.status = 422;
+ break;
+ default:
+ set.status = 400;
+ }
+ return fileContentsResult.error;
+ }
+
+ // Write the file
+ const writeResult = await writeFile(absolutePath, fileContentsResult.value, overwrite);
+
+ if (writeResult.isOk()) {
+ set.status = 201;
+ return fileUploadSuccessMsg;
+ }
+
+ // Handle errors
+ switch (writeResult.error) {
+ case fileAlreadyExistsMsg:
+ set.status = 409;
+ break;
+ case fileMustNotEmptyMsg:
+ set.status = 400;
+ break;
+ case unsupportedContentTypeMsg:
+ set.status = 415;
+ break;
+ default:
+ set.status = 500;
+ return uploadErrorMsg;
+ }
+ return writeResult.error;
+ },
+ {
+ // Add custom parser for application/json data
+ // We don't want Elysia.js to parse it automatically as JSON.
+ parse: async ({request, headers}) => {
+ const contentType = getMediaType(headers);
+
+ if (contentType === 'application/json') {
+ return await request.text();
+ }
+ },
+ },
+ );
+}
+
+/**
+ * Get the media type from the headers (content-type header).
+ * @param headers The headers of the request
+ * @returns The core content type of the request or an empty string if it is not set.
+ */
+function getMediaType(headers: Record): string {
+ // Default to invalid content type to prevent mishaps
+ return headers['content-type']?.split(';')[0].trim().toLowerCase() ?? '';
+}
+
+/**
+ * Get the file contents from the request body depending on the content type
+ * @param contentType The content type of the request
+ * @param body The request body
+ * @returns The contents of the file or an error message
+ */
+async function getFileContents(contentType: string, body: unknown): Promise> {
+ let fileContents: Blob;
+ switch (contentType) {
+ // Binary stream
+ case 'application/octet-stream': {
+ if (body instanceof Blob) {
+ fileContents = body;
+ } else if (body instanceof ArrayBuffer) {
+ fileContents = new Blob([body]);
+ } else if (body instanceof Uint8Array) {
+ fileContents = new Blob([body.buffer]);
+ } else if (typeof Buffer !== 'undefined' && Buffer.isBuffer(body)) {
+ // Node.js Buffer
+ fileContents = new Blob([body]);
+ } else {
+ return err(unsupportedBinaryDataTypeMsg);
+ }
+ break;
+ }
+
+ // Form data
+ case 'multipart/form-data': {
+ if (typeof body === 'object' && body !== null) {
+ if ('file' in body) {
+ const potentialFile = body.file;
+ if (potentialFile instanceof Blob) {
+ fileContents = potentialFile;
+ } else if (potentialFile instanceof Buffer) {
+ fileContents = new Blob([potentialFile]);
+ } else if (
+ potentialFile &&
+ typeof potentialFile === 'object' &&
+ 'stream' in potentialFile &&
+ potentialFile.stream &&
+ typeof potentialFile.stream === 'object' &&
+ 'pipe' in potentialFile.stream &&
+ typeof potentialFile.stream.pipe === 'function'
+ ) {
+ return err('Streams are not supported yet');
+ } else {
+ return err(invalidFileFormatInFormDataMsg);
+ }
+ } else {
+ return err(noFileFieldInFormDataMsg);
+ }
+ } else {
+ return err(invalidFormDataStructure);
+ }
+ break;
+ }
+
+ // Unsupported content type
+ default:
+ return err(unsupportedContentTypeMsg);
+ }
+
+ // Check if the file is empty
+ if (fileContents.size == null || fileContents.size === 0) {
+ return err(fileEmptyMsg);
+ }
+
+ return ok(fileContents);
+}
diff --git a/src/endpoints/list/getList.test.ts b/src/endpoints/list/getList.test.ts
index f1ebc39..1181a46 100644
--- a/src/endpoints/list/getList.test.ts
+++ b/src/endpoints/list/getList.test.ts
@@ -1,19 +1,59 @@
-import {describe, expect, test, beforeEach} from 'bun:test';
+import {describe, expect, test, beforeEach, afterEach} from 'bun:test';
import {buildApp} from '../../app';
-import {setConfig} from '../../utils/config';
import {RequestBuilder} from '../../utils/requestBuilder';
import {UrlBuilder} from '../../utils/urlBuilder';
+import {DirectoryEntry, flattenFiles, listSorterByPath} from '../../utils/directoryUtils';
import {
- illegalPaths,
- loremFileName,
- prepTreeForComparison,
- testingDirectoryPath,
- testingDirectoryRelativePath,
- testingDirectoryTreeDepth1,
- testingDirectoryTreeDepth2,
- testingDirectoryTreeDepth3,
-} from '../../testing/testingUtils.test';
-import {DirectoryEntry} from '../../utils/directoryUtils';
+ FileSystemConstructionEntry,
+ createTestFileSystem,
+ destroyTestFileSystem,
+ generateDirectoryEntryCollections,
+} from '../../testing/testFileSystem';
+import {illegalPaths} from '../../testing/constants';
+
+const testDirectoryContents: FileSystemConstructionEntry = {
+ dir1: {
+ 'file3.empty': '',
+ },
+ dir2: {
+ dir3: {
+ 'file4.empty': '',
+ },
+ },
+ 'file1.lorem': 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.',
+ 'file2.empty': '',
+};
+
+const depth3 = generateDirectoryEntryCollections('/', {
+ dir1: {
+ 'file3.empty': '',
+ },
+ dir2: {
+ dir3: {
+ 'file4.empty': '',
+ },
+ },
+ 'file1.lorem': 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.',
+ 'file2.empty': '',
+} as FileSystemConstructionEntry);
+
+const depth2 = generateDirectoryEntryCollections('/', {
+ dir1: {
+ 'file3.empty': '',
+ },
+ dir2: {
+ dir3: {},
+ },
+ 'file1.lorem': 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.',
+ 'file2.empty': '',
+} as FileSystemConstructionEntry);
+
+const depth1 = generateDirectoryEntryCollections('/', {
+ dir1: {},
+ dir2: {},
+ 'file1.lorem': 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.',
+ 'file2.empty': '',
+} as FileSystemConstructionEntry);
function buildListRequest(
path: string,
@@ -40,63 +80,68 @@ function buildListRequest(
return requestBuilder.build();
}
+/**
+ * Prepares a file/dirs tree for comparison by flattening it and sorting it by path.
+ */
+export function prepTreeForComparison(tree: DirectoryEntry[], excludeDirs: boolean) {
+ return flattenFiles(tree, excludeDirs).sort(listSorterByPath);
+}
+
// App instance
let app: ReturnType;
-beforeEach(() => {
- // Update config
- setConfig({
- apiKey: 'test',
- dataDir: './',
- });
-
- // Create app
+beforeEach(async () => {
+ await createTestFileSystem(testDirectoryContents);
app = buildApp();
});
+afterEach(async () => {
+ await destroyTestFileSystem();
+});
+
describe('getList()', async () => {
test('should return proper depth 1 tree', async () => {
- const request = buildListRequest(testingDirectoryRelativePath, true, 1);
+ const request = buildListRequest('/', true, 1);
const response = await app.handle(request);
expect(response.status).toBe(200);
const body = (await response.json()) as DirectoryEntry[];
const receivedTree = prepTreeForComparison(body, false);
- const expectedTree = prepTreeForComparison(testingDirectoryTreeDepth1, false);
+ const expectedTree = prepTreeForComparison(depth1.tree, false);
expect(receivedTree).toEqual(expectedTree);
});
test('should return proper depth 2 tree', async () => {
- const request = buildListRequest(testingDirectoryRelativePath, true, 2);
+ const request = buildListRequest('/', true, 2);
const response = await app.handle(request);
expect(response.status).toBe(200);
const body = (await response.json()) as DirectoryEntry[];
const receivedTree = prepTreeForComparison(body, false);
- const expectedTree = prepTreeForComparison(testingDirectoryTreeDepth2, false);
+ const expectedTree = prepTreeForComparison(depth2.tree, false);
expect(receivedTree).toEqual(expectedTree);
});
test('should return proper depth 3 tree', async () => {
- const request = buildListRequest(testingDirectoryRelativePath, true, 3);
+ const request = buildListRequest('/', true, 3);
const response = await app.handle(request);
expect(response.status).toBe(200);
const body = (await response.json()) as DirectoryEntry[];
const receivedTree = prepTreeForComparison(body, false);
- const expectedTree = prepTreeForComparison(testingDirectoryTreeDepth3, false);
+ const expectedTree = prepTreeForComparison(depth3.tree, false);
expect(receivedTree).toEqual(expectedTree);
});
// TODO: Move to separate API key tests
test('should return 401 when no API key', async () => {
- const request = buildListRequest(testingDirectoryPath, true, 1, true);
+ const request = buildListRequest('/', true, 1, true);
const response = await app.handle(request);
expect(response.status).toBe(401);
});
@@ -116,7 +161,7 @@ describe('getList()', async () => {
});
test('should return 400 when path is not a directory', async () => {
- const request = buildListRequest(`${testingDirectoryPath}/${loremFileName}`, true, 1);
+ const request = buildListRequest('file1.lorem', true, 1);
const response = await app.handle(request);
expect(response.status).toBe(400);
});
diff --git a/src/endpoints/list/getList.ts b/src/endpoints/list/getList.ts
index 373d682..0ccf056 100644
--- a/src/endpoints/list/getList.ts
+++ b/src/endpoints/list/getList.ts
@@ -1,24 +1,21 @@
-import {join} from 'path';
import {flattenFiles, readDirectoryContents} from '../../utils/directoryUtils';
import Elysia from 'elysia';
-import {getConfig} from '../../utils/config';
-import {isPathValid} from '../../utils/pathUtils';
+import {validateRelativePath} from '../../utils/pathUtils';
export function addGetListEndpoint(app: Elysia) {
return app.get('list', async ({query, set}) => {
// Verify that the path is valid
- let relativePath = query.path ? (query.path as string) : null;
- if (!isPathValid(relativePath)) {
+ const validationResult = await validateRelativePath(query.path);
+ if (validationResult.isErr()) {
set.status = 400;
- return 'You must provide a valid path to a directory';
+ return validationResult.error;
}
- relativePath = relativePath as string;
+ const {relativePath, absolutePath} = validationResult.value;
const includeDirectories = query.dirs === 'true';
const depth = query.depth === undefined ? 1 : parseInt(query.depth as string);
- const directoryPath = join(getConfig().dataDir, relativePath);
- const entries = await readDirectoryContents(directoryPath, relativePath, depth);
+ const entries = await readDirectoryContents(absolutePath, relativePath, depth);
// Handle the occurrence of an error
if (typeof entries === 'string') {
diff --git a/src/testing/constants.ts b/src/testing/constants.ts
new file mode 100644
index 0000000..25107f4
--- /dev/null
+++ b/src/testing/constants.ts
@@ -0,0 +1,69 @@
+import {join} from 'path';
+import {FileSystemConstructionEntry} from './testFileSystem';
+
+export const testDirectory = './testdata';
+
+export const illegalPaths = Object.freeze([
+ '..',
+ '../..',
+ '/hello/../world/../..',
+ './..',
+ './../there.jpg',
+]);
+
+/* TEST FILESYSTEM FS1 */
+
+export const fs1Files = {
+ imagination: {
+ name: 'imagination.txt',
+ relativePath: 'TerryPratchett/imagination.txt',
+ absolutePath: join(testDirectory, 'TerryPratchett/imagination.txt'),
+ contents: 'Stories of imagination tend to upset those without one.',
+ },
+ motivcation: {
+ name: 'motivation.txt',
+ relativePath: 'TerryPratchett/motivation.txt',
+ absolutePath: join(testDirectory, 'TerryPratchett/motivation.txt'),
+ contents:
+ "It's not worth doing something unless someone, somewhere, would much rather you weren't doing it.",
+ },
+ startingOver: {
+ name: 'starting-over.txt',
+ relativePath: 'TerryPratchett/starting-over.txt',
+ absolutePath: join(testDirectory, 'TerryPratchett/starting-over.txt'),
+ contents: 'Coming back to where you started is not the same as never leaving.',
+ },
+ future: {
+ name: 'future.md',
+ relativePath: 'FrankHerbert/future.md',
+ absolutePath: join(testDirectory, '/FrankHerbert/future.md'),
+ contents:
+ 'The concept of progress acts as a protective mechanism to shield us from the terrors of the future.',
+ },
+ openMind: {
+ name: 'open-mind.md',
+ relativePath: 'open-mind.md',
+ absolutePath: join(testDirectory, 'open-mind.md'),
+ contents:
+ 'The trouble with having an open mind, of course, is that people will insist on coming along and trying to put things in it.',
+ },
+ emptiness: {
+ name: 'emptiness.txt',
+ relativePath: '/emptiness.txt',
+ absolutePath: join(testDirectory, 'emptiness.txt'),
+ contents: '',
+ },
+};
+
+export const fs1TestDirectoryContents: FileSystemConstructionEntry = {
+ TerryPratchett: {
+ 'imagination.txt': fs1Files.imagination.contents,
+ 'motivation.txt': fs1Files.motivcation.contents,
+ 'starting-over.txt': fs1Files.startingOver.contents,
+ },
+ FrankHerbert: {
+ 'future.md': fs1Files.future.contents,
+ },
+ 'open-mind.md': fs1Files.openMind.contents,
+ '/emptiness.txt': fs1Files.emptiness.contents,
+};
diff --git a/src/testing/testFileSystem.ts b/src/testing/testFileSystem.ts
new file mode 100644
index 0000000..37a8bbc
--- /dev/null
+++ b/src/testing/testFileSystem.ts
@@ -0,0 +1,123 @@
+import {exists, mkdir} from 'fs/promises';
+import {rimraf} from 'rimraf';
+import {setConfig} from '../utils/config';
+import {join} from 'path';
+import {DirectoryEntry} from '../utils/directoryUtils';
+import {testDirectory} from './constants';
+
+export type FileSystemConstructionEntry = {
+ [key: string]: string | FileSystemConstructionEntry;
+};
+
+/**
+ * Creates a test file system with the specified contents.
+ * @param contents A list of files and directories to create inside the test file system.
+ */
+export async function createTestFileSystem(contents: FileSystemConstructionEntry) {
+ setConfig({
+ apiKey: 'test',
+ dataDir: testDirectory,
+ });
+
+ // If the test directory already exists, delete it and all its contents
+ if (await exists(testDirectory)) {
+ if (!rimraf(testDirectory)) {
+ throw new Error('Could not delete the testdata directory');
+ }
+ }
+
+ // Create the test directory
+ await createDirectory(testDirectory);
+
+ // Create the contents
+ await createContents(testDirectory, contents);
+}
+
+export async function destroyTestFileSystem() {
+ await deleteTestDirectory();
+}
+
+export function generateDirectoryEntryCollections(
+ parentDirectory: string,
+ contents: FileSystemConstructionEntry,
+) {
+ const tree: DirectoryEntry[] = [];
+ let flatList: DirectoryEntry[] = [];
+
+ for (const [name, value] of Object.entries(contents)) {
+ const fullPath = join(parentDirectory, name);
+
+ if (typeof value === 'string') {
+ const entry = {
+ name,
+ fullPath,
+ type: 'file',
+ } as DirectoryEntry;
+ tree.push(entry);
+ flatList.push(entry);
+ } else {
+ const subContents = generateDirectoryEntryCollections(
+ fullPath,
+ value as FileSystemConstructionEntry,
+ );
+
+ tree.push({
+ name,
+ fullPath,
+ type: 'dir',
+ contents: subContents.tree ?? [],
+ });
+
+ flatList = [
+ ...flatList,
+ {
+ name,
+ fullPath,
+ type: 'dir',
+ },
+ ...subContents.flatList,
+ ];
+ }
+ }
+
+ return {tree, flatList};
+}
+
+async function deleteTestDirectory() {
+ if (await exists(testDirectory)) {
+ if (!rimraf(testDirectory)) {
+ throw new Error('Could not delete the testdata directory');
+ }
+ }
+}
+
+/**
+ * Creates the contents of a directory.
+ * @param parentDirectory The directory to create the contents in
+ * @param contents The contents to create. This is a list of files and directories to create inside the root directory.
+ */
+async function createContents(parentDirectory: string, contents: FileSystemConstructionEntry) {
+ for (const [name, value] of Object.entries(contents)) {
+ const fullPath = join(parentDirectory, name);
+
+ if (typeof value === 'string') {
+ await createFile(fullPath, value);
+ } else {
+ await createDirectory(fullPath);
+ await createContents(fullPath, value as FileSystemConstructionEntry);
+ }
+ }
+}
+
+/**
+ * Creates a file at the specified path with the specified contents.
+ * @param path The path of the file to create
+ * @param contents The contents of the file
+ */
+async function createFile(path: string, contents: string) {
+ await Bun.write(path, contents);
+}
+
+async function createDirectory(path: string) {
+ await mkdir(path, {recursive: true});
+}
diff --git a/src/testing/testingUtils.test.ts b/src/testing/testingUtils.test.ts
index 5c90f8d..65628e2 100644
--- a/src/testing/testingUtils.test.ts
+++ b/src/testing/testingUtils.test.ts
@@ -1,4 +1,4 @@
-import {DirectoryEntry, flattenFiles, listSorterByPath} from '../utils/directoryUtils';
+import {FileSystemConstructionEntry} from './testFileSystem';
let fakeTreeEntryId = 0;
@@ -11,21 +11,14 @@ let fakeTreeEntryId = 0;
* @returns Returns an object containing the tree, a flat list of all entries, and a flat list of all files
*/
export function getFakeTree(
- parentPath: string,
maxDepth: number,
requiredFiles: number = 0,
requiredDirs: number = 0,
-): {
- tree: DirectoryEntry[] | undefined;
- flatList: DirectoryEntry[];
- flatListNoDirs: DirectoryEntry[];
-} {
+): FileSystemConstructionEntry {
// If ran out of depth, return empty results
- if (maxDepth === 0) return {tree: [], flatList: [], flatListNoDirs: []};
+ if (maxDepth === 0) return {};
- const tree: DirectoryEntry[] = [];
- let flatList: DirectoryEntry[] = [];
- let flatListNoDirs: DirectoryEntry[] = [];
+ const tree: FileSystemConstructionEntry = {};
if (requiredFiles === 0) {
requiredFiles = Math.floor(Math.random() * 4);
@@ -50,37 +43,10 @@ export function getFakeTree(
const extension = type === 'file' ? '.txt' : '';
const name = `${type}-${id}${extension}`;
- // Get fullPath
- parentPath = parentPath.endsWith('/') ? parentPath : `${parentPath}/`;
- const fullPath = `${parentPath}${name}`;
-
// Generate contents
- const contents =
- type === 'dir'
- ? getFakeTree(fullPath, maxDepth - 1)
- : {tree: [], flatList: [], flatListNoDirs: []};
-
- // Create the entry
- let entry: DirectoryEntry = {
- name,
- fullPath,
- type,
- contents: contents.tree,
- };
-
- const entryNoContents: DirectoryEntry = {
- name,
- fullPath,
- type,
- };
-
- if (type === 'file') entry = entryNoContents;
-
- // Add the entry to the tree and the lists
- tree.push(entry);
- flatList = [...flatList, entryNoContents, ...contents.flatList];
- if (type === 'file') flatListNoDirs.push(entryNoContents);
- flatListNoDirs = [...flatListNoDirs, ...contents.flatListNoDirs];
+ const contents = type === 'dir' ? getFakeTree(maxDepth - 1) : {};
+ if (type === 'file') tree[name] = `Contents of file ${name}`;
+ else tree[name] = contents;
// Decrement required files/dirs
if (type === 'file') requiredFiles--;
@@ -90,131 +56,27 @@ export function getFakeTree(
hitsLeft--;
}
- return {tree, flatList, flatListNoDirs};
+ return tree;
}
-export const illegalPaths = ['..', '../..', '/hello/../world/../..', './..', './../there.jpg'];
-
-export const testingDirectoryPath = './testdir';
-export const testingDirectoryRelativePath = '/testdir';
-
-export const pathToNowhere = `${testingDirectoryPath}/invalid/path`;
-export const loremFileName = 'file1.lorem';
-export const loremFilePath = `${testingDirectoryPath}/${loremFileName}`;
-export const loremFileContents =
- 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.';
-export const emptyFileName = 'file2.empty';
-export const emptyFilePath = `${testingDirectoryPath}/${emptyFileName}`;
-export const dir1Name = 'dir1';
-export const dir1path = `${testingDirectoryPath}/${dir1Name}`;
-
-export const testingDirectoryTreeDepth1: DirectoryEntry[] = [
- {
- name: loremFileName,
- fullPath: `${testingDirectoryRelativePath}/${loremFileName}`,
- type: 'file',
- },
- {
- name: emptyFileName,
- fullPath: `${testingDirectoryRelativePath}/${emptyFileName}`,
- type: 'file',
- },
- {
- name: 'dir2',
- fullPath: `${testingDirectoryRelativePath}/dir2`,
- type: 'dir',
- },
- {
- name: dir1Name,
- fullPath: `${testingDirectoryRelativePath}/${dir1Name}`,
- type: 'dir',
- },
-];
-
-export const testingDirectoryTreeDepth2: DirectoryEntry[] = [
- {
- name: loremFileName,
- fullPath: `${testingDirectoryRelativePath}/${loremFileName}`,
- type: 'file',
- },
- {
- name: emptyFileName,
- fullPath: `${testingDirectoryRelativePath}/${emptyFileName}`,
- type: 'file',
- },
- {
- name: 'dir2',
- fullPath: `${testingDirectoryRelativePath}/dir2`,
- type: 'dir',
- contents: [
- {
- name: 'dir3',
- fullPath: `${testingDirectoryRelativePath}/dir2/dir3`,
- type: 'dir',
- },
- ],
- },
- {
- name: dir1Name,
- fullPath: `${testingDirectoryRelativePath}/${dir1Name}`,
- type: 'dir',
- contents: [
- {
- name: 'file3.empty',
- fullPath: `${testingDirectoryRelativePath}/${dir1Name}/file3.empty`,
- type: 'file',
- },
- ],
- },
-];
-
-export const testingDirectoryTreeDepth3: DirectoryEntry[] = [
- {
- name: loremFileName,
- fullPath: `${testingDirectoryRelativePath}/${loremFileName}`,
- type: 'file',
- },
- {
- name: emptyFileName,
- fullPath: `${testingDirectoryRelativePath}/${emptyFileName}`,
- type: 'file',
- },
- {
- name: 'dir2',
- fullPath: `${testingDirectoryRelativePath}/dir2`,
- type: 'dir',
- contents: [
- {
- name: 'dir3',
- fullPath: `${testingDirectoryRelativePath}/dir2/dir3`,
- type: 'dir',
- contents: [
- {
- name: 'file4.empty',
- fullPath: `${testingDirectoryRelativePath}/dir2/dir3/file4.empty`,
- type: 'file',
- },
- ],
- },
- ],
- },
- {
- name: dir1Name,
- fullPath: `${testingDirectoryRelativePath}/${dir1Name}`,
- type: 'dir',
- contents: [
- {
- name: 'file3.empty',
- fullPath: `${testingDirectoryRelativePath}/${dir1Name}/file3.empty`,
- type: 'file',
- },
- ],
- },
-];
+type TupleOfArrays = {[K in keyof T]: T[K][]};
+type ArrayOfTuples = Array<{[K in keyof T]: T[K]}>;
-/**
- * Prepares a file/dirs tree for comparison by flattening it and sorting it by path.
- */
-export function prepTreeForComparison(tree: DirectoryEntry[], excludeDirs: boolean) {
- return flattenFiles(tree, excludeDirs).sort(listSorterByPath);
+export function getPermutations(
+ ...optionSets: TupleOfArrays
+): ArrayOfTuples {
+ if (optionSets.length === 0) return [[]] as ArrayOfTuples;
+
+ const [firstSet, ...restSets] = optionSets;
+ const restPermutations = getPermutations(...restSets);
+
+ const permutations: ArrayOfTuples = [];
+
+ for (const option of firstSet) {
+ for (const restOption of restPermutations) {
+ permutations.push([option, ...restOption] as any);
+ }
+ }
+
+ return permutations;
}
diff --git a/src/utils/directoryUtils.test.ts b/src/utils/directoryUtils.test.ts
index 7deeae3..0754d50 100644
--- a/src/utils/directoryUtils.test.ts
+++ b/src/utils/directoryUtils.test.ts
@@ -1,75 +1,239 @@
-import {describe, expect, test} from 'bun:test';
+import {afterEach, beforeEach, describe, expect, test} from 'bun:test';
import {
DirectoryEntry,
+ checkIfDirectoryExists,
+ createDirectory,
flattenFiles,
listSorterByPath,
listSorterByType,
readDirectoryContents,
} from './directoryUtils';
+import {getFakeTree} from '../testing/testingUtils.test';
import {
- getFakeTree,
- loremFileName,
- testingDirectoryPath,
- testingDirectoryRelativePath,
- testingDirectoryTreeDepth1,
- testingDirectoryTreeDepth2,
- testingDirectoryTreeDepth3,
-} from '../testing/testingUtils.test';
+ FileSystemConstructionEntry,
+ createTestFileSystem,
+ destroyTestFileSystem,
+ generateDirectoryEntryCollections,
+} from '../testing/testFileSystem';
+import {join} from 'path';
+import {testDirectory} from '../testing/constants';
+
+describe('checkIfDirectoryExists()', () => {
+ const testDirectoryContents: FileSystemConstructionEntry = {
+ dir1: {
+ dir2: {},
+ },
+ 'file1.txt': 'hello',
+ dir3: {
+ 'file2.txt': 'there',
+ },
+ };
+
+ beforeEach(async () => {
+ await createTestFileSystem(testDirectoryContents);
+ });
+
+ afterEach(async () => {
+ await destroyTestFileSystem();
+ });
+
+ test('should return true when directory exists', async () => {
+ expect(await checkIfDirectoryExists(testDirectory)).toBe(true);
+ expect(await checkIfDirectoryExists(join(testDirectory, 'dir1'))).toBe(true);
+ expect(await checkIfDirectoryExists(join(testDirectory, 'dir1/dir2'))).toBe(true);
+ expect(await checkIfDirectoryExists(join(testDirectory, 'dir3'))).toBe(true);
+ });
+
+ test('should return false when directory does not exist', async () => {
+ expect(await checkIfDirectoryExists(join(testDirectory, 'dir10'))).toBe(false);
+ expect(await checkIfDirectoryExists(join(testDirectory, 'hello'))).toBe(false);
+ expect(await checkIfDirectoryExists(join(testDirectory, 'nothing/here'))).toBe(false);
+ });
+
+ test('should return false when pointing to a file', async () => {
+ expect(await checkIfDirectoryExists(join(testDirectory, 'file1.txt'))).toBe(false);
+ expect(await checkIfDirectoryExists(join(testDirectory, 'dir3/file2.txt'))).toBe(false);
+ });
+});
+
+describe('createDirectory()', () => {
+ const testDirectoryContents: FileSystemConstructionEntry = {
+ dir1: {
+ file: 'hello',
+ dir2: {
+ beep: 'boop',
+ },
+ },
+ };
+
+ beforeEach(async () => {
+ await createTestFileSystem(testDirectoryContents);
+ });
+
+ afterEach(async () => {
+ await destroyTestFileSystem();
+ });
+
+ test('should properly create a directory when parent exists', async () => {
+ const newDir1 = join(testDirectory, 'newDir');
+ expect(await checkIfDirectoryExists(newDir1)).toBe(false);
+ expect(await createDirectory(newDir1)).toBe(true);
+ expect(await checkIfDirectoryExists(newDir1)).toBe(true);
+
+ const newDir2 = join(testDirectory, 'dir1/somedir');
+ expect(await checkIfDirectoryExists(newDir2)).toBe(false);
+ expect(await createDirectory(newDir2)).toBe(true);
+ expect(await checkIfDirectoryExists(newDir2)).toBe(true);
+
+ const newDir3 = join(testDirectory, 'dir1/dir2/notafile.txt');
+ expect(await checkIfDirectoryExists(newDir3)).toBe(false);
+ expect(await createDirectory(newDir3)).toBe(true);
+ expect(await checkIfDirectoryExists(newDir3)).toBe(true);
+ });
+
+ test("should properly create a directory when parent doesn't exists", async () => {
+ const newDir1 = join(testDirectory, 'newDir/newDir2');
+ expect(await checkIfDirectoryExists(newDir1)).toBe(false);
+ expect(await createDirectory(newDir1)).toBe(true);
+ expect(await checkIfDirectoryExists(newDir1)).toBe(true);
+
+ const newDir2 = join(testDirectory, 'dir1/somedir/yetanotherdir');
+ expect(await checkIfDirectoryExists(newDir2)).toBe(false);
+ expect(await createDirectory(newDir2)).toBe(true);
+ expect(await checkIfDirectoryExists(newDir2)).toBe(true);
+
+ const newDir3 = join(testDirectory, 'dir1/dir2/notafile.txt/anotherdir/again/again/again/ugh');
+ expect(await checkIfDirectoryExists(newDir3)).toBe(false);
+ expect(await createDirectory(newDir3)).toBe(true);
+ expect(await checkIfDirectoryExists(newDir3)).toBe(true);
+
+ const newDir4 = join(testDirectory, 'dir3/beep/boop');
+ expect(await checkIfDirectoryExists(newDir4)).toBe(false);
+ expect(await createDirectory(newDir4)).toBe(true);
+ expect(await checkIfDirectoryExists(newDir4)).toBe(true);
+ });
+
+ test('should return true when directory already exists', async () => {
+ const newDir1 = join(testDirectory, '/');
+ expect(await checkIfDirectoryExists(newDir1)).toBe(true);
+ expect(await createDirectory(newDir1)).toBe(true);
+ expect(await checkIfDirectoryExists(newDir1)).toBe(true);
+ const newDir2 = join(testDirectory, 'dir1');
+ expect(await checkIfDirectoryExists(newDir2)).toBe(true);
+ expect(await createDirectory(newDir2)).toBe(true);
+ expect(await checkIfDirectoryExists(newDir2)).toBe(true);
+ const newDir3 = join(testDirectory, 'dir1/dir2');
+ expect(await checkIfDirectoryExists(newDir3)).toBe(true);
+ expect(await createDirectory(newDir3)).toBe(true);
+ expect(await checkIfDirectoryExists(newDir3)).toBe(true);
+ });
+
+ test('should return false when target is a file', async () => {
+ const newDir1 = join(testDirectory, '/dir1/file');
+ expect(await checkIfDirectoryExists(newDir1)).toBe(false);
+ expect(await createDirectory(newDir1)).toBe(false);
+ expect(await checkIfDirectoryExists(newDir1)).toBe(false);
+ const newDir2 = join(testDirectory, '/dir1/dir2/beep');
+ expect(await checkIfDirectoryExists(newDir2)).toBe(false);
+ expect(await createDirectory(newDir2)).toBe(false);
+ expect(await checkIfDirectoryExists(newDir2)).toBe(false);
+ });
+});
describe('readDirectoryContents()', () => {
+ const testDirectoryContents: FileSystemConstructionEntry = {
+ dir1: {
+ 'file3.empty': '',
+ },
+ dir2: {
+ dir3: {
+ 'file4.empty': '',
+ },
+ },
+ 'file1.lorem': 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.',
+ 'file2.empty': '',
+ };
+
+ const depth3 = generateDirectoryEntryCollections('/', {
+ dir1: {
+ 'file3.empty': '',
+ },
+ dir2: {
+ dir3: {
+ 'file4.empty': '',
+ },
+ },
+ 'file1.lorem': 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.',
+ 'file2.empty': '',
+ } as FileSystemConstructionEntry);
+
+ const depth2 = generateDirectoryEntryCollections('/', {
+ dir1: {
+ 'file3.empty': '',
+ },
+ dir2: {
+ dir3: {},
+ },
+ 'file1.lorem': 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.',
+ 'file2.empty': '',
+ } as FileSystemConstructionEntry);
+
+ const depth1 = generateDirectoryEntryCollections('/', {
+ dir1: {},
+ dir2: {},
+ 'file1.lorem': 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.',
+ 'file2.empty': '',
+ } as FileSystemConstructionEntry);
+
+ beforeEach(async () => {
+ await createTestFileSystem(testDirectoryContents);
+ });
+
+ afterEach(async () => {
+ await destroyTestFileSystem();
+ });
+
test('should return proper depth 1 tree', async () => {
- const actualTree = (await readDirectoryContents(
- testingDirectoryPath,
- testingDirectoryRelativePath,
- 1,
- )) as DirectoryEntry[];
+ const actualTree = (await readDirectoryContents(testDirectory, '/', 1)) as DirectoryEntry[];
// Flatten and sort to make sure the contents are the same
const actualList = flattenFiles(actualTree, false).sort(listSorterByPath);
- const expectedList = flattenFiles(testingDirectoryTreeDepth1, false).sort(listSorterByPath);
+ const expectedList = flattenFiles(depth1.tree, false).sort(listSorterByPath);
// Use only toEqual() as we don't care about the contents to be undefined. It isn't serialized to JSON.
expect(actualList).toEqual(expectedList);
});
+
test('should return proper depth 2 tree', async () => {
- const actualTree = (await readDirectoryContents(
- testingDirectoryPath,
- testingDirectoryRelativePath,
- 2,
- )) as DirectoryEntry[];
+ const actualTree = (await readDirectoryContents(testDirectory, '/', 2)) as DirectoryEntry[];
// Flatten and sort to make sure the contents are the same
const actualList = flattenFiles(actualTree, false).sort(listSorterByPath);
- const expectedList = flattenFiles(testingDirectoryTreeDepth2, false).sort(listSorterByPath);
+ const expectedList = flattenFiles(depth2.tree, false).sort(listSorterByPath);
// Use only toEqual() as we don't care about the contents to be undefined. It isn't serialized to JSON.
expect(actualList).toEqual(expectedList);
});
+
test('should return proper depth 3 tree', async () => {
- const actualTree = (await readDirectoryContents(
- testingDirectoryPath,
- testingDirectoryRelativePath,
- 3,
- )) as DirectoryEntry[];
+ const actualTree = (await readDirectoryContents(testDirectory, '/', 3)) as DirectoryEntry[];
// Flatten and sort to make sure the contents are the same
const actualList = flattenFiles(actualTree, false).sort(listSorterByPath);
- const expectedList = flattenFiles(testingDirectoryTreeDepth3, false).sort(listSorterByPath);
+ const expectedList = flattenFiles(depth3.tree, false).sort(listSorterByPath);
// Use only toEqual() as we don't care about the contents to be undefined. It isn't serialized to JSON.
expect(actualList).toEqual(expectedList);
});
+
test('should return "no-path" when invalid path', async () => {
- const actualTree = await readDirectoryContents(`${testingDirectoryPath}/invalid/path`, '/', 1);
+ const actualTree = await readDirectoryContents(join(testDirectory, '/invalid/path'), '/', 1);
expect(actualTree).toBe('no-path');
});
+
test('should return "no-dir" when path to file', async () => {
- const actualTree = await readDirectoryContents(
- `${testingDirectoryPath}/${loremFileName}`,
- '/',
- 1,
- );
+ const actualTree = await readDirectoryContents(join(testDirectory, 'file1.lorem'), '/', 1);
expect(actualTree).toBe('no-dir');
});
});
describe('flattenFiles()', () => {
test('including directories should return a list of only files', () => {
- const {tree, flatList} = getFakeTree('', 3, 5, 5);
+ const {tree, flatList} = generateDirectoryEntryCollections('/', getFakeTree(3, 5, 5));
flatList.sort(listSorterByPath);
if (tree === undefined) throw new Error('Tree is undefined - testing data is broken');
const flattenedFiles = flattenFiles(tree, false).sort(listSorterByPath);
@@ -77,12 +241,18 @@ describe('flattenFiles()', () => {
});
test('excluding directories should return a list of files/dirs', () => {
- const {tree, flatListNoDirs} = getFakeTree('', 3, 5, 5);
+ const {tree, flatList} = generateDirectoryEntryCollections('/', getFakeTree(3, 5, 5));
+ const flatListNoDirs = flatList.filter(entry => entry.type === 'file');
flatListNoDirs.sort(listSorterByPath);
if (tree === undefined) throw new Error('Tree is undefined - testing data is broken');
const flattenedFiles = flattenFiles(tree, true).sort(listSorterByPath);
expect(flattenedFiles).toStrictEqual(flatListNoDirs);
});
+
+ test('should return an empty list when tree is empty', () => {
+ const flattenedFiles = flattenFiles([], false);
+ expect(flattenedFiles).toStrictEqual([]);
+ });
});
describe('listSorters', () => {
diff --git a/src/utils/directoryUtils.ts b/src/utils/directoryUtils.ts
index 1e44e2b..6383a0e 100644
--- a/src/utils/directoryUtils.ts
+++ b/src/utils/directoryUtils.ts
@@ -1,5 +1,5 @@
import {join} from 'path';
-import {readdir} from 'fs/promises';
+import {mkdir, readdir, stat} from 'fs/promises';
import {Dirent} from 'fs';
export type DirectoryEntry = {
@@ -9,6 +9,42 @@ export type DirectoryEntry = {
contents?: DirectoryEntry[];
};
+/**
+ * Checks if a directory exists.
+ * @param directoryPath Path to the directory.
+ */
+export async function checkIfDirectoryExists(directoryPath: PathLike): Promise {
+ try {
+ const stats = await stat(directoryPath);
+ return stats.isDirectory();
+ } catch (err) {
+ if ((err as {code: string}).code === 'ENOENT') {
+ return false;
+ } else {
+ throw err;
+ }
+ }
+}
+
+/**
+ * Creates a directory at the specified path. If the directory already exists, it does nothing.
+ * @param directoryPath Path to the directory to be created.
+ */
+export async function createDirectory(directoryPath: PathLike): Promise {
+ // Check if the path is pointing to a file
+ try {
+ const stats = await stat(directoryPath);
+ if (stats.isFile()) {
+ return false;
+ }
+ } catch (err) {
+ // Just continue if the file does not exist
+ }
+
+ const success = await mkdir(directoryPath, {recursive: true});
+ return success != null;
+}
+
/**
* Reads the contents of a directory and returns a list of directory entries in a tree structure.
* @param directoryPath Path to the directory to read
@@ -44,7 +80,7 @@ export async function readDirectoryContents(
// If the entry is a directory and we are not at the maximum depth
// then we need to recursively read the contents of the directory
- let contents = undefined;
+ let contents: DirectoryEntry[] | string = [];
if (isDirectory && depth > 1) {
contents = await readDirectoryContents(join(directoryPath, entry.name), fullPath, depth - 1);
// If contents are of type string, then we have an error
@@ -57,7 +93,7 @@ export async function readDirectoryContents(
name: entry.name,
fullPath: join(relativePath, entry.name),
type: isDirectory ? 'dir' : 'file',
- contents,
+ contents: isDirectory ? contents : undefined,
});
}
@@ -73,7 +109,7 @@ export function flattenFiles(entries: DirectoryEntry[], excludeDirs: boolean): D
return entries.reduce((acc: DirectoryEntry[], curr: DirectoryEntry) => {
if (curr.type === 'file') {
return [...acc, curr];
- } else if (curr.type === 'dir' && curr.contents) {
+ } else if (curr.type === 'dir' && curr.contents != null) {
if (!excludeDirs) {
const currentNoContents = {
name: curr.name,
diff --git a/src/utils/fileUtils.test.ts b/src/utils/fileUtils.test.ts
index c05ee8c..172f536 100644
--- a/src/utils/fileUtils.test.ts
+++ b/src/utils/fileUtils.test.ts
@@ -1,37 +1,209 @@
-import {describe, expect, test} from 'bun:test';
-import {
- dir1path,
- emptyFilePath,
- loremFileContents,
- loremFilePath,
- pathToNowhere,
-} from '../testing/testingUtils.test';
-import {getFile} from './fileUtils';
+import {afterEach, beforeEach, describe, expect, test} from 'bun:test';
+import {getFile, writeFile} from './fileUtils';
+import {createTestFileSystem, destroyTestFileSystem} from '../testing/testFileSystem';
+import {fs1Files, fs1TestDirectoryContents, testDirectory} from '../testing/constants';
+import {join} from 'path';
+import assert from 'assert';
+import {getPermutations} from '../testing/testingUtils.test';
describe('getFile()', () => {
+ const files = fs1Files;
+ const filesList = Object.values(files);
+ const testDirectoryContents = fs1TestDirectoryContents;
+
+ beforeEach(async () => {
+ await createTestFileSystem(testDirectoryContents);
+ });
+
+ afterEach(async () => {
+ await destroyTestFileSystem();
+ });
+
test('should return a file reference if the file is found', async () => {
- const file = await getFile(loremFilePath);
- expect(file).not.toBeNull();
- expect(file?.size).toBe(loremFileContents.length);
- const contents = await file?.text();
- expect(contents).toBe(loremFileContents);
+ for (const fileDef of filesList) {
+ const file = await getFile(fileDef.absolutePath);
+ expect(file).not.toBeNull();
+ expect(file?.size).toBe(fileDef.contents.length);
+ const contents = await file?.text();
+ expect(contents).toBe(fileDef.contents);
+ }
});
test('should return null if the file is not found', async () => {
- const file = await getFile(pathToNowhere);
- expect(file).toBeNull();
+ const file = await getFile('path/to/nowhere');
+ expect(await getFile('/path/to/nowhere')).toBeNull();
+ expect(await getFile('error.log')).toBeNull();
});
test('should return null if the path is not pointing to a file', async () => {
- const file = await getFile(dir1path);
- expect(file).toBeNull();
+ expect(await getFile('/TerryPratchett')).toBeNull();
+ expect(await getFile('TerryPratchett')).toBeNull();
+ expect(await getFile('/')).toBeNull();
+ expect(await getFile('FrankHerbert')).toBeNull();
+ expect(await getFile('/FrankHerbert')).toBeNull();
});
test('should return a file even if empty', async () => {
- const file = await getFile(emptyFilePath);
+ const file = await getFile(files.emptiness.absolutePath);
expect(file).not.toBeNull();
expect(file?.size).toBe(0);
const contents = await file?.text();
expect(contents).toBe('');
});
});
+
+describe('writeFile()', () => {
+ const files = fs1Files;
+ const filesList = Object.values(files);
+ const testDirectoryContents = fs1TestDirectoryContents;
+
+ beforeEach(async () => {
+ await createTestFileSystem(testDirectoryContents);
+ });
+
+ afterEach(async () => {
+ await destroyTestFileSystem();
+ });
+
+ test.each(
+ getPermutations(
+ [
+ {
+ absolutePath: join(testDirectory, '/.newFile.txt'),
+ contents: 'This is a new file.',
+ },
+ {
+ absolutePath: join(testDirectory, 'TerryPratchett/no-extension-file'),
+ contents: '123654789987654321',
+ },
+ ],
+ [false, true],
+ ),
+ )('should write a new file to the file system', async (fileToWrite, overwrite) => {
+ const result = await writeFile(
+ fileToWrite.absolutePath,
+ new Blob([fileToWrite.contents], {type: 'text/plain'}),
+ overwrite,
+ );
+ expect(result.isOk()).toBe(true);
+ assert(result.isOk());
+ expect(result.value).toBe(true);
+ const file = await getFile(fileToWrite.absolutePath);
+ expect(file).not.toBeNull();
+ expect(file?.size).toBe(fileToWrite.contents.length);
+ const contents = await file?.text();
+ expect(contents).toBe(fileToWrite.contents);
+ });
+
+ test.each(getPermutations(filesList))(
+ 'should fail to overwrite a file if not forced',
+ async fileToWrite => {
+ const result = await writeFile(
+ fileToWrite.absolutePath,
+ new Blob(['bunch of nothing'], {type: 'text / plain'}),
+ false,
+ );
+ expect(result.isErr()).toBe(true);
+
+ // Check if the file is still intact
+ const file = await getFile(fileToWrite.absolutePath);
+ expect(file).not.toBeNull();
+ expect(file?.size).toBe(fileToWrite.contents.length);
+ const contents = await file?.text();
+ expect(contents).toBe(fileToWrite.contents);
+ },
+ );
+
+ test.each(getPermutations(filesList))('should overwrite a file if forced', async fileToWrite => {
+ const newContents = 'bunch of nothing';
+ const result = await writeFile(
+ fileToWrite.absolutePath,
+ new Blob([newContents], {type: 'text / plain'}),
+ true, // <- overwrite
+ );
+ expect(result.isOk()).toBe(true);
+ assert(result.isOk());
+ expect(result.value).toBe(true);
+ const file = await getFile(fileToWrite.absolutePath);
+ expect(file).not.toBeNull();
+ expect(file?.size).toBe(newContents.length);
+ const contents = await file?.text();
+ expect(contents).toBe(newContents);
+ });
+
+ test.each(
+ getPermutations([join(testDirectory, 'TerryPratchett'), join(testDirectory, 'FrankHerbert')]),
+ )('should refuse to write the same file as a directory name', async directory => {
+ const result1 = await writeFile(
+ directory,
+ new Blob(['bunch of nothing'], {type: 'text / plain'}),
+ false,
+ );
+ expect(result1.isErr()).toBe(true);
+ // Check it's still a directory
+ const file1 = await getFile(directory);
+ expect(file1).toBeNull();
+ // With overwrite
+ const result2 = await writeFile(
+ directory,
+ new Blob(['bunch of nothing'], {type: 'text / plain'}),
+ true,
+ );
+ expect(result2.isErr()).toBe(true);
+ // Check it's still a directory
+ const file2 = await getFile(directory);
+ expect(file2).toBeNull();
+ });
+
+ test.each(
+ getPermutations(
+ [
+ {
+ absolutePath: join(testDirectory, '/.newFile.txt'),
+ contents: 'This is a new file.',
+ },
+ {
+ absolutePath: join(testDirectory, 'TerryPratchett/no-extension-file'),
+ contents: '123654789987654321',
+ },
+ ],
+ [false, true],
+ ),
+ )('should create the directory if it does not exist', async (fileToWrite, overwrite) => {
+ const result = await writeFile(
+ fileToWrite.absolutePath,
+ new Blob([fileToWrite.contents], {type: 'text/plain'}),
+ overwrite,
+ );
+ expect(result.isOk()).toBe(true);
+ assert(result.isOk());
+ expect(result.value).toBe(true);
+ const file = await getFile(fileToWrite.absolutePath);
+ expect(file).not.toBeNull();
+ expect(file?.size).toBe(fileToWrite.contents.length);
+ const contents = await file?.text();
+ expect(contents).toBe(fileToWrite.contents);
+ });
+
+ test.each(
+ getPermutations(
+ [
+ {
+ absolutePath: join(testDirectory, 'empty/string.txt'),
+ contents: new Blob([''], {type: 'text/plain'}),
+ },
+ {
+ absolutePath: join(testDirectory, 'nil/null/nic/nothing/nada.jpg'),
+ contents: new Blob(),
+ },
+ ],
+ [false, true],
+ ),
+ )('should not allow creating empty files', async (fileToWrite, overwrite) => {
+ const result = await writeFile(fileToWrite.absolutePath, fileToWrite.contents, overwrite);
+ expect(result.isErr()).toBe(true);
+ // Check if the file is not created
+ const file = await getFile(fileToWrite.absolutePath);
+ expect(file).toBeNull();
+ });
+});
diff --git a/src/utils/fileUtils.ts b/src/utils/fileUtils.ts
index 1879ed2..28137e1 100644
--- a/src/utils/fileUtils.ts
+++ b/src/utils/fileUtils.ts
@@ -1,6 +1,20 @@
import {BunFile} from 'bun';
import Bun from 'bun';
+import {Result, err, ok} from 'neverthrow';
+import {
+ dirCreateFailMsg,
+ fileAlreadyExistsMsg,
+ fileMustNotEmptyMsg,
+ unknownErrorMsg,
+} from '../constants/commonResponses';
+import {dirname} from 'path';
+import {checkIfDirectoryExists, createDirectory} from './directoryUtils';
+/**
+ * Gets a file from the specified path.
+ * @param path The path to the file.
+ * @returns Returns the file if it exists, or null if it does not.
+ */
export async function getFile(path: string): Promise {
const file = Bun.file(path);
if (!(await file.exists())) {
@@ -8,3 +22,43 @@ export async function getFile(path: string): Promise {
}
return file;
}
+
+/**
+ * Writes a file to the specified path. Creates the directory if it does not exist.
+ * @param absolutePath The absolute path to the file to write.
+ * @param contents The contents of the file. Can't be empty.
+ * @param overwrite Whether to overwrite the file if it already exists.
+ * @returns Returns true if the file was written successfully, or a string if there was an error.
+ */
+export async function writeFile(
+ absolutePath: string,
+ contents: Blob,
+ overwrite: boolean,
+): Promise> {
+ // Check if contents is empty
+ if (contents.size == null || contents.size === 0) {
+ return err(fileMustNotEmptyMsg);
+ }
+
+ // Check if file exists
+ const existingFile = await getFile(absolutePath);
+ if (existingFile && !overwrite) {
+ return err(fileAlreadyExistsMsg);
+ }
+
+ try {
+ // Create the directory if it does not exist
+ const directoryPath = dirname(absolutePath);
+ if (!(await checkIfDirectoryExists(directoryPath)) && !(await createDirectory(directoryPath))) {
+ return err(dirCreateFailMsg);
+ }
+ // Write the file
+ await Bun.write(absolutePath, contents);
+ return ok(true);
+ } catch (error) {
+ if (error instanceof Error) {
+ return err(error.message);
+ }
+ return err(unknownErrorMsg);
+ }
+}
diff --git a/src/utils/pathUtils.test.ts b/src/utils/pathUtils.test.ts
index 99b7cfa..5dccc14 100644
--- a/src/utils/pathUtils.test.ts
+++ b/src/utils/pathUtils.test.ts
@@ -1,36 +1,67 @@
-import {expect, test} from 'bun:test';
-import {dir1path, emptyFilePath, illegalPaths, loremFilePath} from '../testing/testingUtils.test';
-import {isPathValid} from './pathUtils';
-
-test('should return true when path is valid', () => {
- const validPaths = [
- 'a',
- 'a/b',
- 'a/b/c',
- 'a/b/c/d.png',
- './',
- './a',
- './a/b.jpg',
- loremFilePath,
- emptyFilePath,
- dir1path,
- ];
-
- for (const validPath of validPaths) {
- expect(isPathValid(validPath)).toBe(true);
- }
-});
+import {beforeEach, describe, expect, test} from 'bun:test';
+import {isPathValid, validateRelativePath} from './pathUtils';
+import {getConfig, setConfig} from './config';
+import assert from 'assert';
+import {join} from 'path';
+import {illegalPaths} from '../testing/constants';
-test('should return false when path is null', () => {
- expect(isPathValid(null)).toBe(false);
+beforeEach(() => {
+ // Update config
+ setConfig({
+ apiKey: 'test',
+ dataDir: './',
+ });
});
-test('should return false when path is undefined', () => {
- expect(isPathValid(undefined)).toBe(false);
+describe('isPathValid', () => {
+ test('should return true when path is valid', () => {
+ const validPaths = ['a', 'a/b', 'a/b/c', 'a/b/c/d.png', './', './a', './a/b.jpg'];
+
+ for (const validPath of validPaths) {
+ expect(isPathValid(validPath)).toBe(true);
+ }
+ });
+
+ test('should return false when path is null', () => {
+ expect(isPathValid(null)).toBe(false);
+ });
+
+ test('should return false when path is undefined', () => {
+ expect(isPathValid(undefined)).toBe(false);
+ });
+
+ test('should return false when path is invalid', () => {
+ for (const illegalPath of illegalPaths) {
+ expect(isPathValid(illegalPath)).toBe(false);
+ }
+ });
});
-test('should return false when path is invalid', () => {
- for (const illegalPath of illegalPaths) {
- expect(isPathValid(illegalPath)).toBe(false);
- }
+describe('validateRelativePath', () => {
+ test('should return an error when path is null', () => {
+ expect(validateRelativePath(null).isErr()).toBe(true);
+ });
+
+ test('should return an error when path is undefined', () => {
+ expect(validateRelativePath(undefined).isErr()).toBe(true);
+ });
+
+ test('should return an error when path is invalid', () => {
+ for (const illegalPath of illegalPaths) {
+ expect(validateRelativePath(illegalPath).isErr()).toBe(true);
+ }
+ });
+
+ test('should return a path data object when path is valid', () => {
+ const validPaths = ['a', 'a/b', 'a/b/c', 'a/b/c/d.png', './', './a', './a/b.jpg'];
+
+ for (const validPath of validPaths) {
+ const result = validateRelativePath(validPath);
+ expect(result.isOk()).toBe(true);
+ assert(result.isOk());
+ const {relativePath, absolutePath} = result.value;
+ expect(relativePath).toBe(validPath);
+ expect(absolutePath).toBe(join(getConfig().dataDir, validPath));
+ }
+ });
});
diff --git a/src/utils/pathUtils.ts b/src/utils/pathUtils.ts
index 8881ca9..578935e 100644
--- a/src/utils/pathUtils.ts
+++ b/src/utils/pathUtils.ts
@@ -1,4 +1,31 @@
-export function isPathValid(path: string | null | undefined) {
+import {Result, err, ok} from 'neverthrow';
+import {provideValidPathMsg} from '../constants/commonResponses';
+import {join} from 'path';
+import {getConfig} from './config';
+
+export type PathData = {
+ relativePath: string;
+ absolutePath: string;
+};
+
+export function isPathValid(path: string | null | undefined | unknown) {
// TODO: Add proper checks for path validity
+ if (!path || typeof path !== 'string') {
+ return false;
+ }
return path != null && !path.includes('..');
}
+
+export function validateRelativePath(
+ path: string | null | undefined | unknown,
+): Result {
+ // Verify the path
+ if (!isPathValid(path)) {
+ return err(provideValidPathMsg);
+ }
+
+ // Get the full path to the file
+ const relativePath = path as string;
+ const absolutePath = join(getConfig().dataDir, relativePath);
+ return ok({relativePath, absolutePath});
+}
diff --git a/src/utils/requestBuilder.ts b/src/utils/requestBuilder.ts
index 6175bee..17b8dce 100644
--- a/src/utils/requestBuilder.ts
+++ b/src/utils/requestBuilder.ts
@@ -6,6 +6,7 @@ export class RequestBuilder {
private method: RequestInit['method'] = 'GET';
private urlBuilder: UrlBuilder;
private apiKeyHeader = true;
+ private body: BodyInit | null = null;
constructor(url: string | URL | UrlBuilder) {
if (typeof url === 'string' || url instanceof URL) {
@@ -44,6 +45,14 @@ export class RequestBuilder {
return this;
}
+ /**
+ * Sets the request body.
+ */
+ setBody(body: BodyInit) {
+ this.body = body;
+ return this;
+ }
+
/**
* Out out the `apikey` header.
*/
@@ -66,10 +75,10 @@ export class RequestBuilder {
*/
build() {
let url = this.urlBuilder.build();
- if (url === '') url = 'hello.jpg';
const request = new Request(url, {
method: this.method,
headers: this.headers,
+ body: this.body,
});
if (this.apiKeyHeader) {