diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..06c3eac --- /dev/null +++ b/.eslintignore @@ -0,0 +1,3 @@ +node_modules/ +coverage/ +dist/ diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..bc75913 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,79 @@ +{ + "parser": "@typescript-eslint/parser", + "parserOptions": { + "ecmaVersion": 2022, + "sourceType": "module", + "project": "./tsconfig.lint.json" + }, + "ignorePatterns": ["node_modules"], + "plugins": ["@typescript-eslint", "vitest", "import"], + "extends": [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended", + "plugin:@typescript-eslint/recommended-requiring-type-checking", + "plugin:import/recommended", + "plugin:import/typescript", + "plugin:vitest/recommended", + "prettier" + ], + "rules": { + "@typescript-eslint/no-empty-interface": "warn", + "@typescript-eslint/ban-ts-comment": "off", + "@typescript-eslint/no-use-before-define": "off", + "@typescript-eslint/no-non-null-assertion": "warn", + "@typescript-eslint/no-var-requires": "off", + "@typescript-eslint/indent": "off", + "@typescript-eslint/no-explicit-any": "warn", + "@typescript-eslint/no-unsafe-member-access": "warn", + "@typescript-eslint/explicit-function-return-type": "off", + "@typescript-eslint/consistent-type-imports": "warn", + "@typescript-eslint/no-unused-vars": [ + "warn", + { + "argsIgnorePattern": "^_", + "varsIgnorePattern": "^_", + "caughtErrorsIgnorePattern": "^_" + } + ], + "@typescript-eslint/member-delimiter-style": [ + "error", + { + "multiline": { + "delimiter": "none", + "requireLast": false + }, + "singleline": { + "delimiter": "comma", + "requireLast": false + } + } + ], + "import/no-default-export": "error", + "import/order": [ + "warn", + { + "alphabetize": { "order": "asc" }, + "newlines-between": "always" + } + ], + "object-shorthand": ["error", "always"], + "max-lines": ["error", { "max": 600 }], + "max-params": ["error", { "max": 4 }], + "max-statements": ["error", { "max": 15 }], + "complexity": ["error", { "max": 20 }] + }, + "overrides": [ + { + "files": ["*.test.ts", "*.spec.ts"], + "rules": { + "@typescript-eslint/no-non-null-assertion": "off", + "@typescript-eslint/no-unsafe-return": "off", + "@typescript-eslint/no-unsafe-assignment": "off", + "@typescript-eslint/no-unsafe-member-access": "off", + "@typescript-eslint/no-unsafe-call": "off", + "@typescript-eslint/no-explicit-any": "off", + "max-statements": ["off"] + } + } + ] +} diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..45c4c56 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,34 @@ +version: 2 +updates: + - package-ecosystem: 'github-actions' + directory: '/' + schedule: + interval: 'monthly' + open-pull-requests-limit: 10 + labels: + - 'skip-release' + + - package-ecosystem: 'npm' + directory: '/' + schedule: + interval: 'weekly' + open-pull-requests-limit: 10 + groups: + lint: + patterns: + - '@typescript-eslint/*' + - 'eslint' + - 'eslint-*' + - 'prettier' + - 'jest-fail-on-console' + testing: + patterns: + - 'vitest' + - '@vitest/*' + - 'mockttp' + typescript: + patterns: + - '@types/*' + - 'ts-node' + - 'ts-node-dev' + - 'typescript' diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..2182804 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,9 @@ +## Changes + +Please describe + +## Checklist + +- [ ] Apply one of following labels; `major`, `minor`, `patch` or `skip-release` +- [ ] I've updated the documentation, or no changes were necessary +- [ ] I've updated the tests, or no changes were necessary diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6eb6194 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,32 @@ +name: ci + +on: + pull_request: + +jobs: + build: + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [18.x, 20.x] + + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + + - name: Install + run: | + npm install + + - name: Build TS + run: | + npm run build + + - name: Run Tests + run: | + npm run test diff --git a/.github/workflows/ensure-labels.yml b/.github/workflows/ensure-labels.yml new file mode 100644 index 0000000..1439dd8 --- /dev/null +++ b/.github/workflows/ensure-labels.yml @@ -0,0 +1,25 @@ +# Workflow to ensure pull request has proper labels +name: Ensure labels + +on: + pull_request: + branches: + - main + types: + - opened + - reopened + - synchronize + - ready_for_review + - labeled + - unlabeled + +jobs: + ensure_labels: + name: Ensure PR has proper labeling + runs-on: ubuntu-latest + steps: + - name: Check on of required labels are set + uses: docker://agilepathway/pull-request-label-checker:v1.1.2 + with: + one_of: major,minor,patch,skip-release # Must have exactly-one of these labels + repo_token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/linting.yml b/.github/workflows/linting.yml new file mode 100644 index 0000000..a72b937 --- /dev/null +++ b/.github/workflows/linting.yml @@ -0,0 +1,33 @@ +--- +name: linting + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + build: + runs-on: ubuntu-latest + name: linting + + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + always-auth: false + node-version: 20.x + + - name: Run npm install + run: npm install + + - name: Run lint + run: npm run lint diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..244f823 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,63 @@ +# Workflow for releasing package to npm registry +name: Release to NPM +on: + pull_request: + types: + - closed # Only run after PRs have been merged or closed + branches: + - main # Only run for PRs targeted against main branch + +jobs: + release: + # Only run for pull requests that has been merged (not closed) and that doesn't have `skip-release` label + if: github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'skip-release') == false + name: release + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: main + fetch-depth: 0 # fetch all commits so auto-changelog is correctly generating + token: ${{ secrets.BOT_ACCESS_TOKEN }} + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20.x + scope: '@lokalise' + always-auth: true + registry-url: 'https://registry.npmjs.org' + + - name: Install Dependencies + run: npm install + + - name: Build Package + run: npm run build + + - name: Setup git config + run: | + git config --global user.email "auto-release@lokalise.com" + git config --global user.name "AUTO RELEASE" + + # Apply proper semver according to github labels + + - name: Major label detected + if: contains(github.event.pull_request.labels.*.name, 'major') + run: npm version major + + - name: Minor label detected + if: contains(github.event.pull_request.labels.*.name, 'minor') + run: npm version minor + + - name: Patch label detected + if: contains(github.event.pull_request.labels.*.name, 'patch') + run: npm version patch + + - name: Git push + run: git push origin main && git push --tags + + - name: Release Package + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: npm publish diff --git a/.gitignore b/.gitignore index c6bba59..944369a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,130 +1,9 @@ -# Logs -logs -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* -lerna-debug.log* -.pnpm-debug.log* - -# Diagnostic reports (https://nodejs.org/api/report.html) -report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json - -# Runtime data -pids -*.pid -*.seed -*.pid.lock - -# Directory for instrumented libs generated by jscoverage/JSCover -lib-cov - -# Coverage directory used by tools like istanbul -coverage -*.lcov - -# nyc test coverage -.nyc_output - -# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) -.grunt - -# Bower dependency directory (https://bower.io/) -bower_components - -# node-waf configuration -.lock-wscript - -# Compiled binary addons (https://nodejs.org/api/addons.html) -build/Release - -# Dependency directories -node_modules/ -jspm_packages/ - -# Snowpack dependency directory (https://snowpack.dev/) -web_modules/ - -# TypeScript cache -*.tsbuildinfo - -# Optional npm cache directory -.npm - -# Optional eslint cache +node_modules .eslintcache - -# Optional stylelint cache -.stylelintcache - -# Microbundle cache -.rpt2_cache/ -.rts2_cache_cjs/ -.rts2_cache_es/ -.rts2_cache_umd/ - -# Optional REPL history -.node_repl_history - -# Output of 'npm pack' -*.tgz - -# Yarn Integrity file -.yarn-integrity - -# dotenv environment variable files -.env -.env.development.local -.env.test.local -.env.production.local -.env.local - -# parcel-bundler cache (https://parceljs.org/) -.cache -.parcel-cache - -# Next.js build output -.next -out - -# Nuxt.js build / generate output -.nuxt dist - -# Gatsby files -.cache/ -# Comment in the public line in if your project uses Gatsby and not Next.js -# https://nextjs.org/blog/next-9-1#public-directory-support -# public - -# vuepress build output -.vuepress/dist - -# vuepress v2.x temp and cache directory -.temp -.cache - -# Docusaurus cache and generated files -.docusaurus - -# Serverless directories -.serverless/ - -# FuseBox cache -.fusebox/ - -# DynamoDB Local files -.dynamodb/ - -# TernJS port file -.tern-port - -# Stores VSCode versions used for testing VSCode extensions -.vscode-test - -# yarn v2 -.yarn/cache -.yarn/unplugged -.yarn/build-state.yml -.yarn/install-state.gz -.pnp.* +coverage +/.idea/.gitignore +/.idea/frontend-http-client.iml +/.idea/modules.xml +/.idea/vcs.xml +/package-lock.json diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..01df725 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,152 @@ +# Code of Conduct + +Lokalise uses [Contributor Covenant +v2.0](https://contributor-covenant.org/version/2/0/code_of_conduct) as their +code of conduct. The full text is included +[below](#contributor-covenant-code-of-conduct) in English, and translations are +available from the Contributor Covenant organisation: + +- [contributor-covenant.org/translations](https://www.contributor-covenant.org/translations) +- [github.com/ContributorCovenant](https://github.com/ContributorCovenant/contributor_covenant/tree/release/content/version/2/0) + +Refer to the sections on reporting in this document for the +specific emails that can be used to report and escalate issues. + +## Reporting + +### Project Spaces + +For reporting issues in spaces related to Lokalise please use the email +`support@lokalise.com`. Lokalise handles CoC issues +related to the spaces that it maintains. Projects maintainers commit to: + +- maintain the confidentiality with regard to the reporter of an incident +- to participate in the path for escalation when required. + +## Contributor Covenant Code of Conduct v2.0 + +### Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity and +orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +### Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +- Focusing on what is best not just for us as individuals, but for the overall + community + +Examples of unacceptable behavior include: + +- The use of sexualized language or imagery, and sexual attention or advances of + any kind +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or email address, + without their explicit permission +- Other conduct which could reasonably be considered inappropriate in a + professional setting + +### Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +### Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +### Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at the email +addresses listed above in the [Reporting](#reporting) sections. All complaints will be reviewed and +investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +### Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +#### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +#### 2. Warning + +**Community Impact**: A violation through a single incident or series of +actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or permanent +ban. + +#### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +#### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the +project community. + +### Attribution + +This Code of Conduct is adapted from the [Contributor +Covenant](https://www.contributor-covenant.org), version 2.0, available at +[contributor-covenant.org/version/2/0/code_of_conduct](https://www.contributor-covenant.org/version/2/0/code_of_conduct). + +Community Impact Guidelines were inspired by [Mozilla's code of conduct +enforcement ladder](https://github.com/mozilla/diversity). + +For answers to common questions about this code of conduct, see the FAQ at +[contributor-covenant.org/faq](https://www.contributor-covenant.org/faq). +Translations are available at +[contributor-covenant.org/translations](https://www.contributor-covenant.org/translations). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..50a5833 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,38 @@ +# Contributing to frontend-http-client + +## Rules + +There are a few basic ground-rules for contributors: + +1. **Non-main branches** ought to be used for ongoing work. +1. Contributors should attempt to adhere to the prevailing code-style. +1. Before submitting a PR for a major new feature, or introducing a significant change, please open an issue to discuss the proposal with maintainers. + +## Releases + +- Releases are done automatically after pull requests are merged. +- Please label your PR in accordance with requirements of [SemVer](https://semver.org/) ( `major`, `minor` or `patch`), or add `skip-release` label to avoid releasing after merging pull request. +- Do not bump version numbers in pull requests. + +## Developer's Certificate of Origin 1.1 + +By making a contribution to this project, I certify that: + +- (a) The contribution was created in whole or in part by me and I have the + right to submit it under the open source license indicated in the file; or + +- (b) The contribution is based upon previous work that, to the best of my + knowledge, is covered under an appropriate open source license and I have the + right under that license to submit that work with modifications, whether + created in whole or in part by me, under the same open source license (unless + I am permitted to submit under a different license), as indicated in the file; + or + +- (c) The contribution was provided directly to me by some other person who + certified (a), (b) or (c) and I have not modified it. + +- (d) I understand and agree that this project and the contribution are public + and that a record of the contribution (including all personal information I + submit with it, including my sign-off) is maintained indefinitely and may be + redistributed consistent with this project or the open source license(s) + involved. diff --git a/LICENSE b/LICENSE index 261eeb9..08a6e77 100644 --- a/LICENSE +++ b/LICENSE @@ -175,18 +175,7 @@ END OF TERMS AND CONDITIONS - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] + Copyright 2024 Lokalise Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/README.md b/README.md index 5c2f88c..363791b 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,43 @@ -# frontend-http-client -Opinionated HTTP client for the frontend +# Frontend HTTP client + +## Basic usage + +```ts +import wretch from 'wretch' +import { z } from 'zod' + +const client = wretch('http://localhost:8000') + +const queryParamsSchema = z.object({ + param1: z.string(), + param2: z.number(), +}) + +const requestBodySchema = z.object({ + requestCode: z.number(), +}) + +const responseBodySchema = z.object({ + success: z.boolean(), +}) + +const responseBody = await sendPost(client, { + path: '/', + body: { requestCode: 100 }, + queryParams: { param1: 'test', param2: 123 }, + queryParamsSchema, + requestBodySchema, + responseBodySchema, +}) +``` + +## Credits + +This library is brought to you by a joint effort of Lokalise engineers: + +- [Ondrej Sevcik](https://github.com/ondrejsevcik) +- [Szymon Chudy](https://github.com/szymonchudy) +- [Nivedita Bhat](https://github.com/NiveditaBhat) +- [Arthur Suermondt](https://github.com/arthuracs) +- [Lauris Mikāls](https://github.com/laurismikals) +- [Igor Savin](https://github.com/kibertoad) diff --git a/index.ts b/index.ts new file mode 100644 index 0000000..d59df4c --- /dev/null +++ b/index.ts @@ -0,0 +1 @@ +export { sendPost, sendGet, sendPut, sendDelete, sendPatch } from './src/client' diff --git a/package.json b/package.json new file mode 100644 index 0000000..8db8d55 --- /dev/null +++ b/package.json @@ -0,0 +1,57 @@ +{ + "name": "@lokalise/frontend-http-client", + "version": "0.0.1", + "files": [ + "dist/**", + "LICENSE", + "README.md" + ], + "main": "dist/index.js", + "types": "dist/index.d.ts", + "type": "module", + "author": { + "name": "Lokalise", + "url": "https://lokalise.com/" + }, + "homepage": "https://github.com/lokalise/frontend-http-client", + "repository": { + "type": "git", + "url": "git://github.com/lokalise/frontend-http-client.git" + }, + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "tsc", + "clean": "rimraf dist .eslintcache", + "lint": "eslint --cache --max-warnings=0 . && prettier --check --log-level warn src \"**/*.{json,md,ts,tsx}\" && tsc --noEmit", + "lint:fix": "prettier --write src \"**/*.{json,md,ts,tsx}\" --log-level=warn && eslint . --fix", + "test": "vitest run", + "prepublishOnly": "npm run clean && npm run build" + }, + "dependencies": { + "fast-querystring": "^1.1.2" + }, + "peerDependencies": { + "wretch": "^2.8.0", + "zod": "^3.22.0" + }, + "devDependencies": { + "@lokalise/prettier-config": "^1.0.0", + "@types/node": "^20.11.5", + "@typescript-eslint/eslint-plugin": "^6.19.0", + "@typescript-eslint/parser": "^6.19.0", + "@vitest/coverage-v8": "^1.2.1", + "jest-fail-on-console": "^3.1.2", + "eslint": "^8.55.0", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-import": "^2.29.0", + "eslint-plugin-vitest": "^0.3.17", + "mockttp": "^3.10.1", + "prettier": "^3.2.4", + "rimraf": "^5.0.5", + "typescript": "~5.3.3", + "vitest": "^1.2.1" + }, + "prettier": "@lokalise/prettier-config" +} diff --git a/src/client.test.ts b/src/client.test.ts new file mode 100644 index 0000000..2c7cc19 --- /dev/null +++ b/src/client.test.ts @@ -0,0 +1,737 @@ +/* eslint-disable max-lines */ +import failOnConsole from 'jest-fail-on-console' +import { getLocal } from 'mockttp' +import wretch from 'wretch' +import { z } from 'zod' + +import { sendDelete, sendGet, sendPatch, sendPost, sendPut } from './client' + +describe('frontend-http-client', () => { + const mockServer = getLocal() + + beforeAll(() => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + failOnConsole({ + silenceMessage: (message: string) => message.includes('ZodError'), + }) + }) + beforeEach(() => mockServer.start()) + afterEach(() => mockServer.stop()) + + describe('sendPost', () => { + it('returns deserialized response', async () => { + const client = wretch(mockServer.url) + + await mockServer.forPost('/').thenJson(200, { data: { code: 99 } }) + + const responseSchema = z.object({ + data: z.object({ + code: z.number(), + }), + }) + + const responseBody = await sendPost(client, { + path: '/', + responseBodySchema: responseSchema, + }) + + expect(responseBody).toEqual({ + data: { + code: 99, + }, + }) + }) + + it('returns no content response', async () => { + const client = wretch(mockServer.url) + + await mockServer.forPost('/').thenReply(204) + + const responseBody = await sendPost(client, { + path: '/', + }) + + expect(responseBody).containSubset({ + status: 204, + statusText: 'No Content', + }) + }) + + it('throws an error if response does not pass validation', async () => { + const client = wretch(mockServer.url) + + await mockServer.forPost('/').thenJson(200, { data: { code: 99 } }) + + const responseSchema = z.object({ + code: z.number(), + }) + + await expect( + sendPost(client, { + path: '/', + responseBodySchema: responseSchema, + }), + ).rejects.toThrowErrorMatchingInlineSnapshot(` + [ZodError: [ + { + "code": "invalid_type", + "expected": "number", + "received": "undefined", + "path": [ + "code" + ], + "message": "Required" + } + ]] + `) + }) + + it('throws an error if request does not pass validation', async () => { + const client = wretch(mockServer.url) + + await mockServer.forPost('/').thenJson(200, { data: { code: 99 } }) + + const requestSchema = z.object({ + requestCode: z.number(), + }) + const responseSchema = z.object({ + code: z.number(), + }) + + await expect( + sendPost(client, { + path: '/', + body: {} as any, // otherwise it breaks at compilation already + requestBodySchema: requestSchema, + responseBodySchema: responseSchema, + }), + ).rejects.toThrowErrorMatchingInlineSnapshot(` + [ZodError: [ + { + "code": "invalid_type", + "expected": "number", + "received": "undefined", + "path": [ + "requestCode" + ], + "message": "Required" + } + ]] + `) + }) + + it('throws an error if query params does not pass validation', async () => { + const client = wretch(mockServer.url) + + const testQueryParams = { param1: 'test', param2: 'test' } + + await mockServer.forPost('/').withQuery(testQueryParams).thenJson(200, { success: true }) + + const queryParamsSchema = z.object({ + param1: z.string(), + param2: z.number(), + }) + + const responseSchema = z.object({ + success: z.boolean(), + }) + + await expect( + sendPost(client, { + path: '/', + queryParams: testQueryParams, + queryParamsSchema: queryParamsSchema as any, + responseBodySchema: responseSchema, + }), + ).rejects.toThrowErrorMatchingInlineSnapshot(` + [ZodError: [ + { + "code": "invalid_type", + "expected": "number", + "received": "string", + "path": [ + "param2" + ], + "message": "Expected number, received string" + } + ]] + `) + }) + + it('allows posting request with correct params even if queryParamsSchema is not provided', async () => { + const client = wretch(mockServer.url) + + const testQueryParams = { param1: 'test', param2: 'test' } + + await mockServer.forPost('/').withQuery(testQueryParams).thenJson(200, { success: true }) + + const responseSchema = z.object({ + success: z.boolean(), + }) + + const response = await sendPost(client, { + path: '/', + queryParams: testQueryParams, + queryParamsSchema: undefined, + responseBodySchema: responseSchema, + }) + + expect(response).toEqual({ success: true }) + }) + + it('correctly serializes and sends query parameters', async () => { + const client = wretch(mockServer.url) + + const testQueryParams = { param1: 'test', param2: 123 } + + await mockServer.forPost('/').withQuery(testQueryParams).thenJson(200, { success: true }) + + const requestSchema = z.object({ + param1: z.string(), + param2: z.number(), + param3: z.string().optional(), + }) + + const responseSchema = z.object({ + success: z.boolean(), + }) + + const response = await sendPost(client, { + path: '/', + queryParams: testQueryParams, + queryParamsSchema: requestSchema, + responseBodySchema: responseSchema, + }) + + expect(response).toEqual({ success: true }) + }) + + it('correctly serializes and sends request body', async () => { + const client = wretch(mockServer.url) + + await mockServer.forPost('/').thenJson(200, { success: true }) + + const requestSchema = z.object({ + param1: z.string(), + }) + + const responseSchema = z.object({ + success: z.boolean(), + }) + + const response = await sendPost(client, { + path: '/', + body: { param1: 'test' }, + requestBodySchema: requestSchema, + responseBodySchema: responseSchema, + }) + + expect(response).toEqual({ success: true }) + }) + + it('allows posting request without responseBodySchema', async () => { + const client = wretch(mockServer.url) + + await mockServer.forPost('/').thenJson(200, { success: true }) + + const response = await sendPost(client, { + path: '/', + }) + + expect(response).toEqual({ success: true }) + }) + }) + + describe('sendPut', () => { + it('returns deserialized response', async () => { + const client = wretch(mockServer.url) + + await mockServer.forPut('/').thenJson(200, { data: { code: 99 } }) + + const responseSchema = z.object({ + data: z.object({ + code: z.number(), + }), + }) + + const responseBody = await sendPut(client, { + path: '/', + responseBodySchema: responseSchema, + }) + + expect(responseBody).toEqual({ + data: { + code: 99, + }, + }) + }) + + it('returns no content response', async () => { + const client = wretch(mockServer.url) + + await mockServer.forPut('/').thenReply(204) + + const responseBody = await sendPut(client, { + path: '/', + }) + + expect(responseBody).containSubset({ + status: 204, + statusText: 'No Content', + }) + }) + + it('throws an error if response does not pass validation', async () => { + const client = wretch(mockServer.url) + + await mockServer.forPut('/').thenJson(200, { data: { code: 99 } }) + + const responseSchema = z.object({ + code: z.number(), + }) + + await expect( + sendPut(client, { + path: '/', + responseBodySchema: responseSchema, + }), + ).rejects.toThrowErrorMatchingInlineSnapshot(` + [ZodError: [ + { + "code": "invalid_type", + "expected": "number", + "received": "undefined", + "path": [ + "code" + ], + "message": "Required" + } + ]] + `) + }) + + it('throws an error if request does not pass validation', async () => { + const client = wretch(mockServer.url) + + await mockServer.forPost('/').thenJson(200, { data: { code: 99 } }) + + const requestSchema = z.object({ + requestCode: z.number(), + }) + const responseSchema = z.object({ + code: z.number(), + }) + + await expect( + sendPut(client, { + path: '/', + body: {} as any, // otherwise it breaks at compilation already + requestBodySchema: requestSchema, + responseBodySchema: responseSchema, + }), + ).rejects.toThrowErrorMatchingInlineSnapshot(` + [ZodError: [ + { + "code": "invalid_type", + "expected": "number", + "received": "undefined", + "path": [ + "requestCode" + ], + "message": "Required" + } + ]] + `) + }) + + it('throws an error if query params does not pass validation', async () => { + const client = wretch(mockServer.url) + + const testQueryParams = { param1: 'test', param2: 'test' } + + await mockServer.forPut('/').withQuery(testQueryParams).thenJson(200, { success: true }) + + const queryParamsSchema = z.object({ + param1: z.string(), + param2: z.number(), + }) + + const responseSchema = z.object({ + success: z.boolean(), + }) + + await expect( + sendPut(client, { + path: '/', + queryParams: testQueryParams, + queryParamsSchema: queryParamsSchema as any, + responseBodySchema: responseSchema, + }), + ).rejects.toThrowErrorMatchingInlineSnapshot(` + [ZodError: [ + { + "code": "invalid_type", + "expected": "number", + "received": "string", + "path": [ + "param2" + ], + "message": "Expected number, received string" + } + ]] + `) + }) + + it('correctly serializes and sends query parameters', async () => { + const client = wretch(mockServer.url) + + const testQueryParams = { param1: 'test', param2: 123 } + + await mockServer.forPut('/').withQuery(testQueryParams).thenJson(200, { success: true }) + + const requestSchema = z.object({ + param1: z.string(), + param2: z.number(), + }) + + const responseSchema = z.object({ + success: z.boolean(), + }) + + const response = await sendPut(client, { + path: '/', + queryParams: testQueryParams, + queryParamsSchema: requestSchema, + responseBodySchema: responseSchema, + }) + + expect(response).toEqual({ success: true }) + }) + + it('correctly serializes and sends request body', async () => { + const client = wretch(mockServer.url) + + await mockServer.forPut('/').thenJson(200, { success: true }) + + const requestSchema = z.object({ + param1: z.string(), + }) + + const responseSchema = z.object({ + success: z.boolean(), + }) + + const response = await sendPut(client, { + path: '/', + body: { param1: 'test' }, + requestBodySchema: requestSchema, + responseBodySchema: responseSchema, + }) + + expect(response).toEqual({ success: true }) + }) + }) + + describe('sendPatch', () => { + it('returns deserialized response', async () => { + const client = wretch(mockServer.url) + + await mockServer.forPatch('/').thenJson(200, { data: { code: 99 } }) + + const responseSchema = z.object({ + data: z.object({ + code: z.number(), + }), + }) + + const responseBody = await sendPatch(client, { + path: '/', + responseBodySchema: responseSchema, + }) + + expect(responseBody).toEqual({ + data: { + code: 99, + }, + }) + }) + + it('throws an error if response does not pass validation', async () => { + const client = wretch(mockServer.url) + + await mockServer.forPatch('/').thenJson(200, { data: { code: 99 } }) + + const responseSchema = z.object({ + code: z.number(), + }) + + await expect( + sendPatch(client, { + path: '/', + responseBodySchema: responseSchema, + }), + ).rejects.toThrowErrorMatchingInlineSnapshot(` + [ZodError: [ + { + "code": "invalid_type", + "expected": "number", + "received": "undefined", + "path": [ + "code" + ], + "message": "Required" + } + ]] + `) + }) + + it('throws an error if request does not pass validation', async () => { + const client = wretch(mockServer.url) + + await mockServer.forPatch('/').thenJson(200, { data: { code: 99 } }) + + const requestSchema = z.object({ + requestCode: z.number(), + }) + const responseSchema = z.object({ + code: z.number(), + }) + + await expect( + sendPatch(client, { + path: '/', + body: {} as any, // otherwise it breaks at compilation already + requestBodySchema: requestSchema, + responseBodySchema: responseSchema, + }), + ).rejects.toThrowErrorMatchingInlineSnapshot(` + [ZodError: [ + { + "code": "invalid_type", + "expected": "number", + "received": "undefined", + "path": [ + "requestCode" + ], + "message": "Required" + } + ]] + `) + }) + + it('throws an error if query params does not pass validation', async () => { + const client = wretch(mockServer.url) + + const testQueryParams = { param1: 'test', param2: 'test' } + + await mockServer.forPatch('/').withQuery(testQueryParams).thenJson(200, { success: true }) + + const queryParamsSchema = z.object({ + param1: z.string(), + param2: z.number(), + }) + + const responseSchema = z.object({ + success: z.boolean(), + }) + + await expect( + sendPatch(client, { + path: '/', + queryParams: testQueryParams, + queryParamsSchema: queryParamsSchema as any, + responseBodySchema: responseSchema, + }), + ).rejects.toThrowErrorMatchingInlineSnapshot(` + [ZodError: [ + { + "code": "invalid_type", + "expected": "number", + "received": "string", + "path": [ + "param2" + ], + "message": "Expected number, received string" + } + ]] + `) + }) + + it('correctly serializes and sends query parameters', async () => { + const client = wretch(mockServer.url) + + const testQueryParams = { param1: 'test', param2: 123 } + + await mockServer.forPatch('/').withQuery(testQueryParams).thenJson(200, { success: true }) + + const requestSchema = z.object({ + param1: z.string(), + param2: z.number(), + }) + + const responseSchema = z.object({ + success: z.boolean(), + }) + + const response = await sendPatch(client, { + path: '/', + queryParams: testQueryParams, + queryParamsSchema: requestSchema, + responseBodySchema: responseSchema, + }) + + expect(response).toEqual({ success: true }) + }) + + it('correctly serializes and sends request body', async () => { + const client = wretch(mockServer.url) + + await mockServer.forPatch('/').thenJson(200, { success: true }) + + const requestSchema = z.object({ + param1: z.string(), + }) + + const responseSchema = z.object({ + success: z.boolean(), + }) + + const response = await sendPatch(client, { + path: '/', + body: { param1: 'test' }, + requestBodySchema: requestSchema, + responseBodySchema: responseSchema, + }) + + expect(response).toEqual({ success: true }) + }) + }) + + describe('sendGet', () => { + it('returns deserialized response', async () => { + const client = wretch(mockServer.url) + + await mockServer.forGet('/').thenJson(200, { data: { code: 99 } }) + + const responseSchema = z.object({ + data: z.object({ + code: z.number(), + }), + }) + + const responseBody = await sendGet(client, { + path: '/', + responseBodySchema: responseSchema, + }) + + expect(responseBody).toEqual({ + data: { + code: 99, + }, + }) + }) + + it('throws an error if response does not pass validation', async () => { + const client = wretch(mockServer.url) + + await mockServer.forGet('/').thenJson(200, { data: { code: 99 } }) + + const responseSchema = z.object({ + code: z.number(), + }) + + await expect( + sendGet(client, { + path: '/', + responseBodySchema: responseSchema, + }), + ).rejects.toThrowErrorMatchingInlineSnapshot(` + [ZodError: [ + { + "code": "invalid_type", + "expected": "number", + "received": "undefined", + "path": [ + "code" + ], + "message": "Required" + } + ]] + `) + }) + + it('throws an error if request does not pass validation', async () => { + const client = wretch(mockServer.url) + + await mockServer.forGet('/').thenJson(200, { data: { code: 99 } }) + + const requestSchema = z.object({ + requestCode: z.number(), + }) + const responseSchema = z.object({ + code: z.number(), + }) + + await expect( + sendGet(client, { + path: '/', + queryParams: {} as any, // otherwise it breaks at compilation already + queryParamsSchema: requestSchema, + responseBodySchema: responseSchema, + }), + ).rejects.toThrowErrorMatchingInlineSnapshot(` + [ZodError: [ + { + "code": "invalid_type", + "expected": "number", + "received": "undefined", + "path": [ + "requestCode" + ], + "message": "Required" + } + ]] + `) + }) + + it('returns correct data if everything is ok', async () => { + const client = wretch(mockServer.url) + + await mockServer.forGet('/').thenJson(200, { data: { code: 99 } }) + + const requestSchema = z.object({ + requestCode: z.coerce.number(), + }) + const responseSchema = z.object({ + data: z.object({ + code: z.number(), + }), + }) + + const response = await sendGet(client, { + path: '/', + queryParams: { + requestCode: 99, + }, + queryParamsSchema: requestSchema, + responseBodySchema: responseSchema, + }) + + expect(response.data.code).toBe(99) + }) + }) + + describe('sendDelete', () => { + it('returns a status if proceeded', async () => { + const client = wretch(mockServer.url) + + await mockServer.forDelete('/').thenReply(204) + + const response = await sendDelete(client, { + path: '/', + }) + + expect(response).toMatchObject({ status: 204 }) + }) + }) +}) diff --git a/src/client.ts b/src/client.ts new file mode 100644 index 0000000..c97644f --- /dev/null +++ b/src/client.ts @@ -0,0 +1,236 @@ +import { stringify } from 'fast-querystring' +import { type ZodSchema, type ZodError } from 'zod' + +import { type Either, failure, success, isFailure } from './either' +import type { + CommonRequestParams, + NoQueryParams, + QueryParams, + ResourceChangeParams, + WretchInstance, +} from './types' + +function parseRequestBody({ + body, + requestBodySchema, + path, +}: { + body: RequestBody + requestBodySchema?: ZodSchema + path: string +}): Either { + if (!body) { + return success(body) + } + + if (!requestBodySchema) { + return success(body as RequestBody) + } + + const result = requestBodySchema.safeParse(body) + + if (!result.success) { + console.error({ + path, + body, + error: result.error, + }) + return failure(result.error) + } + + return success(body) +} + +function parseQueryParams({ + queryParams, + queryParamsSchema, + path, +}: { + queryParams: RequestQueryParams + queryParamsSchema?: ZodSchema + path: string +}): Either { + if (!queryParams) { + return success('') + } + + if (!queryParamsSchema) { + return success(`?${stringify(queryParams)}`) + } + + const result = queryParamsSchema.safeParse(queryParams) + + if (!result.success) { + console.error({ + path, + queryParams, + error: result.error, + }) + return failure(result.error) + } + + return success(`?${stringify(queryParams)}`) +} + +function parseResponseBody({ + response, + responseBodySchema, + path, +}: { + response: ResponseBody + responseBodySchema?: ZodSchema + path: string +}): Either { + if (!responseBodySchema) { + return success(response) + } + + const result = responseBodySchema.safeParse(response) + + if (!result.success) { + console.error({ + path, + response, + error: result.error, + }) + + return failure(result.error) + } + + return success(response) +} + +async function sendResourceChange< + T extends WretchInstance, + ResponseBody, + RequestBody extends object | undefined = undefined, + RequestQueryParams extends object | undefined = undefined, +>( + wretch: T, + method: 'post' | 'put' | 'patch', + params: ResourceChangeParams, +) { + const body = parseRequestBody({ + body: params.body, + requestBodySchema: params.requestBodySchema, + path: params.path, + }) + + if (isFailure(body)) { + return Promise.reject(body.error) + } + + const queryParams = parseQueryParams({ + queryParams: params.queryParams, + queryParamsSchema: params.queryParamsSchema, + path: params.path, + }) + + if (isFailure(queryParams)) { + return Promise.reject(queryParams.error) + } + + return wretch[method](body.result, `${params.path}${queryParams.result}`).res( + async (response) => { + if (response.headers.get('content-type')?.includes('application/json')) { + const parsedResponse = parseResponseBody({ + response: (await response.json()) as ResponseBody, + responseBodySchema: params.responseBodySchema, + path: params.path, + }) + + if (isFailure(parsedResponse)) { + return Promise.reject(parsedResponse.error) + } + + return parsedResponse.result + } + + return response as unknown as Promise + }, + ) +} + +/* METHODS */ + +/* GET */ + +export async function sendGet< + T extends WretchInstance, + ResponseBody, + RequestQueryParams extends object | undefined = undefined, +>( + wretch: T, + params: RequestQueryParams extends undefined + ? NoQueryParams + : QueryParams, +): Promise { + const queryParams = parseQueryParams({ + queryParams: params.queryParams, + queryParamsSchema: params.queryParamsSchema, + path: params.path, + }) + + if (isFailure(queryParams)) { + return Promise.reject(queryParams.error) + } + + return wretch + .get(`${params.path}${queryParams.result}`) + .json() + .then((response) => { + const parsedResponse = parseResponseBody({ + response: response as ResponseBody, + responseBodySchema: params.responseBodySchema, + path: params.path, + }) + + if (isFailure(parsedResponse)) { + return Promise.reject(parsedResponse.error) + } + + return parsedResponse.result + }) +} + +/* POST */ + +export function sendPost< + T extends WretchInstance, + ResponseBody, + RequestBody extends object | undefined = undefined, + RequestQueryParams extends object | undefined = undefined, +>(wretch: T, params: ResourceChangeParams) { + return sendResourceChange(wretch, 'post', params) +} + +/* PUT */ + +export function sendPut< + T extends WretchInstance, + ResponseBody, + RequestBody extends object | undefined = undefined, + RequestQueryParams extends object | undefined = undefined, +>(wretch: T, params: ResourceChangeParams) { + return sendResourceChange(wretch, 'put', params) +} + +/* PATCH */ + +export function sendPatch< + T extends WretchInstance, + ResponseBody, + RequestBody extends object | undefined = undefined, + RequestQueryParams extends object | undefined = undefined, +>(wretch: T, params: ResourceChangeParams) { + return sendResourceChange(wretch, 'patch', params) +} + +/* DELETE */ + +export function sendDelete( + wretch: T, + params: Pick, 'path'>, +) { + return wretch.delete(params.path).res() +} diff --git a/src/either.ts b/src/either.ts new file mode 100644 index 0000000..74be47f --- /dev/null +++ b/src/either.ts @@ -0,0 +1,38 @@ +type Left = { + error: T + result?: never +} + +type Right = { + error?: never + result: U +} + +/** + * Either is a functional programming type which is used to communicate errors happening in potentially recoverable scenarios. + * It can return either an error (Left side) or a resolved result (Right side), but not both. + * It is up to caller of the function to handle received error or throw (Public)NonRecoverableError if it cannot. + * + * @see {@link https://antman-does-software.com/stop-catching-errors-in-typescript-use-the-either-type-to-make-your-code-predictable Further reading on motivation for Either type} + */ +export type Either = NonNullable | Right> + +/*** + * Variation of Either, which may or may not have Error set, but always has Result + */ +export type DefiniteEither = { + error?: T + result: U +} + +export const isFailure = (e: Either): e is Left => { + return e.error !== undefined +} + +export const isSuccess = (e: Either): e is Right => { + return e.result !== undefined +} + +export const failure = (error: T): Left => ({ error }) + +export const success = (result: U): Right => ({ result }) diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..97720b9 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,42 @@ +import type { Wretch } from 'wretch' +import type { ZodSchema } from 'zod' + +export type CommonRequestParams = { + path: string + responseBodySchema?: ZodSchema +} + +export type BodyRequestParams = { + body: RequestBody | undefined + requestBodySchema: ZodSchema | undefined +} & CommonRequestParams + +export type NoBodyRequestParams = { + body?: never + requestBodySchema?: never +} & CommonRequestParams + +export type QueryParams = { + queryParams: RequestQueryParams | undefined + queryParamsSchema: ZodSchema | undefined +} & CommonRequestParams + +export type NoQueryParams = { + queryParams?: never + queryParamsSchema?: never +} & CommonRequestParams + +export type ResourceChangeParams< + RequestBody, + ResponseBody, + RequestQueryParams extends object | undefined = undefined, +> = (RequestBody extends object + ? BodyRequestParams + : NoBodyRequestParams) & + (RequestQueryParams extends undefined + ? NoQueryParams + : QueryParams) + +// We don't know which addons Wretch will have, and we don't really care, hence any +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type WretchInstance = Wretch diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..df87d19 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "outDir": "dist", + "module": "ESNext", + "target": "ES2022", + "lib": ["ES2022", "dom"], + "sourceMap": false, + "declaration": true, + "declarationMap": false, + "types": ["node", "vitest/globals"], + "strict": true, + "moduleResolution": "bundler", + "noUnusedLocals": false, + "noUnusedParameters": false, + "noFallthroughCasesInSwitch": true, + "strictNullChecks": true, + "importHelpers": true, + "baseUrl": ".", + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + }, + "include": ["src/**/*"], + "exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"], +} diff --git a/tsconfig.lint.json b/tsconfig.lint.json new file mode 100644 index 0000000..312ca7f --- /dev/null +++ b/tsconfig.lint.json @@ -0,0 +1,5 @@ +{ + "extends": ["./tsconfig.json"], + "include": ["./src/**/*", "index.ts", "vitest.config.ts"], + "exclude": [] +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..22e65c2 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,21 @@ +// eslint-disable-next-line import/no-unresolved +import { defineConfig } from 'vitest/config' + +// eslint-disable-next-line import/no-default-export +export default defineConfig({ + test: { + globals: true, + coverage: { + include: ['src/**/*.ts'], + exclude: ['src/**/*.spec.ts', 'src/**/*.test.ts', 'src/index.ts', 'src/types.ts'], + reporter: ['lcov', 'text-summary'], + all: true, + thresholds: { + lines: 99, + functions: 91, + branches: 100, + statements: 99, + }, + }, + }, +})