diff --git a/.github/COMMIT_CONVENTION.md b/.github/COMMIT_CONVENTION.md deleted file mode 100644 index fc852af..0000000 --- a/.github/COMMIT_CONVENTION.md +++ /dev/null @@ -1,70 +0,0 @@ -## Git Commit Message Convention - -> This is adapted from [Angular's commit convention](https://github.com/conventional-changelog/conventional-changelog/tree/master/packages/conventional-changelog-angular). - -Using conventional commit messages, we can automate the process of generating the CHANGELOG file. All commits messages will automatically be validated against the following regex. - -``` js -/^(revert: )?(feat|fix|docs|style|refactor|perf|test|workflow|ci|chore|types|build|improvement)((.+))?: .{1,50}/ -``` - -## Commit Message Format -A commit message consists of a **header**, **body** and **footer**. The header has a **type**, **scope** and **subject**: - -> The **scope** is optional - -``` -feat(router): add support for prefix - -Prefix makes it easier to append a path to a group of routes -``` - -1. `feat` is type. -2. `router` is scope and is optional -3. `add support for prefix` is the subject -4. The **body** is followed by a blank line. -5. The optional **footer** can be added after the body, followed by a blank line. - -## Types -Only one type can be used at a time and only following types are allowed. - -- feat -- fix -- docs -- style -- refactor -- perf -- test -- workflow -- ci -- chore -- types -- build - -If a type is `feat`, `fix` or `perf`, then the commit will appear in the CHANGELOG.md file. However if there is any BREAKING CHANGE, the commit will always appear in the changelog. - -### Revert -If the commit reverts a previous commit, it should begin with `revert:`, followed by the header of the reverted commit. In the body it should say: `This reverts commit `., where the hash is the SHA of the commit being reverted. - -## Scope -The scope could be anything specifying place of the commit change. For example: `router`, `view`, `querybuilder`, `database`, `model` and so on. - -## Subject -The subject contains succinct description of the change: - -- use the imperative, present tense: "change" not "changed" nor "changes". -- don't capitalize first letter -- no dot (.) at the end - -## Body - -Just as in the **subject**, use the imperative, present tense: "change" not "changed" nor "changes". -The body should include the motivation for the change and contrast this with previous behavior. - -## Footer - -The footer should contain any information about **Breaking Changes** and is also the place to -reference GitHub issues that this commit **Closes**. - -**Breaking Changes** should start with the word `BREAKING CHANGE:` with a space or two newlines. The rest of the commit message is then used for this. - diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md deleted file mode 100644 index f0c5446..0000000 --- a/.github/CONTRIBUTING.md +++ /dev/null @@ -1,46 +0,0 @@ -# Contributing - -AdonisJS is a community driven project. You are free to contribute in any of the following ways. - -- [Coding style](coding-style) -- [Fix bugs by creating PR's](fix-bugs-by-creating-prs) -- [Share an RFC for new features or big changes](share-an-rfc-for-new-features-or-big-changes) -- [Report security issues](report-security-issues) -- [Be a part of the community](be-a-part-of-community) - -## Coding style - -Majority of AdonisJS core packages are written in Typescript. Having a brief knowledge of Typescript is required to contribute to the core. - -## Fix bugs by creating PR's - -We appreciate every time you report a bug in the framework or related libraries. However, taking time to submit a PR can help us in fixing bugs quickly and ensure a healthy and stable eco-system. - -Go through the following points, before creating a new PR. - -1. Create an issue discussing the bug or short-coming in the framework. -2. Once approved, go ahead and fork the REPO. -3. Make sure to start from the `develop`, since this is the upto date branch. -4. Make sure to keep commits small and relevant. -5. We follow [conventional-commits](https://github.com/conventional-changelog/conventional-changelog) to structure our commit messages. Instead of running `git commit`, you must run `npm commit`, which will show you prompts to create a valid commit message. -6. Once done with all the changes, create a PR against the `develop` branch. - -## Share an RFC for new features or big changes - -Sharing PR's for small changes works great. However, when contributing big features to the framework, it is required to go through the RFC process. - -### What is an RFC? - -RFC stands for **Request for Commits**, a standard process followed by many other frameworks including [Ember](https://github.com/emberjs/rfcs), [yarn](https://github.com/yarnpkg/rfcs) and [rust](https://github.com/rust-lang/rfcs). - -In brief, RFC process allows you to talk about the changes with everyone in the community and get a view of the core team before dedicating your time to work on the feature. - -The RFC proposals are created as Pull Request on [adonisjs/rfcs](https://github.com/adonisjs/rfcs) repo. Make sure to read the README to learn about the process in depth. - -## Report security issues - -All of the security issues, must be reported via [email](mailto:virk@adonisjs.com) and not using any of the public channels. - -## Be a part of community - -We welcome you to participate in [GitHub Discussion](https://github.com/adonisjs/core/discussions) and the AdonisJS [Discord Server](https://discord.gg/vDcEjq6). You are free to ask your questions and share your work or contributions made to AdonisJS eco-system. diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md deleted file mode 100644 index e65000c..0000000 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -name: Bug report -about: Report identified bugs ---- - - - -## Prerequisites - -We do our best to reply to all the issues on time. If you will follow the given guidelines, the turn around time will be faster. - -- Lots of raised issues are directly not bugs but instead are design decisions taken by us. -- Make use of our [GH discussions](https://github.com/adonisjs/core/discussions), or [discord server](https://discord.me/adonisjs), if you are not sure that you are reporting a bug. -- Ensure the issue isn't already reported. -- Ensure you are reporting the bug in the correct repo. - -*Delete the above section and the instructions in the sections below before submitting* - -## Package version - - -## Node.js and npm version - - -## Sample Code (to reproduce the issue) - - -## BONUS (a sample repo to reproduce the issue) - diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md deleted file mode 100644 index abd44a5..0000000 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -name: Feature request -about: Propose changes for adding a new feature ---- - - - -## Prerequisites - -We do our best to reply to all the issues on time. If you will follow the given guidelines, the turn around time will be faster. - -## Consider an RFC - -Please create an [RFC](https://github.com/adonisjs/rfcs) instead, if - -- Feature introduces a breaking change -- Demands lots of time and changes in the current code base. - -*Delete the above section and the instructions in the sections below before submitting* - -## Why this feature is required (specific use-cases will be appreciated)? - - -## Have you tried any other work arounds? - - -## Are you willing to work on it with little guidance? - diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md deleted file mode 100644 index 4ff691e..0000000 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ /dev/null @@ -1,28 +0,0 @@ - - -## Proposed changes - -Describe the big picture of your changes here to communicate to the maintainers why we should accept this pull request. If it fixes a bug or resolves a feature request, be sure to link to that issue. - -## Types of changes - -What types of changes does your code introduce? - -_Put an `x` in the boxes that apply_ - -- [ ] Bugfix (non-breaking change which fixes an issue) -- [ ] New feature (non-breaking change which adds functionality) -- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) - -## Checklist - -_Put an `x` in the boxes that apply. You can also fill these out after creating the PR. If you're unsure about any of them, don't hesitate to ask. We're here to help! This is simply a reminder of what we are going to look for before merging your code._ - -- [ ] I have read the [CONTRIBUTING](https://github.com/adonisjs/bodyparser/blob/master/.github/CONTRIBUTING.md) doc -- [ ] Lint and unit tests pass locally with my changes -- [ ] I have added tests that prove my fix is effective or that my feature works. -- [ ] I have added necessary documentation (if appropriate) - -## Further comments - -If this is a relatively large or complex change, kick off the discussion by explaining why you chose the solution you did and what alternatives you considered, etc... diff --git a/.github/labels.json b/.github/labels.json new file mode 100644 index 0000000..ba001c6 --- /dev/null +++ b/.github/labels.json @@ -0,0 +1,170 @@ +[ + { + "name": "Priority: Critical", + "color": "ea0056", + "description": "The issue needs urgent attention", + "aliases": [] + }, + { + "name": "Priority: High", + "color": "5666ed", + "description": "Look into this issue before picking up any new work", + "aliases": [] + }, + { + "name": "Priority: Medium", + "color": "f4ff61", + "description": "Try to fix the issue for the next patch/minor release", + "aliases": [] + }, + { + "name": "Priority: Low", + "color": "87dfd6", + "description": "Something worth considering, but not a top priority for the team", + "aliases": [] + }, + { + "name": "Semver: Alpha", + "color": "008480", + "description": "Will make it's way to the next alpha version of the package", + "aliases": [] + }, + { + "name": "Semver: Major", + "color": "ea0056", + "description": "Has breaking changes", + "aliases": [] + }, + { + "name": "Semver: Minor", + "color": "fbe555", + "description": "Mainly new features and improvements", + "aliases": [] + }, + { + "name": "Semver: Next", + "color": "5666ed", + "description": "Will make it's way to the bleeding edge version of the package", + "aliases": [] + }, + { + "name": "Semver: Patch", + "color": "87dfd6", + "description": "A bug fix", + "aliases": [] + }, + { + "name": "Status: Abandoned", + "color": "ffffff", + "description": "Dropped and not into consideration", + "aliases": ["wontfix"] + }, + { + "name": "Status: Accepted", + "color": "e5fbf2", + "description": "The proposal or the feature has been accepted for the future versions", + "aliases": [] + }, + { + "name": "Status: Blocked", + "color": "ea0056", + "description": "The work on the issue or the PR is blocked. Check comments for reasoning", + "aliases": [] + }, + { + "name": "Status: Completed", + "color": "008672", + "description": "The work has been completed, but not released yet", + "aliases": [] + }, + { + "name": "Status: In Progress", + "color": "73dbc4", + "description": "Still banging the keyboard", + "aliases": ["in progress"] + }, + { + "name": "Status: On Hold", + "color": "f4ff61", + "description": "The work was started earlier, but is on hold now. Check comments for reasoning", + "aliases": ["On Hold"] + }, + { + "name": "Status: Review Needed", + "color": "fbe555", + "description": "Review from the core team is required before moving forward", + "aliases": [] + }, + { + "name": "Status: Awaiting More Information", + "color": "89f8ce", + "description": "Waiting on the issue reporter or PR author to provide more information", + "aliases": [] + }, + { + "name": "Status: Need Contributors", + "color": "7057ff", + "description": "Looking for contributors to help us move forward with this issue or PR", + "aliases": [] + }, + { + "name": "Type: Bug", + "color": "ea0056", + "description": "The issue has indentified a bug", + "aliases": ["bug"] + }, + { + "name": "Type: Security", + "color": "ea0056", + "description": "Spotted security vulnerability and is a top priority for the core team", + "aliases": [] + }, + { + "name": "Type: Duplicate", + "color": "00837e", + "description": "Already answered or fixed previously", + "aliases": ["duplicate"] + }, + { + "name": "Type: Enhancement", + "color": "89f8ce", + "description": "Improving an existing feature", + "aliases": ["enhancement"] + }, + { + "name": "Type: Feature Request", + "color": "483add", + "description": "Request to add a new feature to the package", + "aliases": [] + }, + { + "name": "Type: Invalid", + "color": "dbdbdb", + "description": "Doesn't really belong here. Maybe use discussion threads?", + "aliases": ["invalid"] + }, + { + "name": "Type: Question", + "color": "eceafc", + "description": "Needs clarification", + "aliases": ["help wanted", "question"] + }, + { + "name": "Type: Documentation Change", + "color": "7057ff", + "description": "Documentation needs some improvements", + "aliases": ["documentation"] + }, + { + "name": "Type: Dependencies Update", + "color": "00837e", + "description": "Bump dependencies", + "aliases": ["dependencies"] + }, + { + "name": "Good First Issue", + "color": "008480", + "description": "Want to contribute? Just filter by this label", + "aliases": ["good first issue"] + } +] diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml new file mode 100644 index 0000000..c27fb04 --- /dev/null +++ b/.github/workflows/checks.yml @@ -0,0 +1,14 @@ +name: checks +on: + - push + - pull_request + +jobs: + test: + uses: adonisjs/.github/.github/workflows/test.yml@main + + lint: + uses: adonisjs/.github/.github/workflows/lint.yml@main + + typecheck: + uses: adonisjs/.github/.github/workflows/typecheck.yml@main diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml deleted file mode 100644 index 788df91..0000000 --- a/.github/workflows/test.yml +++ /dev/null @@ -1,39 +0,0 @@ -name: test -on: - - push - - pull_request -jobs: - linux: - runs-on: ubuntu-latest - strategy: - matrix: - node-version: - - 14.15.4 - - 17.x - steps: - - uses: actions/checkout@v2 - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v1 - with: - node-version: ${{ matrix.node-version }} - - name: Install - run: npm install - - name: Run tests - run: npm test - windows: - runs-on: windows-latest - strategy: - matrix: - node-version: - - 14.15.4 - - 17.x - steps: - - uses: actions/checkout@v2 - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v1 - with: - node-version: ${{ matrix.node-version }} - - name: Install - run: npm install - - name: Run tests - run: npm test diff --git a/.husky/commit-msg b/.husky/commit-msg index 4654c12..4002db7 100755 --- a/.husky/commit-msg +++ b/.husky/commit-msg @@ -1,3 +1,4 @@ -#!/bin/sh -. "$(dirname "$0")/_/husky.sh" -HUSKY_GIT_PARAMS=$1 node ./node_modules/@adonisjs/mrm-preset/validate-commit/conventional/validate.js +#!/usr/bin/env sh +. "$(dirname -- "$0")/_/husky.sh" + +npx --no -- commitlint --edit diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index 41fd91d..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,193 +0,0 @@ - -## [2.0.9](https://github.com/adonisjs/adonis-bodyparser/compare/v2.0.8...v2.0.9) (2018-10-16) - - -### Bug Fixes - -* **multipart:** do not process file when filename is empty ([63a113d](https://github.com/adonisjs/adonis-bodyparser/commit/63a113d)) - - - - -## [2.0.8](https://github.com/adonisjs/adonis-bodyparser/compare/v2.0.7...v2.0.8) (2018-10-16) - - -### Bug Fixes - -* **bodyparser:** set request.body after parsing the request daya ([c06fdc7](https://github.com/adonisjs/adonis-bodyparser/commit/c06fdc7)) - - - - -## [2.0.7](https://github.com/adonisjs/adonis-bodyparser/compare/v2.0.6...v2.0.7) (2018-10-16) - - -### Features - -* **file:** expose public runValidations method ([a362492](https://github.com/adonisjs/adonis-bodyparser/commit/a362492)) -* **requestMacro:** add files macro to access all files as an object ([a11aaff](https://github.com/adonisjs/adonis-bodyparser/commit/a11aaff)) -* **validations:** extend validator with files validations ([d0d0b68](https://github.com/adonisjs/adonis-bodyparser/commit/d0d0b68)) - - - - -## [2.0.6](https://github.com/adonisjs/adonis-bodyparser/compare/2.0.5...2.0.6) (2018-10-11) - - -### Features - -* **move:** accept option to overwrite the existing file ([41b661f](https://github.com/adonisjs/adonis-bodyparser/commit/41b661f)) - - - - -## [2.0.5](https://github.com/adonisjs/adonis-bodyparser/compare/v2.0.4...v2.0.5) (2018-09-29) - -### Bug Fixes - -* **dependency**: media-typer shouldn't be a devDeps ([7b465f7](https://github.com/adonisjs/adonis-bodyparser/commit/7b465f71a2195b24a49beb782695f3b9cf9fd584)) - - - -## [2.0.4](https://github.com/adonisjs/adonis-bodyparser/compare/v2.0.3...v2.0.4) (2018-07-16) - - -### Features - -* **bodyparser:** set raw body along with parsed body ([d686e61](https://github.com/adonisjs/adonis-bodyparser/commit/d686e61)), closes [#11](https://github.com/adonisjs/adonis-bodyparser/issues/11) -* **file:** add support for extension validation ([96cfb0c](https://github.com/adonisjs/adonis-bodyparser/commit/96cfb0c)), closes [#9](https://github.com/adonisjs/adonis-bodyparser/issues/9) - - - - -## [2.0.3](https://github.com/adonisjs/adonis-bodyparser/compare/v2.0.1...v2.0.3) (2018-04-26) - - -### Bug Fixes - -* **qs:** pass queryString config to co-body ([2b4e5ec](https://github.com/adonisjs/adonis-bodyparser/commit/2b4e5ec)) - - - - -## [2.0.2](https://github.com/adonisjs/adonis-bodyparser/compare/v2.0.1...v2.0.2) (2018-02-07) - - - - -## [2.0.1](https://github.com/adonisjs/adonis-bodyparser/compare/v2.0.0...v2.0.1) (2018-01-10) - - -### Bug Fixes - -* **multipart:** ignore fields with no name ([6cc93e2](https://github.com/adonisjs/adonis-bodyparser/commit/6cc93e2)), closes [#3](https://github.com/adonisjs/adonis-bodyparser/issues/3) - - - - -# [2.0.0](https://github.com/adonisjs/adonis-bodyparser/compare/v1.0.8...v2.0.0) (2017-11-13) - - -### Features - -* **file:** expose public file properties ([5d4a7df](https://github.com/adonisjs/adonis-bodyparser/commit/5d4a7df)) - - -### BREAKING CHANGES - -* **file:** All apps relying on private properties of the file instance will break - - - - -## [1.0.8](https://github.com/adonisjs/adonis-bodyparser/compare/v1.0.7...v1.0.8) (2017-10-29) - - -### Bug Fixes - -* **file:** generate more unique tmp file names ([257d031](https://github.com/adonisjs/adonis-bodyparser/commit/257d031)), closes [#2](https://github.com/adonisjs/adonis-bodyparser/issues/2) - - -### Features - -* **file:** add option to define custom tmp file names ([0f50a83](https://github.com/adonisjs/adonis-bodyparser/commit/0f50a83)) - - - - -## [1.0.7](https://github.com/adonisjs/adonis-bodyparser/compare/v1.0.6...v1.0.7) (2017-09-27) - - - - -## [1.0.6](https://github.com/adonisjs/adonis-bodyparser/compare/v1.0.5...v1.0.6) (2017-09-02) - - -### Bug Fixes - -* **body:** set request.body public body ([62ff44b](https://github.com/adonisjs/adonis-bodyparser/commit/62ff44b)) - - - - -## [1.0.5](https://github.com/adonisjs/adonis-bodyparser/compare/v1.0.4...v1.0.5) (2017-08-02) - - -### Features - -* **exceptions:** use generic-exceptions package ([f4f3fb4](https://github.com/adonisjs/adonis-bodyparser/commit/f4f3fb4)) -* **instructions:** add instructions.js and md file ([13ff287](https://github.com/adonisjs/adonis-bodyparser/commit/13ff287)) - - - - -## [1.0.4](https://github.com/adonisjs/adonis-bodyparser/compare/v1.0.3...v1.0.4) (2017-06-23) - - -### Features - -* **file:** add moved method to indicate if file is moved ([beb070e](https://github.com/adonisjs/adonis-bodyparser/commit/beb070e)) - - - - -## [1.0.3](https://github.com/adonisjs/adonis-middleware/compare/v1.0.2...v1.0.3) (2017-06-22) - - - - -## [1.0.2](https://github.com/adonisjs/adonis-middleware/compare/v1.0.1...v1.0.2) (2017-06-22) - - -### Bug Fixes - -* **provider:** use correct package via [@adonisjs](https://github.com/adonisjs) org ([12757d5](https://github.com/adonisjs/adonis-middleware/commit/12757d5)) - - - - -## [1.0.1](https://github.com/adonisjs/adonis-middleware/compare/v1.0.0...v1.0.1) (2017-06-22) - - - - -# 1.0.0 (2017-06-13) - - -### Bug Fixes - -* **file:** close fd when used fs.open ([d0c1ac2](https://github.com/adonisjs/adonis-middleware/commit/d0c1ac2)) -* **package:** add missing standardjs dependency ([d895b64](https://github.com/adonisjs/adonis-middleware/commit/d895b64)) -* **provider:** fix wrong require statement ([db57178](https://github.com/adonisjs/adonis-middleware/commit/db57178)) - - -### Features - -* implement body parser ([436bb11](https://github.com/adonisjs/adonis-middleware/commit/436bb11)) -* initial commit ([a1a96a1](https://github.com/adonisjs/adonis-middleware/commit/a1a96a1)) -* **bodyparser:** handle ignore cases of multipart parsing ([23ed658](https://github.com/adonisjs/adonis-middleware/commit/23ed658)) -* **providers:** add provider ([6dee068](https://github.com/adonisjs/adonis-middleware/commit/6dee068)) -* **request:** a request macro to access file or files ([4240022](https://github.com/adonisjs/adonis-middleware/commit/4240022)) - - - diff --git a/LICENSE.md b/LICENSE.md index 1c19428..381426b 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,6 +1,6 @@ # The MIT License -Copyright 2022 Harminder Virk, contributors +Copyright (c) 2023 Harminder Virk Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the 'Software'), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: diff --git a/README.md b/README.md index a71e12f..cfeb214 100644 --- a/README.md +++ b/README.md @@ -1,46 +1,28 @@ -
- -
+# @adonisjs/bodyparser
-
-

BodyParser

-

- BodyParser Middleware For AdonisJS with first class support for file uploads, JSON payloads, raw body and standard form submissions -

