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) {