-
+[![gh-workflow-image]][gh-workflow-url] [![npm-image]][npm-url] ![][typescript-image] [![license-image]][license-url] -
+## Introduction +This repo provides the BodyParser middleware to process AdonisJS HTTP request body. The middleware has parsers for **JSON**, **URLencoded form**, and **Multipart** content types. -
+## Official Documentation +The documentation is available on the [AdonisJS website](https://docs.adonisjs.com/guides/bodyparser_middleware) -[![gh-workflow-image]][gh-workflow-url] [![typescript-image]][typescript-url] [![npm-image]][npm-url] [![license-image]][license-url] [![synk-image]][synk-url] +## Contributing +One of the primary goals of AdonisJS is to have a vibrant community of users and contributors who believes in the principles of the framework. -
+We encourage you to read the [contribution guide](https://github.com/adonisjs/.github/blob/main/docs/CONTRIBUTING.md) before contributing to the framework. -
-

- - Website - - | - - Guides - - | - - Contributing - -

-
+## Code of Conduct +In order to ensure that the AdonisJS community is welcoming to all, please review and abide by the [Code of Conduct](https://github.com/adonisjs/.github/blob/main/docs/CODE_OF_CONDUCT.md). -
- Built with ❤︎ by Harminder Virk -
+## License +AdonisJS Bodyparser is open-sourced software licensed under the [MIT license](LICENSE.md). -[gh-workflow-image]: https://img.shields.io/github/workflow/status/adonisjs/bodyparser/test?style=for-the-badge -[gh-workflow-url]: https://github.com/adonisjs/bodyparser/actions/workflows/test.yml "Github action" +[gh-workflow-image]: https://img.shields.io/github/actions/workflow/status/adonisjs/bodyparser/checks.yml?style=for-the-badge +[gh-workflow-url]: https://github.com/adonisjs/bodyparser/actions/workflows/checks.yml "Github action" [typescript-image]: https://img.shields.io/badge/Typescript-294E80.svg?style=for-the-badge&logo=typescript [typescript-url]: "typescript" @@ -50,6 +32,3 @@ [license-image]: https://img.shields.io/npm/l/@adonisjs/bodyparser?color=blueviolet&style=for-the-badge [license-url]: LICENSE.md "license" - -[synk-image]: https://img.shields.io/snyk/vulnerabilities/github/adonisjs/bodyparser?label=Synk%20Vulnerabilities&style=for-the-badge -[synk-url]: https://snyk.io/test/github/adonisjs/bodyparser?targetFile=package.json "synk" diff --git a/adonis-typings/bodyparser.ts b/adonis-typings/bodyparser.ts deleted file mode 100644 index d7abf9b..0000000 --- a/adonis-typings/bodyparser.ts +++ /dev/null @@ -1,241 +0,0 @@ -/* - * @adonisjs/bodyparser - * - * (c) Harminder Virk - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -declare module '@ioc:Adonis/Core/BodyParser' { - import { Readable } from 'stream' - import { DisksList, WriteOptions } from '@ioc:Adonis/Core/Drive' - import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext' - - /** - * Qs module config - */ - type QueryStringConfig = { - depth?: number - allowPrototypes?: boolean - plainObjects?: boolean - parameterLimit?: number - arrayLimit?: number - ignoreQueryPrefix?: boolean - delimiter?: RegExp | string - allowDots?: boolean - charset?: string - charsetSentinel?: boolean - interpretNumericEntities?: boolean - parseArrays?: boolean - comma?: boolean - } - - /** - * Base config used by all types - */ - type BodyParserBaseConfig = { - encoding: string - limit: string | number - types: string[] - } - - /** - * Body parser config for parsing JSON requests - */ - export type BodyParserJSONConfig = BodyParserBaseConfig & { - strict: boolean - } - - /** - * Parser config for parsing form data - */ - export type BodyParserFormConfig = BodyParserBaseConfig & { - queryString: QueryStringConfig - convertEmptyStringsToNull: boolean - } - - /** - * Parser config for parsing raw body (untouched) - */ - export type BodyParserRawConfig = BodyParserBaseConfig & { - queryString: QueryStringConfig - } - - /** - * Parser config for parsing multipart requests - */ - export type BodyParserMultipartConfig = BodyParserBaseConfig & { - autoProcess: boolean - maxFields: number - processManually: string[] - convertEmptyStringsToNull: boolean - fieldsLimit?: number | string - tmpFileName?(): string - } - - /** - * Body parser config for all different types - */ - export type BodyParserConfig = { - whitelistedMethods: string[] - json: BodyParserJSONConfig - form: BodyParserFormConfig - raw: BodyParserRawConfig - multipart: BodyParserMultipartConfig - } - - /** - * ------------------------------------ - * Multipart related options - * ------------------------------------ - */ - - /** - * Readable stream along with some extra data. This is what - * is passed to `onFile` handlers. - */ - export type MultipartStream = Readable & { - headers: { - [key: string]: string - } - name: string - filename: string - bytes: number - file: MultipartFileContract - } - - /** - * The callback handler for a given file part - */ - export type PartHandler = ( - part: MultipartStream, - reportChunk: (chunk: Buffer) => void - ) => Promise<({ filePath?: string; tmpPath?: string } & { [key: string]: any }) | void> - - /** - * Multipart class contract, since it is exposed on the request object, - * we need the interface to extend typings. - */ - export interface MultipartContract { - state: 'idle' | 'processing' | 'error' | 'success' - abort(error: any): void - onFile( - name: string, - options: Partial, - callback: PartHandler - ): this - process(config?: Partial<{ limit: string | number; maxFields: number }>): Promise - } - - /** - * ------------------------------------ - * Multipart file related options - * ------------------------------------ - */ - - /** - * The options that can be used to validate a given - * file - */ - export type FileValidationOptions = { - size: string | number - extnames: string[] - } - - /** - * Error shape for file upload errors - */ - export type FileUploadError = { - fieldName: string - clientName: string - message: string - type: 'size' | 'extname' | 'fatal' - } - - /** - * Shape of file.toJSON return value - */ - export type FileJSON = { - fieldName: string - clientName: string - size: number - filePath?: string - fileName?: string - type?: string - extname?: string - subtype?: string - state: 'idle' | 'streaming' | 'consumed' | 'moved' - isValid: boolean - validated: boolean - errors: FileUploadError[] - meta: any - } - - /** - * Multipart file interface - */ - export interface MultipartFileContract { - isMultipartFile: true - fieldName: string - clientName: string - size: number - headers: { [key: string]: string } - tmpPath?: string - filePath?: string - fileName?: string - type?: string - extname?: string - subtype?: string - state: 'idle' | 'streaming' | 'consumed' | 'moved' - isValid: boolean - hasErrors: boolean - validated: boolean - errors: FileUploadError[] - sizeLimit?: number | string - allowedExtensions?: string[] - meta: any - - /** - * Run validations on the file - */ - validate(): void - - /** - * Mark file as moved - */ - markAsMoved(fileName: string, filePath: string): void - - /** - * Move file from temporary path to a different location. Self consumed - * streams cannot be moved unless `tmpPath` is defined explicitly. - */ - move(location: string, options?: { name?: string; overwrite?: boolean }): Promise - - /** - * Move file to a pre-registered drive disk - */ - moveToDisk( - location: string, - options?: WriteOptions & { name?: string }, - diskName?: keyof DisksList - ): Promise - - /** - * Get JSON representation of file - */ - toJSON(): FileJSON - } - - /** - * Shape of the bodyparser middleware class constructor - */ - export interface BodyParserMiddlewareContract { - new (config: BodyParserConfig): { - handle(ctx: HttpContextContract, next: () => void): any - } - } - - const BodyParserMiddleware: BodyParserMiddlewareContract - export default BodyParserMiddleware -} diff --git a/adonis-typings/request.ts b/adonis-typings/request.ts deleted file mode 100644 index e76d1ff..0000000 --- a/adonis-typings/request.ts +++ /dev/null @@ -1,26 +0,0 @@ -/* - * @adonisjs/bodyparser - * - * (c) Harminder Virk - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -/** - * Extending the `request` interface on the core module - */ -declare module '@ioc:Adonis/Core/Request' { - import { - MultipartContract, - FileValidationOptions, - MultipartFileContract, - } from '@ioc:Adonis/Core/BodyParser' - - interface RequestContract { - file(key: string, options?: Partial): MultipartFileContract | null - files(key: string, options?: Partial): MultipartFileContract[] - allFiles(): { [field: string]: MultipartFileContract | MultipartFileContract[] } - multipart: MultipartContract - } -} diff --git a/bin/japaTypes.ts b/bin/japaTypes.ts deleted file mode 100644 index 7ee3401..0000000 --- a/bin/japaTypes.ts +++ /dev/null @@ -1,2 +0,0 @@ -import '@japa/assert' -declare module '@japa/runner' {} diff --git a/bin/test.ts b/bin/test.ts index 0fa07ed..5d4b52e 100644 --- a/bin/test.ts +++ b/bin/test.ts @@ -1,9 +1,8 @@ import 'reflect-metadata' import { assert } from '@japa/assert' -import { specReporter } from '@japa/spec-reporter' -import { runFailedTests } from '@japa/run-failed-tests' -import { processCliArgs, configure, run } from '@japa/runner' +import { fileSystem } from '@japa/file-system' +import { processCLIArgs, configure, run } from '@japa/runner' /* |-------------------------------------------------------------------------- @@ -18,14 +17,10 @@ import { processCliArgs, configure, run } from '@japa/runner' | | Please consult japa.dev/runner-config for the config docs. */ +processCLIArgs(process.argv.slice(2)) configure({ - ...processCliArgs(process.argv.slice(2)), - ...{ - files: ['test/**/*.spec.ts'], - plugins: [assert(), runFailedTests()], - reporters: [specReporter()], - importer: (filePath: string) => import(filePath), - }, + files: ['tests/**/*.spec.ts'], + plugins: [assert(), fileSystem()], }) /* diff --git a/factories/file_factory.ts b/factories/file_factory.ts new file mode 100644 index 0000000..7321a5a --- /dev/null +++ b/factories/file_factory.ts @@ -0,0 +1,63 @@ +/* + * @adonisjs/bodyparser + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { MultipartFile } from '../src/multipart/file.js' +import { FileValidationOptions } from '../src/types.js' + +type FileFactoryParameters = { + fieldName: string + clientName: string + headers: any + size: number + extname: string + type: string + subtype: string +} + +/** + * File factory exposes the API to create fake multipart file instances + * for testing + */ +export class MultipartFileFactory { + #parameters: Partial = {} + + /** + * Merge factory params + */ + merge(params: Partial): this { + this.#parameters = Object.assign(this.#parameters, params) + return this + } + + /** + * Create an instance of multipart file + */ + create(validationOptions?: Partial) { + const file = new MultipartFile( + { + fieldName: this.#parameters.fieldName || 'file', + clientName: + this.#parameters.clientName || this.#parameters.extname + ? `file.${this.#parameters.extname}` + : 'file', + headers: this.#parameters.headers || {}, + }, + validationOptions || {} + ) + + file.size = this.#parameters.size || 0 + file.extname = this.#parameters.extname + file.type = this.#parameters.type + file.subtype = this.#parameters.subtype + file.state = 'consumed' + + file.validate() + return file + } +} diff --git a/factories/main.ts b/factories/main.ts new file mode 100644 index 0000000..493261b --- /dev/null +++ b/factories/main.ts @@ -0,0 +1,11 @@ +/* + * @adonisjs/bodyparser + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +export { MultipartFileFactory } from './file_factory.js' +export { BodyParserMiddlewareFactory } from './middleware_factory.js' diff --git a/factories/middleware_factory.ts b/factories/middleware_factory.ts new file mode 100644 index 0000000..e96cf0b --- /dev/null +++ b/factories/middleware_factory.ts @@ -0,0 +1,34 @@ +/* + * @adonisjs/bodyparser + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import lodash from '@poppinss/utils/lodash' + +import { defineConfig } from '../src/define_config.js' +import { BodyParserMiddleware } from '../src/bodyparser_middleware.js' +import type { BodyParserConfig, BodyParserOptionalConfig } from '../src/types.js' + +/** + * Factory to create bodyparser middleware instance + */ +export class BodyParserMiddlewareFactory { + #config: BodyParserConfig = defineConfig({}) + + #getConfig(): BodyParserConfig { + return this.#config + } + + merge(config: BodyParserOptionalConfig) { + this.#config = lodash.merge(this.#config, config) + return this + } + + create() { + return new BodyParserMiddleware(this.#getConfig()) + } +} diff --git a/index.ts b/index.ts new file mode 100644 index 0000000..a601a3f --- /dev/null +++ b/index.ts @@ -0,0 +1,28 @@ +/* + * @adonisjs/bodyparser + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import type { Multipart } from './src/multipart/main.js' +import { MultipartFile } from './src/multipart/file.js' +import type { FileValidationOptions } from './src/types.js' + +export { defineConfig } from './src/define_config.js' +export { MultipartFile, Multipart } + +/** + * Extending request class with custom properties. + */ +declare module '@adonisjs/http-server' { + export interface Request { + multipart: Multipart + __raw_files: Record + allFiles(): Record + file(key: string, options?: Partial): MultipartFile | null + files(key: string, options?: Partial): MultipartFile[] + } +} diff --git a/package.json b/package.json index 45821e5..16b5589 100644 --- a/package.json +++ b/package.json @@ -1,163 +1,152 @@ { "name": "@adonisjs/bodyparser", - "version": "8.1.9", - "description": "AdonisJs body parser to read and parse HTTP request bodies", - "main": "build/providers/BodyParserProvider.js", + "version": "10.0.0-3", + "description": "BodyParser middleware for AdonisJS http server to read and parse request body", + "main": "build/index.js", + "type": "module", "files": [ - "build/src", - "build/adonis-typings", - "build/providers" + "build", + "!build/bin", + "!build/tests", + "!build/tests_helpers" ], + "engines": { + "node": ">=18.16.0" + }, + "exports": { + ".": "./build/index.js", + "./factories": "./build/factories/main.js", + "./bodyparser_middleware": "./build/src/bodyparser_middleware.js", + "./types": "./build/src/types.js" + }, "scripts": { - "mrm": "mrm --preset=@adonisjs/mrm-preset", "pretest": "npm run lint", - "test": "node -r @adonisjs/require-ts/build/register bin/test.ts", - "clean": "del build", - "compile": "npm run lint && npm run clean && tsc", + "test": "cross-env NODE_DEBUG=adonisjs:bodyparser c8 npm run quick:test", + "clean": "del-cli build", + "typecheck": "tsc --noEmit", + "precompile": "npm run lint && npm run clean", + "compile": "tsup-node && tsc --emitDeclarationOnly --declaration", "build": "npm run compile", - "commit": "git-cz", - "release": "np --message=\"chore(release): %s\"", + "release": "np", "version": "npm run build", + "format": "prettier --write .", "prepublishOnly": "npm run build", "lint": "eslint . --ext=.ts", - "format": "prettier --write .", - "sync-labels": "github-label-sync --labels ./node_modules/@adonisjs/mrm-preset/gh-labels.json adonisjs/bodyparser" + "sync-labels": "github-label-sync --labels .github/labels.json adonisjs/bodyparser", + "quick:test": "node --loader=ts-node/esm bin/test.ts" }, "devDependencies": { - "@adonisjs/application": "^5.2.5", - "@adonisjs/drive": "^2.3.0", - "@adonisjs/encryption": "^4.0.8", - "@adonisjs/http-server": "^5.11.0", - "@adonisjs/mrm-preset": "^5.0.3", - "@adonisjs/require-ts": "^2.0.13", - "@japa/assert": "^1.3.6", - "@japa/run-failed-tests": "^1.1.0", - "@japa/runner": "^2.2.1", - "@japa/spec-reporter": "^1.3.1", - "@poppinss/dev-utils": "^2.0.3", - "@types/bytes": "^3.1.1", - "@types/fs-extra": "^9.0.13", - "@types/media-typer": "^1.1.1", - "@types/node": "^18.8.3", - "@types/supertest": "^2.0.12", - "@types/uuid": "^8.3.4", - "commitizen": "^4.2.5", - "cz-conventional-changelog": "^3.3.0", - "del-cli": "^5.0.0", - "eslint": "^8.24.0", - "eslint-config-prettier": "^8.5.0", - "eslint-plugin-adonis": "^2.1.1", - "eslint-plugin-prettier": "^4.2.1", - "github-label-sync": "^2.2.0", - "husky": "^8.0.1", - "mrm": "^4.1.6", - "node-fetch": "^2.6.11", - "np": "^7.6.2", - "prettier": "^2.7.1", - "reflect-metadata": "^0.1.13", - "supertest": "^6.3.0", - "typescript": "^4.8.4" - }, - "peerDependencies": { - "@adonisjs/application": "^5.0.0", - "@adonisjs/drive": "^2.0.0", - "@adonisjs/http-server": "^5.0.0" - }, - "nyc": { - "exclude": [ - "test" - ], - "extension": [ - ".ts" - ] - }, - "license": "MIT", - "husky": { - "hooks": { - "commit-msg": "node ./node_modules/@adonisjs/mrm-preset/validateCommit/conventional/validate.js" - } - }, - "config": { - "commitizen": { - "path": "cz-conventional-changelog" - } - }, - "np": { - "contents": ".", - "anyBranch": false + "@adonisjs/application": "^8.0.0", + "@adonisjs/encryption": "^6.0.0", + "@adonisjs/eslint-config": "^1.2.1", + "@adonisjs/events": "^9.0.0", + "@adonisjs/fold": "^10.0.0", + "@adonisjs/http-server": "^7.0.0", + "@adonisjs/logger": "^6.0.0", + "@adonisjs/prettier-config": "^1.2.1", + "@adonisjs/tsconfig": "^1.2.1", + "@commitlint/cli": "^18.4.4", + "@commitlint/config-conventional": "^18.4.4", + "@japa/assert": "^2.1.0", + "@japa/file-system": "^2.1.1", + "@japa/runner": "^3.1.1", + "@poppinss/file-generator": "^2.1.2", + "@swc/core": "^1.3.102", + "@types/bytes": "^3.1.4", + "@types/fs-extra": "^11.0.4", + "@types/inflation": "^2.0.4", + "@types/media-typer": "^1.1.3", + "@types/node": "^20.10.6", + "@types/supertest": "^6.0.2", + "c8": "^9.0.0", + "cross-env": "^7.0.3", + "del-cli": "^5.1.0", + "eslint": "^8.56.0", + "fs-extra": "^11.2.0", + "github-label-sync": "^2.3.1", + "husky": "^8.0.3", + "np": "^9.2.0", + "prettier": "^3.1.1", + "reflect-metadata": "^0.2.1", + "supertest": "^6.3.3", + "ts-node": "^10.9.2", + "tsup": "^8.0.1", + "typescript": "^5.3.3", + "undici": "^6.2.1" }, "dependencies": { - "@poppinss/co-body": "^1.1.3", + "@paralleldrive/cuid2": "^2.2.2", + "@poppinss/macroable": "^1.0.1", "@poppinss/multiparty": "^2.0.1", - "@poppinss/utils": "^5.0.0", + "@poppinss/utils": "^6.7.0", + "@types/qs": "^6.9.11", "bytes": "^3.1.2", - "file-type": "^16.5.4", - "fs-extra": "^10.1.0", + "file-type": "^18.7.0", + "inflation": "^2.1.0", "media-typer": "^1.1.0", - "slash": "^3.0.0" + "qs": "^6.11.1", + "raw-body": "^2.5.2" }, - "publishConfig": { - "access": "public", - "tag": "latest" - }, - "directories": { - "test": "test" - }, - "repository": { - "type": "git", - "url": "git+https://github.com/adonisjs/adonis-bodyparser.git" + "peerDependencies": { + "@adonisjs/http-server": "^7.0.0" }, "keywords": [ "adonisjs", "bodyparser", "multipart" ], + "license": "MIT", "author": "virk,adonisjs", + "repository": { + "type": "git", + "url": "git+https://github.com/adonisjs/bodyparser.git" + }, "bugs": { - "url": "https://github.com/adonisjs/adonis-bodyparser/issues" + "url": "https://github.com/adonisjs/bodyparser/issues" }, - "homepage": "https://github.com/adonisjs/adonis-bodyparser#readme", - "mrmConfig": { - "core": true, - "license": "MIT", - "services": [ - "github-actions" - ], - "minNodeVersion": "14.15.4", - "probotApps": [ - "stale", - "lock" + "homepage": "https://github.com/adonisjs/bodyparser#readme", + "commitlint": { + "extends": [ + "@commitlint/config-conventional" + ] + }, + "publishConfig": { + "access": "public", + "tag": "next" + }, + "np": { + "message": "chore(release): %s", + "tag": "next", + "branch": "main", + "anyBranch": false + }, + "c8": { + "reporter": [ + "text", + "html" ], - "runGhActionsOnWindows": true + "exclude": [ + "tests/**", + "test_factories/**", + ".yalc/**" + ] }, "eslintConfig": { - "extends": [ - "plugin:adonis/typescriptPackage", - "prettier" - ], - "plugins": [ - "prettier" - ], - "rules": { - "prettier/prettier": [ - "error", - { - "endOfLine": "auto" - } - ] - } + "extends": "@adonisjs/eslint-config/package" }, - "eslintIgnore": [ - "build" - ], - "prettier": { - "trailingComma": "es5", - "semi": false, - "singleQuote": true, - "useTabs": false, - "quoteProps": "consistent", - "bracketSpacing": true, - "arrowParens": "always", - "printWidth": 100 + "prettier": "@adonisjs/prettier-config", + "tsup": { + "entry": [ + "./index.ts", + "./src/types.ts", + "./src/bodyparser_middleware.ts", + "./factories/main.ts" + ], + "outDir": "./build", + "clean": true, + "format": "esm", + "dts": false, + "sourcemap": true, + "target": "esnext" } } diff --git a/providers/BodyParserProvider.ts b/providers/BodyParserProvider.ts deleted file mode 100644 index e871e08..0000000 --- a/providers/BodyParserProvider.ts +++ /dev/null @@ -1,34 +0,0 @@ -/* - * @adonisjs/bodyparser - * - * (c) Harminder Virk - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { ApplicationContract } from '@ioc:Adonis/Core/Application' - -export default class BodyParserProvider { - constructor(protected app: ApplicationContract) {} - - public static needsApplication = true - - /** - * Registers the bodyparser middleware namespace to the container. - */ - public register() { - this.app.container.bind('Adonis/Core/BodyParser', () => { - const { BodyParserMiddleware } = require('../src/BodyParser/index') - return BodyParserMiddleware - }) - } - - /** - * Adding the `file` macro to add support for reading request files. - */ - public boot() { - const extendRequest = require('../src/Bindings/Request').default - extendRequest(this.app.container.resolveBinding('Adonis/Core/Request')) - } -} diff --git a/resources/sample-pdf-download-10-mb.pdf b/resources/sample-pdf-download-10-mb.pdf new file mode 100644 index 0000000..e1095cd Binary files /dev/null and b/resources/sample-pdf-download-10-mb.pdf differ diff --git a/sample.xls b/resources/sample.xls similarity index 100% rename from sample.xls rename to resources/sample.xls diff --git a/sample.xlsx b/resources/sample.xlsx similarity index 100% rename from sample.xlsx rename to resources/sample.xlsx diff --git a/unicorn-wo-ext b/resources/unicorn-wo-ext similarity index 100% rename from unicorn-wo-ext rename to resources/unicorn-wo-ext diff --git a/unicorn.png b/resources/unicorn.png similarity index 100% rename from unicorn.png rename to resources/unicorn.png diff --git a/src/Bindings/Request.ts b/src/Bindings/Request.ts deleted file mode 100644 index 6d9c111..0000000 --- a/src/Bindings/Request.ts +++ /dev/null @@ -1,80 +0,0 @@ -/* - * @adonisjs/bodyparser - * - * (c) Harminder Virk - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -/// - -import { lodash } from '@poppinss/utils' -import { FileValidationOptions } from '@ioc:Adonis/Core/BodyParser' -import { RequestConstructorContract } from '@ioc:Adonis/Core/Request' - -import { File } from '../Multipart/File' - -/** - * Updates the validation options on the file instance - */ -function setFileOptions(file: File, options?: Partial) { - if (file.sizeLimit === undefined && options && options.size) { - file.sizeLimit = options.size - } - - if (file.allowedExtensions === undefined && options && options.extnames) { - file.allowedExtensions = options.extnames - } -} - -/** - * A boolean to know if file is an instance of multipart - * file class - */ -function isInstanceOfFile(file: any): file is File { - return file && file instanceof File -} - -/** - * Extend the Request class by adding `file` and `files` macro to read processed - * files - */ -export default function extendRequest(Request: RequestConstructorContract) { - /** - * Fetch a single file - */ - Request.macro('file', function getFile(key: string, options?: Partial) { - let file: unknown = lodash.get(this.allFiles(), key) - file = Array.isArray(file) ? file[0] : file - - if (!isInstanceOfFile(file)) { - return null - } - - setFileOptions(file, options) - file.validate() - return file - }) - - /** - * Fetch an array of files - */ - Request.macro('files', function getFiles(key: string, options?: Partial) { - let files: unknown[] = lodash.get(this.allFiles(), key) - files = Array.isArray(files) ? files : files ? [files] : [] - - return files.filter(isInstanceOfFile).map((file) => { - setFileOptions(file, options) - file.validate() - return file - }) - }) - - /** - * Fetch all files - */ - Request.macro('allFiles', function allFiles() { - return this['__raw_files'] || {} - }) -} diff --git a/src/BodyParser/index.ts b/src/BodyParser/index.ts deleted file mode 100644 index 5c5d27a..0000000 --- a/src/BodyParser/index.ts +++ /dev/null @@ -1,250 +0,0 @@ -/* - * @adonisjs/bodyparser - * - * (c) Harminder Virk - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -/// - -import { tmpdir } from 'os' -import coBody from '@poppinss/co-body' -import { join, isAbsolute } from 'path' -import { Exception } from '@poppinss/utils' -import { inject } from '@adonisjs/application' -import { cuid } from '@poppinss/utils/build/helpers' - -import type { ConfigContract } from '@ioc:Adonis/Core/Config' -import type { DriveManagerContract } from '@ioc:Adonis/Core/Drive' -import type { BodyParserConfig } from '@ioc:Adonis/Core/BodyParser' -import type { HttpContextContract } from '@ioc:Adonis/Core/HttpContext' - -import { Multipart } from '../Multipart' -import { streamFile } from '../Multipart/streamFile' - -/** - * BodyParser middleware parses the incoming request body and set it as - * request body to be read later in the request lifecycle. - */ -@inject(['Adonis/Core/Config', 'Adonis/Core/Drive']) -export class BodyParserMiddleware { - /** - * Bodyparser config - */ - private config: BodyParserConfig - - constructor(Config: ConfigContract, private drive: DriveManagerContract) { - this.config = Config.get('bodyparser', {}) - } - - /** - * Returns config for a given type - */ - private getConfigFor(type: K): BodyParserConfig[K] { - const config = this.config[type] - config['returnRawBody'] = true - return config - } - - /** - * Ensures that types exists and have length - */ - private ensureTypes(types: string[]): boolean { - return !!(types && types.length) - } - - /** - * Returns a boolean telling if request `content-type` header - * matches the expected types or not - */ - private isType(request: HttpContextContract['request'], types: string[]): boolean { - return !!(this.ensureTypes(types) && request.is(types)) - } - - /** - * Returns a proper Adonis style exception for popular error codes - * returned by https://github.com/stream-utils/raw-body#readme. - */ - private getExceptionFor(error: { type: string; status: number; message: string }) { - switch (error.type) { - case 'encoding.unsupported': - return new Exception(error.message, error.status, 'E_ENCODING_UNSUPPORTED') - case 'entity.too.large': - return new Exception(error.message, error.status, 'E_REQUEST_ENTITY_TOO_LARGE') - case 'request.aborted': - return new Exception(error.message, error.status, 'E_REQUEST_ABORTED') - default: - return error - } - } - - /** - * Returns the tmp path for storing the files temporarly - */ - private getTmpPath(config: BodyParserConfig['multipart']) { - if (typeof config.tmpFileName === 'function') { - const tmpPath = config.tmpFileName() - return isAbsolute(tmpPath) ? tmpPath : join(tmpdir(), tmpPath) - } - - return join(tmpdir(), cuid()) - } - - /** - * Handle HTTP request body by parsing it as per the user - * config - */ - public async handle(ctx: HttpContextContract, next: () => Promise): Promise { - /** - * Initiating the `__raw_files` private property as an object - */ - ctx.request['__raw_files'] = {} - const requestMethod = ctx.request.method() - - /** - * Only process for whitelisted nodes - */ - if (!this.config.whitelistedMethods.includes(requestMethod)) { - ctx.logger.trace(`bodyparser skipping method ${requestMethod}`) - return next() - } - - /** - * Return early when request body is empty. Many clients set the `Content-length = 0` - * when request doesn't have any body, which is not handled by the below method. - * - * The main point of `hasBody` is to early return requests with empty body created by - * clients with missing headers. - */ - if (!ctx.request.hasBody()) { - ctx.logger.trace('bodyparser skipping empty body') - return next() - } - - /** - * Handle multipart form - */ - const multipartConfig = this.getConfigFor('multipart') - - if (this.isType(ctx.request, multipartConfig.types)) { - ctx.logger.trace('bodyparser parsing as multipart body') - - ctx.request.multipart = new Multipart( - ctx, - { - maxFields: multipartConfig.maxFields, - limit: multipartConfig.limit, - fieldsLimit: multipartConfig.fieldsLimit, - convertEmptyStringsToNull: multipartConfig.convertEmptyStringsToNull, - }, - this.drive - ) - - /** - * Skip parsing when `autoProcess` is disabled or route matches one - * of the defined processManually route patterns. - */ - if ( - !multipartConfig.autoProcess || - multipartConfig.processManually.indexOf(ctx.route!.pattern) > -1 - ) { - return next() - } - - /** - * Make sure we are not running any validations on the uploaded files. They are - * deferred for the end user when they will access file using `request.file` - * method. - */ - ctx.request.multipart.onFile('*', { deferValidations: true }, async (part, reporter) => { - /** - * We need to abort the main request when we are unable to process any - * file. Otherwise the error will endup on the file object, which - * is incorrect. - */ - try { - const tmpPath = this.getTmpPath(multipartConfig) - await streamFile(part, tmpPath, reporter) - return { tmpPath } - } catch (error) { - ctx.request.multipart.abort(error) - } - }) - - const action = ctx.profiler.profile('bodyparser:multipart') - - try { - await ctx.request.multipart.process() - action.end() - return next() - } catch (error) { - action.end({ error }) - throw error - } - } - - /** - * Handle url-encoded form data - */ - const formConfig = this.getConfigFor('form') - if (this.isType(ctx.request, formConfig.types)) { - ctx.logger.trace('bodyparser parsing as form request') - const action = ctx.profiler.profile('bodyparser:urlencoded') - - try { - const { parsed, raw } = await coBody.form(ctx.request.request, formConfig) - ctx.request.setInitialBody(parsed) - ctx.request.updateRawBody(raw) - action.end() - return next() - } catch (error) { - action.end({ error }) - throw this.getExceptionFor(error) - } - } - - /** - * Handle content with JSON types - */ - const jsonConfig = this.getConfigFor('json') - if (this.isType(ctx.request, jsonConfig.types)) { - ctx.logger.trace('bodyparser parsing as json body') - const action = ctx.profiler.profile('bodyparser:json') - - try { - const { parsed, raw } = await coBody.json(ctx.request.request, jsonConfig) - ctx.request.setInitialBody(parsed) - ctx.request.updateRawBody(raw) - action.end() - return next() - } catch (error) { - action.end({ error }) - throw this.getExceptionFor(error) - } - } - - /** - * Handles raw request body - */ - const rawConfig = this.getConfigFor('raw') - if (this.isType(ctx.request, rawConfig.types)) { - ctx.logger.trace('bodyparser parsing as raw body') - const action = ctx.profiler.profile('bodyparser:raw') - - try { - const { raw } = await coBody.text(ctx.request.request, rawConfig) - ctx.request.setInitialBody({}) - ctx.request.updateRawBody(raw) - action.end() - return next() - } catch (error) { - action.end({ error }) - throw this.getExceptionFor(error) - } - } - - await next() - } -} diff --git a/src/Multipart/File.ts b/src/Multipart/File.ts deleted file mode 100644 index db2faf1..0000000 --- a/src/Multipart/File.ts +++ /dev/null @@ -1,277 +0,0 @@ -/* - * @adonisjs/bodyparser - * - * (c) Harminder Virk - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -/// - -import slash from 'slash' -import { join } from 'path' -import { Exception } from '@poppinss/utils' -import { move, createReadStream } from 'fs-extra' -import { cuid } from '@poppinss/utils/build/helpers' -import { DisksList, WriteOptions, DriveManagerContract } from '@ioc:Adonis/Core/Drive' - -import { - FileJSON, - FileUploadError, - FileValidationOptions, - MultipartFileContract, -} from '@ioc:Adonis/Core/BodyParser' - -import { SizeValidator } from './Validators/Size' -import { ExtensionValidator } from './Validators/Extensions' - -/** - * The file holds the meta/data for an uploaded file, along with - * an errors occurred during the upload process. - */ -export class File implements MultipartFileContract { - private sizeValidator = new SizeValidator(this) - private extensionValidator = new ExtensionValidator(this) - - /** - * A boolean to know if file is an instance of this class - * or not - */ - public isMultipartFile: true = true - - /** - * Field name is the name of the field - */ - public fieldName = this.data.fieldName - - /** - * Client name is the file name on the user client - */ - public clientName = this.data.clientName - - /** - * The headers sent as part of the multipart request - */ - public headers = this.data.headers - - /** - * File size in bytes - */ - public size: number = 0 - - /** - * The extname for the file. - */ - public extname?: string - - /** - * Upload errors - */ - public errors: FileUploadError[] = [] - - /** - * Type and subtype are extracted from the `content-type` - * header or from the file magic number - */ - public type?: string - public subtype?: string - - /** - * File path is only set after the move operation - */ - public filePath?: string - - /** - * File name is only set after the move operation. It is the relative - * path of the moved file - */ - public fileName?: string - - /** - * Tmp path, only exists when file is uploaded using the - * classic mode. - */ - public tmpPath?: string - - /** - * The file meta data - */ - public meta: any = {} - - /** - * The state of the file - */ - public state: 'idle' | 'streaming' | 'consumed' | 'moved' = 'idle' - - /** - * Whether or not the validations have been executed - */ - public get validated(): boolean { - return this.sizeValidator.validated && this.extensionValidator.validated - } - - /** - * A boolean to know if file has one or more errors - */ - public get isValid() { - return this.errors.length === 0 - } - - /** - * Opposite of [[this.isValid]] - */ - public get hasErrors() { - return !this.isValid - } - - /** - * The maximum file size limit - */ - public get sizeLimit() { - return this.sizeValidator.maxLimit - } - public set sizeLimit(limit: number | string | undefined) { - this.sizeValidator.maxLimit = limit - } - - /** - * Extensions allowed - */ - public get allowedExtensions() { - return this.extensionValidator.extensions - } - public set allowedExtensions(extensions: string[] | undefined) { - this.extensionValidator.extensions = extensions - } - - constructor( - private data: { fieldName: string; clientName: string; headers: any }, - validationOptions: Partial, - private drive: DriveManagerContract - ) { - this.sizeLimit = validationOptions.size - this.allowedExtensions = validationOptions.extnames - } - - /** - * Validate the file - */ - public validate() { - this.extensionValidator.validate() - this.sizeValidator.validate() - } - - /** - * Mark file as moved - */ - public markAsMoved(fileName: string, filePath: string) { - this.filePath = filePath - this.fileName = fileName - this.state = 'moved' - } - - /** - * Moves the file to a given location. Multiple calls to the `move` method are allowed, - * incase you want to move a file to multiple locations. - */ - public async move( - location: string, - options?: { name?: string; overwrite?: boolean } - ): Promise { - if (!this.tmpPath) { - throw new Exception( - 'tmpPath must be set on the file before moving it', - 500, - 'E_MISSING_FILE_TMP_PATH' - ) - } - - options = Object.assign({ name: this.clientName, overwrite: true }, options) - const filePath = join(location, options.name!) - - try { - await move(this.tmpPath, filePath, { overwrite: options.overwrite! }) - this.markAsMoved(options.name!, filePath) - } catch (error) { - if (error.message.includes('dest already exists')) { - throw new Exception( - `"${options.name!}" already exists at "${location}". Set "overwrite = true" to overwrite it`, - 500 - ) - } - throw error - } - } - - /** - * Move file to a drive disk - */ - public async moveToDisk( - location: string, - options?: WriteOptions & { name?: string }, - diskName?: keyof DisksList - ): Promise { - const driver = diskName ? this.drive.use(diskName) : this.drive.use() - - const fileName = - (driver.name as any) === 'fake' - ? this.clientName - : options?.name - ? options.name - : `${cuid()}.${this.extname}` - - /** - * Move file as normal when using the local driver - */ - if (driver.name === 'local') { - await this.move(driver.makePath(location), { - name: fileName, - overwrite: true, - }) - return - } - - /** - * Make a unix style key for cloud drivers, since the cloud - * key is not a filesystem path - */ - const key = slash(join(location || './', fileName)) - - /** - * Set the content type for cloud drivers - */ - options = options || {} - if (this.type && this.subtype && !options.contentType) { - options.contentType = `${this.type}/${this.subtype}` - } - - if (options.contentLength === undefined) { - options.contentLength = this.size - } - - await driver.putStream(key, createReadStream(this.tmpPath!), options) - this.markAsMoved(key, await driver.getUrl(key)) - } - - /** - * Returns file JSON representation - */ - public toJSON(): FileJSON { - return { - fieldName: this.fieldName, - clientName: this.clientName, - size: this.size, - filePath: this.filePath, - fileName: this.fileName, - type: this.type, - extname: this.extname, - subtype: this.subtype, - state: this.state, - isValid: this.isValid, - validated: this.validated, - errors: this.errors, - meta: this.meta, - } - } -} diff --git a/src/Multipart/Validators/Extensions.ts b/src/Multipart/Validators/Extensions.ts deleted file mode 100644 index 688b15e..0000000 --- a/src/Multipart/Validators/Extensions.ts +++ /dev/null @@ -1,124 +0,0 @@ -/* - * @adonisjs/bodyparser - * - * (c) Harminder Virk - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -/// - -import { MultipartFileContract } from '@ioc:Adonis/Core/BodyParser' - -/** - * Validates the file extension - */ -export class ExtensionValidator { - private allowedExtensions?: string[] = [] - public validated: boolean = false - - /** - * Update the expected file extensions - */ - public get extensions(): string[] | undefined { - return this.allowedExtensions - } - public set extensions(extnames: string[] | undefined) { - if (this.allowedExtensions && this.allowedExtensions.length) { - throw new Error('Cannot update allowed extension names after file has been validated') - } - - this.validated = false - this.allowedExtensions = extnames - } - - constructor(private file: MultipartFileContract) {} - - /** - * Report error to the file - */ - private reportError() { - /** - * File is invalid, so report the error - */ - const suffix = this.allowedExtensions!.length === 1 ? 'is' : 'are' - const message = [ - `Invalid file extension ${this.file.extname}.`, - `Only ${this.allowedExtensions!.join(', ')} ${suffix} allowed`, - ].join(' ') - - this.file.errors.push({ - fieldName: this.file.fieldName, - clientName: this.file.clientName, - message: message, - type: 'extname', - }) - } - - /** - * Validating the file in the streaming mode. During this mode - * we defer the validation, until we get the file extname. - */ - private validateWhenGettingStreamed() { - if (!this.file.extname) { - return - } - - this.validated = true - - /** - * Valid extension type - */ - if (this.allowedExtensions!.includes(this.file.extname)) { - return - } - - this.reportError() - } - - /** - * Validate the file extension after it has been streamed - */ - private validateAfterConsumed() { - this.validated = true - - /** - * Valid extension type - */ - if (this.allowedExtensions!.includes(this.file.extname || '')) { - return - } - - this.reportError() - } - - /** - * Validate the file - */ - public validate(): void { - /** - * Do not validate if already validated - */ - if (this.validated) { - return - } - - /** - * Do not run validations, when constraints on the extension are not set - */ - if (!Array.isArray(this.allowedExtensions) || this.allowedExtensions.length === 0) { - this.validated = true - return - } - - if (this.file.state === 'streaming') { - this.validateWhenGettingStreamed() - return - } - - if (this.file.state === 'consumed') { - this.validateAfterConsumed() - } - } -} diff --git a/src/Multipart/Validators/Size.ts b/src/Multipart/Validators/Size.ts deleted file mode 100644 index 6d59d84..0000000 --- a/src/Multipart/Validators/Size.ts +++ /dev/null @@ -1,110 +0,0 @@ -/* - * @adonisjs/bodyparser - * - * (c) Harminder Virk - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -/// - -import bytes from 'bytes' -import { MultipartFileContract } from '@ioc:Adonis/Core/BodyParser' - -/** - * Size validator validates the file size - */ -export class SizeValidator { - private maximumAllowedLimit?: number | string - private bytesLimit: number = 0 - - public validated: boolean = false - - /** - * Defining the maximum bytes the file can have - */ - public get maxLimit(): number | string | undefined { - return this.maximumAllowedLimit - } - public set maxLimit(limit: number | string | undefined) { - if (this.maximumAllowedLimit !== undefined) { - throw new Error('Cannot reset sizeLimit after file has been validated') - } - - this.validated = false - this.maximumAllowedLimit = limit - - if (this.maximumAllowedLimit) { - this.bytesLimit = - typeof this.maximumAllowedLimit === 'string' - ? bytes(this.maximumAllowedLimit) - : this.maximumAllowedLimit - } - } - - constructor(private file: MultipartFileContract) {} - - /** - * Reporting error to the file - */ - private reportError() { - this.file.errors.push({ - fieldName: this.file.fieldName, - clientName: this.file.clientName, - message: `File size should be less than ${bytes(this.bytesLimit)}`, - type: 'size', - }) - } - - /** - * Validating file size while it is getting streamed. We only mark - * the file as `validated` when it's validation fails. Otherwise - * we keep re-validating the file as we receive more data. - */ - private validateWhenGettingStreamed() { - if (this.file.size > this.bytesLimit) { - this.validated = true - this.reportError() - } - } - - /** - * We have the final file size after the stream has been consumed. At this - * stage we always mark `validated = true`. - */ - private validateAfterConsumed() { - this.validated = true - if (this.file.size > this.bytesLimit) { - this.reportError() - } - } - - /** - * Validate the file size - */ - public validate() { - if (this.validated) { - return - } - - /** - * Do not attempt to validate when `maximumAllowedLimit` is not - * defined. - */ - if (this.maximumAllowedLimit === undefined) { - this.validated = true - return - } - - if (this.file.state === 'streaming') { - this.validateWhenGettingStreamed() - return - } - - if (this.file.state === 'consumed') { - this.validateAfterConsumed() - return - } - } -} diff --git a/src/bindings/request.ts b/src/bindings/request.ts new file mode 100644 index 0000000..c89a514 --- /dev/null +++ b/src/bindings/request.ts @@ -0,0 +1,98 @@ +/* + * @adonisjs/bodyparser + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import lodash from '@poppinss/utils/lodash' +import { Request } from '@adonisjs/http-server' +import { RuntimeException } from '@poppinss/utils' + +import debug from '../debug.js' +import { MultipartFile } from '../multipart/file.js' +import type { FileValidationOptions } from '../types.js' + +/** + * Updates the validation options on the file instance + */ +function setFileOptions(file: MultipartFile, options?: Partial) { + if (file.sizeLimit === undefined && options && options.size) { + file.sizeLimit = options.size + } + + if (file.allowedExtensions === undefined && options && options.extnames) { + file.allowedExtensions = options.extnames + } +} + +/** + * A boolean to know if file is an instance of multipart + * file class + */ +function isInstanceOfFile(file: any): file is MultipartFile { + return file && file instanceof MultipartFile +} + +debug('extending request class with "file", "files" and "allFiles" macros') + +/** + * Serialize files alongside rest of the request files + */ +Request.macro('toJSON', function (this: Request) { + return { + ...this.serialize(), + files: this['__raw_files'] || {}, + } +}) + +/** + * Fetch a single file + */ +Request.macro( + 'file', + function getFile(this: Request, key: string, options?: Partial) { + let file: unknown = lodash.get(this.allFiles(), key) + file = Array.isArray(file) ? file[0] : file + + if (!isInstanceOfFile(file)) { + return null + } + + setFileOptions(file, options) + file.validate() + return file + } +) + +/** + * Fetch an array of files + */ +Request.macro( + 'files', + function getFiles(this: Request, key: string, options?: Partial) { + let files: unknown[] = lodash.get(this.allFiles(), key) + files = Array.isArray(files) ? files : files ? [files] : [] + + return files.filter(isInstanceOfFile).map((file) => { + setFileOptions(file, options) + file.validate() + return file + }) + } +) + +/** + * Fetch all files + */ +Request.macro('allFiles', function allFiles(this: Request) { + if (!this.__raw_files) { + throw new RuntimeException( + 'Cannot read files. Make sure the bodyparser middleware is registered' + ) + } + + return this['__raw_files'] +}) diff --git a/src/bodyparser_middleware.ts b/src/bodyparser_middleware.ts new file mode 100644 index 0000000..2b4489f --- /dev/null +++ b/src/bodyparser_middleware.ts @@ -0,0 +1,261 @@ +/* + * @adonisjs/bodyparser + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { tmpdir } from 'node:os' +import { Exception } from '@poppinss/utils' +import { join, isAbsolute } from 'node:path' +import { createId } from '@paralleldrive/cuid2' +import type { HttpContext } from '@adonisjs/http-server' +import type { NextFn } from '@adonisjs/http-server/types' + +import debug from './debug.js' +import { parseForm } from './parsers/form.js' +import { parseJSON } from './parsers/json.js' +import { Multipart } from './multipart/main.js' +import type { BodyParserConfig } from './types.js' +import { streamFile } from './multipart/stream_file.js' + +/** + * Bindings to extend request + */ +import './bindings/request.js' +import { parseText } from './parsers/text.js' + +/** + * BodyParser middleware parses the incoming request body and set it as + * request body to be read later in the request lifecycle. + */ +export class BodyParserMiddleware { + /** + * Bodyparser config + */ + #config: BodyParserConfig + + constructor(config: BodyParserConfig) { + this.#config = config + debug('using config %O', this.#config) + } + + /** + * Returns config for a given type + */ + #getConfigFor(type: K): BodyParserConfig[K] { + const config = this.#config[type] + return config + } + + /** + * Ensures that types exists and have length + */ + #ensureTypes(types: string[]): boolean { + return !!(types && types.length) + } + + /** + * Returns a boolean telling if request `content-type` header + * matches the expected types or not + */ + #isType(request: HttpContext['request'], types: string[]): boolean { + return !!(this.#ensureTypes(types) && request.is(types)) + } + + /** + * Returns a proper Adonis style exception for popular error codes + * returned by https://github.com/stream-utils/raw-body#readme. + */ + #getExceptionFor(error: { type: string; status: number; message: string }) { + switch (error.type) { + case 'encoding.unsupported': + return new Exception(error.message, { + status: error.status, + code: 'E_ENCODING_UNSUPPORTED', + }) + case 'entity.too.large': + return new Exception(error.message, { + status: error.status, + code: 'E_REQUEST_ENTITY_TOO_LARGE', + }) + case 'request.aborted': + return new Exception(error.message, { status: error.status, code: 'E_REQUEST_ABORTED' }) + default: + return error + } + } + + /** + * Returns the tmp path for storing the files temporarly + */ + #getTmpPath(config: BodyParserConfig['multipart']) { + if (typeof config.tmpFileName === 'function') { + const tmpPath = config.tmpFileName() + return isAbsolute(tmpPath) ? tmpPath : join(tmpdir(), tmpPath) + } + + return join(tmpdir(), createId()) + } + + /** + * Handle HTTP request body by parsing it as per the user + * config + */ + async handle(ctx: HttpContext, next: NextFn) { + /** + * Initiating the `__raw_files` property as an object + */ + ctx.request['__raw_files'] = {} + const requestUrl = ctx.request.url() + const requestMethod = ctx.request.method() + + /** + * Only process for whitelisted nodes + */ + if (!this.#config.allowedMethods.includes(requestMethod)) { + debug('skipping HTTP request "%s:%s"', requestMethod, requestUrl) + return next() + } + + /** + * Return early when request body is empty. Many clients set the `Content-length = 0` + * when request doesn't have any body, which is not handled by the below method. + * + * The main point of `hasBody` is to early return requests with empty body created by + * clients with missing headers. + */ + if (!ctx.request.hasBody()) { + debug('skipping as request has no body "%s:%s"', requestMethod, requestUrl) + return next() + } + + /** + * Handle multipart form + */ + const multipartConfig = this.#getConfigFor('multipart') + + if (this.#isType(ctx.request, multipartConfig.types)) { + debug('detected multipart request "%s:%s"', requestMethod, requestUrl) + + ctx.request.multipart = new Multipart(ctx, { + maxFields: multipartConfig.maxFields, + limit: multipartConfig.limit, + fieldsLimit: multipartConfig.fieldsLimit, + convertEmptyStringsToNull: multipartConfig.convertEmptyStringsToNull, + }) + + /** + * Skip parsing when `autoProcess` is disabled + */ + if (multipartConfig.autoProcess === false) { + debug('skipping auto processing of multipart request "%s:%s"', requestMethod, requestUrl) + return next() + } + + /** + * Skip parsing when the current route matches one of the defined + * processManually route patterns. + */ + if (ctx.route && multipartConfig.processManually.includes(ctx.route.pattern)) { + debug('skipping auto processing of multipart request "%s:%s"', requestMethod, requestUrl) + return next() + } + + /** + * Skip parsing when the current route matches one of the "autoProcess" + * patterns + */ + if ( + ctx.route && + Array.isArray(multipartConfig.autoProcess) && + !multipartConfig.autoProcess.includes(ctx.route.pattern) + ) { + debug('skipping auto processing of multipart request "%s:%s"', requestMethod, requestUrl) + return next() + } + + /** + * Make sure we are not running any validations on the uploaded files. They are + * deferred for the end user when they will access file using `request.file` + * method. + */ + debug('auto processing multipart request "%s:%s"', requestMethod, requestUrl) + ctx.request.multipart.onFile('*', { deferValidations: true }, async (part, reporter) => { + /** + * We need to abort the main request when we are unable to process any + * file. Otherwise the error will endup on the file object, which + * is incorrect. + */ + try { + const tmpPath = this.#getTmpPath(multipartConfig) + await streamFile(part, tmpPath, reporter) + return { tmpPath } + } catch (error) { + ctx.request.multipart.abort(error) + } + }) + + try { + await ctx.request.multipart.process() + return next() + } catch (error) { + throw error + } + } + + /** + * Handle url-encoded form data + */ + const formConfig = this.#getConfigFor('form') + if (this.#isType(ctx.request, formConfig.types)) { + debug('detected urlencoded request "%s:%s"', requestMethod, requestUrl) + + try { + const { parsed, raw } = await parseForm(ctx.request.request, formConfig) + ctx.request.setInitialBody(parsed) + ctx.request.updateRawBody(raw) + return next() + } catch (error) { + throw this.#getExceptionFor(error) + } + } + + /** + * Handle content with JSON types + */ + const jsonConfig = this.#getConfigFor('json') + if (this.#isType(ctx.request, jsonConfig.types)) { + debug('detected JSON request "%s:%s"', requestMethod, requestUrl) + + try { + const { parsed, raw } = await parseJSON(ctx.request.request, jsonConfig) + ctx.request.setInitialBody(parsed) + ctx.request.updateRawBody(raw) + return next() + } catch (error) { + throw this.#getExceptionFor(error) + } + } + + /** + * Handles raw request body + */ + const rawConfig = this.#getConfigFor('raw') + if (this.#isType(ctx.request, rawConfig.types)) { + debug('parsing raw body "%s:%s"', requestMethod, requestUrl) + + try { + ctx.request.setInitialBody({}) + ctx.request.updateRawBody(await parseText(ctx.request.request, rawConfig)) + return next() + } catch (error) { + throw this.#getExceptionFor(error) + } + } + + await next() + } +} diff --git a/adonis-typings/index.ts b/src/debug.ts similarity index 57% rename from adonis-typings/index.ts rename to src/debug.ts index a9f1025..ddac559 100644 --- a/adonis-typings/index.ts +++ b/src/debug.ts @@ -1,11 +1,12 @@ /* * @adonisjs/bodyparser * - * (c) Harminder Virk + * (c) AdonisJS * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ -/// -/// +import { debuglog } from 'node:util' + +export default debuglog('adonisjs:bodyparser') diff --git a/src/define_config.ts b/src/define_config.ts new file mode 100644 index 0000000..016debc --- /dev/null +++ b/src/define_config.ts @@ -0,0 +1,61 @@ +/* + * @adonisjs/bodyparser + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { BodyParserConfig, BodyParserOptionalConfig } from './types.js' + +/** + * Define config for the bodyparser middleware. Your defined config will be + * merged with the default config + */ +export function defineConfig(config: BodyParserOptionalConfig): BodyParserConfig { + return { + allowedMethods: config.allowedMethods || ['POST', 'PUT', 'PATCH', 'DELETE'], + + form: { + encoding: 'utf-8', + limit: '1mb', + queryString: {}, + types: ['application/x-www-form-urlencoded'], + convertEmptyStringsToNull: true, + ...config.form, + }, + + json: { + encoding: 'utf-8', + limit: '1mb', + strict: true, + types: [ + 'application/json', + 'application/json-patch+json', + 'application/vnd.api+json', + 'application/csp-report', + ], + convertEmptyStringsToNull: true, + ...config.json, + }, + + raw: { + encoding: 'utf-8', + limit: '1mb', + types: ['text/*'], + ...config.raw, + }, + + multipart: { + autoProcess: true, + processManually: [], + encoding: 'utf-8', + maxFields: 1000, + limit: '20mb', + types: ['multipart/form-data'], + convertEmptyStringsToNull: true, + ...config.multipart, + }, + } +} diff --git a/src/FormFields/index.ts b/src/form_fields.ts similarity index 70% rename from src/FormFields/index.ts rename to src/form_fields.ts index 2ad5370..b1252f5 100644 --- a/src/FormFields/index.ts +++ b/src/form_fields.ts @@ -1,22 +1,25 @@ /* * @adonisjs/bodyparser * - * (c) Harminder Virk + * (c) AdonisJS * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ -import { lodash } from '@poppinss/utils' +import lodash from '@poppinss/utils/lodash' /** * A jar of form fields to store form data by handling * array gracefully */ export class FormFields { - private fields: any = {} + #fields: any = {} + #config: { convertEmptyStringsToNull: boolean } - constructor(private config: { convertEmptyStringsToNull: boolean }) {} + constructor(config: { convertEmptyStringsToNull: boolean }) { + this.#config = config + } /** * Add a new key/value pair. The keys with array like @@ -35,13 +38,13 @@ export class FormFields { * formfields.add('username[0]', 'nikk') * ``` */ - public add(key: string, value: any): void { + add(key: string, value: any): void { let isArray = false /** * Convert empty strings to null */ - if (this.config.convertEmptyStringsToNull && value === '') { + if (this.#config.convertEmptyStringsToNull && value === '') { value = null } @@ -57,17 +60,17 @@ export class FormFields { /** * Check to see if value exists or set it (if missing) */ - const existingValue = lodash.get(this.fields, key) + const existingValue = lodash.get(this.#fields, key) if (!existingValue) { - lodash.set(this.fields, key, isArray ? [value] : value) + lodash.set(this.#fields, key, isArray ? [value] : value) return } /** * Mutate existing value if it's an array */ - if (existingValue instanceof Array) { + if (Array.isArray(existingValue)) { existingValue.push(value) return } @@ -75,13 +78,13 @@ export class FormFields { /** * Set new value + existing value */ - lodash.set(this.fields, key, [existingValue, value]) + lodash.set(this.#fields, key, [existingValue, value]) } /** * Returns the copy of form fields */ - public get() { - return this.fields + get() { + return this.#fields } } diff --git a/src/helpers.ts b/src/helpers.ts new file mode 100644 index 0000000..1472936 --- /dev/null +++ b/src/helpers.ts @@ -0,0 +1,113 @@ +/* + * @adonisjs/bodyparser + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import type { Mode } from 'node:fs' +import mediaTyper from 'media-typer' +import { dirname, extname } from 'node:path' +import { RuntimeException } from '@poppinss/utils' +import { fileTypeFromBuffer, supportedExtensions } from 'file-type' +import { access, mkdir, copyFile, unlink, rename } from 'node:fs/promises' + +/** + * We can detect file types for these files using the magic + * number + */ +export const supportMagicFileTypes = supportedExtensions + +/** + * Attempts to parse the file mime type using the file magic number + */ +function parseMimeType(mime: string): { type: string; subtype: string } | null { + try { + const { type, subtype } = mediaTyper.parse(mime) + return { type, subtype } + } catch (error) { + return null + } +} + +/** + * Returns the file `type`, `subtype` and `extension`. + */ +export async function getFileType( + fileContents: Buffer +): Promise { + /** + * Attempt to detect file type from it's content + */ + const magicType = await fileTypeFromBuffer(fileContents) + if (magicType) { + return Object.assign({ ext: magicType.ext }, parseMimeType(magicType.mime)) + } + + return null +} + +/** + * Computes file name from the file type + */ +export function computeFileTypeFromName( + clientName: string, + headers: { [key: string]: string } +): { ext: string; type?: string; subtype?: string } { + /** + * Otherwise fallback to file extension from it's client name + * and pull type/subtype from the headers content type. + */ + return Object.assign( + { ext: extname(clientName).replace(/^\./, '') }, + parseMimeType(headers['content-type']) + ) +} + +/** + * Check if a file already exists + */ +export async function pathExists(filePath: string) { + try { + await access(filePath) + return true + } catch { + return false + } +} + +/** + * Move file from source to destination with a fallback to move file across + * paritions and devices. + */ +export async function moveFile( + sourcePath: string, + destinationPath: string, + options: { overwrite: boolean; directoryMode?: Mode } = { overwrite: true } +) { + if (!sourcePath || !destinationPath) { + throw new RuntimeException('"sourcePath" and "destinationPath" required') + } + + if (!options.overwrite && (await pathExists(destinationPath))) { + throw new RuntimeException(`The destination file already exists: "${destinationPath}"`) + } + + await mkdir(dirname(destinationPath), { + recursive: true, + mode: options.directoryMode, + }) + + try { + await rename(sourcePath, destinationPath) + } catch (error) { + if (error.code === 'EXDEV') { + await copyFile(sourcePath, destinationPath) + await unlink(sourcePath) + } else { + throw error + } + } +} diff --git a/src/multipart/file.ts b/src/multipart/file.ts new file mode 100644 index 0000000..dd695dd --- /dev/null +++ b/src/multipart/file.ts @@ -0,0 +1,220 @@ +/* + * @adonisjs/bodyparser + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { join } from 'node:path' +import { Exception } from '@poppinss/utils' +import Macroable from '@poppinss/macroable' + +import { moveFile } from '../helpers.js' +import { SizeValidator } from './validators/size.js' +import { ExtensionValidator } from './validators/extensions.js' +import type { FileJSON, FileUploadError, FileValidationOptions } from '../types.js' + +/** + * The file holds the meta/data for an uploaded file, along with + * an errors occurred during the upload process. + */ +export class MultipartFile extends Macroable { + /** + * File validators + */ + #sizeValidator = new SizeValidator(this) + #extensionValidator = new ExtensionValidator(this) + + /** + * A boolean to know if file is an instance of this class + * or not + */ + isMultipartFile: true = true + + /** + * Field name is the name of the field + */ + fieldName: string + + /** + * Client name is the file name on the user client + */ + clientName: string + + /** + * The headers sent as part of the multipart request + */ + headers: Record + + /** + * File size in bytes + */ + size: number = 0 + + /** + * The extname for the file. + */ + extname?: string + + /** + * Upload errors + */ + errors: FileUploadError[] = [] + + /** + * Type and subtype are extracted from the `content-type` + * header or from the file magic number + */ + type?: string + subtype?: string + + /** + * File path is only set after the move operation + */ + filePath?: string + + /** + * File name is only set after the move operation. It is the relative + * path of the moved file + */ + fileName?: string + + /** + * Tmp path, only exists when file is uploaded using the + * classic mode. + */ + tmpPath?: string + + /** + * The file meta data + */ + meta: any = {} + + /** + * The state of the file + */ + state: 'idle' | 'streaming' | 'consumed' | 'moved' = 'idle' + + /** + * Whether or not the validations have been executed + */ + get validated(): boolean { + return this.#sizeValidator.validated && this.#extensionValidator.validated + } + + /** + * A boolean to know if file has one or more errors + */ + get isValid() { + return this.errors.length === 0 + } + + /** + * Opposite of [[this.isValid]] + */ + get hasErrors() { + return !this.isValid + } + + /** + * The maximum file size limit + */ + get sizeLimit() { + return this.#sizeValidator.maxLimit + } + + set sizeLimit(limit: number | string | undefined) { + this.#sizeValidator.maxLimit = limit + } + + /** + * Extensions allowed + */ + get allowedExtensions() { + return this.#extensionValidator.extensions + } + + set allowedExtensions(extensions: string[] | undefined) { + this.#extensionValidator.extensions = extensions + } + + constructor( + data: { fieldName: string; clientName: string; headers: any }, + validationOptions: Partial + ) { + super() + this.sizeLimit = validationOptions.size + this.allowedExtensions = validationOptions.extnames + this.fieldName = data.fieldName + this.clientName = data.clientName + this.headers = data.headers + } + + /** + * Validate the file + */ + validate() { + this.#extensionValidator.validate() + this.#sizeValidator.validate() + } + + /** + * Mark file as moved + */ + markAsMoved(fileName: string, filePath: string) { + this.filePath = filePath + this.fileName = fileName + this.state = 'moved' + } + + /** + * Moves the file to a given location. Multiple calls to the `move` method are allowed, + * incase you want to move a file to multiple locations. + */ + async move(location: string, options?: { name?: string; overwrite?: boolean }): Promise { + if (!this.tmpPath) { + throw new Exception('property "tmpPath" must be set on the file before moving it', { + status: 500, + code: 'E_MISSING_FILE_TMP_PATH', + }) + } + + options = Object.assign({ name: this.clientName, overwrite: true }, options) + const filePath = join(location, options.name!) + + try { + await moveFile(this.tmpPath, filePath, { overwrite: options.overwrite! }) + this.markAsMoved(options.name!, filePath) + } catch (error) { + if (error.message.includes('destination file already')) { + throw new Exception( + `"${options.name!}" already exists at "${location}". Set "overwrite = true" to overwrite it` + ) + } + throw error + } + } + + /** + * Returns file JSON representation + */ + toJSON(): FileJSON { + return { + fieldName: this.fieldName, + clientName: this.clientName, + size: this.size, + filePath: this.filePath, + fileName: this.fileName, + type: this.type, + extname: this.extname, + subtype: this.subtype, + state: this.state, + isValid: this.isValid, + validated: this.validated, + errors: this.errors, + meta: this.meta, + } + } +} diff --git a/src/Multipart/index.ts b/src/multipart/main.ts similarity index 57% rename from src/Multipart/index.ts rename to src/multipart/main.ts index 9dee37f..11a9241 100644 --- a/src/Multipart/index.ts +++ b/src/multipart/main.ts @@ -1,117 +1,128 @@ /* * @adonisjs/bodyparser * - * (c) Harminder Virk + * (c) AdonisJS * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ -/// +// @ts-expect-error +import multiparty from '@poppinss/multiparty' import bytes from 'bytes' import { Exception } from '@poppinss/utils' -import multiparty from '@poppinss/multiparty' -import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext' +import type { HttpContext } from '@adonisjs/http-server' -import { +import debug from '../debug.js' +import { FormFields } from '../form_fields.js' +import { PartHandler } from './part_handler.js' +import type { MultipartStream, - MultipartContract, + FileValidationOptions, PartHandler as PartHandlerType, -} from '@ioc:Adonis/Core/BodyParser' - -import { FormFields } from '../FormFields' -import { PartHandler } from './PartHandler' -import { DriveManagerContract } from '@ioc:Adonis/Core/Drive' +} from '../types.js' /** * Multipart class offers a low level API to interact the incoming * HTTP request data as a stream. This makes it super easy to * write files to s3 without saving them to the disk first. */ -export class Multipart implements MultipartContract { +export class Multipart { + #ctx: HttpContext + #config: Partial<{ + limit: string | number + fieldsLimit: string | number + maxFields: number + convertEmptyStringsToNull: boolean + }> + /** * The registered handlers to handle the file uploads */ - private handlers: { + #handlers: { [key: string]: { handler: PartHandlerType - options: Parameters[1] + options: Partial } } = {} /** * Collected fields from the multipart stream */ - private fields = new FormFields({ - convertEmptyStringsToNull: this.config.convertEmptyStringsToNull === true, - }) + #fields: FormFields /** * Collected files from the multipart stream. Files are only collected * when there is an attached listener for a given file. */ - private files = new FormFields({ - convertEmptyStringsToNull: this.config.convertEmptyStringsToNull === true, - }) + #files: FormFields /** * We track the finishing of `this.onFile` async handlers * to make sure that `process` promise resolves for all * handlers to finish. */ - private pendingHandlers = 0 + #pendingHandlers = 0 /** * The reference to underlying multiparty form */ - private form: multiparty.Form + #form: multiparty.Form /** * Total size limit of the multipart stream. If it goes beyond * the limit, then an exception will be raised. */ - private upperLimit?: number + #upperLimit?: number /** * Total size in bytes for all the fields (not the files) */ - private maxFieldsSize?: number + #maxFieldsSize?: number /** * A track of total number of file bytes processed so far */ - private processedBytes: number = 0 + #processedBytes: number = 0 /** * The current state of the multipart form handler */ - public state: 'idle' | 'processing' | 'error' | 'success' = 'idle' + state: 'idle' | 'processing' | 'error' | 'success' = 'idle' constructor( - private ctx: HttpContextContract, - private config: Partial<{ + ctx: HttpContext, + config: Partial<{ limit: string | number fieldsLimit: string | number maxFields: number convertEmptyStringsToNull: boolean - }> = {}, - private drive: DriveManagerContract - ) {} + }> = {} + ) { + this.#ctx = ctx + this.#config = config + this.#fields = new FormFields({ + convertEmptyStringsToNull: this.#config.convertEmptyStringsToNull === true, + }) + this.#files = new FormFields({ + convertEmptyStringsToNull: this.#config.convertEmptyStringsToNull === true, + }) + } /** * Returns a boolean telling whether all streams have been * consumed along with all handlers execution */ - private isClosed(): boolean { - return this.form['flushing'] <= 0 && this.pendingHandlers <= 0 + #isClosed(): boolean { + return this.#form['flushing'] <= 0 && this.#pendingHandlers <= 0 } /** * Removes array like expression from the part name to * find the handler */ - private getHandlerName(name: string): string { + #getHandlerName(name: string): string { return name.replace(/\[\d*\]/, '') } @@ -119,14 +130,17 @@ export class Multipart implements MultipartContract { * Validates and returns an error when upper limit is defined and * processed bytes is over the upper limit */ - private validateProcessedBytes(chunkLength: number) { - if (!this.upperLimit) { + #validateProcessedBytes(chunkLength: number) { + if (!this.#upperLimit) { return } - this.processedBytes += chunkLength - if (this.processedBytes > this.upperLimit) { - return new Exception('request entity too large', 413, 'E_REQUEST_ENTITY_TOO_LARGE') + this.#processedBytes += chunkLength + if (this.#processedBytes > this.#upperLimit) { + return new Exception('request entity too large', { + code: 'E_REQUEST_ENTITY_TOO_LARGE', + status: 413, + }) } } @@ -135,7 +149,7 @@ export class Multipart implements MultipartContract { * by resuming the part, if there is no defined * handler */ - private async handlePart(part: MultipartStream) { + async #handlePart(part: MultipartStream) { /** * Skip parts with empty name or empty filenames. The empty * filenames takes place when user doesn't upload a file @@ -146,30 +160,32 @@ export class Multipart implements MultipartContract { return } - const name = this.getHandlerName(part.name) + const name = this.#getHandlerName(part.name) /** * Skip, if their is no handler to consume the part. */ - const handler = this.handlers[name] || this.handlers['*'] + const handler = this.#handlers[name] || this.#handlers['*'] if (!handler) { + debug('skipping multipart part as there are no handlers "%s"', name) part.resume() return } - this.pendingHandlers++ + debug('processing multipart part "%s"', name) + this.#pendingHandlers++ /** * Instantiate the part handler */ - const partHandler = new PartHandler(part, handler.options, this.drive) + const partHandler = new PartHandler(part, handler.options) partHandler.begin() /** * Track the file instance created by the part handler. The end user * must be able to access these files. */ - this.files.add(partHandler.file.fieldName, partHandler.file) + this.#files.add(partHandler.file.fieldName, partHandler.file) part.file = partHandler.file try { @@ -184,7 +200,7 @@ export class Multipart implements MultipartContract { * Keeping an eye on total bytes processed so far and shortcircuit * request when more than expected bytes have been received. */ - const error = this.validateProcessedBytes(lineLength) + const error = this.#validateProcessedBytes(lineLength) if (error) { part.emit('error', error) this.abort(error) @@ -194,7 +210,7 @@ export class Multipart implements MultipartContract { try { await partHandler.reportProgress(line, lineLength) } catch (err) { - this.ctx.logger.fatal( + this.#ctx.logger.fatal( 'Unhandled multipart stream error. Make sure to handle "error" events for all manually processed streams' ) } @@ -211,52 +227,52 @@ export class Multipart implements MultipartContract { await partHandler.reportError(error) } - this.pendingHandlers-- + this.#pendingHandlers-- } /** * Record the fields inside multipart contract */ - private handleField(key: string, value: string) { + #handleField(key: string, value: string) { if (!key) { return } - this.fields.add(key, value) + this.#fields.add(key, value) } /** * Processes the user config and computes the `upperLimit` value from * it. */ - private processConfig(config?: Parameters[0]) { - this.config = Object.assign(this.config, config) + #processConfig(config?: Partial<{ limit: string | number; maxFields: number }>) { + this.#config = Object.assign(this.#config, config) /** * Getting bytes from the `config.fieldsLimit` option, which can * also be a string. */ - this.maxFieldsSize = - typeof this.config!.fieldsLimit === 'string' - ? bytes(this.config.fieldsLimit) - : this.config!.fieldsLimit + this.#maxFieldsSize = + typeof this.#config!.fieldsLimit === 'string' + ? bytes(this.#config.fieldsLimit) + : this.#config!.fieldsLimit /** * Getting bytes from the `config.limit` option, which can * also be a string */ - this.upperLimit = - typeof this.config!.limit === 'string' ? bytes(this.config!.limit) : this.config!.limit + this.#upperLimit = + typeof this.#config!.limit === 'string' ? bytes(this.#config!.limit) : this.#config!.limit } /** * Mark the process as finished */ - private finish(newState: 'error' | 'success') { + #finish(newState: 'error' | 'success') { if (this.state === 'idle' || this.state === 'processing') { this.state = newState - this.ctx.request['__raw_files'] = this.files.get() - this.ctx.request.setInitialBody(this.fields.get()) + ;(this.#ctx.request as any)['__raw_files'] = this.#files.get() + this.#ctx.request.setInitialBody(this.#fields.get()) } } @@ -273,62 +289,79 @@ export class Multipart implements MultipartContract { * }) * ``` */ - public onFile( + onFile( name: string, - options: Parameters[1], + options: Partial, handler: PartHandlerType ): this { - this.handlers[name] = { handler, options } + this.#handlers[name] = { handler, options } return this } /** * Abort request by emitting error */ - public abort(error: any): void { - this.form.emit('error', error) + abort(error: any): void { + this.#form.emit('error', error) } /** * Process the request by going all the file and field * streams. */ - public process(config?: Parameters[0]): Promise { + process(config?: Partial<{ limit: string | number; maxFields: number }>): Promise { return new Promise((resolve, reject) => { if (this.state !== 'idle') { reject( - new Exception('multipart stream has already been consumed', 500, 'E_RUNTIME_EXCEPTION') + new Exception('multipart stream has already been consumed', { + code: 'E_RUNTIME_EXCEPTION', + }) ) return } this.state = 'processing' - this.processConfig(config) + this.#processConfig(config) - this.form = new multiparty.Form({ - maxFields: this.config!.maxFields, - maxFieldsSize: this.maxFieldsSize, + this.#form = new multiparty.Form({ + maxFields: this.#config!.maxFields, + maxFieldsSize: this.#maxFieldsSize, }) + debug('processing multipart body') + /** * Raise error when form encounters an * error */ - this.form.on('error', (error: Error) => { - this.finish('error') + this.#form.on('error', (error: Error) => { + this.#finish('error') process.nextTick(() => { - if (this.ctx.request.request.readable) { - this.ctx.request.request.resume() + if (this.#ctx.request.request.readable) { + this.#ctx.request.request.resume() } if (error.message.match(/stream ended unexpectedly/)) { - reject(new Exception('Invalid multipart request', 400, 'E_INVALID_MULTIPART_REQUEST')) + reject( + new Exception('Invalid multipart request', { + status: 400, + code: 'E_INVALID_MULTIPART_REQUEST', + }) + ) } else if (error.message.match(/maxFields [0-9]+ exceeded/)) { - reject(new Exception('Fields length limit exceeded', 413, 'E_REQUEST_ENTITY_TOO_LARGE')) + reject( + new Exception('Fields length limit exceeded', { + status: 413, + code: 'E_REQUEST_ENTITY_TOO_LARGE', + }) + ) } else if (error.message.match(/maxFieldsSize [0-9]+ exceeded/)) { reject( - new Exception('Fields size in bytes exceeded', 413, 'E_REQUEST_ENTITY_TOO_LARGE') + new Exception('Fields size in bytes exceeded', { + status: 413, + code: 'E_REQUEST_ENTITY_TOO_LARGE', + }) ) } else { reject(error) @@ -341,16 +374,16 @@ export class Multipart implements MultipartContract { * promise when all parts are consumed and processed * by their handlers */ - this.form.on('part', async (part: MultipartStream) => { - await this.handlePart(part) + this.#form.on('part', async (part: MultipartStream) => { + await this.#handlePart(part) /** * When a stream finishes before the handler, the close `event` * will not resolve the current Promise. So in that case, we * check and resolve from here */ - if (this.isClosed()) { - this.finish('success') + if (this.#isClosed()) { + this.#finish('success') resolve() } }) @@ -358,9 +391,9 @@ export class Multipart implements MultipartContract { /** * Listen for fields */ - this.form.on('field', (key: string, value: any) => { + this.#form.on('field', (key: string, value: any) => { try { - this.handleField(key, value) + this.#handleField(key, value) } catch (error) { this.abort(error) } @@ -370,14 +403,14 @@ export class Multipart implements MultipartContract { * Resolve promise on close, when all internal * file handlers are done processing files */ - this.form.on('close', () => { - if (this.isClosed()) { - this.finish('success') + this.#form.on('close', () => { + if (this.#isClosed()) { + this.#finish('success') resolve() } }) - this.form.parse(this.ctx.request.request) + this.#form.parse(this.#ctx.request.request) }) } } diff --git a/src/Multipart/PartHandler.ts b/src/multipart/part_handler.ts similarity index 71% rename from src/Multipart/PartHandler.ts rename to src/multipart/part_handler.ts index f913f3c..ce6d876 100644 --- a/src/Multipart/PartHandler.ts +++ b/src/multipart/part_handler.ts @@ -1,21 +1,18 @@ /* * @adonisjs/bodyparser * - * (c) Harminder Virk + * (c) AdonisJS * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ -/// - -import { extname } from 'path' +import { extname } from 'node:path' import { Exception } from '@poppinss/utils' -import { DriveManagerContract } from '@ioc:Adonis/Core/Drive' -import { MultipartStream, FileValidationOptions } from '@ioc:Adonis/Core/BodyParser' -import { File } from './File' -import { computeFileTypeFromName, getFileType, supportMagicFileTypes } from '../utils' +import { MultipartFile } from './file.js' +import type { MultipartStream, FileValidationOptions } from '../types.js' +import { computeFileTypeFromName, getFileType, supportMagicFileTypes } from '../helpers.js' /** * Part handler handles the progress of a stream and also internally validates @@ -29,11 +26,22 @@ import { computeFileTypeFromName, getFileType, supportMagicFileTypes } from '../ * stream by themselves and report each chunk to this class. */ export class PartHandler { + #part: MultipartStream + #options: Partial + /** * The stream buffer reported by the stream consumer. We hold the buffer until are * able to detect the file extension and then buff memory is released */ - private buff?: Buffer + #buff?: Buffer + + /** + * A boolean to know, if we have emitted the error event after one or + * more validation errors. We need this flag, since the race conditions + * between `data` and `error` events will trigger multiple `error` + * emit. + */ + #emittedValidationError = false /** * A boolean to know if we can use the magic number to detect the file type. This is how it @@ -47,8 +55,8 @@ export class PartHandler { * * Think of this as using the optimal way for validating the file type */ - private get canFileTypeBeDetected() { - const fileExtension = extname(this.part.filename).replace(/^\./, '') as any + get #canFileTypeBeDetected() { + const fileExtension = extname(this.#part.filename).replace(/^\./, '') as any return fileExtension ? supportMagicFileTypes.has(fileExtension) : true } @@ -57,44 +65,38 @@ export class PartHandler { * Creating a new file object for each part inside the multipart * form data */ - public file = new File( - { - clientName: this.part.filename, - fieldName: this.part.name, - headers: this.part.headers, - }, - { - size: this.options.size, - extnames: this.options.extnames, - }, - this.drive - ) - - /** - * A boolean to know, if we have emitted the error event after one or - * more validation errors. We need this flag, since the race conditions - * between `data` and `error` events will trigger multiple `error` - * emit. - */ - private emittedValidationError = false + file: MultipartFile constructor( - private part: MultipartStream, - private options: Partial, - private drive: DriveManagerContract - ) {} + part: MultipartStream, + options: Partial + ) { + this.#part = part + this.#options = options + this.file = new MultipartFile( + { + clientName: part.filename, + fieldName: part.name, + headers: part.headers, + }, + { + size: options.size, + extnames: options.extnames, + } + ) + } /** * Detects the file type and extension and also validates it when validations * are not deferred. */ - private async detectFileTypeAndExtension() { - if (!this.buff) { + async #detectFileTypeAndExtension() { + if (!this.#buff) { return } - const fileType = this.canFileTypeBeDetected - ? await getFileType(this.buff) + const fileType = this.#canFileTypeBeDetected + ? await getFileType(this.#buff) : computeFileTypeFromName(this.file.clientName, this.file.headers) if (fileType) { @@ -108,17 +110,17 @@ export class PartHandler { * Skip the stream or end it forcefully. This is invoked when the * streaming consumer reports an error */ - private skipEndStream() { - this.part.emit('close') + #skipEndStream() { + this.#part.emit('close') } /** * Finish the process of listening for any more events and mark the * file state as consumed. */ - private finish() { + #finish() { this.file.state = 'consumed' - if (!this.options.deferValidations) { + if (!this.#options.deferValidations) { this.file.validate() } } @@ -127,7 +129,7 @@ export class PartHandler { * Start the process the updating the file state * to streaming mode. */ - public begin() { + begin() { this.file.state = 'streaming' } @@ -135,7 +137,7 @@ export class PartHandler { * Handles the file upload progress by validating the file size and * extension. */ - public async reportProgress(line: Buffer, bufferLength: number) { + async reportProgress(line: Buffer, bufferLength: number) { /** * Do not consume stream data when file state is not `streaming`. Stream * events race conditions may emit the `data` event after the `error` @@ -151,10 +153,10 @@ export class PartHandler { * file extension from it's content. */ if (this.file.extname === undefined) { - this.buff = this.buff ? Buffer.concat([this.buff, line]) : line - await this.detectFileTypeAndExtension() + this.#buff = this.#buff ? Buffer.concat([this.#buff, line]) : line + await this.#detectFileTypeAndExtension() } else { - this.buff = undefined + this.#buff = undefined } /** @@ -165,7 +167,7 @@ export class PartHandler { /** * Validate the file on every chunk, unless validations have been deferred. */ - if (this.options.deferValidations) { + if (this.#options.deferValidations) { return } @@ -175,11 +177,14 @@ export class PartHandler { * call `reportError`. */ this.file.validate() - if (!this.file.isValid && !this.emittedValidationError) { - this.emittedValidationError = true - this.part.emit( + if (!this.file.isValid && !this.#emittedValidationError) { + this.#emittedValidationError = true + this.#part.emit( 'error', - new Exception('one or more validations failed', 400, 'E_STREAM_VALIDATION_FAILURE') + new Exception('one or more validations failed', { + code: 'E_STREAM_VALIDATION_FAILURE', + status: 400, + }) ) } } @@ -189,13 +194,13 @@ export class PartHandler { * apart from the one reported by this class. For example: The `s3` failure * due to some bad credentails. */ - public async reportError(error: any) { + async reportError(error: any) { if (this.file.state !== 'streaming') { return } - this.skipEndStream() - this.finish() + this.#skipEndStream() + this.#finish() if (error.code === 'E_STREAM_VALIDATION_FAILURE') { return @@ -215,9 +220,7 @@ export class PartHandler { /** * Report success data about the file. */ - public async reportSuccess( - data?: { filePath?: string; tmpPath?: string } & { [key: string]: any } - ) { + async reportSuccess(data?: { filePath?: string; tmpPath?: string } & { [key: string]: any }) { if (this.file.state !== 'streaming') { return } @@ -227,7 +230,7 @@ export class PartHandler { * consuming the stream */ if (this.file.extname === undefined) { - await this.detectFileTypeAndExtension() + await this.#detectFileTypeAndExtension() } if (data) { @@ -243,6 +246,6 @@ export class PartHandler { this.file.meta = meta || {} } - this.finish() + this.#finish() } } diff --git a/src/Multipart/streamFile.ts b/src/multipart/stream_file.ts similarity index 74% rename from src/Multipart/streamFile.ts rename to src/multipart/stream_file.ts index 5ce5a18..a727e32 100644 --- a/src/Multipart/streamFile.ts +++ b/src/multipart/stream_file.ts @@ -1,18 +1,16 @@ /* * @adonisjs/bodyparser * - * (c) Harminder Virk + * (c) AdonisJS * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ -import { promisify } from 'util' -import { unlink } from 'fs-extra' -import { createWriteStream } from 'fs' -import { Readable, pipeline } from 'stream' - -const pump = promisify(pipeline) +import { Readable } from 'node:stream' +import { unlink } from 'node:fs/promises' +import { createWriteStream } from 'node:fs' +import { pipeline } from 'node:stream/promises' /** * Writes readable stream to the given location by properly cleaning up readable @@ -31,7 +29,7 @@ export async function streamFile( const writeStream = createWriteStream(location) try { - await pump(readStream, writeStream) + await pipeline(readStream, writeStream) } catch (error) { unlink(writeStream.path).catch(() => {}) throw error diff --git a/src/multipart/validators/extensions.ts b/src/multipart/validators/extensions.ts new file mode 100644 index 0000000..9710e56 --- /dev/null +++ b/src/multipart/validators/extensions.ts @@ -0,0 +1,128 @@ +/* + * @adonisjs/bodyparser + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { MultipartFile } from '../file.js' + +/** + * Validates the file extension + */ +export class ExtensionValidator { + #file: MultipartFile + #allowedExtensions?: string[] = [] + + validated: boolean = false + + /** + * Update the expected file extensions + */ + get extensions(): string[] | undefined { + return this.#allowedExtensions + } + + set extensions(extnames: string[] | undefined) { + if (this.#allowedExtensions && this.#allowedExtensions.length) { + throw new Error('Cannot update allowed extension names after file has been validated') + } + + this.validated = false + this.#allowedExtensions = extnames + } + + constructor(file: MultipartFile) { + this.#file = file + } + + /** + * Report error to the file + */ + #reportError() { + /** + * File is invalid, so report the error + */ + const suffix = this.#allowedExtensions!.length === 1 ? 'is' : 'are' + + const message = [ + `Invalid file extension ${this.#file.extname}.`, + `Only ${this.#allowedExtensions!.join(', ')} ${suffix} allowed`, + ].join(' ') + + this.#file.errors.push({ + fieldName: this.#file.fieldName, + clientName: this.#file.clientName, + message: message, + type: 'extname', + }) + } + + /** + * Validating the file in the streaming mode. During this mode + * we defer the validation, until we get the file extname. + */ + #validateWhenGettingStreamed() { + if (!this.#file.extname) { + return + } + + this.validated = true + + /** + * Valid extension type + */ + if (this.#allowedExtensions!.includes(this.#file.extname)) { + return + } + + this.#reportError() + } + + /** + * Validate the file extension after it has been streamed + */ + #validateAfterConsumed() { + this.validated = true + + /** + * Valid extension type + */ + if (this.#allowedExtensions!.includes(this.#file.extname || '')) { + return + } + + this.#reportError() + } + + /** + * Validate the file + */ + validate(): void { + /** + * Do not validate if already validated + */ + if (this.validated) { + return + } + + /** + * Do not run validations, when constraints on the extension are not set + */ + if (!Array.isArray(this.#allowedExtensions) || this.#allowedExtensions.length === 0) { + this.validated = true + return + } + + if (this.#file.state === 'streaming') { + this.#validateWhenGettingStreamed() + return + } + + if (this.#file.state === 'consumed') { + this.#validateAfterConsumed() + } + } +} diff --git a/src/multipart/validators/size.ts b/src/multipart/validators/size.ts new file mode 100644 index 0000000..d7adaee --- /dev/null +++ b/src/multipart/validators/size.ts @@ -0,0 +1,111 @@ +/* + * @adonisjs/bodyparser + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import bytes from 'bytes' +import { MultipartFile } from '../file.js' + +/** + * Size validator validates the file size + */ +export class SizeValidator { + #file: MultipartFile + #maximumAllowedLimit?: number | string + #bytesLimit: number = 0 + + validated: boolean = false + + /** + * Defining the maximum bytes the file can have + */ + get maxLimit(): number | string | undefined { + return this.#maximumAllowedLimit + } + set maxLimit(limit: number | string | undefined) { + if (this.#maximumAllowedLimit !== undefined) { + throw new Error('Cannot reset sizeLimit after file has been validated') + } + + this.validated = false + this.#maximumAllowedLimit = limit + + if (this.#maximumAllowedLimit) { + this.#bytesLimit = + typeof this.#maximumAllowedLimit === 'string' + ? bytes(this.#maximumAllowedLimit) + : this.#maximumAllowedLimit + } + } + + constructor(file: MultipartFile) { + this.#file = file + } + + /** + * Reporting error to the file + */ + #reportError() { + this.#file.errors.push({ + fieldName: this.#file.fieldName, + clientName: this.#file.clientName, + message: `File size should be less than ${bytes(this.#bytesLimit)}`, + type: 'size', + }) + } + + /** + * Validating file size while it is getting streamed. We only mark + * the file as `validated` when it's validation fails. Otherwise + * we keep re-validating the file as we receive more data. + */ + #validateWhenGettingStreamed() { + if (this.#file.size > this.#bytesLimit) { + this.validated = true + this.#reportError() + } + } + + /** + * We have the final file size after the stream has been consumed. At this + * stage we always mark `validated = true`. + */ + #validateAfterConsumed() { + this.validated = true + if (this.#file.size > this.#bytesLimit) { + this.#reportError() + } + } + + /** + * Validate the file size + */ + validate() { + if (this.validated) { + return + } + + /** + * Do not attempt to validate when `maximumAllowedLimit` is not + * defined. + */ + if (this.#maximumAllowedLimit === undefined) { + this.validated = true + return + } + + if (this.#file.state === 'streaming') { + this.#validateWhenGettingStreamed() + return + } + + if (this.#file.state === 'consumed') { + this.#validateAfterConsumed() + return + } + } +} diff --git a/src/parsers/form.ts b/src/parsers/form.ts new file mode 100644 index 0000000..b9b5d8c --- /dev/null +++ b/src/parsers/form.ts @@ -0,0 +1,71 @@ +/* + * @adonisjs/bodyparser + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import raw from 'raw-body' +import inflate from 'inflation' +import qs, { IParseOptions } from 'qs' +import type { IncomingMessage } from 'node:http' +import { BodyParserFormConfig } from '../types.js' + +/** + * Parse x-www-form-urlencoded request body + */ +export async function parseForm(req: IncomingMessage, options: Partial) { + /** + * Shallow clone options + */ + const normalizedOptions = Object.assign( + { + encoding: 'utf8', + limit: '56kb', + length: 0, + }, + options + ) + + /** + * Shallow clone query string options + */ + const queryStringOptions: IParseOptions = Object.assign({}, normalizedOptions.queryString) + + /** + * Mimicing behavior of + * https://github.com/poppinss/co-body/blob/master/lib/form.js#L30 + */ + if (queryStringOptions.allowDots === undefined) { + queryStringOptions.allowDots = true + } + + /** + * Mimicing behavior of + * https://github.com/poppinss/co-body/blob/master/lib/form.js#L35 + */ + const contentLength = req.headers['content-length'] + const encoding = req.headers['content-encoding'] || 'identity' + if (contentLength && encoding === 'identity') { + normalizedOptions.length = ~~contentLength + } + + /** + * Convert empty strings to null + */ + if (normalizedOptions.convertEmptyStringsToNull) { + queryStringOptions.decoder = function (str, defaultDecoder, charset, type) { + const value = defaultDecoder(str, defaultDecoder, charset) + if (type === 'value' && value === '') { + return null + } + return value + } + } + + const requestBody = await raw(inflate(req), normalizedOptions) + const parsed = qs.parse(requestBody, queryStringOptions) + return { parsed, raw: requestBody } +} diff --git a/src/parsers/json.ts b/src/parsers/json.ts new file mode 100644 index 0000000..eecdb2f --- /dev/null +++ b/src/parsers/json.ts @@ -0,0 +1,104 @@ +/* + * @adonisjs/bodyparser + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import raw from 'raw-body' +import inflate from 'inflation' +import json from '@poppinss/utils/json' +import { Exception } from '@poppinss/utils' +import type { IncomingMessage } from 'node:http' +import { BodyParserJSONConfig } from '../types.js' + +/** + * Allowed whitespace is defined in RFC 7159 + * http://www.rfc-editor.org/rfc/rfc7159.txt + */ +// eslint-disable-next-line no-control-regex +const strictJSONReg = /^[\x20\x09\x0a\x0d]*(\[|\{)/ + +/** + * JSON reviver to convert empty strings to null + */ +function convertEmptyStringsToNull(key: string, value: any) { + if (key === '') { + return value + } + + if (value === '') { + return null + } + + return value +} + +/** + * Parses JSON request body + */ +export async function parseJSON(req: IncomingMessage, options: Partial) { + /** + * Shallow clone options + */ + const normalizedOptions = Object.assign( + { + encoding: 'utf8', + limit: '1mb', + length: 0, + }, + options + ) + + /** + * Mimicing behavior of + * https://github.com/poppinss/co-body/blob/master/lib/json.js#L47 + */ + const contentLength = req.headers['content-length'] + const encoding = req.headers['content-encoding'] || 'identity' + if (contentLength && encoding === 'identity') { + normalizedOptions.length = ~~contentLength + } + + const strict = normalizedOptions.strict !== false + const reviver = normalizedOptions.convertEmptyStringsToNull + ? convertEmptyStringsToNull + : undefined + + const requestBody = await raw(inflate(req), normalizedOptions) + + /** + * Do not parse body when request body is empty + */ + if (!requestBody) { + return strict + ? { + parsed: {}, + raw: requestBody, + } + : { + parsed: requestBody, + raw: requestBody, + } + } + + /** + * Test JSON body to ensure it is valid JSON in strict mode + */ + if (strict && !strictJSONReg.test(requestBody)) { + throw new Exception('Invalid JSON, only supports object and array', { status: 422 }) + } + + try { + return { + parsed: json.safeParse(requestBody, reviver), + raw: requestBody, + } + } catch (error) { + error.status = 400 + error.body = requestBody + throw error + } +} diff --git a/src/parsers/text.ts b/src/parsers/text.ts new file mode 100644 index 0000000..01a32a2 --- /dev/null +++ b/src/parsers/text.ts @@ -0,0 +1,42 @@ +/* + * @adonisjs/bodyparser + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import raw from 'raw-body' +import inflate from 'inflation' +import type { IncomingMessage } from 'node:http' +import { BodyParserRawConfig } from '../types.js' + +/** + * Inflates request body + */ +export function parseText(req: IncomingMessage, options: Partial) { + /** + * Shallow clone options + */ + const normalizedOptions = Object.assign( + { + encoding: 'utf8', + limit: '1mb', + length: 0, + }, + options + ) + + /** + * Mimicing behavior of + * https://github.com/poppinss/co-body/blob/master/lib/text.js#L30 + */ + const contentLength = req.headers['content-length'] + const encoding = req.headers['content-encoding'] || 'identity' + if (contentLength && encoding === 'identity') { + normalizedOptions.length = ~~contentLength + } + + return raw(inflate(req), normalizedOptions) +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..e472db0 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,168 @@ +/* + * @adonisjs/bodyparser + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { Readable } from 'node:stream' +import type { MultipartFile } from './multipart/file.js' + +/** + * Qs module config + */ +type QueryStringConfig = { + depth?: number + allowPrototypes?: boolean + plainObjects?: boolean + parameterLimit?: number + arrayLimit?: number + ignoreQueryPrefix?: boolean + delimiter?: RegExp | string + allowDots?: boolean + charset?: 'utf-8' | 'iso-8859-1' | undefined + charsetSentinel?: boolean + interpretNumericEntities?: boolean + parseArrays?: boolean + comma?: boolean +} + +/** + * Base config used by all types + */ +type BodyParserBaseConfig = { + encoding: string + limit: string | number + types: string[] +} + +/** + * Body parser config for parsing JSON requests + */ +export type BodyParserJSONConfig = BodyParserBaseConfig & { + strict: boolean + convertEmptyStringsToNull: boolean +} + +/** + * Parser config for parsing form data + */ +export type BodyParserFormConfig = BodyParserBaseConfig & { + queryString: QueryStringConfig + convertEmptyStringsToNull: boolean +} + +/** + * Parser config for parsing raw body (untouched) + */ +export type BodyParserRawConfig = BodyParserBaseConfig + +/** + * Parser config for parsing multipart requests + */ +export type BodyParserMultipartConfig = BodyParserBaseConfig & { + autoProcess: boolean | string[] + maxFields: number + processManually: string[] + convertEmptyStringsToNull: boolean + fieldsLimit?: number | string + tmpFileName?(): string +} + +/** + * Body parser config for all supported form types + */ +export type BodyParserConfig = { + allowedMethods: string[] + json: BodyParserJSONConfig + form: BodyParserFormConfig + raw: BodyParserRawConfig + multipart: BodyParserMultipartConfig +} + +/** + * Body parser config with partial config + */ +export type BodyParserOptionalConfig = { + allowedMethods?: string[] + json?: Partial + form?: Partial + raw?: Partial + multipart?: Partial +} + +/** + * ------------------------------------ + * Multipart related options + * ------------------------------------ + */ + +/** + * Readable stream along with some extra data. This is what + * is passed to `onFile` handlers. + */ +export type MultipartStream = Readable & { + headers: { + [key: string]: string + } + name: string + filename: string + bytes: number + file: MultipartFile +} + +/** + * The callback handler for a given file part + */ +export type PartHandler = ( + part: MultipartStream, + reportChunk: (chunk: Buffer) => void +) => Promise<({ filePath?: string; tmpPath?: string } & { [key: string]: any }) | void> + +/** + * ------------------------------------ + * Multipart file related options + * ------------------------------------ + */ + +/** + * The options that can be used to validate a given + * file + */ +export type FileValidationOptions = { + size: string | number + extnames: string[] +} + +/** + * Error shape for file upload errors + */ +export type FileUploadError = { + fieldName: string + clientName: string + message: string + type: 'size' | 'extname' | 'fatal' +} + +/** + * Shape of file.toJSON return value + */ +export type FileJSON = { + fieldName: string + clientName: string + size: number + filePath?: string + fileName?: string + type?: string + extname?: string + subtype?: string + state: 'idle' | 'streaming' | 'consumed' | 'moved' + isValid: boolean + validated: boolean + errors: FileUploadError[] + meta: any +} + +export { MultipartFile } diff --git a/src/utils.ts b/src/utils.ts deleted file mode 100644 index 02356e8..0000000 --- a/src/utils.ts +++ /dev/null @@ -1,66 +0,0 @@ -/* - * @adonisjs/bodyparser - * - * (c) Harminder Virk - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -/// - -import { extname } from 'path' -import { fromBuffer, extensions } from 'file-type' -import mediaTyper from 'media-typer' - -/** - * We can detect file types for these files using the magic - * number - */ -export const supportMagicFileTypes = extensions - -/** - * Attempts to parse the file mime type using the file magic number - */ -function parseMimeType(mime: string): { type: string; subtype: string } | null { - try { - const { type, subtype } = mediaTyper.parse(mime) - return { type, subtype } - } catch (error) { - return null - } -} - -/** - * Returns the file `type`, `subtype` and `extension`. - */ -export async function getFileType( - fileContents: Buffer -): Promise { - /** - * Attempt to detect file type from it's content - */ - const magicType = await fromBuffer(fileContents) - if (magicType) { - return Object.assign({ ext: magicType.ext }, parseMimeType(magicType.mime)) - } - - return null -} - -/** - * Computes file name from the file type - */ -export function computeFileTypeFromName( - clientName: string, - headers: { [key: string]: string } -): { ext: string; type?: string; subtype?: string } { - /** - * Otherwise fallback to file extension from it's client name - * and pull type/subtype from the headers content type. - */ - return Object.assign( - { ext: extname(clientName).replace(/^\./, '') }, - parseMimeType(headers['content-type']) - ) -} diff --git a/test-helpers/drive.ts b/test-helpers/drive.ts deleted file mode 100644 index d8a727a..0000000 --- a/test-helpers/drive.ts +++ /dev/null @@ -1,8 +0,0 @@ -declare module '@ioc:Adonis/Core/Drive' { - interface DisksList { - local: { - implementation: LocalDriverContract - config: LocalDriverConfig - } - } -} diff --git a/test-helpers/index.ts b/test-helpers/index.ts deleted file mode 100644 index 3931d4c..0000000 --- a/test-helpers/index.ts +++ /dev/null @@ -1,119 +0,0 @@ -/* - * @adonisjs/bodyparser - * - * (c) Harminder Virk - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -/// - -import slash from 'slash' -import { EOL } from 'os' -import { join } from 'path' -import { Filesystem } from '@poppinss/dev-utils' -import { Application } from '@adonisjs/application' -import { BodyParserConfig } from '@ioc:Adonis/Core/BodyParser' - -const contents = JSON.stringify(require('../package.json'), null, 2).split('\n').join(EOL) - -export const xlsFilePath = join(__dirname, '../sample.xls') -export const xlsxFilePath = join(__dirname, '../sample.xlsx') -export const packageFilePath = join(__dirname, '../package.json') -export const packageFileSize = Buffer.from(contents, 'utf-8').length + EOL.length -export const sleep = (time: number) => new Promise((resolve) => setTimeout(resolve, time)) -export const fs = new Filesystem(join(__dirname, 'app')) - -export const bodyParserConfig: BodyParserConfig = { - whitelistedMethods: ['POST', 'PUT', 'PATCH', 'DELETE'], - json: { - encoding: 'utf-8', - limit: '1mb', - strict: true, - types: [ - 'application/json', - 'application/json-patch+json', - 'application/vnd.api+json', - 'application/csp-report', - ], - }, - form: { - encoding: 'utf-8', - limit: '1mb', - queryString: {}, - convertEmptyStringsToNull: true, - types: ['application/x-www-form-urlencoded'], - }, - raw: { - encoding: 'utf-8', - limit: '1mb', - queryString: {}, - types: ['text/*'], - }, - multipart: { - autoProcess: true, - convertEmptyStringsToNull: true, - processManually: [], - encoding: 'utf-8', - maxFields: 1000, - limit: '20mb', - types: ['multipart/form-data'], - }, -} - -/** - * Setup application - */ -export async function setupApp(providers?: string[]) { - const app = new Application(fs.basePath, 'web', { - providers: ['@adonisjs/encryption', '@adonisjs/http-server', '@adonisjs/drive'].concat( - providers || [] - ), - }) - await fs.add('.env', '') - await fs.add( - 'config/app.ts', - ` - export const appKey = 'verylongandrandom32charsecretkey' - export const http = { - trustProxy: () => true, - cookie: {}, - } - ` - ) - - await fs.add( - 'config/bodyparser.ts', - ` - const bodyParserConfig = ${JSON.stringify(bodyParserConfig)} - export default bodyParserConfig - ` - ) - - await fs.add( - 'config/drive.ts', - ` - const driveConfig = { - disk: 'local', - disks: { - local: { - driver: 'local', - root: '${slash(join(fs.basePath, 'uploads'))}', - basePath: '/', - serveAssets: true, - visibility: 'public' - } - } - } - - export default driveConfig - ` - ) - - await app.setup() - await app.registerProviders() - await app.bootProviders() - - return app -} diff --git a/test/body-parser-provider.spec.ts b/test/body-parser-provider.spec.ts deleted file mode 100644 index 3c86cd8..0000000 --- a/test/body-parser-provider.spec.ts +++ /dev/null @@ -1,35 +0,0 @@ -/* - * @adonisjs/bodyparser - * - * (c) Harminder Virk - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { test } from '@japa/runner' -import { BodyParserMiddleware } from '../src/BodyParser' -import { setupApp, fs } from '../test-helpers' - -test.group('BodyParser Provider', (group) => { - group.each.teardown(async () => { - await fs.cleanup().catch(() => {}) - }) - - test('register bodyparser provider', async ({ assert }) => { - const app = await setupApp(['../../providers/BodyParserProvider']) - assert.deepEqual(app.container.use('Adonis/Core/BodyParser'), BodyParserMiddleware) - assert.instanceOf( - app.container.make(app.container.use('Adonis/Core/BodyParser')), - BodyParserMiddleware - ) - }) - - test('extend request class by adding the file methods', async ({ assert }) => { - const app = await setupApp(['../../providers/BodyParserProvider']) - assert.deepEqual(app.container.use('Adonis/Core/BodyParser'), BodyParserMiddleware) - assert.property(app.container.use('Adonis/Core/Request').prototype, 'file') - assert.property(app.container.use('Adonis/Core/Request').prototype, 'files') - assert.property(app.container.use('Adonis/Core/Request').prototype, 'allFiles') - }) -}) diff --git a/test/multipart.spec.ts b/test/multipart.spec.ts deleted file mode 100644 index 5167406..0000000 --- a/test/multipart.spec.ts +++ /dev/null @@ -1,890 +0,0 @@ -/* - * @adonisjs/bodyparser - * - * (c) Harminder Virk - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -/// - -import { test } from '@japa/runner' -import { join } from 'path' -import supertest from 'supertest' -import { createServer } from 'http' -import { pathExists, remove, createWriteStream } from 'fs-extra' -import { ApplicationContract } from '@ioc:Adonis/Core/Application' - -import { Multipart } from '../src/Multipart' -import { File } from '../src/Multipart/File' -import { - fs, - sleep, - setupApp, - xlsFilePath, - xlsxFilePath, - packageFilePath, - packageFileSize, -} from '../test-helpers' - -let app: ApplicationContract - -test.group('Multipart', (group) => { - group.setup(async () => { - app = await setupApp() - return () => fs.cleanup() - }) - - test('process file by attaching handler on field name', async ({ assert }) => { - let files: null | { [key: string]: File } = null - - const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - const multipart = new Multipart( - ctx, - { maxFields: 1000, limit: 8000 }, - app.container.use('Adonis/Core/Drive') - ) - - multipart.onFile('package', {}, (part, reporter) => { - return new Promise((resolve, reject) => { - part.on('data', (line) => { - reporter(line) - }) - part.on('error', reject) - part.on('end', resolve) - }) - }) - - await multipart.process() - files = ctx.request['__raw_files'] - res.end() - }) - - await supertest(server).post('/').attach('package', packageFilePath) - assert.property(files, 'package') - assert.isTrue(files!.package.isValid) - assert.equal(files!.package.state, 'consumed') - assert.equal(files!.package.size, packageFileSize) - }) - - test('error inside onFile handler should propogate to file errors', async ({ assert }) => { - let files: null | { [key: string]: File } = null - - const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - const multipart = new Multipart( - ctx, - { maxFields: 1000, limit: 8000 }, - app.container.use('Adonis/Core/Drive') - ) - - multipart.onFile('package', {}, (part, reporter) => { - return new Promise((_resolve, reject) => { - part.on('data', (line) => { - reporter(line) - }) - part.on('error', reject) - reject(Error('Cannot process')) - }) - }) - - await multipart.process() - files = ctx.request['__raw_files'] || null - res.end() - }) - - await supertest(server).post('/').attach('package', packageFilePath) - assert.property(files, 'package') - assert.isFalse(files!.package.isValid) - assert.equal(files!.package.state, 'consumed') - assert.deepEqual(files!.package.errors, [ - { - fieldName: 'package', - clientName: 'package.json', - message: 'Cannot process', - type: 'fatal', - }, - ]) - }) - - test('wait for promise to return even when part has been streamed', async ({ assert }) => { - let files: null | { [key: string]: File } = null - const stack: string[] = [] - - const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - const multipart = new Multipart( - ctx, - { maxFields: 1000, limit: 8000 }, - app.container.use('Adonis/Core/Drive') - ) - - multipart.onFile('package', {}, async (part, reporter) => { - part.on('data', (line) => { - reporter(line) - }) - - stack.push('before') - part.resume() - await sleep(100) - stack.push('after') - }) - - await multipart.process() - files = ctx.request['__raw_files'] - stack.push('ended') - res.end() - }) - - await supertest(server).post('/').attach('package', packageFilePath) - assert.deepEqual(stack, ['before', 'after', 'ended']) - assert.property(files, 'package') - assert.isTrue(files!.package.isValid) - assert.equal(files!.package.state, 'consumed') - assert.equal(files!.package.size, packageFileSize) - }) - - test('work fine when stream is piped to a destination', async ({ assert }) => { - const SAMPLE_FILE_PATH = join(__dirname, './sample.json') - let files: null | { [key: string]: File } = null - - const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - const multipart = new Multipart( - ctx, - { maxFields: 1000, limit: 8000 }, - app.container.use('Adonis/Core/Drive') - ) - - multipart.onFile('package', {}, (part, reporter) => { - return new Promise((resolve, reject) => { - part.pause() - part.on('data', (line) => { - reporter(line) - }) - - part.on('error', reject) - part.on('end', resolve) - part.pipe(createWriteStream(SAMPLE_FILE_PATH)) - }) - }) - - await multipart.process() - files = ctx.request['__raw_files'] - - const hasFile = await pathExists(SAMPLE_FILE_PATH) - res.end(String(hasFile)) - }) - - const { text } = await supertest(server).post('/').attach('package', packageFilePath) - - assert.property(files, 'package') - assert.isTrue(files!.package.isValid) - assert.equal(files!.package.size, packageFileSize) - assert.equal(files!.package.state, 'consumed') - assert.equal(text, 'true') - - await remove(SAMPLE_FILE_PATH) - }) - - test('work fine with array of files', async ({ assert }) => { - const stack: string[] = [] - let files: null | { [key: string]: File } = null - - const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - const multipart = new Multipart( - ctx, - { maxFields: 1000, limit: 8000 }, - app.container.use('Adonis/Core/Drive') - ) - - multipart.onFile('package', {}, async (part, reporter) => { - part.on('data', reporter) - - stack.push('before') - part.resume() - await sleep(100) - stack.push('after') - }) - - await multipart.process() - files = ctx.request['__raw_files'] - stack.push('ended') - res.end() - }) - - await supertest(server).post('/').attach('package[]', packageFilePath) - - assert.deepEqual(stack, ['before', 'after', 'ended']) - assert.property(files, 'package') - assert.isTrue(files!.package[0].isValid) - assert.equal(files!.package[0].state, 'consumed') - assert.equal(files!.package[0].size, packageFileSize) - }) - - test('work fine with indexed array of files', async ({ assert }) => { - const stack: string[] = [] - let files: null | { [key: string]: File } = null - - const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - const multipart = new Multipart( - ctx, - { maxFields: 1000, limit: 8000 }, - app.container.use('Adonis/Core/Drive') - ) - - multipart.onFile('package', {}, async (part, reporter) => { - part.on('data', reporter) - - stack.push('before') - part.resume() - await sleep(100) - stack.push('after') - }) - - await multipart.process() - files = ctx.request['__raw_files'] - stack.push('ended') - res.end() - }) - - await supertest(server).post('/').attach('package[0]', packageFilePath) - assert.deepEqual(stack, ['before', 'after', 'ended']) - assert.property(files, 'package') - assert.isTrue(files!.package[0].isValid) - assert.equal(files!.package[0].state, 'consumed') - assert.equal(files!.package[0].size, packageFileSize) - }) - - test('pass file to wildcard handler when defined', async ({ assert }) => { - const stack: string[] = [] - let files: null | { [key: string]: File } = null - - const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - const multipart = new Multipart( - ctx, - { maxFields: 1000, limit: 8000 }, - app.container.use('Adonis/Core/Drive') - ) - - multipart.onFile('*', {}, async (part, reporter) => { - part.on('data', reporter) - - stack.push('before') - part.resume() - await sleep(100) - stack.push('after') - }) - - await multipart.process() - files = ctx.request['__raw_files'] - stack.push('ended') - res.end() - }) - - await supertest(server).post('/').attach('package', packageFilePath) - assert.deepEqual(stack, ['before', 'after', 'ended']) - assert.property(files, 'package') - assert.isTrue(files!.package.isValid) - assert.equal(files!.package.state, 'consumed') - assert.equal(files!.package.size, packageFileSize) - }) - - test('collect fields automatically', async ({ assert }) => { - const stack: string[] = [] - let files: null | { [key: string]: File } = null - let fields: null | { [key: string]: any } = null - - const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - const multipart = new Multipart( - ctx, - { maxFields: 1000, limit: 8000 }, - app.container.use('Adonis/Core/Drive') - ) - - multipart.onFile('*', {}, (part, reporter) => { - return new Promise((resolve, reject) => { - part.on('data', reporter) - part.on('error', reject) - part.on('end', resolve) - stack.push('file') - part.resume() - }) - }) - - await multipart.process() - files = ctx.request['__raw_files'] - fields = ctx.request.all() - stack.push('ended') - res.end() - }) - - await supertest(server).post('/').attach('package', packageFilePath).field('name', 'virk') - - assert.deepEqual(stack, ['file', 'ended']) - assert.property(files, 'package') - assert.isTrue(files!.package.isValid) - assert.equal(files!.package.size, packageFileSize) - assert.equal(files!.package.state, 'consumed') - assert.deepEqual(fields, { name: 'virk' }) - }) - - test('FIELDS: raise error when process is invoked multiple times', async ({ assert }) => { - const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - const multipart = new Multipart( - ctx, - { maxFields: 1000, limit: 8000 }, - app.container.use('Adonis/Core/Drive') - ) - - try { - await multipart.process() - await multipart.process() - res.end() - } catch (error) { - res.writeHead(500) - res.end(error.message) - } - }) - - const { text } = await supertest(server).post('/').field('name', 'virk') - - assert.equal(text, 'E_RUNTIME_EXCEPTION: multipart stream has already been consumed') - }) - - test('FIELDS: raise error when maxFields are crossed', async ({ assert }) => { - const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - const multipart = new Multipart( - ctx, - { maxFields: 1, limit: 8000 }, - app.container.use('Adonis/Core/Drive') - ) - - try { - await multipart.process() - res.end() - } catch (error) { - res.writeHead(500) - res.end(error.message) - } - }) - - const { text } = await supertest(server).post('/').field('name', 'virk').field('age', '22') - - assert.equal(text, 'E_REQUEST_ENTITY_TOO_LARGE: Fields length limit exceeded') - }) - - test('FIELDS: raise error when bytes limit is crossed', async ({ assert }) => { - const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - const multipart = new Multipart( - ctx, - { maxFields: 1000, fieldsLimit: 2 }, - app.container.use('Adonis/Core/Drive') - ) - - try { - await multipart.process() - res.end() - } catch (error) { - res.writeHead(500) - res.end(error.message) - } - }) - - const { text } = await supertest(server).post('/').field('name', 'virk').field('age', '22') - assert.equal(text, 'E_REQUEST_ENTITY_TOO_LARGE: Fields size in bytes exceeded') - }) - - test('disrupt file uploads error when total bytes limit is crossed', async ({ assert }) => { - assert.plan(2) - - const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - const multipart = new Multipart( - ctx, - { maxFields: 1000, limit: 20 }, - app.container.use('Adonis/Core/Drive') - ) - - multipart.onFile('package', {}, (part, report) => { - return new Promise((resolve, reject) => { - part.on('error', (error) => { - assert.equal(error.message, 'E_REQUEST_ENTITY_TOO_LARGE: request entity too large') - reject(error) - }) - - part.on('data', report) - part.on('end', resolve) - }) - }) - - try { - await multipart.process() - res.end() - } catch (error) { - res.writeHead(500) - res.end(error.message) - } - }) - - try { - const { text } = await supertest(server) - .post('/') - .attach('package', packageFilePath) - .field('name', 'virk') - .field('age', '22') - - assert.equal(text, 'E_REQUEST_ENTITY_TOO_LARGE: request entity too large') - } catch (error) { - assert.oneOf(error.code, ['ECONNABORTED', 'ECONNRESET']) - } - }) - - test('disrupt part streaming when validation fails', async ({ assert }) => { - let files: null | { [key: string]: File } = null - assert.plan(5) - - const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - const multipart = new Multipart( - ctx, - { maxFields: 1000, limit: 8000 }, - app.container.use('Adonis/Core/Drive') - ) - - multipart.onFile( - '*', - { - size: 10, - }, - (part, reporter) => { - return new Promise((resolve, reject) => { - part.on('error', (error: any) => { - assert.equal(error.code, 'E_STREAM_VALIDATION_FAILURE') - reject(error) - }) - part.on('end', resolve) - part.on('data', reporter) - }) - } - ) - - await multipart.process() - files = ctx.request['__raw_files'] || null - res.end() - }) - - await supertest(server).post('/').attach('package', packageFilePath) - - assert.property(files, 'package') - assert.isFalse(files!.package.isValid) - assert.equal(files!.package.state, 'consumed') - assert.deepEqual(files!.package.errors, [ - { - type: 'size', - clientName: 'package.json', - fieldName: 'package', - message: 'File size should be less than 10B', - }, - ]) - }) - - test('validate stream only once', async ({ assert }) => { - assert.plan(5) - let files: null | { [key: string]: File } = null - - const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - const multipart = new Multipart( - ctx, - { maxFields: 1000 }, - app.container.use('Adonis/Core/Drive') - ) - - multipart.onFile('*', { size: 10 }, (part, reporter) => { - return new Promise((resolve, reject) => { - part.on('error', (error: any) => { - assert.equal(error.code, 'E_STREAM_VALIDATION_FAILURE') - reject(error) - }) - - part.on('end', resolve) - part.on('data', reporter) - }) - }) - - await multipart.process() - files = ctx.request['__raw_files'] || null - res.end() - }) - - await supertest(server).post('/').attach('profile', join(__dirname, '..', 'unicorn.png')) - - assert.property(files, 'profile') - assert.isFalse(files!.profile.isValid) - assert.equal(files!.profile.state, 'consumed') - assert.deepEqual(files!.profile.errors, [ - { - type: 'size', - clientName: 'unicorn.png', - fieldName: 'profile', - message: 'File size should be less than 10B', - }, - ]) - }) - - test('report extension validation errors', async ({ assert }) => { - assert.plan(4) - - let files: null | { [key: string]: File } = null - - const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - const multipart = new Multipart( - ctx, - { maxFields: 1000, limit: 8000 }, - app.container.use('Adonis/Core/Drive') - ) - - multipart.onFile( - '*', - { - extnames: ['jpg'], - }, - (part, reporter) => { - return new Promise((resolve, reject) => { - part.on('error', reject) - part.on('end', resolve) - part.on('data', reporter) - }) - } - ) - - await multipart.process() - files = ctx.request['__raw_files'] || null - res.end() - }) - - await supertest(server).post('/').attach('package', packageFilePath) - - assert.property(files, 'package') - assert.isFalse(files!.package.isValid) - assert.equal(files!.package.state, 'consumed') - assert.deepEqual(files!.package.errors, [ - { - type: 'extname', - clientName: 'package.json', - fieldName: 'package', - message: 'Invalid file extension json. Only jpg is allowed', - }, - ]) - }) - - test('do not run validations when deferValidations is set to true', async ({ assert }) => { - let files: null | { [key: string]: File } = null - - const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - const multipart = new Multipart( - ctx, - { maxFields: 1000, limit: 8000 }, - app.container.use('Adonis/Core/Drive') - ) - - multipart.onFile( - '*', - { - size: 10, - deferValidations: true, - }, - (part, reporter) => { - return new Promise((resolve, reject) => { - part.on('data', reporter) - part.on('end', resolve) - part.on('error', reject) - }) - } - ) - - await multipart.process() - files = ctx.request['__raw_files'] || null - res.end() - }) - - await supertest(server).post('/').attach('package', packageFilePath) - - assert.property(files, 'package') - assert.isTrue(files!.package.isValid) - assert.isFalse(files!.package.validated) - assert.equal(files!.package.state, 'consumed') - assert.equal(files!.package.extname, 'json') - assert.deepEqual(files!.package.errors, []) - }) - - test('work fine when stream is errored without reading', async ({ assert }) => { - let files: null | { [key: string]: File } = null - - const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - const multipart = new Multipart( - ctx, - { maxFields: 1000, limit: 8000 }, - app.container.use('Adonis/Core/Drive') - ) - - multipart.onFile('package', {}, (part) => { - return new Promise((resolve) => { - part.on('error', () => { - /** - * Node.js doesn't emit the "close" event when stream is not flowing - */ - part.emit('close') - - /** - * We resolve, to reproduce the above defined behavior. If we reject - * the promise, then the part handler will emit "end" instead - */ - resolve() - }) - - part.on('end', () => { - resolve() - }) - - part.emit('error', new Error('abort')) - }) - }) - - await multipart.process() - files = ctx.request['__raw_files'] - res.end() - }) - - await supertest(server).post('/').attach('package', packageFilePath) - assert.property(files, 'package') - assert.isTrue(files!.package.isValid) - assert.equal(files!.package.size, 0) - assert.equal(files!.package.state, 'consumed') - }) - - test('end request when abort is called without ending the part', async ({ assert }) => { - let files: null | { [key: string]: File } = null - - const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - const multipart = new Multipart( - ctx, - { maxFields: 1000, limit: 8000 }, - app.container.use('Adonis/Core/Drive') - ) - - multipart.onFile('package', {}, (part) => { - return new Promise((resolve) => { - part.on('error', (error) => { - setTimeout(() => multipart.abort(error)) - - /** - * We resolve, to reproduce the above defined behavior. If we reject - * the promise, then the part handler will emit "end" instead - */ - resolve() - }) - - part.on('end', () => { - resolve() - }) - - part.emit('error', new Error('abort')) - }) - }) - - try { - await multipart.process() - } catch {} - - files = ctx.request['__raw_files'] - res.end() - }) - - await supertest(server).post('/').attach('package', packageFilePath) - assert.property(files, 'package') - assert.isTrue(files!.package.isValid) - assert.equal(files!.package.size, 0) - assert.equal(files!.package.state, 'consumed') - }).retry(3) - - test('report extension validation errors when unable to detect extension till completion', async ({ - assert, - }) => { - assert.plan(4) - - let files: null | { [key: string]: File } = null - - const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - const multipart = new Multipart( - ctx, - { maxFields: 1000, limit: 8000 }, - app.container.use('Adonis/Core/Drive') - ) - - multipart.onFile( - '*', - { - extnames: ['jpg'], - }, - (part, reporter) => { - return new Promise((resolve, reject) => { - part.on('error', reject) - part.on('end', resolve) - part.on('data', reporter) - }) - } - ) - - await multipart.process() - files = ctx.request['__raw_files'] || null - res.end() - }) - - await supertest(server).post('/').attach('package', packageFilePath) - - assert.property(files, 'package') - assert.isFalse(files!.package.isValid) - assert.equal(files!.package.state, 'consumed') - assert.deepEqual(files!.package.errors, [ - { - type: 'extname', - clientName: 'package.json', - fieldName: 'package', - message: 'Invalid file extension json. Only jpg is allowed', - }, - ]) - }) - - test('correctly retrieve file extension from magic number', async ({ assert }) => { - let files: null | { [key: string]: File } = null - - const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - const multipart = new Multipart( - ctx, - { maxFields: 1000 }, - app.container.use('Adonis/Core/Drive') - ) - - multipart.onFile('*', {}, (part, reporter) => { - return new Promise((resolve, reject) => { - part.on('error', reject) - part.on('end', resolve) - part.on('data', reporter) - }) - }) - - await multipart.process() - files = ctx.request['__raw_files'] || null - res.end() - }) - - await supertest(server) - .post('/') - .attach('picture', join(__dirname, '..', 'unicorn-wo-ext'), { contentType: 'image/png' }) - - assert.property(files, 'picture') - assert.isTrue(files!.picture.isValid) - assert.equal(files!.picture.state, 'consumed') - assert.equal(files!.picture.extname, 'png') - assert.lengthOf(files!.picture.errors, 0) - }) - - test('validate xlsx extension', async ({ assert }) => { - let files: null | { [key: string]: File } = null - - const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - const multipart = new Multipart( - ctx, - { maxFields: 1000, limit: 8000 }, - app.container.use('Adonis/Core/Drive') - ) - - multipart.onFile( - '*', - { - extnames: ['xlsx'], - }, - (part, reporter) => { - return new Promise((resolve, reject) => { - part.on('error', reject) - part.on('end', resolve) - part.on('data', reporter) - }) - } - ) - - await multipart.process() - files = ctx.request['__raw_files'] || null - res.end() - }) - - await supertest(server).post('/').attach('report', xlsxFilePath) - - assert.property(files, 'report') - assert.isTrue(files!.report.isValid) - assert.equal(files!.report.state, 'consumed') - assert.equal(files!.report.extname, 'xlsx') - assert.lengthOf(files!.report.errors, 0) - }) - - test('validate xls extension', async ({ assert }) => { - let files: null | { [key: string]: File } = null - - const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - const multipart = new Multipart( - ctx, - { maxFields: 1000, limit: 10000 }, - app.container.use('Adonis/Core/Drive') - ) - - multipart.onFile( - '*', - { - extnames: ['xls'], - }, - (part, reporter) => { - return new Promise((resolve, reject) => { - part.on('error', reject) - part.on('end', resolve) - part.on('data', reporter) - }) - } - ) - - try { - await multipart.process() - } catch (error) { - console.log(error) - } - files = ctx.request['__raw_files'] || null - res.end() - }) - - await supertest(server).post('/').attach('report', xlsFilePath) - - assert.property(files, 'report') - assert.isTrue(files!.report.isValid) - assert.equal(files!.report.state, 'consumed') - assert.equal(files!.report.extname, 'xls') - assert.lengthOf(files!.report.errors, 0) - }) -}) diff --git a/test/body-parser.spec.ts b/tests/body_parser.spec.ts similarity index 59% rename from test/body-parser.spec.ts rename to tests/body_parser.spec.ts index bc40669..370caec 100644 --- a/test/body-parser.spec.ts +++ b/tests/body_parser.spec.ts @@ -1,55 +1,38 @@ /* * @adonisjs/bodyparser * - * (c) Harminder Virk + * (c) AdonisJS * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ -/// -/// - import 'reflect-metadata' -import { join } from 'path' -import { tmpdir } from 'os' -import fetch from 'node-fetch' +import { fetch } from 'undici' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import supertest from 'supertest' import { test } from '@japa/runner' -import { createServer } from 'http' -import { lodash } from '@poppinss/utils' -import { pathExists, remove, readFile, outputFile } from 'fs-extra' -import { RequestConstructorContract } from '@ioc:Adonis/Core/Request' -import { Request as BaseRequest } from '@adonisjs/http-server/build/src/Request' - -import { MultipartFileContract } from '@ioc:Adonis/Core/BodyParser' -import { ApplicationContract } from '@ioc:Adonis/Core/Application' - -import { Multipart } from '../src/Multipart' -import extendRequest from '../src/Bindings/Request' -import { BodyParserMiddleware } from '../src/BodyParser' -import { packageFilePath, packageFileSize, setupApp, fs } from '../test-helpers' - -const Request = BaseRequest as unknown as RequestConstructorContract -let app: ApplicationContract - -test.group('BodyParser Middleware | generic', (group) => { - group.each.setup(() => { - extendRequest(Request) - }) - - group.each.setup(async () => { - app = await setupApp() - return async () => await fs.cleanup() - }) - +import { pathExists } from 'fs-extra' +import { createServer } from 'node:http' +import { readFile } from 'node:fs/promises' +import { + RequestFactory, + ResponseFactory, + HttpContextFactory, +} from '@adonisjs/http-server/factories' +import { Multipart } from '../src/multipart/main.js' +import { MultipartFile } from '../src/multipart/file.js' +import { BodyParserMiddlewareFactory } from '../factories/middleware_factory.js' +import { packageFilePath, packageFileSize, unicornFilePath } from '../tests_helpers/main.js' + +test.group('BodyParser Middleware', () => { test('do not parse get requests', async ({ assert }) => { const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - const middleware = new BodyParserMiddleware( - app.container.use('Adonis/Core/Config'), - app.container.use('Adonis/Core/Drive') - ) + const request = new RequestFactory().merge({ req, res }).create() + const response = new ResponseFactory().merge({ req, res }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + const middleware = new BodyParserMiddlewareFactory().create() await middleware.handle(ctx, async () => { res.writeHead(200, { 'content-type': 'application/json' }) @@ -64,11 +47,10 @@ test.group('BodyParser Middleware | generic', (group) => { test('by pass when body is empty', async ({ assert }) => { const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - const middleware = new BodyParserMiddleware( - app.container.use('Adonis/Core/Config'), - app.container.use('Adonis/Core/Drive') - ) + const request = new RequestFactory().merge({ req, res }).create() + const response = new ResponseFactory().merge({ req, res }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + const middleware = new BodyParserMiddlewareFactory().create() await middleware.handle(ctx, async () => { res.writeHead(200, { 'content-type': 'application/json' }) @@ -83,11 +65,10 @@ test.group('BodyParser Middleware | generic', (group) => { test('by pass when content type is not supported', async ({ assert }) => { const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - const middleware = new BodyParserMiddleware( - app.container.use('Adonis/Core/Config'), - app.container.use('Adonis/Core/Drive') - ) + const request = new RequestFactory().merge({ req, res }).create() + const response = new ResponseFactory().merge({ req, res }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + const middleware = new BodyParserMiddlewareFactory().create() await middleware.handle(ctx, async () => { res.writeHead(200, { 'content-type': 'application/json' }) @@ -104,23 +85,13 @@ test.group('BodyParser Middleware | generic', (group) => { }) }) -test.group('BodyParser Middleware | form data', (group) => { - group.each.setup(() => { - extendRequest(Request) - }) - - group.each.setup(async () => { - app = await setupApp() - return async () => await fs.cleanup() - }) - +test.group('BodyParser Middleware | form data', () => { test('handle request with form data', async ({ assert }) => { const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - const middleware = new BodyParserMiddleware( - app.container.use('Adonis/Core/Config'), - app.container.use('Adonis/Core/Drive') - ) + const request = new RequestFactory().merge({ req, res }).create() + const response = new ResponseFactory().merge({ req, res }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + const middleware = new BodyParserMiddlewareFactory().create() await middleware.handle(ctx, async () => { res.writeHead(200, { 'content-type': 'application/json' }) @@ -135,17 +106,16 @@ test.group('BodyParser Middleware | form data', (group) => { test('abort if request size is over limit', async ({ assert }) => { const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - - lodash.merge(app.container.use('Adonis/Core/Config').get('bodyparser'), { - form: { - limit: 2, - }, - }) - const middleware = new BodyParserMiddleware( - app.container.use('Adonis/Core/Config'), - app.container.use('Adonis/Core/Drive') - ) + const request = new RequestFactory().merge({ req, res }).create() + const response = new ResponseFactory().merge({ req, res }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + const middleware = new BodyParserMiddlewareFactory() + .merge({ + form: { + limit: 2, + }, + }) + .create() try { await middleware.handle(ctx, async () => {}) @@ -161,22 +131,21 @@ test.group('BodyParser Middleware | form data', (group) => { .send({ username: 'virk' }) .expect(413) - assert.deepEqual(text, 'E_REQUEST_ENTITY_TOO_LARGE: request entity too large') + assert.deepEqual(text, 'request entity too large') }) test('abort if specified encoding is not supported', async ({ assert }) => { const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - - lodash.merge(app.container.use('Adonis/Core/Config').get('bodyparser'), { - form: { - encoding: 'foo', - }, - }) - const middleware = new BodyParserMiddleware( - app.container.use('Adonis/Core/Config'), - app.container.use('Adonis/Core/Drive') - ) + const request = new RequestFactory().merge({ req, res }).create() + const response = new ResponseFactory().merge({ req, res }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + const middleware = new BodyParserMiddlewareFactory() + .merge({ + form: { + encoding: 'foo', + }, + }) + .create() try { await middleware.handle(ctx, async () => {}) @@ -192,16 +161,15 @@ test.group('BodyParser Middleware | form data', (group) => { .send({ username: 'virk' }) .expect(415) - assert.deepEqual(text, 'E_ENCODING_UNSUPPORTED: specified encoding unsupported') + assert.deepEqual(text, 'specified encoding unsupported') }) test('ignore fields with empty name', async ({ assert }) => { const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - const middleware = new BodyParserMiddleware( - app.container.use('Adonis/Core/Config'), - app.container.use('Adonis/Core/Drive') - ) + const request = new RequestFactory().merge({ req, res }).create() + const response = new ResponseFactory().merge({ req, res }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + const middleware = new BodyParserMiddlewareFactory().create() await middleware.handle(ctx, async () => { res.writeHead(200, { 'content-type': 'application/json' }) @@ -215,11 +183,10 @@ test.group('BodyParser Middleware | form data', (group) => { test('convert empty strings to null', async ({ assert }) => { const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - const middleware = new BodyParserMiddleware( - app.container.use('Adonis/Core/Config'), - app.container.use('Adonis/Core/Drive') - ) + const request = new RequestFactory().merge({ req, res }).create() + const response = new ResponseFactory().merge({ req, res }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + const middleware = new BodyParserMiddlewareFactory().create() await middleware.handle(ctx, async () => { res.writeHead(200, { 'content-type': 'application/json' }) @@ -236,12 +203,10 @@ test.group('BodyParser Middleware | form data', (group) => { test('abort when multipart body is invalid', async ({ assert, cleanup }) => { const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - - const middleware = new BodyParserMiddleware( - app.container.use('Adonis/Core/Config'), - app.container.use('Adonis/Core/Drive') - ) + const request = new RequestFactory().merge({ req, res }).create() + const response = new ResponseFactory().merge({ req, res }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + const middleware = new BodyParserMiddlewareFactory().create() try { await middleware.handle(ctx, async () => {}) @@ -250,8 +215,10 @@ test.group('BodyParser Middleware | form data', (group) => { res.end(error.message) } }) + cleanup(() => { + server.close() + }) - cleanup(() => server.close()) await new Promise((resolve) => server.listen(3333, 'localhost', () => resolve())) const response = await fetch('http://localhost:3333', { @@ -262,17 +229,15 @@ test.group('BodyParser Middleware | form data', (group) => { body: '--9d01a3fb93deedb4d0a81389271d097f28fd67e2fcbff2932befc0458ad7\x0d\x0aContent-Disposition: form-data; name="test"; filename="csv_files/test.csv"\x0d\x0aContent-Type: application/octet-stream\x0d\x0a\x0d\x0atest123', }) - assert.equal(await response.text(), 'E_INVALID_MULTIPART_REQUEST: Invalid multipart request') + assert.equal(await response.text(), 'Invalid multipart request') }) test('abort when multipart body is invalid newline characters', async ({ assert, cleanup }) => { const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - - const middleware = new BodyParserMiddleware( - app.container.use('Adonis/Core/Config'), - app.container.use('Adonis/Core/Drive') - ) + const request = new RequestFactory().merge({ req, res }).create() + const response = new ResponseFactory().merge({ req, res }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + const middleware = new BodyParserMiddlewareFactory().create() try { await middleware.handle(ctx, async () => {}) @@ -282,7 +247,9 @@ test.group('BodyParser Middleware | form data', (group) => { } }) - cleanup(() => server.close()) + cleanup(() => { + server.close() + }) await new Promise((resolve) => server.listen(3333, 'localhost', () => resolve())) const response = await fetch('http://localhost:3333', { @@ -297,23 +264,13 @@ test.group('BodyParser Middleware | form data', (group) => { }) }) -test.group('BodyParser Middleware | json', (group) => { - group.each.setup(() => { - extendRequest(Request) - }) - - group.each.setup(async () => { - app = await setupApp() - return async () => await fs.cleanup() - }) - +test.group('BodyParser Middleware | json', () => { test('handle request with json body', async ({ assert }) => { const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - const middleware = new BodyParserMiddleware( - app.container.use('Adonis/Core/Config'), - app.container.use('Adonis/Core/Drive') - ) + const request = new RequestFactory().merge({ req, res }).create() + const response = new ResponseFactory().merge({ req, res }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + const middleware = new BodyParserMiddlewareFactory().create() await middleware.handle(ctx, async () => { res.writeHead(200, { 'content-type': 'application/json' }) @@ -328,17 +285,16 @@ test.group('BodyParser Middleware | json', (group) => { test('abort if request size is over limit', async ({ assert }) => { const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - lodash.merge(app.container.use('Adonis/Core/Config').get('bodyparser'), { - json: { - limit: 2, - }, - }) - - const middleware = new BodyParserMiddleware( - app.container.use('Adonis/Core/Config'), - app.container.use('Adonis/Core/Drive') - ) + const request = new RequestFactory().merge({ req, res }).create() + const response = new ResponseFactory().merge({ req, res }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + const middleware = new BodyParserMiddlewareFactory() + .merge({ + json: { + limit: 2, + }, + }) + .create() try { await middleware.handle(ctx, async () => {}) @@ -354,16 +310,15 @@ test.group('BodyParser Middleware | json', (group) => { .send({ username: 'virk' }) .expect(413) - assert.deepEqual(text, 'E_REQUEST_ENTITY_TOO_LARGE: request entity too large') + assert.deepEqual(text, 'request entity too large') }) test('ignore fields with empty name', async ({ assert }) => { const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - const middleware = new BodyParserMiddleware( - app.container.use('Adonis/Core/Config'), - app.container.use('Adonis/Core/Drive') - ) + const request = new RequestFactory().merge({ req, res }).create() + const response = new ResponseFactory().merge({ req, res }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + const middleware = new BodyParserMiddlewareFactory().create() await middleware.handle(ctx, async () => { res.writeHead(200, { 'content-type': 'application/json' }) @@ -375,25 +330,35 @@ test.group('BodyParser Middleware | json', (group) => { assert.deepEqual(body, { '': 'virk' }) }) -}) -test.group('BodyParser Middleware | raw body', (group) => { - group.each.setup(() => { - extendRequest(Request) - }) + test('convert empty strings to null', async ({ assert }) => { + const server = createServer(async (req, res) => { + const request = new RequestFactory().merge({ req, res }).create() + const response = new ResponseFactory().merge({ req, res }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + const middleware = new BodyParserMiddlewareFactory().create() + + await middleware.handle(ctx, async () => { + res.writeHead(200, { 'content-type': 'application/json' }) + res.end(JSON.stringify(ctx.request.all())) + }) + }) + + const { body } = await supertest(server).post('/').type('json').send({ name: '' }) - group.each.setup(async () => { - app = await setupApp() - return async () => await fs.cleanup() + assert.deepEqual(body, { + name: null, + }) }) +}) +test.group('BodyParser Middleware | raw body', () => { test('handle request with raw body', async ({ assert }) => { const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - const middleware = new BodyParserMiddleware( - app.container.use('Adonis/Core/Config'), - app.container.use('Adonis/Core/Drive') - ) + const request = new RequestFactory().merge({ req, res }).create() + const response = new ResponseFactory().merge({ req, res }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + const middleware = new BodyParserMiddlewareFactory().create() await middleware.handle(ctx, async () => { res.writeHead(200, { 'content-type': 'application/json' }) @@ -411,17 +376,17 @@ test.group('BodyParser Middleware | raw body', (group) => { test('abort if request size is over limit', async ({ assert }) => { const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - lodash.merge(app.container.use('Adonis/Core/Config').get('bodyparser'), { - raw: { - limit: 2, - }, - }) + const request = new RequestFactory().merge({ req, res }).create() + const response = new ResponseFactory().merge({ req, res }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + const middleware = new BodyParserMiddlewareFactory() + .merge({ + raw: { + limit: 2, + }, + }) + .create() - const middleware = new BodyParserMiddleware( - app.container.use('Adonis/Core/Config'), - app.container.use('Adonis/Core/Drive') - ) try { await middleware.handle(ctx, async () => {}) } catch (error) { @@ -436,35 +401,27 @@ test.group('BodyParser Middleware | raw body', (group) => { .send(JSON.stringify({ username: 'virk' })) .expect(413) - assert.deepEqual(text, 'E_REQUEST_ENTITY_TOO_LARGE: request entity too large') + assert.deepEqual(text, 'request entity too large') }) }) -test.group('BodyParser Middleware | multipart', (group) => { - group.each.setup(() => { - extendRequest(Request) - }) - - group.each.setup(async () => { - app = await setupApp() - return async () => await fs.cleanup() - }) - +test.group('BodyParser Middleware | multipart', () => { test('handle request with just files', async ({ assert }) => { const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - const middleware = new BodyParserMiddleware( - app.container.use('Adonis/Core/Config'), - app.container.use('Adonis/Core/Drive') - ) + const request = new RequestFactory().merge({ req, res }).create() + const response = new ResponseFactory().merge({ req, res }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + const middleware = new BodyParserMiddlewareFactory().create() await middleware.handle(ctx, async () => { + const pkgFile = ctx.request['__raw_files'].package as MultipartFile + res.writeHead(200, { 'content-type': 'application/json' }) res.end( JSON.stringify({ - tmpPath: ctx.request['__raw_files'].package.tmpPath, - size: ctx.request['__raw_files'].package.size, - validated: ctx.request['__raw_files'].package.validated, + tmpPath: pkgFile.tmpPath, + size: pkgFile.size, + validated: pkgFile.validated, }) ) }) @@ -479,18 +436,19 @@ test.group('BodyParser Middleware | multipart', (group) => { test('handle request with files and fields', async ({ assert }) => { const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - const middleware = new BodyParserMiddleware( - app.container.use('Adonis/Core/Config'), - app.container.use('Adonis/Core/Drive') - ) + const request = new RequestFactory().merge({ req, res }).create() + const response = new ResponseFactory().merge({ req, res }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + const middleware = new BodyParserMiddlewareFactory().create() await middleware.handle(ctx, async () => { + const pkgFile = ctx.request['__raw_files'].package as MultipartFile + res.writeHead(200, { 'content-type': 'application/json' }) res.end( JSON.stringify({ - size: ctx.request['__raw_files'].package.size, - validated: ctx.request['__raw_files'].package.validated, + size: pkgFile.size, + validated: pkgFile.validated, username: ctx.request.input('username'), }) ) @@ -509,11 +467,10 @@ test.group('BodyParser Middleware | multipart', (group) => { test('handle request array of files', async ({ assert }) => { const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - const middleware = new BodyParserMiddleware( - app.container.use('Adonis/Core/Config'), - app.container.use('Adonis/Core/Drive') - ) + const request = new RequestFactory().merge({ req, res }).create() + const response = new ResponseFactory().merge({ req, res }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + const middleware = new BodyParserMiddlewareFactory().create() await middleware.handle(ctx, async () => { res.writeHead(200, { 'content-type': 'application/json' }) @@ -537,22 +494,19 @@ test.group('BodyParser Middleware | multipart', (group) => { let index = 0 const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - - lodash.merge(app.container.use('Adonis/Core/Config').get('bodyparser'), { - multipart: { - autoProcess: true, - tmpFileName() { - return `${index++}.tmp` + const request = new RequestFactory().merge({ req, res }).create() + const response = new ResponseFactory().merge({ req, res }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + const middleware = new BodyParserMiddlewareFactory() + .merge({ + multipart: { + tmpFileName() { + return `${index++}.tmp` + }, + limit: packageFileSize * 2 - 10, }, - types: ['multipart/form-data'], - limit: packageFileSize * 2 - 10, - }, - }) - const middleware = new BodyParserMiddleware( - app.container.use('Adonis/Core/Config'), - app.container.use('Adonis/Core/Drive') - ) + }) + .create() try { await middleware.handle(ctx, async () => {}) @@ -567,22 +521,21 @@ test.group('BodyParser Middleware | multipart', (group) => { .attach('package[]', packageFilePath) .attach('package[]', packageFilePath) - assert.equal(text, 'E_REQUEST_ENTITY_TOO_LARGE: request entity too large') + assert.equal(text, 'request entity too large') const file1 = await pathExists(join(tmpdir(), '0.tmp')) const file2 = await pathExists(join(tmpdir(), '1.tmp')) assert.isTrue(file1) assert.isFalse(file2) - }) + }).retry(2) test('handle request with empty field name', async ({ assert }) => { const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - const middleware = new BodyParserMiddleware( - app.container.use('Adonis/Core/Config'), - app.container.use('Adonis/Core/Drive') - ) + const request = new RequestFactory().merge({ req, res }).create() + const response = new ResponseFactory().merge({ req, res }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + const middleware = new BodyParserMiddlewareFactory().create() await middleware.handle(ctx, async () => { res.writeHead(200, { 'content-type': 'application/json' }) @@ -600,11 +553,10 @@ test.group('BodyParser Middleware | multipart', (group) => { test('handle request with empty file name', async ({ assert }) => { const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - const middleware = new BodyParserMiddleware( - app.container.use('Adonis/Core/Config'), - app.container.use('Adonis/Core/Drive') - ) + const request = new RequestFactory().merge({ req, res }).create() + const response = new ResponseFactory().merge({ req, res }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + const middleware = new BodyParserMiddlewareFactory().create() await middleware.handle(ctx, async () => { res.writeHead(200) @@ -621,17 +573,16 @@ test.group('BodyParser Middleware | multipart', (group) => { assert.plan(2) const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - lodash.merge(app.container.use('Adonis/Core/Config').get('bodyparser'), { - multipart: { - autoProcess: false, - }, - }) - - const middleware = new BodyParserMiddleware( - app.container.use('Adonis/Core/Config'), - app.container.use('Adonis/Core/Drive') - ) + const request = new RequestFactory().merge({ req, res }).create() + const response = new ResponseFactory().merge({ req, res }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + const middleware = new BodyParserMiddlewareFactory() + .merge({ + multipart: { + autoProcess: false, + }, + }) + .create() await middleware.handle(ctx, async () => { assert.deepEqual(ctx.request['__raw_files'], {}) @@ -648,18 +599,60 @@ test.group('BodyParser Middleware | multipart', (group) => { assert.plan(2) const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - lodash.merge(app.container.use('Adonis/Core/Config').get('bodyparser'), { - multipart: { - autoProcess: true, - processManually: ['/'], - }, + const request = new RequestFactory().merge({ req, res }).create() + const response = new ResponseFactory().merge({ req, res }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + ctx.route = { + pattern: '/', + execute: () => {}, + handler: () => {}, + meta: {}, + middleware: {} as any, + } + + const middleware = new BodyParserMiddlewareFactory() + .merge({ + multipart: { + autoProcess: true, + processManually: ['/'], + }, + }) + .create() + + await middleware.handle(ctx, async () => { + assert.deepEqual(ctx.request.__raw_files, {}) + assert.instanceOf(ctx.request.multipart, Multipart) + await ctx.request.multipart.process() + res.end() }) + }) + + await supertest(server).post('/').attach('package', packageFilePath).field('username', 'virk') + }).retry(3) - const middleware = new BodyParserMiddleware( - app.container.use('Adonis/Core/Config'), - app.container.use('Adonis/Core/Drive') - ) + test('do not process request when processManually has dynamic route', async ({ assert }) => { + assert.plan(2) + + const server = createServer(async (req, res) => { + const request = new RequestFactory().merge({ req, res }).create() + const response = new ResponseFactory().merge({ req, res }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + ctx.route = { + pattern: '/project/:id/file', + execute: () => {}, + handler: () => {}, + meta: {}, + middleware: {} as any, + } + + const middleware = new BodyParserMiddlewareFactory() + .merge({ + multipart: { + autoProcess: true, + processManually: ['/project/:id/file'], + }, + }) + .create() await middleware.handle(ctx, async () => { assert.deepEqual(ctx.request['__raw_files'], {}) @@ -670,27 +663,30 @@ test.group('BodyParser Middleware | multipart', (group) => { }) await supertest(server).post('/').attach('package', packageFilePath).field('username', 'virk') - }).retry(3) + }) - test('do not process request when processManually has dynamic route', async ({ assert }) => { + test('do not process request when autoProcess route does not match', async ({ assert }) => { assert.plan(2) const server = createServer(async (req, res) => { - const ctx = app.container - .use('Adonis/Core/HttpContext') - .create('/project/:id/file', { id: 1 }, req, res) - - lodash.merge(app.container.use('Adonis/Core/Config').get('bodyparser'), { - multipart: { - autoProcess: true, - processManually: ['/project/:id/file'], - }, - }) + const request = new RequestFactory().merge({ req, res }).create() + const response = new ResponseFactory().merge({ req, res }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + ctx.route = { + pattern: '/project/:id/file', + execute: () => {}, + handler: () => {}, + meta: {}, + middleware: {} as any, + } - const middleware = new BodyParserMiddleware( - app.container.use('Adonis/Core/Config'), - app.container.use('Adonis/Core/Drive') - ) + const middleware = new BodyParserMiddlewareFactory() + .merge({ + multipart: { + autoProcess: ['/projects/:id/assets'], + }, + }) + .create() await middleware.handle(ctx, async () => { assert.deepEqual(ctx.request['__raw_files'], {}) @@ -703,31 +699,72 @@ test.group('BodyParser Middleware | multipart', (group) => { await supertest(server).post('/').attach('package', packageFilePath).field('username', 'virk') }) - test('detect file ext and mime type using magic number', async ({ assert }) => { + test('process request when autoProcess route does matches', async ({ assert }) => { const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - const middleware = new BodyParserMiddleware( - app.container.use('Adonis/Core/Config'), - app.container.use('Adonis/Core/Drive') - ) + const request = new RequestFactory().merge({ req, res }).create() + const response = new ResponseFactory().merge({ req, res }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + ctx.route = { + pattern: '/projects/:id/assets', + execute: () => {}, + handler: () => {}, + meta: {}, + middleware: {} as any, + } + + const middleware = new BodyParserMiddlewareFactory() + .merge({ + multipart: { + autoProcess: ['/projects/:id/assets'], + }, + }) + .create() await middleware.handle(ctx, async () => { + const pkgFile = ctx.request['__raw_files'].package as MultipartFile + res.writeHead(200, { 'content-type': 'application/json' }) res.end( JSON.stringify({ - type: ctx.request['__raw_files'].avatar.type, - subtype: ctx.request['__raw_files'].avatar.subtype, - extname: ctx.request['__raw_files'].avatar.extname, + tmpPath: pkgFile.tmpPath, + size: pkgFile.size, + validated: pkgFile.validated, }) ) }) }) - const { body } = await supertest(server) - .post('/') - .attach('avatar', join(__dirname, '../unicorn.png'), { - contentType: 'application/json', + const { body } = await supertest(server).post('/').attach('package', packageFilePath) + + assert.isAbove(body.size, 0) + assert.exists(body.tmpPath) + assert.isFalse(body.validated) + }) + + test('detect file ext and mime type using magic number', async ({ assert }) => { + const server = createServer(async (req, res) => { + const request = new RequestFactory().merge({ req, res }).create() + const response = new ResponseFactory().merge({ req, res }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + const middleware = new BodyParserMiddlewareFactory().create() + + await middleware.handle(ctx, async () => { + const avatar = ctx.request['__raw_files'].avatar as MultipartFile + + res.writeHead(200, { 'content-type': 'application/json' }) + res.end( + JSON.stringify({ + type: avatar.type, + subtype: avatar.subtype, + extname: avatar.extname, + }) + ) }) + }) + + const { body } = await supertest(server).post('/').attach('avatar', unicornFilePath, { + contentType: 'application/json', + }) assert.deepEqual(body, { type: 'image', @@ -738,11 +775,10 @@ test.group('BodyParser Middleware | multipart', (group) => { test('validate file when access via request.file method', async ({ assert }) => { const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - const middleware = new BodyParserMiddleware( - app.container.use('Adonis/Core/Config'), - app.container.use('Adonis/Core/Drive') - ) + const request = new RequestFactory().merge({ req, res }).create() + const response = new ResponseFactory().merge({ req, res }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + const middleware = new BodyParserMiddlewareFactory().create() await middleware.handle(ctx, async () => { res.writeHead(200, { 'content-type': 'application/json' }) @@ -778,11 +814,10 @@ test.group('BodyParser Middleware | multipart', (group) => { test('validate array of files when access via request.file method', async ({ assert }) => { const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - const middleware = new BodyParserMiddleware( - app.container.use('Adonis/Core/Config'), - app.container.use('Adonis/Core/Drive') - ) + const request = new RequestFactory().merge({ req, res }).create() + const response = new ResponseFactory().merge({ req, res }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + const middleware = new BodyParserMiddlewareFactory().create() await middleware.handle(ctx, async () => { res.writeHead(200, { 'content-type': 'application/json' }) @@ -839,11 +874,10 @@ test.group('BodyParser Middleware | multipart', (group) => { test('pull first file even when source is an array', async ({ assert }) => { const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - const middleware = new BodyParserMiddleware( - app.container.use('Adonis/Core/Config'), - app.container.use('Adonis/Core/Drive') - ) + const request = new RequestFactory().merge({ req, res }).create() + const response = new ResponseFactory().merge({ req, res }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + const middleware = new BodyParserMiddlewareFactory().create() await middleware.handle(ctx, async () => { res.writeHead(200, { 'content-type': 'application/json' }) @@ -882,11 +916,10 @@ test.group('BodyParser Middleware | multipart', (group) => { test("return null when file doesn't exists", async ({ assert }) => { const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - const middleware = new BodyParserMiddleware( - app.container.use('Adonis/Core/Config'), - app.container.use('Adonis/Core/Drive') - ) + const request = new RequestFactory().merge({ req, res }).create() + const response = new ResponseFactory().merge({ req, res }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + const middleware = new BodyParserMiddlewareFactory().create() await middleware.handle(ctx, async () => { res.writeHead(200, { 'content-type': 'application/json' }) @@ -901,11 +934,10 @@ test.group('BodyParser Middleware | multipart', (group) => { test("return empty array file doesn't exists", async ({ assert }) => { const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - const middleware = new BodyParserMiddleware( - app.container.use('Adonis/Core/Config'), - app.container.use('Adonis/Core/Drive') - ) + const request = new RequestFactory().merge({ req, res }).create() + const response = new ResponseFactory().merge({ req, res }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + const middleware = new BodyParserMiddlewareFactory().create() await middleware.handle(ctx, async () => { res.writeHead(200, { 'content-type': 'application/json' }) @@ -920,11 +952,10 @@ test.group('BodyParser Middleware | multipart', (group) => { test('get file from nested object', async ({ assert }) => { const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - const middleware = new BodyParserMiddleware( - app.container.use('Adonis/Core/Config'), - app.container.use('Adonis/Core/Drive') - ) + const request = new RequestFactory().merge({ req, res }).create() + const response = new ResponseFactory().merge({ req, res }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + const middleware = new BodyParserMiddlewareFactory().create() await middleware.handle(ctx, async () => { res.writeHead(200, { 'content-type': 'application/json' }) @@ -951,21 +982,18 @@ test.group('BodyParser Middleware | multipart', (group) => { assert.deepEqual(body.errors, []) }) - test('move file to a given location', async ({ assert }) => { - const uploadsDir = join(__dirname, 'uploads') - + test('move file to a given location', async ({ assert, fs }) => { const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - const middleware = new BodyParserMiddleware( - app.container.use('Adonis/Core/Config'), - app.container.use('Adonis/Core/Drive') - ) + const request = new RequestFactory().merge({ req, res }).create() + const response = new ResponseFactory().merge({ req, res }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + const middleware = new BodyParserMiddlewareFactory().create() await middleware.handle(ctx, async () => { const pkgFile = ctx.request.file('package')! try { - await pkgFile.move(uploadsDir) + await pkgFile.move(fs.basePath) assert.equal(pkgFile.state, 'moved') res.writeHead(200, { 'content-type': 'application/json' }) res.end() @@ -978,28 +1006,23 @@ test.group('BodyParser Middleware | multipart', (group) => { await supertest(server).post('/').attach('package', packageFilePath).expect(200) - const uploadedFileContents = await readFile(join(uploadsDir, 'package.json'), 'utf-8') + const uploadedFileContents = await fs.contents('package.json') const originalFileContents = await readFile(packageFilePath, 'utf-8') assert.equal(uploadedFileContents, originalFileContents) - - await remove(uploadsDir) }) - test('move file with custom name', async ({ assert }) => { - const uploadsDir = join(__dirname, 'uploads') - + test('move file with custom name', async ({ assert, fs }) => { const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - const middleware = new BodyParserMiddleware( - app.container.use('Adonis/Core/Config'), - app.container.use('Adonis/Core/Drive') - ) + const request = new RequestFactory().merge({ req, res }).create() + const response = new ResponseFactory().merge({ req, res }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + const middleware = new BodyParserMiddlewareFactory().create() await middleware.handle(ctx, async () => { const pkgFile = ctx.request.file('package')! try { - await pkgFile.move(uploadsDir, { + await pkgFile.move(fs.basePath, { name: `myapp.${pkgFile.subtype}`, }) assert.equal(pkgFile.state, 'moved') @@ -1014,32 +1037,27 @@ test.group('BodyParser Middleware | multipart', (group) => { await supertest(server).post('/').attach('package', packageFilePath).expect(200) - const uploadedFileContents = await readFile(join(uploadsDir, 'myapp.json'), 'utf-8') + const uploadedFileContents = await fs.contents('myapp.json') const originalFileContents = await readFile(packageFilePath, 'utf-8') assert.equal(uploadedFileContents, originalFileContents) - - await remove(uploadsDir) }) - test('raise error when destination file already exists', async ({ assert }) => { - const uploadsDir = join(__dirname, 'uploads') - + test('raise error when destination file already exists', async ({ assert, fs }) => { const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - const middleware = new BodyParserMiddleware( - app.container.use('Adonis/Core/Config'), - app.container.use('Adonis/Core/Drive') - ) + const request = new RequestFactory().merge({ req, res }).create() + const response = new ResponseFactory().merge({ req, res }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + const middleware = new BodyParserMiddlewareFactory().create() await middleware.handle(ctx, async () => { const pkgFile = ctx.request.file('package')! try { - await pkgFile.move(uploadsDir, { overwrite: false }) + await pkgFile.move(fs.basePath, { overwrite: false }) } catch (error) { assert.equal( error.message, - `"package.json" already exists at "${uploadsDir}". Set "overwrite = true" to overwrite it` + `"package.json" already exists at "${fs.basePath}". Set "overwrite = true" to overwrite it` ) assert.equal(pkgFile.state, 'consumed') res.writeHead(200, { 'content-type': 'application/json' }) @@ -1048,20 +1066,16 @@ test.group('BodyParser Middleware | multipart', (group) => { }) }) - await outputFile(join(uploadsDir, 'package.json'), JSON.stringify({})) - + await fs.create('package.json', JSON.stringify({})) await supertest(server).post('/').attach('package', packageFilePath).expect(200) - - await remove(uploadsDir) }) test('validate file extension and file size seperately', async ({ assert }) => { const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - const middleware = new BodyParserMiddleware( - app.container.use('Adonis/Core/Config'), - app.container.use('Adonis/Core/Drive') - ) + const request = new RequestFactory().merge({ req, res }).create() + const response = new ResponseFactory().merge({ req, res }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + const middleware = new BodyParserMiddlewareFactory().create() await middleware.handle(ctx, async () => { res.writeHead(200, { 'content-type': 'application/json' }) @@ -1108,11 +1122,10 @@ test.group('BodyParser Middleware | multipart', (group) => { test('calling validate multiple times must be a noop', async ({ assert }) => { const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - const middleware = new BodyParserMiddleware( - app.container.use('Adonis/Core/Config'), - app.container.use('Adonis/Core/Drive') - ) + const request = new RequestFactory().merge({ req, res }).create() + const response = new ResponseFactory().merge({ req, res }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + const middleware = new BodyParserMiddlewareFactory().create() await middleware.handle(ctx, async () => { res.writeHead(200, { 'content-type': 'application/json' }) @@ -1165,11 +1178,10 @@ test.group('BodyParser Middleware | multipart', (group) => { assert, }) => { const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - const middleware = new BodyParserMiddleware( - app.container.use('Adonis/Core/Config'), - app.container.use('Adonis/Core/Drive') - ) + const request = new RequestFactory().merge({ req, res }).create() + const response = new ResponseFactory().merge({ req, res }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + const middleware = new BodyParserMiddlewareFactory().create() await middleware.handle(ctx, async () => { res.writeHead(200, { 'content-type': 'application/json' }) @@ -1214,11 +1226,10 @@ test.group('BodyParser Middleware | multipart', (group) => { test('updating sizeLimit multiple times must not be allowed', async ({ assert }) => { const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - const middleware = new BodyParserMiddleware( - app.container.use('Adonis/Core/Config'), - app.container.use('Adonis/Core/Drive') - ) + const request = new RequestFactory().merge({ req, res }).create() + const response = new ResponseFactory().merge({ req, res }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + const middleware = new BodyParserMiddlewareFactory().create() await middleware.handle(ctx, async () => { const pkgFile = ctx.request.file('package', { size: 10 })! @@ -1244,11 +1255,10 @@ test.group('BodyParser Middleware | multipart', (group) => { test('updating allowedExtensions multiple times must not be allowed', async ({ assert }) => { const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - const middleware = new BodyParserMiddleware( - app.container.use('Adonis/Core/Config'), - app.container.use('Adonis/Core/Drive') - ) + const request = new RequestFactory().merge({ req, res }).create() + const response = new ResponseFactory().merge({ req, res }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + const middleware = new BodyParserMiddlewareFactory().create() await middleware.handle(ctx, async () => { const pkgFile = ctx.request.file('package', { extnames: ['json'] })! @@ -1274,17 +1284,16 @@ test.group('BodyParser Middleware | multipart', (group) => { test('get all files as an object', async ({ assert }) => { const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - const middleware = new BodyParserMiddleware( - app.container.use('Adonis/Core/Config'), - app.container.use('Adonis/Core/Drive') - ) + const request = new RequestFactory().merge({ req, res }).create() + const response = new ResponseFactory().merge({ req, res }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + const middleware = new BodyParserMiddlewareFactory().create() await middleware.handle(ctx, async () => { res.writeHead(200, { 'content-type': 'application/json' }) const allFiles = ctx.request.allFiles() const files = Object.keys(allFiles).map((field) => { - const file = allFiles[field] as MultipartFileContract + const file = allFiles[field] as MultipartFile return { field: field, tmpPath: file.tmpPath!, @@ -1311,11 +1320,10 @@ test.group('BodyParser Middleware | multipart', (group) => { test('convert empty strings to null', async ({ assert }) => { const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - const middleware = new BodyParserMiddleware( - app.container.use('Adonis/Core/Config'), - app.container.use('Adonis/Core/Drive') - ) + const request = new RequestFactory().merge({ req, res }).create() + const response = new ResponseFactory().merge({ req, res }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + const middleware = new BodyParserMiddlewareFactory().create() await middleware.handle(ctx, async () => { res.writeHead(200, { 'content-type': 'application/json' }) @@ -1331,38 +1339,4 @@ test.group('BodyParser Middleware | multipart', (group) => { assert.deepEqual(body, { username: null }) }) - - test('move file using drive', async ({ assert }) => { - const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - const middleware = new BodyParserMiddleware( - app.container.use('Adonis/Core/Config'), - app.container.use('Adonis/Core/Drive') - ) - - await middleware.handle(ctx, async () => { - const pkgFile = ctx.request.file('package')! - - try { - await pkgFile.moveToDisk('./') - assert.equal(pkgFile.state, 'moved') - res.writeHead(200, { 'content-type': 'application/json' }) - res.write(JSON.stringify({ filePath: pkgFile.filePath })) - res.end() - } catch (error) { - res.writeHead(500, { 'content-type': 'application/json' }) - res.end(error.message) - } - }) - }) - - const { body } = await supertest(server) - .post('/') - .attach('package', packageFilePath) - .expect(200) - - const uploadedFileContents = await readFile(body.filePath, 'utf-8') - const originalFileContents = await readFile(packageFilePath, 'utf-8') - assert.equal(uploadedFileContents, originalFileContents) - }) }) diff --git a/test/form-fields.spec.ts b/tests/form_fields.spec.ts similarity index 97% rename from test/form-fields.spec.ts rename to tests/form_fields.spec.ts index bd185a4..35bcd3b 100644 --- a/test/form-fields.spec.ts +++ b/tests/form_fields.spec.ts @@ -1,14 +1,14 @@ /* * @adonisjs/bodyparser * - * (c) Harminder Virk + * (c) AdonisJS * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ import { test } from '@japa/runner' -import { FormFields } from '../src/FormFields' +import { FormFields } from '../src/form_fields.js' test.group('Form Fields Parser', () => { test('add a plain key value pair to form fields', ({ assert }) => { diff --git a/tests/multipart.spec.ts b/tests/multipart.spec.ts new file mode 100644 index 0000000..f7e34e9 --- /dev/null +++ b/tests/multipart.spec.ts @@ -0,0 +1,1012 @@ +/* + * @adonisjs/bodyparser + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { join } from 'node:path' +import supertest from 'supertest' +import { test } from '@japa/runner' +import { createServer } from 'node:http' +import { fileURLToPath } from 'node:url' +import { createWriteStream } from 'node:fs' +import string from '@poppinss/utils/string' +import { ensureDir, pathExists } from 'fs-extra' +import fileGenerator from '@poppinss/file-generator' +import { + RequestFactory, + ResponseFactory, + HttpContextFactory, +} from '@adonisjs/http-server/factories' +import { Multipart } from '../src/multipart/main.js' +import { MultipartFile } from '../src/multipart/file.js' +import { + sleep, + xlsFilePath, + largePdfFile, + xlsxFilePath, + packageFilePath, + packageFileSize, + unicornFilePath, + unicornNoExtFilePath, +} from '../tests_helpers/main.js' + +const BASE_URL = new URL('./tmp/', import.meta.url) +const BASE_PATH = fileURLToPath(BASE_URL) + +test.group('Multipart', () => { + test('process file by attaching handler on field name', async ({ assert }) => { + let files: null | Record = null + + const server = createServer(async (req, res) => { + const request = new RequestFactory().merge({ req, res }).create() + const response = new ResponseFactory().merge({ req, res }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + const multipart = new Multipart(ctx, { maxFields: 1000, limit: 8000 }) + + multipart.onFile('package', {}, (part, reporter) => { + return new Promise((resolve, reject) => { + part.on('data', (line) => { + reporter(line) + }) + part.on('error', reject) + part.on('end', resolve) + }) + }) + + await multipart.process() + files = ctx.request['__raw_files'] + res.end() + }) + + await supertest(server).post('/').attach('package', packageFilePath) + assert.property(files, 'package') + assert.isTrue(files!.package instanceof MultipartFile && files!.package.isValid) + assert.equal(files!.package instanceof MultipartFile && files!.package.state, 'consumed') + assert.equal(files!.package instanceof MultipartFile && files!.package.size, packageFileSize) + }) + + test('get file JSON representation', async ({ assert }) => { + let files: null | Record = null + + const server = createServer(async (req, res) => { + const request = new RequestFactory().merge({ req, res }).create() + const response = new ResponseFactory().merge({ req, res }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + const multipart = new Multipart(ctx, { maxFields: 1000, limit: 8000 }) + + multipart.onFile('package', {}, (part, reporter) => { + return new Promise((resolve, reject) => { + part.on('data', (line) => { + reporter(line) + }) + part.on('error', reject) + part.on('end', resolve) + }) + }) + + await multipart.process() + files = ctx.request['__raw_files'] + res.end() + }) + + await supertest(server).post('/').attach('package', packageFilePath) + assert.property(files, 'package') + + const pkgFile = files!.package as MultipartFile + assert.deepEqual(pkgFile.toJSON(), { + clientName: 'package.json', + errors: [], + extname: 'json', + fieldName: 'package', + fileName: undefined, + filePath: undefined, + isValid: true, + meta: {}, + size: packageFileSize, + state: 'consumed', + subtype: 'json', + type: 'application', + validated: true, + }) + }) + + test('raise error when file.move method is called without a tmp path', async ({ assert }) => { + let files: null | Record = null + + const server = createServer(async (req, res) => { + const request = new RequestFactory().merge({ req, res }).create() + const response = new ResponseFactory().merge({ req, res }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + const multipart = new Multipart(ctx, { maxFields: 1000, limit: 8000 }) + + multipart.onFile('package', {}, (part, reporter) => { + return new Promise((resolve, reject) => { + part.on('data', (line) => { + reporter(line) + }) + part.on('error', reject) + part.on('end', resolve) + }) + }) + + await multipart.process() + files = ctx.request['__raw_files'] + res.end() + }) + + await supertest(server).post('/').attach('package', packageFilePath) + const pkgFile = files!.package as MultipartFile + + await assert.rejects( + () => pkgFile.move(join(BASE_PATH, './')), + 'property "tmpPath" must be set on the file before moving it' + ) + }) + + test('error inside onFile handler should propogate to file errors', async ({ assert }) => { + let files: null | Record = null + + const server = createServer(async (req, res) => { + const request = new RequestFactory().merge({ req, res }).create() + const response = new ResponseFactory().merge({ req, res }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + const multipart = new Multipart(ctx, { maxFields: 1000, limit: 8000 }) + + multipart.onFile('package', {}, (part, reporter) => { + return new Promise((_resolve, reject) => { + part.on('data', (line) => { + reporter(line) + }) + part.on('error', reject) + reject(Error('Cannot process')) + }) + }) + + await multipart.process() + files = ctx.request['__raw_files'] || null + res.end() + }) + + await supertest(server).post('/').attach('package', packageFilePath) + assert.property(files, 'package') + assert.isFalse(files!.package instanceof MultipartFile && files!.package.isValid) + assert.equal(files!.package instanceof MultipartFile && files!.package.state, 'consumed') + assert.deepEqual(files!.package instanceof MultipartFile && files!.package.errors, [ + { + fieldName: 'package', + clientName: 'package.json', + message: 'Cannot process', + type: 'fatal', + }, + ]) + }) + + test('wait for promise to return even when part has been streamed', async ({ assert }) => { + let files: null | Record = null + const stack: string[] = [] + + const server = createServer(async (req, res) => { + const request = new RequestFactory().merge({ req, res }).create() + const response = new ResponseFactory().merge({ req, res }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + const multipart = new Multipart(ctx, { maxFields: 1000, limit: 8000 }) + + multipart.onFile('package', {}, async (part, reporter) => { + part.on('data', (line) => { + reporter(line) + }) + + stack.push('before') + part.resume() + await sleep(100) + stack.push('after') + }) + + await multipart.process() + files = ctx.request['__raw_files'] + stack.push('ended') + res.end() + }) + + await supertest(server).post('/').attach('package', packageFilePath) + assert.deepEqual(stack, ['before', 'after', 'ended']) + assert.property(files, 'package') + assert.isTrue(files!.package instanceof MultipartFile && files!.package.isValid) + assert.equal(files!.package instanceof MultipartFile && files!.package.state, 'consumed') + assert.equal(files!.package instanceof MultipartFile && files!.package.size, packageFileSize) + }) + + test('work fine when stream is piped to a destination', async ({ assert, fs }) => { + await ensureDir(fs.basePath) + const SAMPLE_FILE_PATH = join(fs.basePath, './sample.json') + + let files: null | Record = null + + const server = createServer(async (req, res) => { + const request = new RequestFactory().merge({ req, res }).create() + const response = new ResponseFactory().merge({ req, res }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + const multipart = new Multipart(ctx, { maxFields: 1000, limit: 8000 }) + + multipart.onFile('package', {}, (part, reporter) => { + return new Promise((resolve, reject) => { + part.pause() + part.on('data', (line) => { + reporter(line) + }) + + part.on('error', reject) + part.on('end', resolve) + part.pipe(createWriteStream(SAMPLE_FILE_PATH)) + }) + }) + + await multipart.process() + files = ctx.request['__raw_files'] + + const hasFile = await pathExists(SAMPLE_FILE_PATH) + res.end(String(hasFile)) + }) + + const { text } = await supertest(server).post('/').attach('package', packageFilePath) + + assert.property(files, 'package') + assert.isTrue(files!.package instanceof MultipartFile && files!.package.isValid) + assert.equal(files!.package instanceof MultipartFile && files!.package.size, packageFileSize) + assert.equal(files!.package instanceof MultipartFile && files!.package.state, 'consumed') + assert.equal(text, 'true') + }) + + test('process array of files', async ({ assert }) => { + const stack: string[] = [] + let files: null | Record = null + + const server = createServer(async (req, res) => { + const request = new RequestFactory().merge({ req, res }).create() + const response = new ResponseFactory().merge({ req, res }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + const multipart = new Multipart(ctx, { maxFields: 1000, limit: 8000 }) + + multipart.onFile('package', {}, async (part, reporter) => { + part.on('data', reporter) + + stack.push('before') + part.resume() + await sleep(100) + stack.push('after') + }) + + await multipart.process() + files = ctx.request['__raw_files'] + stack.push('ended') + res.end() + }) + + await supertest(server).post('/').attach('package[]', packageFilePath) + + assert.deepEqual(stack, ['before', 'after', 'ended']) + assert.property(files, 'package') + assert.isTrue(Array.isArray(files!.package) && files!.package[0].isValid) + assert.equal(Array.isArray(files!.package) && files!.package[0].state, 'consumed') + assert.equal(Array.isArray(files!.package) && files!.package[0].size, packageFileSize) + }) + + test('work fine with indexed array of files', async ({ assert }) => { + const stack: string[] = [] + + let files: null | Record = null + const server = createServer(async (req, res) => { + const request = new RequestFactory().merge({ req, res }).create() + const response = new ResponseFactory().merge({ req, res }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + const multipart = new Multipart(ctx, { maxFields: 1000, limit: 8000 }) + + multipart.onFile('package', {}, async (part, reporter) => { + part.on('data', reporter) + + stack.push('before') + part.resume() + await sleep(100) + stack.push('after') + }) + + await multipart.process() + files = ctx.request['__raw_files'] + stack.push('ended') + res.end() + }) + + await supertest(server).post('/').attach('package[0]', packageFilePath) + assert.deepEqual(stack, ['before', 'after', 'ended']) + assert.property(files, 'package') + assert.isTrue(Array.isArray(files!.package) && files!.package[0].isValid) + assert.equal(Array.isArray(files!.package) && files!.package[0].state, 'consumed') + assert.equal(Array.isArray(files!.package) && files!.package[0].size, packageFileSize) + }) + + test('pass file to wildcard handler when defined', async ({ assert }) => { + const stack: string[] = [] + + let files: null | Record = null + const server = createServer(async (req, res) => { + const request = new RequestFactory().merge({ req, res }).create() + const response = new ResponseFactory().merge({ req, res }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + const multipart = new Multipart(ctx, { maxFields: 1000, limit: 8000 }) + + multipart.onFile('*', {}, async (part, reporter) => { + part.on('data', reporter) + + stack.push('before') + part.resume() + await sleep(100) + stack.push('after') + }) + + await multipart.process() + files = ctx.request['__raw_files'] + stack.push('ended') + res.end() + }) + + await supertest(server).post('/').attach('package', packageFilePath) + assert.deepEqual(stack, ['before', 'after', 'ended']) + assert.property(files, 'package') + assert.isTrue(files!.package instanceof MultipartFile && files!.package.isValid) + assert.equal(files!.package instanceof MultipartFile && files!.package.state, 'consumed') + assert.equal(files!.package instanceof MultipartFile && files!.package.size, packageFileSize) + }) + + test('collect fields automatically', async ({ assert }) => { + const stack: string[] = [] + + let fields: null | { [key: string]: any } = null + let files: null | Record = null + + const server = createServer(async (req, res) => { + const request = new RequestFactory().merge({ req, res }).create() + const response = new ResponseFactory().merge({ req, res }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + const multipart = new Multipart(ctx, { maxFields: 1000, limit: 8000 }) + + multipart.onFile('*', {}, (part, reporter) => { + return new Promise((resolve, reject) => { + part.on('data', reporter) + part.on('error', reject) + part.on('end', resolve) + stack.push('file') + part.resume() + }) + }) + + await multipart.process() + files = ctx.request['__raw_files'] + fields = ctx.request.all() + stack.push('ended') + res.end() + }) + + await supertest(server).post('/').attach('package', packageFilePath).field('name', 'virk') + + assert.deepEqual(stack, ['file', 'ended']) + assert.property(files, 'package') + assert.isTrue(files!.package instanceof MultipartFile && files!.package.isValid) + assert.equal(files!.package instanceof MultipartFile && files!.package.size, packageFileSize) + assert.equal(files!.package instanceof MultipartFile && files!.package.state, 'consumed') + assert.deepEqual(fields, { name: 'virk' }) + }) + + test('FIELDS: raise error when process is invoked multiple times', async ({ assert }) => { + const server = createServer(async (req, res) => { + const request = new RequestFactory().merge({ req, res }).create() + const response = new ResponseFactory().merge({ req, res }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + const multipart = new Multipart(ctx, { maxFields: 1000, limit: 8000 }) + + try { + await multipart.process() + await multipart.process() + res.end() + } catch (error) { + res.writeHead(500) + res.end(error.message) + } + }) + + const { text } = await supertest(server).post('/').field('name', 'virk') + + assert.equal(text, 'multipart stream has already been consumed') + }) + + test('FIELDS: raise error when maxFields are crossed', async ({ assert }) => { + const server = createServer(async (req, res) => { + const request = new RequestFactory().merge({ req, res }).create() + const response = new ResponseFactory().merge({ req, res }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + const multipart = new Multipart(ctx, { maxFields: 1, limit: 8000 }) + + try { + await multipart.process() + res.end() + } catch (error) { + res.writeHead(500) + res.end(error.message) + } + }) + + const { text } = await supertest(server).post('/').field('name', 'virk').field('age', '22') + + assert.equal(text, 'Fields length limit exceeded') + }) + + test('FIELDS: raise error when bytes limit is crossed', async ({ assert }) => { + const server = createServer(async (req, res) => { + const request = new RequestFactory().merge({ req, res }).create() + const response = new ResponseFactory().merge({ req, res }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + const multipart = new Multipart(ctx, { maxFields: 1000, fieldsLimit: 2 }) + + try { + await multipart.process() + res.end() + } catch (error) { + res.writeHead(500) + res.end(error.message) + } + }) + + const { text } = await supertest(server).post('/').field('name', 'virk').field('age', '22') + assert.equal(text, 'Fields size in bytes exceeded') + }) + + test('disrupt file uploads error when total bytes limit is crossed', async ({ assert }) => { + assert.plan(2) + + const server = createServer(async (req, res) => { + const request = new RequestFactory().merge({ req, res }).create() + const response = new ResponseFactory().merge({ req, res }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + const multipart = new Multipart(ctx, { maxFields: 100, limit: 20 }) + + multipart.onFile('package', {}, (part, report) => { + return new Promise((resolve, reject) => { + part.on('error', (error) => { + assert.equal(error.message, 'request entity too large') + reject(error) + }) + + part.on('data', report) + part.on('end', resolve) + }) + }) + + try { + await multipart.process() + res.end() + } catch (error) { + res.writeHead(500) + res.end(error.message) + } + }) + + try { + const { text } = await supertest(server) + .post('/') + .attach('package', packageFilePath) + .field('name', 'virk') + .field('age', '22') + + assert.equal(text, 'request entity too large') + } catch (error) { + assert.oneOf(error.code, ['ECONNABORTED', 'ECONNRESET']) + } + }) + + test('disrupt part streaming when validation fails', async ({ assert }) => { + assert.plan(5) + + let files: null | Record = null + const server = createServer(async (req, res) => { + const request = new RequestFactory().merge({ req, res }).create() + const response = new ResponseFactory().merge({ req, res }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + const multipart = new Multipart(ctx, { maxFields: 1000, limit: 8000 }) + + multipart.onFile( + '*', + { + size: 10, + }, + (part, reporter) => { + return new Promise((resolve, reject) => { + part.on('error', (error: any) => { + assert.equal(error.code, 'E_STREAM_VALIDATION_FAILURE') + reject(error) + }) + part.on('end', resolve) + part.on('data', reporter) + }) + } + ) + + await multipart.process() + files = ctx.request['__raw_files'] || null + res.end() + }) + + await supertest(server).post('/').attach('package', packageFilePath) + + assert.property(files, 'package') + assert.isFalse(files!.package instanceof MultipartFile && files!.package.isValid) + assert.equal(files!.package instanceof MultipartFile && files!.package.state, 'consumed') + assert.deepEqual(files!.package instanceof MultipartFile && files!.package.errors, [ + { + type: 'size', + clientName: 'package.json', + fieldName: 'package', + message: 'File size should be less than 10B', + }, + ]) + }) + + test('validate stream only once', async ({ assert }) => { + assert.plan(5) + + let files: null | Record = null + const server = createServer(async (req, res) => { + const request = new RequestFactory().merge({ req, res }).create() + const response = new ResponseFactory().merge({ req, res }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + const multipart = new Multipart(ctx, { maxFields: 1000 }) + + multipart.onFile('*', { size: 10 }, (part, reporter) => { + return new Promise((resolve, reject) => { + part.on('error', (error: any) => { + assert.equal(error.code, 'E_STREAM_VALIDATION_FAILURE') + reject(error) + }) + + part.on('end', resolve) + part.on('data', reporter) + }) + }) + + await multipart.process() + files = ctx.request['__raw_files'] || null + res.end() + }) + + await supertest(server).post('/').attach('profile', unicornFilePath) + + assert.property(files, 'profile') + assert.isFalse(files!.profile instanceof MultipartFile && files!.profile.isValid) + assert.equal(files!.profile instanceof MultipartFile && files!.profile.state, 'consumed') + assert.deepEqual(files!.profile instanceof MultipartFile && files!.profile.errors, [ + { + type: 'size', + clientName: 'unicorn.png', + fieldName: 'profile', + message: 'File size should be less than 10B', + }, + ]) + }) + + test('report extension validation errors', async ({ assert }) => { + assert.plan(4) + + let files: null | Record = null + const server = createServer(async (req, res) => { + const request = new RequestFactory().merge({ req, res }).create() + const response = new ResponseFactory().merge({ req, res }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + const multipart = new Multipart(ctx, { maxFields: 1000, limit: 8000 }) + + multipart.onFile( + '*', + { + extnames: ['jpg'], + }, + (part, reporter) => { + return new Promise((resolve, reject) => { + part.on('error', reject) + part.on('end', resolve) + part.on('data', reporter) + }) + } + ) + + await multipart.process() + files = ctx.request['__raw_files'] || null + res.end() + }) + + await supertest(server).post('/').attach('package', packageFilePath) + + assert.property(files, 'package') + assert.isFalse(files!.package instanceof MultipartFile && files!.package.isValid) + assert.equal(files!.package instanceof MultipartFile && files!.package.state, 'consumed') + assert.deepEqual(files!.package instanceof MultipartFile && files!.package.errors, [ + { + type: 'extname', + clientName: 'package.json', + fieldName: 'package', + message: 'Invalid file extension json. Only jpg is allowed', + }, + ]) + }) + + test('do not run validations when deferValidations is set to true', async ({ assert }) => { + let files: null | Record = null + const server = createServer(async (req, res) => { + const request = new RequestFactory().merge({ req, res }).create() + const response = new ResponseFactory().merge({ req, res }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + const multipart = new Multipart(ctx, { maxFields: 1000, limit: 8000 }) + + multipart.onFile( + '*', + { + size: 10, + deferValidations: true, + }, + (part, reporter) => { + return new Promise((resolve, reject) => { + part.on('data', reporter) + part.on('end', resolve) + part.on('error', reject) + }) + } + ) + + await multipart.process() + files = ctx.request['__raw_files'] || null + res.end() + }) + + await supertest(server).post('/').attach('package', packageFilePath) + + assert.property(files, 'package') + assert.isTrue(files!.package instanceof MultipartFile && files!.package.isValid) + assert.isFalse(files!.package instanceof MultipartFile && files!.package.validated) + assert.equal(files!.package instanceof MultipartFile && files!.package.state, 'consumed') + assert.equal(files!.package instanceof MultipartFile && files!.package.extname, 'json') + assert.deepEqual(files!.package instanceof MultipartFile && files!.package.errors, []) + }) + + test('work fine when stream is errored without reading', async ({ assert }) => { + let files: null | Record = null + const server = createServer(async (req, res) => { + const request = new RequestFactory().merge({ req, res }).create() + const response = new ResponseFactory().merge({ req, res }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + const multipart = new Multipart(ctx, { maxFields: 1000, limit: 8000 }) + + multipart.onFile('package', {}, (part) => { + return new Promise((resolve) => { + part.on('error', () => { + /** + * Node.js doesn't emit the "close" event when stream is not flowing + */ + part.emit('close') + + /** + * We resolve, to reproduce the above defined behavior. If we reject + * the promise, then the part handler will emit "end" instead + */ + resolve() + }) + + part.on('end', () => { + resolve() + }) + + part.emit('error', new Error('abort')) + }) + }) + + await multipart.process() + files = ctx.request['__raw_files'] + res.end() + }) + + await supertest(server).post('/').attach('package', packageFilePath) + assert.property(files, 'package') + assert.isTrue(files!.package instanceof MultipartFile && files!.package.isValid) + assert.equal(files!.package instanceof MultipartFile && files!.package.size, 0) + assert.equal(files!.package instanceof MultipartFile && files!.package.state, 'consumed') + }) + + test('end request when abort is called without ending the part', async ({ assert }) => { + let files: null | Record = null + const server = createServer(async (req, res) => { + const request = new RequestFactory().merge({ req, res }).create() + const response = new ResponseFactory().merge({ req, res }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + const multipart = new Multipart(ctx, { maxFields: 1000, limit: 8000 }) + + multipart.onFile('package', {}, (part) => { + return new Promise((resolve) => { + part.on('error', (error) => { + setTimeout(() => multipart.abort(error)) + + /** + * We resolve, to reproduce the above defined behavior. If we reject + * the promise, then the part handler will emit "end" instead + */ + resolve() + }) + + part.on('end', () => { + resolve() + }) + + part.emit('error', new Error('abort')) + }) + }) + + try { + await multipart.process() + } catch {} + + files = ctx.request['__raw_files'] + res.end() + }) + + await supertest(server).post('/').attach('package', packageFilePath) + assert.property(files, 'package') + assert.isTrue(files!.package instanceof MultipartFile && files!.package.isValid) + assert.equal(files!.package instanceof MultipartFile && files!.package.size, 0) + assert.equal(files!.package instanceof MultipartFile && files!.package.state, 'consumed') + }).retry(3) + + test('report extension validation errors when unable to detect extension till completion', async ({ + assert, + }) => { + assert.plan(4) + + let files: null | Record = null + const server = createServer(async (req, res) => { + const request = new RequestFactory().merge({ req, res }).create() + const response = new ResponseFactory().merge({ req, res }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + const multipart = new Multipart(ctx, { maxFields: 1000, limit: 8000 }) + + multipart.onFile( + '*', + { + extnames: ['jpg'], + }, + (part, reporter) => { + return new Promise((resolve, reject) => { + part.on('error', reject) + part.on('end', resolve) + part.on('data', reporter) + }) + } + ) + + await multipart.process() + files = ctx.request['__raw_files'] || null + res.end() + }) + + await supertest(server).post('/').attach('package', packageFilePath) + + assert.property(files, 'package') + assert.isFalse(files!.package instanceof MultipartFile && files!.package.isValid) + assert.equal(files!.package instanceof MultipartFile && files!.package.state, 'consumed') + assert.deepEqual(files!.package instanceof MultipartFile && files!.package.errors, [ + { + type: 'extname', + clientName: 'package.json', + fieldName: 'package', + message: 'Invalid file extension json. Only jpg is allowed', + }, + ]) + }) + + test('correctly retrieve file extension from magic number', async ({ assert }) => { + let files: null | Record = null + const server = createServer(async (req, res) => { + const request = new RequestFactory().merge({ req, res }).create() + const response = new ResponseFactory().merge({ req, res }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + const multipart = new Multipart(ctx, { maxFields: 1000 }) + + multipart.onFile('*', {}, (part, reporter) => { + return new Promise((resolve, reject) => { + part.on('error', reject) + part.on('end', resolve) + part.on('data', reporter) + }) + }) + + await multipart.process() + files = ctx.request['__raw_files'] || null + res.end() + }) + + await supertest(server).post('/').attach('picture', unicornNoExtFilePath, { + contentType: 'image/png', + }) + + assert.property(files, 'picture') + assert.instanceOf(files!.picture, MultipartFile) + + const picture = files!.picture as MultipartFile + assert.isTrue(picture.isValid) + assert.equal(picture.state, 'consumed') + assert.equal(picture.extname, 'png') + assert.lengthOf(picture.errors, 0) + }) + + test('validate xlsx extension', async ({ assert }) => { + let files: null | Record = null + const server = createServer(async (req, res) => { + const request = new RequestFactory().merge({ req, res }).create() + const response = new ResponseFactory().merge({ req, res }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + const multipart = new Multipart(ctx, { maxFields: 1000, limit: 8000 }) + + multipart.onFile( + '*', + { + extnames: ['xlsx'], + }, + (part, reporter) => { + return new Promise((resolve, reject) => { + part.on('error', reject) + part.on('end', resolve) + part.on('data', reporter) + }) + } + ) + + await multipart.process() + files = ctx.request['__raw_files'] || null + res.end() + }) + + await supertest(server).post('/').attach('report', xlsxFilePath) + + assert.property(files, 'report') + assert.instanceOf(files!.report, MultipartFile) + + const report = files!.report as MultipartFile + + assert.isTrue(report.isValid) + assert.equal(report.state, 'consumed') + assert.equal(report.extname, 'xlsx') + assert.lengthOf(report.errors, 0) + }) + + test('validate xls extension', async ({ assert }) => { + let files: null | Record = null + const server = createServer(async (req, res) => { + const request = new RequestFactory().merge({ req, res }).create() + const response = new ResponseFactory().merge({ req, res }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + const multipart = new Multipart(ctx, { maxFields: 1000, limit: 10000 }) + + multipart.onFile( + '*', + { + extnames: ['xls'], + }, + (part, reporter) => { + return new Promise((resolve, reject) => { + part.on('error', reject) + part.on('end', resolve) + part.on('data', reporter) + }) + } + ) + + try { + await multipart.process() + } catch (error) { + console.log(error) + } + files = ctx.request['__raw_files'] || null + res.end() + }) + + await supertest(server).post('/').attach('report', xlsFilePath) + + assert.property(files, 'report') + assert.instanceOf(files!.report, MultipartFile) + + const report = files!.report as MultipartFile + + assert.isTrue(report.isValid) + assert.equal(report.state, 'consumed') + assert.equal(report.extname, 'xls') + assert.lengthOf(report.errors, 0) + }) + + test('process medium sized files', async ({ assert }) => { + let files: null | Record = null + + const server = createServer(async (req, res) => { + const request = new RequestFactory().merge({ req, res }).create() + const response = new ResponseFactory().merge({ req, res }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + const multipart = new Multipart(ctx, { maxFields: 1000, limit: '20 mb' }) + + multipart.onFile('report', {}, (part, reporter) => { + return new Promise((resolve, reject) => { + part.on('data', (line) => { + reporter(line) + }) + part.on('error', reject) + part.on('end', resolve) + }) + }) + + await multipart.process() + files = ctx.request['__raw_files'] + res.end() + }) + + await supertest(server).post('/').attach('report', largePdfFile) + assert.property(files, 'report') + + const report = files!.report as MultipartFile + + assert.isTrue(report.isValid) + assert.equal(report.state, 'consumed') + assert.isAbove(report.size, string.bytes.parse('10mb')) + }) + + test('process large xlsx file', async ({ assert }) => { + let files: null | Record = null + const server = createServer(async (req, res) => { + const request = new RequestFactory().merge({ req, res }).create() + const response = new ResponseFactory().merge({ req, res }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + const multipart = new Multipart(ctx, { maxFields: 1000, limit: '10 mb' }) + + multipart.onFile( + '*', + { + extnames: ['xlsx'], + }, + (part, reporter) => { + return new Promise((resolve, reject) => { + part.on('error', reject) + part.on('end', resolve) + part.on('data', reporter) + }) + } + ) + + try { + await multipart.process() + } catch (error) { + console.log(error) + } + files = ctx.request['__raw_files'] || null + res.end() + }) + + const xlsFile = await fileGenerator.generateXlsx('8 mb') + await supertest(server).post('/').attach('report', xlsFile.contents, { + filename: xlsFile.name, + contentType: xlsFile.mime, + }) + + assert.property(files, 'report') + assert.instanceOf(files!.report, MultipartFile) + + const report = files!.report as MultipartFile + + assert.isTrue(report.isValid) + assert.equal(report.state, 'consumed') + assert.equal(report.extname, 'xlsx') + assert.lengthOf(report.errors, 0) + }) +}) diff --git a/tests/multipart_file_factory.spec.ts b/tests/multipart_file_factory.spec.ts new file mode 100644 index 0000000..b193e0d --- /dev/null +++ b/tests/multipart_file_factory.spec.ts @@ -0,0 +1,36 @@ +/* + * @adonisjs/bodyparser + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { test } from '@japa/runner' +import { MultipartFile } from '../src/multipart/file.js' +import { MultipartFileFactory } from '../factories/file_factory.js' + +test.group('Multipart file factory', () => { + test('create file instance', ({ assert }) => { + const file = new MultipartFileFactory().create() + assert.instanceOf(file, MultipartFile) + assert.isTrue(file.isValid) + }) + + test('validate file size', ({ assert }) => { + const file = new MultipartFileFactory().merge({ size: 1024 * 1024 }).create({ size: '1kb' }) + assert.isFalse(file.isValid) + + const file1 = new MultipartFileFactory().merge({ size: 1024 * 1024 }).create({ size: '1mb' }) + assert.isTrue(file1.isValid) + }) + + test('validate file extension', ({ assert }) => { + const file = new MultipartFileFactory().create({ extnames: ['jpg'] }) + assert.isFalse(file.isValid) + + const file1 = new MultipartFileFactory().merge({ extname: 'jpg' }).create({ extnames: ['jpg'] }) + assert.isTrue(file1.isValid) + }) +}) diff --git a/tests/parsers/form.spec.ts b/tests/parsers/form.spec.ts new file mode 100644 index 0000000..0272837 --- /dev/null +++ b/tests/parsers/form.spec.ts @@ -0,0 +1,248 @@ +/* + * @adonisjs/bodyparser + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import supertest from 'supertest' +import { test } from '@japa/runner' +import { createServer } from 'node:http' +import { parseForm } from '../../src/parsers/form.js' + +test.group('Form parser', () => { + test('parse valid request body', async ({ assert }) => { + const server = createServer(async (req, res) => { + const body = await parseForm(req, {}) + res.writeHead(200, { 'content-type': 'application/json' }) + res.end(JSON.stringify(body)) + }) + + const { body } = await supertest(server) + .post('/') + .type('form') + .send({ foo: { bar: 'baz' } }) + .expect(200) + + assert.deepEqual(body, { + parsed: { foo: { bar: 'baz' } }, + raw: 'foo%5Bbar%5D=baz', + }) + }) + + test('should throw 415 with invalid content encoding', async ({ assert }) => { + const server = createServer(async (req, res) => { + try { + const body = await parseForm(req, {}) + res.writeHead(200, { 'content-type': 'application/json' }) + res.end(JSON.stringify(body)) + } catch (error) { + res.writeHead(error.status) + res.end(error.message) + } + }) + + const { text } = await supertest(server) + .post('/') + .type('form') + .set('content-encoding', 'invalid') + .send({ foo: { bar: 'baz' } }) + .expect(415) + + assert.equal(text, 'Unsupported Content-Encoding: invalid') + }) + + test('parse until default depth (ie 5)', async ({ assert }) => { + const server = createServer(async (req, res) => { + try { + const body = await parseForm(req, { + queryString: { + depth: 5, + }, + }) + res.writeHead(200, { 'content-type': 'application/json' }) + res.end(JSON.stringify(body)) + } catch (error) { + res.writeHead(error.status) + res.end(error.message) + } + }) + + const { body } = await supertest(server) + .post('/') + .type('form') + .send({ + level1: { level2: { level3: { level4: { level5: { level6: { level7: 'Hello' } } } } } }, + }) + .expect(200) + + assert.deepEqual(body.parsed, { + level1: { level2: { level3: { level4: { level5: { level6: { '[level7]': 'Hello' } } } } } }, + }) + }) + + test('parse until configured depth', async ({ assert }) => { + const server = createServer(async (req, res) => { + try { + const body = await parseForm(req, { + queryString: { + depth: 10, + }, + }) + res.writeHead(200, { 'content-type': 'application/json' }) + res.end(JSON.stringify(body)) + } catch (error) { + res.writeHead(error.status) + res.end(error.message) + } + }) + + const { body } = await supertest(server) + .post('/') + .type('form') + .send({ + level1: { level2: { level3: { level4: { level5: { level6: { level7: 'Hello' } } } } } }, + }) + .expect(200) + + assert.deepEqual(body.parsed, { + level1: { level2: { level3: { level4: { level5: { level6: { level7: 'Hello' } } } } } }, + }) + }) + + test('allow dots by default', async ({ assert }) => { + const server = createServer(async (req, res) => { + try { + const body = await parseForm(req, { + queryString: { + depth: 5, + }, + }) + res.writeHead(200, { 'content-type': 'application/json' }) + res.end(JSON.stringify(body)) + } catch (error) { + res.writeHead(error.status) + res.end(error.message) + } + }) + + const { body } = await supertest(server).post('/').type('form').send('a.b=1&a.c=2').expect(200) + assert.deepEqual(body.parsed, { a: { b: '1', c: '2' } }) + }) + + test('disable dots', async ({ assert }) => { + const server = createServer(async (req, res) => { + try { + const body = await parseForm(req, { + queryString: { + allowDots: false, + }, + }) + res.writeHead(200, { 'content-type': 'application/json' }) + res.end(JSON.stringify(body)) + } catch (error) { + res.writeHead(error.status) + res.end(error.message) + } + }) + + const { body } = await supertest(server).post('/').type('form').send('a.b=1&a.c=2').expect(200) + assert.deepEqual(body.parsed, { 'a.b': '1', 'a.c': '2' }) + }) + + test('JSON poisoning: remove inline __proto__ properties', async ({ assert }) => { + const server = createServer(async (req, res) => { + try { + const body = await parseForm(req, { + queryString: { + allowDots: false, + }, + }) + res.writeHead(200, { 'content-type': 'application/json' }) + res.end(JSON.stringify(body)) + } catch (error) { + res.writeHead(error.status) + res.end(error.message) + } + }) + + const { body } = await supertest(server) + .post('/') + .type('form') + .send('foo=bar&__proto__[admin]=true') + .expect(200) + + assert.notProperty(body, 'admin') + }) + + test('convert empty string to null', async ({ assert }) => { + const server = createServer(async (req, res) => { + try { + const body = await parseForm(req, { + convertEmptyStringsToNull: true, + }) + res.writeHead(200, { 'content-type': 'application/json' }) + res.end(JSON.stringify(body)) + } catch (error) { + res.writeHead(error.status) + res.end(error.message) + } + }) + + const { body } = await supertest(server) + .post('/') + .type('form') + .send({ foo: { bar: '' } }) + .expect(200) + + assert.deepEqual(body.parsed, { foo: { bar: null } }) + }) + + test('do not convert empty string to null when not enabled', async ({ assert }) => { + const server = createServer(async (req, res) => { + try { + const body = await parseForm(req, { + convertEmptyStringsToNull: false, + }) + res.writeHead(200, { 'content-type': 'application/json' }) + res.end(JSON.stringify(body)) + } catch (error) { + res.writeHead(error.status) + res.end(error.message) + } + }) + + const { body } = await supertest(server) + .post('/') + .type('form') + .send({ foo: { bar: '' } }) + .expect(200) + + assert.deepEqual(body.parsed, { foo: { bar: '' } }) + }) + + test('do not convert empty keys to null', async ({ assert }) => { + const server = createServer(async (req, res) => { + try { + const body = await parseForm(req, { + convertEmptyStringsToNull: true, + }) + res.writeHead(200, { 'content-type': 'application/json' }) + res.end(JSON.stringify(body)) + } catch (error) { + res.writeHead(error.status) + res.end(error.message) + } + }) + + const { body } = await supertest(server) + .post('/') + .type('form') + .send({ foo: { '': 'foo', 'a': 'b' } }) + .expect(200) + + assert.deepEqual(body.parsed, { foo: { 0: 'foo', a: 'b' } }) + }) +}) diff --git a/tests/parsers/json.spec.ts b/tests/parsers/json.spec.ts new file mode 100644 index 0000000..08686f0 --- /dev/null +++ b/tests/parsers/json.spec.ts @@ -0,0 +1,253 @@ +/* + * @adonisjs/bodyparser + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import supertest from 'supertest' +import { test } from '@japa/runner' +import { createServer } from 'node:http' +import { parseJSON } from '../../src/parsers/json.js' + +test.group('JSON parser', () => { + test('parse valid request body', async ({ assert }) => { + const server = createServer(async (req, res) => { + const body = await parseJSON(req, {}) + res.writeHead(200, { 'content-type': 'application/json' }) + res.end(JSON.stringify(body)) + }) + + const { body } = await supertest(server) + .post('/') + .send({ foo: { bar: 'baz' } }) + .expect(200) + + assert.deepEqual(body, { + parsed: { foo: { bar: 'baz' } }, + raw: JSON.stringify({ foo: { bar: 'baz' } }), + }) + }) + + test('should throw 415 with invalid content encoding', async ({ assert }) => { + const server = createServer(async (req, res) => { + try { + const body = await parseJSON(req, {}) + res.writeHead(200, { 'content-type': 'application/json' }) + res.end(JSON.stringify(body)) + } catch (error) { + res.writeHead(error.status) + res.end(error.message) + } + }) + + const { text } = await supertest(server) + .post('/') + .type('json') + .set('content-encoding', 'invalid') + .send({ foo: { bar: 'baz' } }) + .expect(415) + + assert.equal(text, 'Unsupported Content-Encoding: invalid') + }) + + test('return empty string when content-length=0 and strict is false', async ({ assert }) => { + const server = createServer(async (req, res) => { + const body = await parseJSON(req, { + strict: false, + }) + res.writeHead(200, { 'content-type': 'application/json' }) + res.end(JSON.stringify(body)) + }) + + const { body } = await supertest(server).post('/').set('content-length', '0').expect(200) + + assert.deepEqual(body, { + parsed: '', + raw: '', + }) + }) + + test('return empty object when content-length=0 and strict is true', async ({ assert }) => { + const server = createServer(async (req, res) => { + const body = await parseJSON(req, { + strict: true, + }) + res.writeHead(200, { 'content-type': 'application/json' }) + res.end(JSON.stringify(body)) + }) + + const { body } = await supertest(server).post('/').set('content-length', '0').expect(200) + + assert.deepEqual(body, { + parsed: {}, + raw: '', + }) + }) + + test('fail for invalid json when strict mode is disabled', async ({ assert }) => { + const server = createServer(async (req, res) => { + try { + const body = await parseJSON(req, { + strict: false, + }) + res.writeHead(200, { 'content-type': 'application/json' }) + res.end(JSON.stringify(body)) + } catch (error) { + res.writeHead(error.status) + res.end(error.message) + } + }) + + const { text } = await supertest(server) + .post('/') + .set('content-type', 'application/json') + .send('{"foo": "bar') + .expect(400) + + assert.match(text, /Unexpected end of JSON input|Unterminated string in JSON/) + }) + + test('fail for invalid json when strict mode is enabled', async ({ assert }) => { + const server = createServer(async (req, res) => { + try { + const body = await parseJSON(req, { + strict: true, + }) + res.writeHead(200, { 'content-type': 'application/json' }) + res.end(JSON.stringify(body)) + } catch (error) { + res.writeHead(error.status) + res.end(error.message) + } + }) + + const { text } = await supertest(server) + .post('/') + .set('content-type', 'application/json') + .send('{"foo": "bar') + .expect(400) + + assert.match(text, /Unexpected end of JSON input|Unterminated string in JSON/) + }) + + test('parse non-object json', async ({ assert }) => { + const server = createServer(async (req, res) => { + try { + const body = await parseJSON(req, { + strict: false, + }) + res.writeHead(200, { 'content-type': 'application/json' }) + res.end(JSON.stringify(body)) + } catch (error) { + res.writeHead(error.status) + res.end(error.message) + } + }) + + const { body } = await supertest(server) + .post('/') + .set('content-type', 'application/json') + .send('"foo"') + .expect(200) + + assert.deepEqual(body, { + parsed: 'foo', + raw: '"foo"', + }) + }) + + test('fail for non-object json in strict mode', async ({ assert }) => { + const server = createServer(async (req, res) => { + try { + const body = await parseJSON(req, { + strict: true, + }) + res.writeHead(200, { 'content-type': 'application/json' }) + res.end(JSON.stringify(body)) + } catch (error) { + res.writeHead(error.status) + res.end(error.message) + } + }) + + const { text } = await supertest(server) + .post('/') + .set('content-type', 'application/json') + .send('"foo"') + .expect(422) + + assert.equal(text, 'Invalid JSON, only supports object and array') + }) + + test('convert empty string to null', async ({ assert }) => { + const server = createServer(async (req, res) => { + try { + const body = await parseJSON(req, { + convertEmptyStringsToNull: true, + }) + res.writeHead(200, { 'content-type': 'application/json' }) + res.end(JSON.stringify(body)) + } catch (error) { + res.writeHead(error.status) + res.end(error.message) + } + }) + + const { body } = await supertest(server).post('/').type('json').send({ foo: '' }).expect(200) + assert.deepEqual(body, { + parsed: { + foo: null, + }, + raw: JSON.stringify({ foo: '' }), + }) + }) + + test('do not convert empty string to null when disabled', async ({ assert }) => { + const server = createServer(async (req, res) => { + try { + const body = await parseJSON(req, { + convertEmptyStringsToNull: false, + }) + res.writeHead(200, { 'content-type': 'application/json' }) + res.end(JSON.stringify(body)) + } catch (error) { + res.writeHead(error.status) + res.end(error.message) + } + }) + + const { body } = await supertest(server).post('/').type('json').send({ foo: '' }).expect(200) + assert.deepEqual(body, { + parsed: { + foo: '', + }, + raw: JSON.stringify({ foo: '' }), + }) + }) + + test('do not convert empty keys to null', async ({ assert }) => { + const server = createServer(async (req, res) => { + try { + const body = await parseJSON(req, { + convertEmptyStringsToNull: true, + }) + res.writeHead(200, { 'content-type': 'application/json' }) + res.end(JSON.stringify(body)) + } catch (error) { + res.writeHead(error.status) + res.end(error.message) + } + }) + + const { body } = await supertest(server).post('/').type('json').send({ '': '' }).expect(200) + assert.deepEqual(body, { + parsed: { + '': '', + }, + raw: JSON.stringify({ '': '' }), + }) + }) +}) diff --git a/tests/parsers/raw.spec.ts b/tests/parsers/raw.spec.ts new file mode 100644 index 0000000..3d82d01 --- /dev/null +++ b/tests/parsers/raw.spec.ts @@ -0,0 +1,47 @@ +/* + * @adonisjs/bodyparser + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import supertest from 'supertest' +import { test } from '@japa/runner' +import { createServer } from 'node:http' +import { parseText } from '../../src/parsers/text.js' + +test.group('Raw parser', () => { + test('inflate request body', async ({ assert }) => { + const server = createServer(async (req, res) => { + const body = await parseText(req, {}) + res.writeHead(200) + res.end(body) + }) + + const { text } = await supertest(server).post('/').send('Hello World!').expect(200) + assert.equal(text, 'Hello World!') + }) + + test('fail with 415 when content encoding is invalid', async ({ assert }) => { + const server = createServer(async (req, res) => { + try { + const body = await parseText(req, {}) + res.writeHead(200) + res.end(body) + } catch (error) { + res.writeHead(error.status) + res.end(error.message) + } + }) + + const { text } = await supertest(server) + .post('/') + .set('content-encoding', 'invalid') + .send('Hello World!') + .expect(415) + + assert.equal(text, 'Unsupported Content-Encoding: invalid') + }) +}) diff --git a/test/stream-file.spec.ts b/tests/stream_file.spec.ts similarity index 50% rename from test/stream-file.spec.ts rename to tests/stream_file.spec.ts index ad66cfd..c74fbc8 100644 --- a/test/stream-file.spec.ts +++ b/tests/stream_file.spec.ts @@ -1,44 +1,47 @@ /* * @adonisjs/bodyparser * - * (c) Harminder Virk + * (c) AdonisJS * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ +import fs from 'fs-extra' +import { join } from 'node:path' import { test } from '@japa/runner' -import { join } from 'path' -import { Filesystem } from '@poppinss/dev-utils' -import { createReadStream, pathExists } from 'fs-extra' +import { fileURLToPath } from 'node:url' -import { streamFile } from '../src/Multipart/streamFile' +import { retry } from '../tests_helpers/main.js' +import { streamFile } from '../src/multipart/stream_file.js' -const fs = new Filesystem(join(__dirname, 'app')) -const SAMPLE_FILE = join(fs.basePath, 'hello-out.txt') -const MAIN_FILE = join(fs.basePath, 'hello-in.txt') +const BASE_URL = new URL('./tmp/', import.meta.url) +const BASE_PATH = fileURLToPath(BASE_URL) + +const SAMPLE_FILE = join(BASE_PATH, 'hello-out.txt') +const MAIN_FILE = join(BASE_PATH, 'hello-in.txt') test.group('streamFile', (group) => { group.each.teardown(async () => { - await fs.cleanup().catch(() => {}) + await retry(() => fs.remove(BASE_PATH), 3)() }) test('write readable stream to the destination', async ({ assert }) => { - await fs.add(MAIN_FILE, 'hello') + await fs.outputFile(MAIN_FILE, 'hello') - const file = createReadStream(MAIN_FILE) + const file = fs.createReadStream(MAIN_FILE) await streamFile(file, SAMPLE_FILE) - const hasFile = await pathExists(SAMPLE_FILE) + const hasFile = await fs.pathExists(SAMPLE_FILE) assert.isTrue(hasFile) }) test('raise error when stream gets interuppted', async ({ assert }) => { assert.plan(1) - await fs.add(MAIN_FILE, 'hello\nhi\nhow are you') + await fs.outputFile(MAIN_FILE, 'hello\nhi\nhow are you') - const file = createReadStream(MAIN_FILE) + const file = fs.createReadStream(MAIN_FILE) file.on('readable', () => { setTimeout(() => { if (!file.destroyed) { @@ -52,5 +55,5 @@ test.group('streamFile', (group) => { } catch (error) { assert.equal(error, 'blowup') } - }).retry(3) + }).skip(process.platform === 'win32', 'The teardown hook fails on windows') }) diff --git a/tests_helpers/main.ts b/tests_helpers/main.ts new file mode 100644 index 0000000..3ac190f --- /dev/null +++ b/tests_helpers/main.ts @@ -0,0 +1,44 @@ +/* + * @adonisjs/bodyparser + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import fsExtra from 'fs-extra' +import { fileURLToPath } from 'node:url' + +const pkgFileContents = await fsExtra.readFile(new URL('../package.json', import.meta.url)) + +export const largePdfFile = fileURLToPath( + new URL('../resources/sample-pdf-download-10-mb.pdf', import.meta.url) +) +export const xlsFilePath = fileURLToPath(new URL('../resources/sample.xls', import.meta.url)) +export const xlsxFilePath = fileURLToPath(new URL('../resources/sample.xlsx', import.meta.url)) +export const unicornFilePath = fileURLToPath(new URL('../resources/unicorn.png', import.meta.url)) +export const unicornNoExtFilePath = fileURLToPath( + new URL('../resources/unicorn-wo-ext', import.meta.url) +) +export const packageFilePath = fileURLToPath(new URL('../package.json', import.meta.url)) +export const packageFileSize = pkgFileContents.length + +export const sleep = (time: number) => new Promise((resolve) => setTimeout(resolve, time)) +export const retry = (callback: () => Promise, maxCounts: number) => { + let counts = 0 + async function run() { + try { + await callback() + } catch (error) { + counts++ + if (counts === maxCounts) { + throw error + } + + await run() + } + } + + return run +} diff --git a/tsconfig.json b/tsconfig.json index de558ed..ad0cc44 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,12 +1,7 @@ { - "extends": "./node_modules/@adonisjs/mrm-preset/_tsconfig", + "extends": "@adonisjs/tsconfig/tsconfig.package.json", "compilerOptions": { - "skipLibCheck": true, - "experimentalDecorators": true, - "emitDecoratorMetadata": true - }, - "files": [ - "./node_modules/@adonisjs/encryption/build/adonis-typings/encryption.d.ts", - "./node_modules/@adonisjs/drive/build/adonis-typings/index.d.ts" - ] + "rootDir": "./", + "outDir": "./build" + } }