Compare commits

..

93 Commits

Author SHA1 Message Date
Gregory Schier
5684479f1d Remove old rust cache action 2023-03-12 22:48:43 -07:00
Gregory Schier
2d1603601c Better rust cache 2023-03-12 22:47:43 -07:00
Gregory Schier
f5394b2210 Start GraphQL support 2023-03-12 22:43:25 -07:00
Gregory Schier
833db5df06 Fix artifact tag 2023-03-12 21:41:15 -07:00
Gregory Schier
525ac7e980 Remove wasm stuff 2023-03-12 21:25:31 -07:00
Gregory Schier
44a747c80a Use tauri action 2023-03-12 21:13:08 -07:00
Gregory Schier
2056e7f40a Fix traffic lights thingy 2023-03-12 20:47:52 -07:00
Gregory Schier
9b6c1ad364 Cache cargo bin for "install" 2023-03-12 19:10:39 -07:00
Gregory Schier
34987bcacb Refformat 2023-03-12 19:03:27 -07:00
Gregory Schier
b62c11222a Fix artifact upload 2023-03-12 19:01:48 -07:00
Gregory Schier
b3cee3ace3 Fix dev 2023-03-12 18:39:02 -07:00
Gregory Schier
222c054c95 Split out macos deps 2023-03-12 18:36:25 -07:00
Gregory Schier
46f18a2491 Cache workflow 2023-03-12 18:28:14 -07:00
Gregory Schier
f2ca8e2753 Add wasm-pack 2023-03-12 18:19:20 -07:00
Gregory Schier
b0d243c378 Install rsw 2023-03-12 18:14:38 -07:00
Gregory Schier
6161fb86c8 Fix artifact names 2023-03-12 18:13:00 -07:00
Gregory Schier
b09cc91fe5 Fix build command 2023-03-12 18:11:24 -07:00
Gregory Schier
ef1638cbb3 Update secrets context 2023-03-12 18:07:57 -07:00
Gregory Schier
00ef8743f2 Update workflow name 2023-03-12 18:05:45 -07:00
Gregory Schier
68222659e3 Fix workflow 2023-03-12 18:05:13 -07:00
Gregory Schier
69420a4bba Start of auto updates 2023-03-12 18:04:11 -07:00
Gregory Schier
0161bbaeb1 Fix tabbing to tabs 2023-03-11 23:32:39 -08:00
Gregory Schier
948dbfe3cc Fix eslint errors 2023-03-11 23:29:25 -08:00
Gregory Schier
338ba8b189 Got tab content scrolling working 2023-03-11 22:36:13 -08:00
Gregory Schier
ca4655b441 Removed some debug stuff 2023-03-10 10:43:15 -08:00
Gregory Schier
bf37499428 Refactor editor to update better 2023-03-10 10:39:23 -08:00
Gregory Schier
0b94b57e2a Fix headers persistence and better sending 2023-03-09 13:38:17 -08:00
Gregory Schier
fc40aead98 Hook up header editor! 2023-03-09 13:07:13 -08:00
Gregory Schier
7d7f934e6a Fix 2023-03-09 10:58:27 -08:00
Gregory Schier
d5fbf4d622 Fix blur de-select speed 2023-03-09 10:57:34 -08:00
Gregory Schier
e4f6c919dc Fix Codemirror performance!! 2023-03-09 10:50:55 -08:00
Gregory Schier
4d806ff2b1 Switch to Preact!!! 2023-03-09 00:47:25 -08:00
Gregory Schier
bf8f12274f Move some things around 2023-03-08 23:20:15 -08:00
Gregory Schier
f4f438d9fe Better scrollbar color 2023-03-08 19:23:24 -08:00
Gregory Schier
2434f373be Zoom, better sizes, color picker, sidebar footer 2023-03-08 19:22:04 -08:00
Gregory Schier
2bb2061f97 Read-only editor 2023-03-08 16:53:13 -08:00
Gregory Schier
2c011a5c2a More theme tweaks 2023-03-08 16:37:20 -08:00
Gregory Schier
f66b0ccea1 Debounce autocomplete 2023-03-08 11:25:20 -08:00
Gregory Schier
665dd8447d Minor theme updates again 2023-03-08 09:43:35 -08:00
Gregory Schier
1b61ce31e6 Editor tweaks 2023-03-07 23:05:33 -08:00
Gregory Schier
ef4d960698 Remove unneeded space 2023-03-07 22:58:13 -08:00
Gregory Schier
b6d557b632 Fix small view 2023-03-07 22:55:51 -08:00
Gregory Schier
b700bd356c Minor style tweaks 2023-03-07 22:21:58 -08:00
Gregory Schier
620dd7d3ef Lots more theme stuff 2023-03-07 21:52:21 -08:00
Gregory Schier
6575121902 Start of themes 2023-03-07 11:24:38 -08:00
Gregory Schier
7c1755a0dc More subtle layout tweaks 2023-03-06 08:57:57 -08:00
Gregory Schier
8ad301a666 More layout fiddling and error page 2023-03-04 22:26:00 -08:00
Gregory Schier
ae24cd4939 More work on the layout 2023-03-04 21:51:17 -08:00
Gregory Schier
7152e1845e Try new layout and a bunch of editor fixes 2023-03-04 19:06:12 -08:00
Gregory Schier
96c1dd4081 Fix autocomplete inside dialog 2023-03-03 17:03:20 -08:00
Gregory Schier
87c7b3a663 Beginnings of Header Editor 2023-03-03 13:18:57 -08:00
Gregory Schier
c1be46a539 Fix tailwind dark selector 2023-03-03 07:54:19 -08:00
Gregory Schier
4655e0018b Fix content type in URL 2023-03-02 23:17:09 -08:00
Gregory Schier
da5ba2e3be Add Dialog component 2023-03-02 18:46:10 -08:00
Gregory Schier
aaf95f565f More colors 2023-03-02 17:56:53 -08:00
Gregory Schier
f32b984e77 Minor style tweaks 2023-03-02 16:16:41 -08:00
Gregory Schier
548aa4c7cd Improved autocompletion! 2023-03-02 11:14:51 -08:00
Gregory Schier
0ccceaac77 Rename, fix autocomplete and singleline, etc... 2023-03-02 10:42:43 -08:00
Gregory Schier
70f534f1d8 Editor placeholder 2023-03-01 14:22:10 -08:00
Gregory Schier
61fe95b300 Some minor bugs 2023-03-01 14:16:02 -08:00
Gregory Schier
915e0e8613 Fix migrations for build and iframe rendering 2023-03-01 10:31:50 -08:00
Gregory Schier
aace2580da Tweaks 2023-03-01 10:19:21 -08:00
Gregory Schier
3d36905664 Response streaming 2023-03-01 09:05:00 -08:00
Gregory Schier
0d671423da Autocomplete, and more CM stuff! 2023-02-28 22:54:54 -08:00
Gregory Schier
aebfcb9437 Some small tweaks 2023-02-28 17:25:59 -08:00
Gregory Schier
be7ef7beb1 Better editor updating 2023-02-28 12:41:03 -08:00
Gregory Schier
d77ed0c5cc URL highlighting with inline CM 2023-02-28 11:26:26 -08:00
Gregory Schier
e57e7bcec5 Implement request deletion 2023-02-27 15:42:06 -08:00
Gregory Schier
a637842ce4 Tauri events for request model updates 2023-02-27 13:28:50 -08:00
Gregory Schier
fc54ec49af Split request upsert command 2023-02-27 10:00:57 -08:00
Gregory Schier
5c43d8510a Add toggle for pretty view 2023-02-27 09:08:48 -08:00
Gregory Schier
83f84ded8d Small tweaks 2023-02-26 15:25:55 -08:00
Gregory Schier
5658da34a2 Add variable highlighting widgets 2023-02-26 15:06:14 -08:00
Gregory Schier
38e8ef6535 Dropdown scrolling 2023-02-25 23:33:07 -08:00
Gregory Schier
8c89b06238 Show response body size 2023-02-25 23:08:19 -08:00
Gregory Schier
d85c021305 A bunch more small things 2023-02-25 23:04:31 -08:00
Gregory Schier
83bb18df03 Added react-router 2023-02-25 18:04:14 -08:00
Gregory Schier
93105a3e89 Migrations and initial data stuff 2023-02-25 16:39:18 -08:00
Gregory Schier
ba3b899115 Minor tweaks 2023-02-24 17:01:48 -08:00
Gregory Schier
fcfbc1d1da Dummy requests in sidebar 2023-02-24 16:46:56 -08:00
Gregory Schier
72486b448c Codemirror initial value support 2023-02-24 16:43:47 -08:00
Gregory Schier
7dea1b7870 Send request body 2023-02-24 16:09:19 -08:00
Gregory Schier
4de2c496c9 Vendor basicSetup 2023-02-24 14:51:56 -08:00
Gregory Schier
9e1393a392 Additional methods and tweaks 2023-02-24 14:10:25 -08:00
Gregory Schier
0901690ed6 Focus states 2023-02-24 12:35:13 -08:00
Gregory Schier
95303648cc Hook up theme and clear responses 2023-02-24 12:13:30 -08:00
Gregory Schier
1dbb08c045 SQLite store in proper dir 2023-02-22 20:18:14 -08:00
Gregory Schier
00a7d9a180 Started on grid layout 2023-02-22 19:44:44 -08:00
Gregory Schier
6c549dc086 Save responses in DB 2023-02-22 18:53:44 -08:00
Gregory Schier
dc368e326a Better URL bar 2023-02-22 16:15:25 -08:00
Gregory Schier
e42188a627 Cleaner URL bar and some improvements 2023-02-22 15:58:04 -08:00
Gregory Schier
7a6a337eff Refactor classname usage 2023-02-21 18:03:57 -08:00
Gregory Schier
3907344884 Some minor tweaks 2023-02-21 17:56:48 -08:00
903 changed files with 16547 additions and 75920 deletions

View File

@@ -1,6 +1,5 @@
node_modules/
dist/
.eslintrc.cjs
.prettierrc.cjs
src-web/postcss.config.cjs
src-web/vite.config.ts
.eslintrc.cjs
env.d.ts

View File

@@ -1,49 +1,31 @@
module.exports = {
extends: [
'eslint:recommended',
'plugin:react/recommended',
'plugin:react-hooks/recommended',
'plugin:import/recommended',
'plugin:jsx-a11y/recommended',
'plugin:@typescript-eslint/recommended',
'eslint-config-prettier',
],
plugins: ['react-refresh'],
parser: '@typescript-eslint/parser',
parserOptions: {
project: ['./tsconfig.json'],
},
ignorePatterns: [
'scripts/**/*',
'packages/plugin-runtime/**/*',
'packages/plugin-runtime-types/**/*',
'src-tauri/**/*',
'src-web/tailwind.config.cjs',
'src-web/vite.config.ts',
],
settings: {
react: {
version: 'detect',
},
'import/resolver': {
node: {
paths: ['src-web'],
extensions: ['.ts', '.tsx'],
},
},
},
rules: {
'react-refresh/only-export-components': 'error',
'jsx-a11y/no-autofocus': 'off',
'react/react-in-jsx-scope': 'off',
'import/no-unresolved': 'off',
'@typescript-eslint/consistent-type-imports': [
'error',
{
prefer: 'type-imports',
disallowTypeAnnotations: true,
fixStyle: 'separate-type-imports',
},
extends: [
'eslint:recommended',
'plugin:react/recommended',
'plugin:import/recommended',
'plugin:jsx-a11y/recommended',
'plugin:@typescript-eslint/recommended',
'eslint-config-prettier',
],
},
ignorePatterns: ['src-tauri/**/*'],
settings: {
react: {
version: 'detect',
},
'import/resolver': {
node: {
paths: ['src-web'],
extensions: ['.js', '.jsx', '.ts', '.tsx'],
},
},
},
rules: {
"jsx-a11y/no-autofocus": "warn",
"react/react-in-jsx-scope": "off",
"@typescript-eslint/consistent-type-imports": ["error", {
prefer: "type-imports",
disallowTypeAnnotations: true,
fixStyle: "separate-type-imports",
}]
},
};

2
.gitattributes vendored
View File

@@ -1,2 +0,0 @@
src-tauri/vendored/**/* linguist-generated=true
src-tauri/gen/schemas/**/* linguist-generated=true

15
.github/FUNDING.yml vendored
View File

@@ -1,15 +0,0 @@
# These are supported funding model platforms
github: gschier
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
thanks_dev: # Replace with a single thanks.dev username
custom: https://yaak.app/pricing

View File

@@ -1,38 +0,0 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: ''
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Desktop (please complete the following information):**
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
- Version [e.g. 22]
**Smartphone (please complete the following information):**
- Device: [e.g. iPhone6]
- OS: [e.g. iOS8.1]
- Browser [e.g. stock browser, safari]
- Version [e.g. 22]
**Additional context**
Add any other context about the problem here.

View File

@@ -1,5 +0,0 @@
blank_issues_enabled: false
contact_links:
- name: Bugs, Feedback, Feature Requests, and Questions
url: https://feedback.yaak.app
about: "Please report to Yaak's public feedback board. Issues will be created and linked here when applicable."

50
.github/workflows/artifacts.yml vendored Normal file
View File

@@ -0,0 +1,50 @@
name: Generate Artifacts
on:
push:
tags: [ v* ]
jobs:
build-artifacts:
runs-on: ${{ matrix.platform }}
strategy:
fail-fast: false
matrix:
# platform: [ ubuntu-latest, macos-latest, windows-latest ]
platform: [ macos-latest ]
steps:
- uses: actions/checkout@v3
- uses: dtolnay/rust-toolchain@stable
- name: Cache Rust
uses: actions/cache@v2
with:
path: |
~/.cargo/registry
~/.cargo/git
./src-tauri/target
key: ${{ runner.os }}-cargo-${{ hashFiles('src-tauri/Cargo.lock') }}
- uses: actions/setup-node@v3
with:
node-version: 18
cache: 'npm'
- name: install dependencies (ubuntu only)
if: matrix.platform == 'ubuntu-20.04'
run: |
sudo apt-get update
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libappindicator3-dev librsvg2-dev patchelf
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
- uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
with:
tagName: v__VERSION__
releaseName: 'App v__VERSION__'
releaseBody: 'See the assets to download this version and install.'
releaseDraft: true
prerelease: false

View File

@@ -1,18 +0,0 @@
on:
pull_request:
branches: [develop]
name: CI (JS)
jobs:
test:
name: Lint/Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npm run lint
- run: npm test

View File

@@ -1,36 +0,0 @@
on:
pull_request:
branches: [develop]
paths:
- src-tauri/**
- .github/workflows/**
name: CI (Rust)
defaults:
run:
working-directory: src-tauri
jobs:
test:
name: Check/Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev
- uses: dtolnay/rust-toolchain@stable
- uses: actions/cache@v3
continue-on-error: false
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
restore-keys: ${{ runner.os }}-cargo-
- run: cargo check --all
- run: cargo test --all

View File

@@ -1,113 +0,0 @@
name: Generate Artifacts
on:
push:
tags: [ v* ]
jobs:
build-artifacts:
permissions:
contents: write
name: Build
strategy:
fail-fast: false
matrix:
include:
- platform: 'macos-latest' # for Arm-based Macs (M1 and above).
args: '--target aarch64-apple-darwin'
yaak_arch: 'arm64'
- platform: 'macos-latest' # for Intel-based Macs.
args: '--target x86_64-apple-darwin'
yaak_arch: 'x64'
- platform: 'ubuntu-22.04'
args: ''
yaak_arch: 'x64'
- platform: 'windows-latest'
args: ''
yaak_arch: 'x64'
runs-on: ${{ matrix.platform }}
timeout-minutes: 40
steps:
- name: Checkout yaakapp/app
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 22
- name: install Rust stable
uses: dtolnay/rust-toolchain@stable
with:
# Those targets are only used on macos runners so it's in an `if` to slightly speed up windows and linux builds.
targets: ${{ matrix.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
- uses: actions/cache@v3
continue-on-error: false
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
src-tauri/target/
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
restore-keys: ${{ runner.os }}-cargo-
- name: install dependencies (ubuntu only)
if: matrix.platform == 'ubuntu-22.04' # This must match the platform value defined above.
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
- name: install dependencies (windows only)
if: matrix.platform == 'windows-latest'
run: cargo install --force trusted-signing-cli --version 0.5.0
- name: Install NPM Dependencies
run: npm ci
- name: Install Protoc for plugin-runtime
uses: arduino/setup-protoc@v3
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
- name: Run JS build
run: npm run build
- name: Run lint
run: npm run lint
- name: Set version
run: npm run replace-version
env:
YAAK_VERSION: ${{ github.ref_name }}
- uses: tauri-apps/tauri-action@v0
env:
YAAK_TARGET_ARCH: ${{ matrix.yaak_arch }}
ENABLE_CODE_SIGNING: ${{ secrets.APPLE_CERTIFICATE }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
# Apple signing stuff
APPLE_CERTIFICATE: ${{ matrix.platform == 'macos-latest' && secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ matrix.platform == 'macos-latest' && secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_ID: ${{ matrix.platform == 'macos-latest' && secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ matrix.platform == 'macos-latest' && secrets.APPLE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ matrix.platform == 'macos-latest' && secrets.APPLE_SIGNING_IDENTITY }}
APPLE_TEAM_ID: ${{ matrix.platform == 'macos-latest' && secrets.APPLE_TEAM_ID }}
# Windows signing stuff
AZURE_CLIENT_ID: ${{ matrix.platform == 'windows-latest' && secrets.AZURE_CLIENT_ID }}
AZURE_CLIENT_SECRET: ${{ matrix.platform == 'windows-latest' && secrets.AZURE_CLIENT_SECRET }}
AZURE_TENANT_ID: ${{ matrix.platform == 'windows-latest' && secrets.AZURE_TENANT_ID }}
with:
tagName: 'v__VERSION__'
releaseName: 'Release __VERSION__'
releaseBody: '[Changelog __VERSION__](https://yaak.app/blog/__VERSION__)'
releaseDraft: true
prerelease: false
args: ${{ matrix.args }}

7
.gitignore vendored
View File

@@ -22,12 +22,5 @@ dist-ssr
*.njsproj
*.sln
*.sw?
.eslintcache
*.sqlite
*.sqlite-*
.cargo
.tmp
tmp

2
.nvmrc
View File

@@ -1 +1 @@
20
18

View File

@@ -1,4 +1,3 @@
node_modules/
dist/
out/
.prettierrc.cjs

8
.prettierrc.cjs Normal file
View File

@@ -0,0 +1,8 @@
module.exports = {
"trailingComma": "all",
"tabWidth": 2,
"semi": true,
"singleQuote": true,
"printWidth": 100,
"bracketSpacing": true
}

View File

@@ -1,8 +0,0 @@
export default {
"trailingComma": "all",
"tabWidth": 2,
"semi": true,
"singleQuote": true,
"printWidth": 100,
"bracketSpacing": true
}

14
.run/Dev Desktop.run.xml Normal file
View File

@@ -0,0 +1,14 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Dev Desktop" type="js.build_tools.npm">
<package-json value="$PROJECT_DIR$/package.json" />
<command value="run" />
<scripts>
<script value="tauri-dev" />
</scripts>
<node-interpreter value="project" />
<envs>
<env name="DATABASE_URL" value="sqlite://$USER_HOME$/Library/Application%20Support/co.schier.yaak/db.sqlite" />
</envs>
<method v="2" />
</configuration>
</component>

View File

@@ -1,65 +0,0 @@
# Developer Setup
Yaak is a combined Node.js and Rust monorepo. It is a [Tauri](https://tauri.app) project, so
uses Rust and HTML/CSS/JS for the main application but there is also a plugin system powered
by a Node.js sidecar that communicates to the app over gRPC.
Because of the moving parts, there are a few setup steps required before development can
begin.
## Prerequisites
Make sure you have the following tools installed:
- [Node.js](https://nodejs.org/en/download/package-manager)
- [Rust](https://www.rust-lang.org/tools/install)
Check the installations with the following commands:
```shell
node -v
npm -v
rustc --version
```
Install the NPM dependencies:
```shell
npm install
```
Run the `bootstrap` command to do some initial setup:
```shell
npm run bootstrap
```
## Run the App
After bootstrapping, start the app in development mode:
```shell
npm start
```
## SQLite Migrations
New migrations can be created from the `src-tauri/` directory:
```shell
cd src-tauri
sqlx migrate add migration-name
```
Run the app to apply the migrations.
If nothing happens, try `cargo clean` and run the app again.
_Note: Development builds use a separate database location from production builds._
## Lezer Grammer Generation
```sh
# Example
lezer-generator components/core/Editor/<LANG>/<LANG>.grammar > components/core/Editor/<LANG>/<LANG>.ts
```

21
LICENSE
View File

@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2024 Yaak
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:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -1,34 +1,3 @@
# Yaak API Client
# Tauri REST Client
Yaak is a desktop API client for interacting with REST, GraphQL, Server Sent Events (SSE), WebSocket, and gRPC
APIs. It's built using [Tauri](https://tauri.app), Rust, and ReactJS.
![366149288-f18e963f-0b68-4ecb-b8b8-cb71aa9aec02](https://github.com/user-attachments/assets/ca83b7ad-5708-411b-8faf-e36b365841a4)
## Contribution Policy
Yaak is open source, but only accepting contributions for bug fixes. To get started,
visit [`DEVELOPMENT.md`](DEVELOPMENT.md) for tips on setting up your environment.
## Feature Overview
- 🪂 Import data from Postman, Insomnia, OpenAPI, Swagger, or Curl.<br/>
- 📤 Send requests via REST, GraphQL, Server Sent Events (SSE), WebSockets, or gRPC.<br/>
- 🔐 Automatically authorize requests with OAuth 2.0, JWT tokens, Basic Auth, and more.<br/>
- 🔎 Filter response bodies using JSONPath or XPath queries.<br/>
- ⛓️ Chain together multiple requests to dynamically reference values.<br/>
- 📂 Organize requests into workspaces and nested folders.<br/>
- 🧮 Use environment variables to easily switch between Prod and Dev.<br/>
- 🛡️ Secure arbitrary text values with end-to-end encryption<br/>
- 🏷️ Send dynamic values like UUIDs or timestamps using template tags.<br/>
- 🎨 Choose from many of the included themes, or make your own.<br/>
- 💽 Mirror workspace data to a directory for integration with Git or Dropbox.<br/>
- 📜 View response history for each request.<br/>
- 🔌 Create your own plugins for authentication, template tags, and more!<br/>
- 🛜 Configure a proxy to access firewall-blocked APIs
## Useful Resources
- [Feedback and Bug Reports](https://feedback.yaak.app)
- [Documentation](https://feedback.yaak.app/help)
- [Yaak vs Postman](https://yaak.app/blog/postman-alternative)
It's a REST client, yo.

16
index.html Normal file
View File

@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tauri + React + TS</title>
</head>
<body>
<div id="root"></div>
<div id="cm-portal" class="cm-portal" style="pointer-events: auto"></div>
<div id="radix-portal" class="cm-portal"></div>
<script type="module" src="/src-web/main.tsx"></script>
</body>
</html>

23473
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,84 +1,72 @@
{
"name": "yaak-app",
"name": "tauri-app",
"private": true,
"version": "0.0.0",
"repository": {
"type": "git",
"url": "git+https://github.com/mountain-loop/yaak.git"
},
"workspaces": [
"packages/plugin-runtime",
"packages/plugin-runtime-types",
"packages/common-lib",
"plugins/auth-basic",
"plugins/auth-bearer",
"plugins/auth-jwt",
"plugins/auth-oauth2",
"plugins/exporter-curl",
"plugins/filter-jsonpath",
"plugins/filter-xpath",
"plugins/importer-curl",
"plugins/importer-insomnia",
"plugins/importer-openapi",
"plugins/importer-postman",
"plugins/importer-yaak",
"plugins/template-function-cookie",
"plugins/template-function-encode",
"plugins/template-function-fs",
"plugins/template-function-hash",
"plugins/template-function-json",
"plugins/template-function-prompt",
"plugins/template-function-regex",
"plugins/template-function-request",
"plugins/template-function-response",
"plugins/template-function-uuid",
"plugins/template-function-xml",
"src-tauri/yaak-crypto",
"src-tauri/yaak-git",
"src-tauri/yaak-license",
"src-tauri/yaak-mac-window",
"src-tauri/yaak-models",
"src-tauri/yaak-plugins",
"src-tauri/yaak-sse",
"src-tauri/yaak-sync",
"src-tauri/yaak-templates",
"src-tauri/yaak-ws",
"src-web"
],
"type": "module",
"scripts": {
"start": "npm run app-dev",
"app-build": "tauri build",
"app-dev": "tauri dev --no-watch --config ./src-tauri/tauri-dev.conf.json",
"build": "npm run --workspaces --if-present build",
"build-plugins": "npm run --workspaces --if-present build",
"bootstrap": "run-p bootstrap:* && npm run --workspaces --if-present bootstrap",
"bootstrap:vendor-node": "node scripts/vendor-node.cjs",
"bootstrap:vendor-plugins": "node scripts/vendor-plugins.cjs",
"bootstrap:vendor-protoc": "node scripts/vendor-protoc.cjs",
"lint": "npm run --workspaces --if-present lint",
"replace-version": "node scripts/replace-version.cjs",
"tauri": "tauri",
"tauri-before-build": "npm run bootstrap && npm run --workspaces --if-present build",
"tauri-before-dev": "workspaces-run --parallel -- npm run --workspaces --if-present dev"
"tauri-dev": "tauri dev",
"tauri-build": "npm run build:icon && tauri build",
"build": "npm run build:frontend",
"dev": "vite dev",
"lint": "tsc && eslint . --ext .ts,.tsx",
"build:icon": "tauri icon src-tauri/icons/icon.png",
"build:frontend": "vite build",
"test": "vitest",
"coverage": "vitest run --coverage"
},
"dependencies": {
"jotai": "^2.12.2"
"@codemirror/commands": "^6.2.1",
"@codemirror/lang-html": "^6.4.2",
"@codemirror/lang-javascript": "^6.1.4",
"@codemirror/lang-json": "^6.0.1",
"@codemirror/lang-xml": "^6.0.2",
"@codemirror/language": "^6.6.0",
"@codemirror/search": "^6.2.3",
"@lezer/generator": "^1.2.2",
"@lezer/highlight": "^1.1.3",
"@lezer/lr": "^1.3.3",
"@radix-ui/react-dialog": "^1.0.2",
"@radix-ui/react-dropdown-menu": "^2.0.2",
"@radix-ui/react-icons": "^1.2.0",
"@radix-ui/react-popover": "1.0.3",
"@radix-ui/react-scroll-area": "^1.0.2",
"@radix-ui/react-separator": "^1.0.1",
"@radix-ui/react-tabs": "^1.0.3",
"@tanstack/react-query": "^4.24.10",
"@tauri-apps/api": "^1.2.0",
"classnames": "^2.3.2",
"cm6-graphql": "^0.0.4-canary-b30a2325.0",
"codemirror": "^6.0.1",
"format-graphql": "^1.4.0",
"framer-motion": "^9.0.4",
"parse-color": "^1.0.0",
"preact-router": "^4.1.0",
"react-helmet-async": "^1.3.0",
"react-use": "^17.4.0"
},
"devDependencies": {
"@tauri-apps/cli": "^2.4.1",
"@typescript-eslint/eslint-plugin": "^8.27.0",
"@typescript-eslint/parser": "^8.27.0",
"@yaakapp/cli": "^0.1.5",
"eslint": "^8",
"eslint-config-prettier": "^8",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-react": "^7.37.2",
"eslint-plugin-react-hooks": "^5.1.0",
"nodejs-file-downloader": "^4.13.0",
"npm-run-all": "^4.1.5",
"prettier": "^3.4.2",
"typescript": "^5.8.2",
"workspaces-run": "^1.0.2"
"@preact/preset-vite": "^2.5.0",
"@tailwindcss/nesting": "^0.0.0-insiders.565cd3e",
"@tauri-apps/cli": "^1.2.2",
"@types/node": "^18.7.10",
"@types/parse-color": "^1.0.1",
"@types/parse-json": "^4.0.0",
"@typescript-eslint/eslint-plugin": "^5.52.0",
"@typescript-eslint/parser": "^5.52.0",
"autoprefixer": "^10.4.13",
"concurrently": "^7.6.0",
"eslint": "^8.34.0",
"eslint-config-prettier": "^8.6.0",
"eslint-plugin-import": "^2.27.5",
"eslint-plugin-jsx-a11y": "^6.7.1",
"eslint-plugin-react": "^7.32.2",
"postcss": "^8.4.21",
"postcss-nesting": "^11.2.1",
"prettier": "^2.8.4",
"tailwindcss": "^3.2.7",
"typescript": "^4.6.4",
"vite": "^4.0.0",
"vite-plugin-top-level-await": "^1.2.4",
"vitest": "^0.29.2"
}
}

View File

@@ -1,20 +0,0 @@
export function formatSize(bytes: number): string {
let num;
let unit;
if (bytes > 1000 * 1000 * 1000) {
num = bytes / 1000 / 1000 / 1000;
unit = 'GB';
} else if (bytes > 1000 * 1000) {
num = bytes / 1000 / 1000;
unit = 'MB';
} else if (bytes > 1000) {
num = bytes / 1000;
unit = 'KB';
} else {
num = bytes;
unit = 'B';
}
return `${Math.round(num * 10) / 10} ${unit}`;
}

View File

@@ -1 +0,0 @@
export * from './debounce';

View File

@@ -1,6 +0,0 @@
{
"name": "@yaakapp-internal/lib",
"private": true,
"version": "1.0.0",
"main": "index.ts"
}

View File

@@ -1,2 +0,0 @@
lib
node_modules

View File

@@ -1,47 +0,0 @@
{
"name": "@yaakapp/api",
"version": "0.1.17",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@yaakapp/api",
"version": "0.1.17",
"dependencies": {
"@types/node": "^22.5.4"
},
"devDependencies": {
"typescript": "^5.6.2"
}
},
"node_modules/@types/node": {
"version": "22.5.4",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.5.4.tgz",
"integrity": "sha512-FDuKUJQm/ju9fT/SeX/6+gBzoPzlVCzfzmGkwKvRHQVxi4BntVbyIwf6a4Xn62mrvndLiml6z/UBXIdEVjQLXg==",
"license": "MIT",
"dependencies": {
"undici-types": "~6.19.2"
}
},
"node_modules/typescript": {
"version": "5.6.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.2.tgz",
"integrity": "sha512-NW8ByodCSNCwZeghjN3o+JX5OFH0Ojg6sadjEKY4huZ52TqbJTJnDo5+Tw98lSy63NZvi4n+ez5m2u5d4PkZyw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.19.8",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz",
"integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==",
"license": "MIT"
}
}
}

View File

@@ -1,25 +0,0 @@
{
"name": "@yaakapp/api",
"version": "0.6.0",
"main": "lib/index.js",
"typings": "./lib/index.d.ts",
"files": [
"lib/**/*"
],
"scripts": {
"bootstrap": "npm run build",
"build": "run-s build:copy-types build:tsc",
"build:tsc": "tsc",
"build:copy-types": "run-p build:copy-types:*",
"build:copy-types:root": "cpy --flat ../../src-tauri/yaak-plugins/bindings/*.ts ./src/bindings",
"build:copy-types:next": "cpy --flat ../../src-tauri/yaak-plugins/bindings/serde_json/*.ts ./src/bindings/serde_json",
"publish": "npm publish",
"prepublishOnly": "npm run build"
},
"dependencies": {
"@types/node": "^22.5.4"
},
"devDependencies": {
"cpy-cli": "^5.0.0"
}
}

View File

@@ -1,447 +0,0 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { Environment } from "./gen_models.js";
import type { Folder } from "./gen_models.js";
import type { GrpcRequest } from "./gen_models.js";
import type { HttpRequest } from "./gen_models.js";
import type { HttpResponse } from "./gen_models.js";
import type { JsonValue } from "./serde_json/JsonValue.js";
import type { WebsocketRequest } from "./gen_models.js";
import type { Workspace } from "./gen_models.js";
export type BootRequest = { dir: string, watch: boolean, };
export type BootResponse = { name: string, version: string, };
export type CallHttpAuthenticationActionArgs = { contextId: string, values: { [key in string]?: JsonPrimitive }, };
export type CallHttpAuthenticationActionRequest = { index: number, pluginRefId: string, args: CallHttpAuthenticationActionArgs, };
export type CallHttpAuthenticationRequest = { contextId: string, values: { [key in string]?: JsonPrimitive }, method: string, url: string, headers: Array<HttpHeader>, };
export type CallHttpAuthenticationResponse = {
/**
* HTTP headers to add to the request. Existing headers will be replaced, while
* new headers will be added.
*/
setHeaders: Array<HttpHeader>, };
export type CallHttpRequestActionArgs = { httpRequest: HttpRequest, };
export type CallHttpRequestActionRequest = { index: number, pluginRefId: string, args: CallHttpRequestActionArgs, };
export type CallTemplateFunctionArgs = { purpose: RenderPurpose, values: { [key in string]?: JsonValue }, };
export type CallTemplateFunctionRequest = { name: string, args: CallTemplateFunctionArgs, };
export type CallTemplateFunctionResponse = { value: string | null, };
export type CloseWindowRequest = { label: string, };
export type Color = "primary" | "secondary" | "info" | "success" | "notice" | "warning" | "danger";
export type CompletionOptionType = "constant" | "variable";
export type Content = { "type": "text", content: string, } | { "type": "markdown", content: string, };
export type CopyTextRequest = { text: string, };
export type DeleteKeyValueRequest = { key: string, };
export type DeleteKeyValueResponse = { deleted: boolean, };
export type EditorLanguage = "text" | "javascript" | "json" | "html" | "xml" | "graphql" | "markdown";
export type EmptyPayload = {};
export type ErrorResponse = { error: string, };
export type ExportHttpRequestRequest = { httpRequest: HttpRequest, };
export type ExportHttpRequestResponse = { content: string, };
export type FileFilter = { name: string,
/**
* File extensions to require
*/
extensions: Array<string>, };
export type FilterRequest = { content: string, filter: string, };
export type FilterResponse = { content: string, };
export type FindHttpResponsesRequest = { requestId: string, limit?: number, };
export type FindHttpResponsesResponse = { httpResponses: Array<HttpResponse>, };
export type FormInput = { "type": "text" } & FormInputText | { "type": "editor" } & FormInputEditor | { "type": "select" } & FormInputSelect | { "type": "checkbox" } & FormInputCheckbox | { "type": "file" } & FormInputFile | { "type": "http_request" } & FormInputHttpRequest | { "type": "accordion" } & FormInputAccordion | { "type": "banner" } & FormInputBanner | { "type": "markdown" } & FormInputMarkdown;
export type FormInputAccordion = { label: string, inputs?: Array<FormInput>, hidden?: boolean, };
export type FormInputBanner = { inputs?: Array<FormInput>, hidden?: boolean, color?: Color, };
export type FormInputBase = {
/**
* The name of the input. The value will be stored at this object attribute in the resulting data
*/
name: string,
/**
* Whether this input is visible for the given configuration. Use this to
* make branching forms.
*/
hidden?: boolean,
/**
* Whether the user must fill in the argument
*/
optional?: boolean,
/**
* The label of the input
*/
label?: string,
/**
* Visually hide the label of the input
*/
hideLabel?: boolean,
/**
* The default value
*/
defaultValue?: string, disabled?: boolean,
/**
* Longer description of the input, likely shown in a tooltip
*/
description?: string, };
export type FormInputCheckbox = {
/**
* The name of the input. The value will be stored at this object attribute in the resulting data
*/
name: string,
/**
* Whether this input is visible for the given configuration. Use this to
* make branching forms.
*/
hidden?: boolean,
/**
* Whether the user must fill in the argument
*/
optional?: boolean,
/**
* The label of the input
*/
label?: string,
/**
* Visually hide the label of the input
*/
hideLabel?: boolean,
/**
* The default value
*/
defaultValue?: string, disabled?: boolean,
/**
* Longer description of the input, likely shown in a tooltip
*/
description?: string, };
export type FormInputEditor = {
/**
* Placeholder for the text input
*/
placeholder?: string | null,
/**
* Don't show the editor gutter (line numbers, folds, etc.)
*/
hideGutter?: boolean,
/**
* Language for syntax highlighting
*/
language?: EditorLanguage, readOnly?: boolean, completionOptions?: Array<GenericCompletionOption>,
/**
* The name of the input. The value will be stored at this object attribute in the resulting data
*/
name: string,
/**
* Whether this input is visible for the given configuration. Use this to
* make branching forms.
*/
hidden?: boolean,
/**
* Whether the user must fill in the argument
*/
optional?: boolean,
/**
* The label of the input
*/
label?: string,
/**
* Visually hide the label of the input
*/
hideLabel?: boolean,
/**
* The default value
*/
defaultValue?: string, disabled?: boolean,
/**
* Longer description of the input, likely shown in a tooltip
*/
description?: string, };
export type FormInputFile = {
/**
* The title of the file selection window
*/
title: string,
/**
* Allow selecting multiple files
*/
multiple?: boolean, directory?: boolean, defaultPath?: string, filters?: Array<FileFilter>,
/**
* The name of the input. The value will be stored at this object attribute in the resulting data
*/
name: string,
/**
* Whether this input is visible for the given configuration. Use this to
* make branching forms.
*/
hidden?: boolean,
/**
* Whether the user must fill in the argument
*/
optional?: boolean,
/**
* The label of the input
*/
label?: string,
/**
* Visually hide the label of the input
*/
hideLabel?: boolean,
/**
* The default value
*/
defaultValue?: string, disabled?: boolean,
/**
* Longer description of the input, likely shown in a tooltip
*/
description?: string, };
export type FormInputHttpRequest = {
/**
* The name of the input. The value will be stored at this object attribute in the resulting data
*/
name: string,
/**
* Whether this input is visible for the given configuration. Use this to
* make branching forms.
*/
hidden?: boolean,
/**
* Whether the user must fill in the argument
*/
optional?: boolean,
/**
* The label of the input
*/
label?: string,
/**
* Visually hide the label of the input
*/
hideLabel?: boolean,
/**
* The default value
*/
defaultValue?: string, disabled?: boolean,
/**
* Longer description of the input, likely shown in a tooltip
*/
description?: string, };
export type FormInputMarkdown = { content: string, hidden?: boolean, };
export type FormInputSelect = {
/**
* The options that will be available in the select input
*/
options: Array<FormInputSelectOption>,
/**
* The name of the input. The value will be stored at this object attribute in the resulting data
*/
name: string,
/**
* Whether this input is visible for the given configuration. Use this to
* make branching forms.
*/
hidden?: boolean,
/**
* Whether the user must fill in the argument
*/
optional?: boolean,
/**
* The label of the input
*/
label?: string,
/**
* Visually hide the label of the input
*/
hideLabel?: boolean,
/**
* The default value
*/
defaultValue?: string, disabled?: boolean,
/**
* Longer description of the input, likely shown in a tooltip
*/
description?: string, };
export type FormInputSelectOption = { label: string, value: string, };
export type FormInputText = {
/**
* Placeholder for the text input
*/
placeholder?: string | null,
/**
* Placeholder for the text input
*/
password?: boolean,
/**
* Whether to allow newlines in the input, like a <textarea/>
*/
multiLine?: boolean, completionOptions?: Array<GenericCompletionOption>,
/**
* The name of the input. The value will be stored at this object attribute in the resulting data
*/
name: string,
/**
* Whether this input is visible for the given configuration. Use this to
* make branching forms.
*/
hidden?: boolean,
/**
* Whether the user must fill in the argument
*/
optional?: boolean,
/**
* The label of the input
*/
label?: string,
/**
* Visually hide the label of the input
*/
hideLabel?: boolean,
/**
* The default value
*/
defaultValue?: string, disabled?: boolean,
/**
* Longer description of the input, likely shown in a tooltip
*/
description?: string, };
export type GenericCompletionOption = { label: string, detail?: string, info?: string, type?: CompletionOptionType, boost?: number, };
export type GetCookieValueRequest = { name: string, };
export type GetCookieValueResponse = { value: string | null, };
export type GetHttpAuthenticationConfigRequest = { contextId: string, values: { [key in string]?: JsonPrimitive }, };
export type GetHttpAuthenticationConfigResponse = { args: Array<FormInput>, pluginRefId: string, actions?: Array<HttpAuthenticationAction>, };
export type GetHttpAuthenticationSummaryResponse = { name: string, label: string, shortLabel: string, };
export type GetHttpRequestActionsRequest = Record<string, never>;
export type GetHttpRequestActionsResponse = { actions: Array<HttpRequestAction>, pluginRefId: string, };
export type GetHttpRequestByIdRequest = { id: string, };
export type GetHttpRequestByIdResponse = { httpRequest: HttpRequest | null, };
export type GetKeyValueRequest = { key: string, };
export type GetKeyValueResponse = { value?: string, };
export type GetTemplateFunctionsResponse = { functions: Array<TemplateFunction>, pluginRefId: string, };
export type HttpAuthenticationAction = { label: string, icon?: Icon, };
export type HttpHeader = { name: string, value: string, };
export type HttpRequestAction = { label: string, icon?: Icon, };
export type Icon = "alert_triangle" | "check" | "check_circle" | "chevron_down" | "copy" | "info" | "pin" | "search" | "trash" | "_unknown";
export type ImportRequest = { content: string, };
export type ImportResources = { workspaces: Array<Workspace>, environments: Array<Environment>, folders: Array<Folder>, httpRequests: Array<HttpRequest>, grpcRequests: Array<GrpcRequest>, websocketRequests: Array<WebsocketRequest>, };
export type ImportResponse = { resources: ImportResources, };
export type InternalEvent = { id: string, pluginRefId: string, pluginName: string, replyId: string | null, windowContext: PluginWindowContext, payload: InternalEventPayload, };
export type InternalEventPayload = { "type": "boot_request" } & BootRequest | { "type": "boot_response" } & BootResponse | { "type": "reload_request" } & EmptyPayload | { "type": "reload_response" } & EmptyPayload | { "type": "terminate_request" } | { "type": "terminate_response" } | { "type": "import_request" } & ImportRequest | { "type": "import_response" } & ImportResponse | { "type": "filter_request" } & FilterRequest | { "type": "filter_response" } & FilterResponse | { "type": "export_http_request_request" } & ExportHttpRequestRequest | { "type": "export_http_request_response" } & ExportHttpRequestResponse | { "type": "send_http_request_request" } & SendHttpRequestRequest | { "type": "send_http_request_response" } & SendHttpRequestResponse | { "type": "list_cookie_names_request" } & ListCookieNamesRequest | { "type": "list_cookie_names_response" } & ListCookieNamesResponse | { "type": "get_cookie_value_request" } & GetCookieValueRequest | { "type": "get_cookie_value_response" } & GetCookieValueResponse | { "type": "get_http_request_actions_request" } & EmptyPayload | { "type": "get_http_request_actions_response" } & GetHttpRequestActionsResponse | { "type": "call_http_request_action_request" } & CallHttpRequestActionRequest | { "type": "get_template_functions_request" } | { "type": "get_template_functions_response" } & GetTemplateFunctionsResponse | { "type": "call_template_function_request" } & CallTemplateFunctionRequest | { "type": "call_template_function_response" } & CallTemplateFunctionResponse | { "type": "get_http_authentication_summary_request" } & EmptyPayload | { "type": "get_http_authentication_summary_response" } & GetHttpAuthenticationSummaryResponse | { "type": "get_http_authentication_config_request" } & GetHttpAuthenticationConfigRequest | { "type": "get_http_authentication_config_response" } & GetHttpAuthenticationConfigResponse | { "type": "call_http_authentication_request" } & CallHttpAuthenticationRequest | { "type": "call_http_authentication_response" } & CallHttpAuthenticationResponse | { "type": "call_http_authentication_action_request" } & CallHttpAuthenticationActionRequest | { "type": "call_http_authentication_action_response" } & EmptyPayload | { "type": "copy_text_request" } & CopyTextRequest | { "type": "copy_text_response" } & EmptyPayload | { "type": "render_http_request_request" } & RenderHttpRequestRequest | { "type": "render_http_request_response" } & RenderHttpRequestResponse | { "type": "get_key_value_request" } & GetKeyValueRequest | { "type": "get_key_value_response" } & GetKeyValueResponse | { "type": "set_key_value_request" } & SetKeyValueRequest | { "type": "set_key_value_response" } & SetKeyValueResponse | { "type": "delete_key_value_request" } & DeleteKeyValueRequest | { "type": "delete_key_value_response" } & DeleteKeyValueResponse | { "type": "open_window_request" } & OpenWindowRequest | { "type": "window_navigate_event" } & WindowNavigateEvent | { "type": "window_close_event" } | { "type": "close_window_request" } & CloseWindowRequest | { "type": "template_render_request" } & TemplateRenderRequest | { "type": "template_render_response" } & TemplateRenderResponse | { "type": "show_toast_request" } & ShowToastRequest | { "type": "show_toast_response" } & EmptyPayload | { "type": "prompt_text_request" } & PromptTextRequest | { "type": "prompt_text_response" } & PromptTextResponse | { "type": "get_http_request_by_id_request" } & GetHttpRequestByIdRequest | { "type": "get_http_request_by_id_response" } & GetHttpRequestByIdResponse | { "type": "find_http_responses_request" } & FindHttpResponsesRequest | { "type": "find_http_responses_response" } & FindHttpResponsesResponse | { "type": "empty_response" } & EmptyPayload | { "type": "error_response" } & ErrorResponse;
export type JsonPrimitive = string | number | boolean | null;
export type ListCookieNamesRequest = {};
export type ListCookieNamesResponse = { names: Array<string>, };
export type OpenWindowRequest = { url: string,
/**
* Label for the window. If not provided, a random one will be generated.
*/
label: string, title?: string, size?: WindowSize, dataDirKey?: string, };
export type PluginWindowContext = { "type": "none" } | { "type": "label", label: string, workspace_id: string | null, };
export type PromptTextRequest = { id: string, title: string, label: string, description?: string, defaultValue?: string, placeholder?: string,
/**
* Text to add to the confirmation button
*/
confirmText?: string,
/**
* Text to add to the cancel button
*/
cancelText?: string,
/**
* Require the user to enter a non-empty value
*/
required?: boolean, };
export type PromptTextResponse = { value: string | null, };
export type RenderHttpRequestRequest = { httpRequest: HttpRequest, purpose: RenderPurpose, };
export type RenderHttpRequestResponse = { httpRequest: HttpRequest, };
export type RenderPurpose = "send" | "preview";
export type SendHttpRequestRequest = { httpRequest: Partial<HttpRequest>, };
export type SendHttpRequestResponse = { httpResponse: HttpResponse, };
export type SetKeyValueRequest = { key: string, value: string, };
export type SetKeyValueResponse = {};
export type ShowToastRequest = { message: string, color?: Color, icon?: Icon, };
export type TemplateFunction = { name: string, description?: string,
/**
* Also support alternative names. This is useful for not breaking existing
* tags when changing the `name` property
*/
aliases?: Array<string>, args: Array<TemplateFunctionArg>, };
/**
* Similar to FormInput, but contains
*/
export type TemplateFunctionArg = FormInput;
export type TemplateRenderRequest = { data: JsonValue, purpose: RenderPurpose, };
export type TemplateRenderResponse = { data: JsonValue, };
export type WindowNavigateEvent = { url: string, };
export type WindowSize = { width: number, height: number, };

View File

@@ -1,25 +0,0 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type Environment = { model: "environment", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, base: boolean, variables: Array<EnvironmentVariable>, };
export type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, };
export type Folder = { model: "folder", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, name: string, sortPriority: number, };
export type GrpcRequest = { model: "grpc_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authenticationType: string | null, authentication: Record<string, any>, description: string, message: string, metadata: Array<HttpRequestHeader>, method: string | null, name: string, service: string | null, sortPriority: number, url: string, };
export type HttpRequest = { model: "http_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, body: Record<string, any>, bodyType: string | null, description: string, headers: Array<HttpRequestHeader>, method: string, name: string, sortPriority: number, url: string, urlParameters: Array<HttpUrlParameter>, };
export type HttpRequestHeader = { enabled?: boolean, name: string, value: string, id?: string, };
export type HttpResponse = { model: "http_response", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, bodyPath: string | null, contentLength: number | null, elapsed: number, elapsedHeaders: number, error: string | null, headers: Array<HttpResponseHeader>, remoteAddr: string | null, status: number, statusReason: string | null, state: HttpResponseState, url: string, version: string | null, };
export type HttpResponseHeader = { name: string, value: string, };
export type HttpResponseState = "initialized" | "connected" | "closed";
export type HttpUrlParameter = { enabled?: boolean, name: string, value: string, id?: string, };
export type WebsocketRequest = { model: "websocket_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, message: string, name: string, sortPriority: number, url: string, urlParameters: Array<HttpUrlParameter>, };
export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, name: string, encryptionKeyChallenge: string | null, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, };

View File

@@ -1,3 +0,0 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type JsonValue = number | string | boolean | Array<JsonValue> | { [key in string]?: JsonValue } | null;

View File

@@ -1,2 +0,0 @@
export type AtLeast<T, K extends keyof T> = Partial<T> & Pick<T, K>;
export type MaybePromise<T> = Promise<T> | T;

View File

@@ -1,9 +0,0 @@
export type * from './plugins';
export type * from './themes';
export * from './bindings/gen_models';
export * from './bindings/gen_events';
// Some extras for utility
export type { PartialImportResources } from './plugins/ImporterPlugin';

View File

@@ -1,29 +0,0 @@
import {
CallHttpAuthenticationActionArgs,
CallHttpAuthenticationRequest,
CallHttpAuthenticationResponse,
FormInput,
GetHttpAuthenticationConfigRequest,
GetHttpAuthenticationSummaryResponse,
HttpAuthenticationAction,
} from '../bindings/gen_events';
import { MaybePromise } from '../helpers';
import { Context } from './Context';
type DynamicFormInput = FormInput & {
dynamic(
ctx: Context,
args: GetHttpAuthenticationConfigRequest,
): MaybePromise<Partial<FormInput> | undefined | null>;
};
export type AuthenticationPlugin = GetHttpAuthenticationSummaryResponse & {
args: (FormInput | DynamicFormInput)[];
onApply(
ctx: Context,
args: CallHttpAuthenticationRequest,
): MaybePromise<CallHttpAuthenticationResponse>;
actions?: (HttpAuthenticationAction & {
onSelect(ctx: Context, args: CallHttpAuthenticationActionArgs): Promise<void> | void;
})[];
};

View File

@@ -1,59 +0,0 @@
import type {
FindHttpResponsesRequest,
FindHttpResponsesResponse,
GetCookieValueRequest,
GetCookieValueResponse,
GetHttpRequestByIdRequest,
GetHttpRequestByIdResponse,
ListCookieNamesResponse,
OpenWindowRequest,
PromptTextRequest,
PromptTextResponse,
RenderHttpRequestRequest,
RenderHttpRequestResponse,
SendHttpRequestRequest,
SendHttpRequestResponse,
ShowToastRequest,
TemplateRenderRequest,
TemplateRenderResponse,
} from '../bindings/gen_events.ts';
export interface Context {
clipboard: {
copyText(text: string): Promise<void>;
};
toast: {
show(args: ShowToastRequest): Promise<void>;
};
prompt: {
text(args: PromptTextRequest): Promise<PromptTextResponse['value']>;
};
store: {
set<T>(key: string, value: T): Promise<void>;
get<T>(key: string): Promise<T | undefined>;
delete(key: string): Promise<boolean>;
};
window: {
openUrl(
args: OpenWindowRequest & {
onNavigate?: (args: { url: string }) => void;
onClose?: () => void;
},
): Promise<{ close: () => void }>;
};
cookies: {
listNames(): Promise<ListCookieNamesResponse['names']>;
getValue(args: GetCookieValueRequest): Promise<GetCookieValueResponse['value']>;
};
httpRequest: {
send(args: SendHttpRequestRequest): Promise<SendHttpRequestResponse['httpResponse']>;
getById(args: GetHttpRequestByIdRequest): Promise<GetHttpRequestByIdResponse['httpRequest']>;
render(args: RenderHttpRequestRequest): Promise<RenderHttpRequestResponse['httpRequest']>;
};
httpResponse: {
find(args: FindHttpResponsesRequest): Promise<FindHttpResponsesResponse['httpResponses']>;
};
templates: {
render(args: TemplateRenderRequest): Promise<TemplateRenderResponse['data']>;
};
}

View File

@@ -1,12 +0,0 @@
import type { Context } from './Context';
type FilterPluginResponse = { filtered: string };
export type FilterPlugin = {
name: string;
description?: string;
onFilter(
ctx: Context,
args: { payload: string; filter: string; mimeType: string },
): Promise<FilterPluginResponse> | FilterPluginResponse;
};

View File

@@ -1,6 +0,0 @@
import type { CallHttpRequestActionArgs, HttpRequestAction } from '../bindings/gen_events';
import type { Context } from './Context';
export type HttpRequestActionPlugin = HttpRequestAction & {
onSelect(ctx: Context, args: CallHttpRequestActionArgs): Promise<void> | void;
};

View File

@@ -1,25 +0,0 @@
import { ImportResources } from '../bindings/gen_events';
import { AtLeast } from '../helpers';
import type { Context } from './Context';
type RootFields = 'name' | 'id' | 'model';
type CommonFields = RootFields | 'workspaceId';
export type PartialImportResources = {
workspaces: Array<AtLeast<ImportResources['workspaces'][0], RootFields>>;
environments: Array<AtLeast<ImportResources['environments'][0], CommonFields>>;
folders: Array<AtLeast<ImportResources['folders'][0], CommonFields>>;
httpRequests: Array<AtLeast<ImportResources['httpRequests'][0], CommonFields>>;
grpcRequests: Array<AtLeast<ImportResources['grpcRequests'][0], CommonFields>>;
websocketRequests: Array<AtLeast<ImportResources['websocketRequests'][0], CommonFields>>;
};
export type ImportPluginResponse = null | {
resources: PartialImportResources;
};
export type ImporterPlugin = {
name: string;
description?: string;
onImport(ctx: Context, args: { text: string }): Promise<ImportPluginResponse>;
};

View File

@@ -1,12 +0,0 @@
import {
CallTemplateFunctionArgs,
TemplateFunction,
} from "../bindings/gen_events";
import { Context } from "./Context";
export type TemplateFunctionPlugin = TemplateFunction & {
onRender(
ctx: Context,
args: CallTemplateFunctionArgs,
): Promise<string | null>;
};

View File

@@ -1,8 +0,0 @@
import { Index } from "../themes";
import { Context } from "./Context";
export type ThemePlugin = {
name: string;
description?: string;
getTheme(ctx: Context, fileContents: string): Promise<Index>;
};

View File

@@ -1,20 +0,0 @@
import { AuthenticationPlugin } from './AuthenticationPlugin';
import type { FilterPlugin } from './FilterPlugin';
import type { HttpRequestActionPlugin } from './HttpRequestActionPlugin';
import type { ImporterPlugin } from './ImporterPlugin';
import type { TemplateFunctionPlugin } from './TemplateFunctionPlugin';
import type { ThemePlugin } from './ThemePlugin';
export type { Context } from './Context';
/**
* The global structure of a Yaak plugin
*/
export type PluginDefinition = {
importer?: ImporterPlugin;
theme?: ThemePlugin;
filter?: FilterPlugin;
authentication?: AuthenticationPlugin;
httpRequestActions?: HttpRequestActionPlugin[];
templateFunctions?: TemplateFunctionPlugin[];
};

View File

@@ -1,44 +0,0 @@
export type Colors = {
surface: string;
surfaceHighlight?: string;
surfaceActive?: string;
text: string;
textSubtle?: string;
textSubtlest?: string;
border?: string;
borderSubtle?: string;
borderFocus?: string;
shadow?: string;
backdrop?: string;
selection?: string;
primary?: string;
secondary?: string;
info?: string;
success?: string;
notice?: string;
warning?: string;
danger?: string;
};
export type Index = Colors & {
id: string;
name: string;
components?: Partial<{
dialog: Partial<Colors>;
menu: Partial<Colors>;
toast: Partial<Colors>;
sidebar: Partial<Colors>;
responsePane: Partial<Colors>;
appHeader: Partial<Colors>;
button: Partial<Colors>;
banner: Partial<Colors>;
placeholder: Partial<Colors>;
urlBar: Partial<Colors>;
editor: Partial<Colors>;
input: Partial<Colors>;
}>;
};

View File

@@ -1,15 +0,0 @@
{
"compilerOptions": {
"module": "node16",
"target": "es6",
"lib": ["es2021"],
"declaration": true,
"declarationDir": "./lib",
"outDir": "./lib",
"strict": true,
"types": ["node"]
},
"files": [
"src/index.ts"
]
}

View File

@@ -1,10 +0,0 @@
{
"name": "@yaakapp-internal/plugin-runtime",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@yaakapp-internal/plugin-runtime"
}
}
}

View File

@@ -1,14 +0,0 @@
{
"name": "@yaakapp-internal/plugin-runtime",
"scripts": {
"bootstrap": "npm run build",
"build": "run-p build:*",
"build:main": "esbuild src/index.ts --bundle --platform=node --outfile=../../src-tauri/vendored/plugin-runtime/index.cjs"
},
"dependencies": {
"ws": "^8.18.0"
},
"devDependencies": {
"@types/ws": "^8.5.13"
}
}

View File

@@ -1,19 +0,0 @@
import type { InternalEvent } from '@yaakapp/api';
export class EventChannel {
#listeners = new Set<(event: InternalEvent) => void>();
emit(e: InternalEvent) {
for (const l of this.#listeners) {
l(e);
}
}
listen(cb: (e: InternalEvent) => void) {
this.#listeners.add(cb);
}
unlisten(cb: (e: InternalEvent) => void) {
this.#listeners.delete(cb);
}
}

View File

@@ -1,27 +0,0 @@
import type { BootRequest, InternalEvent } from '@yaakapp/api';
import type { EventChannel } from './EventChannel';
import { PluginInstance, PluginWorkerData } from './PluginInstance';
export class PluginHandle {
#instance: PluginInstance;
constructor(
readonly pluginRefId: string,
readonly bootRequest: BootRequest,
readonly pluginToAppEvents: EventChannel,
) {
const workerData: PluginWorkerData = {
pluginRefId: this.pluginRefId,
bootRequest: this.bootRequest,
};
this.#instance = new PluginInstance(workerData, pluginToAppEvents);
}
sendToWorker(event: InternalEvent) {
this.#instance.postMessage(event);
}
terminate() {
this.#instance.terminate();
}
}

View File

@@ -1,598 +0,0 @@
import {
BootRequest,
DeleteKeyValueResponse,
FindHttpResponsesResponse,
FormInput,
GetCookieValueRequest,
GetCookieValueResponse,
GetHttpRequestByIdResponse,
GetKeyValueResponse,
HttpAuthenticationAction,
HttpRequestAction,
InternalEvent,
InternalEventPayload,
ListCookieNamesResponse,
PluginWindowContext,
PromptTextResponse,
RenderHttpRequestResponse,
SendHttpRequestResponse,
TemplateFunction,
TemplateFunctionArg,
TemplateRenderResponse,
} from '@yaakapp-internal/plugins';
import { Context, PluginDefinition } from '@yaakapp/api';
import { JsonValue } from '@yaakapp/api/lib/bindings/serde_json/JsonValue';
import console from 'node:console';
import { readFileSync, type Stats, statSync, watch } from 'node:fs';
import path from 'node:path';
import { EventChannel } from './EventChannel';
import { migrateTemplateFunctionSelectOptions } from './migrations';
export interface PluginWorkerData {
bootRequest: BootRequest;
pluginRefId: string;
}
export class PluginInstance {
#workerData: PluginWorkerData;
#mod: PluginDefinition;
#pkg: { name?: string; version?: string };
#pluginToAppEvents: EventChannel;
#appToPluginEvents: EventChannel;
constructor(workerData: PluginWorkerData, pluginEvents: EventChannel) {
this.#workerData = workerData;
this.#pluginToAppEvents = pluginEvents;
this.#appToPluginEvents = new EventChannel();
// Forward incoming events to onMessage()
this.#appToPluginEvents.listen(async (event) => {
await this.#onMessage(event);
});
// Reload plugin if the JS or package.json changes
const windowContextNone: PluginWindowContext = { type: 'none' };
const fileChangeCallback = async () => {
this.#importModule();
return this.#sendPayload(windowContextNone, { type: 'reload_response' }, null);
};
if (this.#workerData.bootRequest.watch) {
watchFile(this.#pathMod(), fileChangeCallback);
watchFile(this.#pathPkg(), fileChangeCallback);
}
this.#mod = {};
this.#pkg = JSON.parse(readFileSync(this.#pathPkg(), 'utf8'));
// TODO: Re-implement this now that we're not using workers
// prefixStdout(`[plugin][${this.#pkg.name}] %s`);
this.#importModule();
}
postMessage(event: InternalEvent) {
this.#appToPluginEvents.emit(event);
}
terminate() {
this.#unimportModule();
}
async #onMessage(event: InternalEvent) {
const ctx = this.#newCtx(event);
const { windowContext, payload, id: replyId } = event;
try {
if (payload.type === 'boot_request') {
// console.log('Plugin initialized', pkg.name, { capabilities, enableWatch });
const payload: InternalEventPayload = {
type: 'boot_response',
name: this.#pkg.name ?? 'unknown',
version: this.#pkg.version ?? '0.0.1',
};
this.#sendPayload(windowContext, payload, replyId);
return;
}
if (payload.type === 'terminate_request') {
const payload: InternalEventPayload = {
type: 'terminate_response',
};
this.#sendPayload(windowContext, payload, replyId);
return;
}
if (
payload.type === 'import_request' &&
typeof this.#mod?.importer?.onImport === 'function'
) {
const reply = await this.#mod.importer.onImport(ctx, {
text: payload.content,
});
if (reply != null) {
const replyPayload: InternalEventPayload = {
type: 'import_response',
// deno-lint-ignore no-explicit-any
resources: reply.resources as any,
};
this.#sendPayload(windowContext, replyPayload, replyId);
return;
} else {
// Continue, to send back an empty reply
}
}
if (payload.type === 'filter_request' && typeof this.#mod?.filter?.onFilter === 'function') {
const reply = await this.#mod.filter.onFilter(ctx, {
filter: payload.filter,
payload: payload.content,
mimeType: payload.type,
});
const replyPayload: InternalEventPayload = {
type: 'filter_response',
content: reply.filtered,
};
this.#sendPayload(windowContext, replyPayload, replyId);
return;
}
if (
payload.type === 'get_http_request_actions_request' &&
Array.isArray(this.#mod?.httpRequestActions)
) {
const reply: HttpRequestAction[] = this.#mod.httpRequestActions.map((a) => ({
...a,
// Add everything except onSelect
onSelect: undefined,
}));
const replyPayload: InternalEventPayload = {
type: 'get_http_request_actions_response',
pluginRefId: this.#workerData.pluginRefId,
actions: reply,
};
this.#sendPayload(windowContext, replyPayload, replyId);
return;
}
if (
payload.type === 'get_template_functions_request' &&
Array.isArray(this.#mod?.templateFunctions)
) {
const reply: TemplateFunction[] = this.#mod.templateFunctions.map((templateFunction) => {
return {
...migrateTemplateFunctionSelectOptions(templateFunction),
// Add everything except render
onRender: undefined,
};
});
const replyPayload: InternalEventPayload = {
type: 'get_template_functions_response',
pluginRefId: this.#workerData.pluginRefId,
functions: reply,
};
this.#sendPayload(windowContext, replyPayload, replyId);
return;
}
if (payload.type === 'get_http_authentication_summary_request' && this.#mod?.authentication) {
const { name, shortLabel, label } = this.#mod.authentication;
const replyPayload: InternalEventPayload = {
type: 'get_http_authentication_summary_response',
name,
label,
shortLabel,
};
this.#sendPayload(windowContext, replyPayload, replyId);
return;
}
if (payload.type === 'get_http_authentication_config_request' && this.#mod?.authentication) {
const { args, actions } = this.#mod.authentication;
const resolvedArgs: FormInput[] = [];
for (let i = 0; i < args.length; i++) {
let v = args[i];
if ('dynamic' in v) {
const dynamicAttrs = await v.dynamic(ctx, payload);
const { dynamic, ...other } = v;
resolvedArgs.push({ ...other, ...dynamicAttrs } as FormInput);
} else {
resolvedArgs.push(v);
}
}
const resolvedActions: HttpAuthenticationAction[] = [];
for (const { onSelect, ...action } of actions ?? []) {
resolvedActions.push(action);
}
const replyPayload: InternalEventPayload = {
type: 'get_http_authentication_config_response',
args: resolvedArgs,
actions: resolvedActions,
pluginRefId: this.#workerData.pluginRefId,
};
this.#sendPayload(windowContext, replyPayload, replyId);
return;
}
if (payload.type === 'call_http_authentication_request' && this.#mod?.authentication) {
const auth = this.#mod.authentication;
if (typeof auth?.onApply === 'function') {
applyFormInputDefaults(auth.args, payload.values);
const result = await auth.onApply(ctx, payload);
this.#sendPayload(
windowContext,
{
type: 'call_http_authentication_response',
setHeaders: result.setHeaders,
},
replyId,
);
return;
}
}
if (
payload.type === 'call_http_authentication_action_request' &&
this.#mod.authentication != null
) {
const action = this.#mod.authentication.actions?.[payload.index];
if (typeof action?.onSelect === 'function') {
await action.onSelect(ctx, payload.args);
this.#sendEmpty(windowContext, replyId);
return;
}
}
if (
payload.type === 'call_http_request_action_request' &&
Array.isArray(this.#mod.httpRequestActions)
) {
const action = this.#mod.httpRequestActions[payload.index];
if (typeof action?.onSelect === 'function') {
await action.onSelect(ctx, payload.args);
this.#sendEmpty(windowContext, replyId);
return;
}
}
if (
payload.type === 'call_template_function_request' &&
Array.isArray(this.#mod?.templateFunctions)
) {
const fn = this.#mod.templateFunctions.find((a) => a.name === payload.name);
if (typeof fn?.onRender === 'function') {
applyFormInputDefaults(fn.args, payload.args.values);
const result = await fn.onRender(ctx, payload.args);
this.#sendPayload(
windowContext,
{
type: 'call_template_function_response',
value: result ?? null,
},
replyId,
);
return;
}
}
if (payload.type === 'reload_request') {
this.#importModule();
}
} catch (err) {
const error = `${err}`.replace(/^Error:\s*/g, '');
console.log('Plugin call threw exception', payload.type, '→', error);
this.#sendPayload(windowContext, { type: 'error_response', error }, replyId);
return;
}
// No matches, so send back an empty response so the caller doesn't block forever
this.#sendEmpty(windowContext, replyId);
}
#pathMod() {
return path.posix.join(this.#workerData.bootRequest.dir, 'build', 'index.js');
}
#pathPkg() {
return path.join(this.#workerData.bootRequest.dir, 'package.json');
}
#unimportModule() {
const id = require.resolve(this.#pathMod());
delete require.cache[id];
}
#importModule() {
const id = require.resolve(this.#pathMod());
delete require.cache[id];
this.#mod = require(id).plugin;
}
#buildEventToSend(
windowContext: PluginWindowContext,
payload: InternalEventPayload,
replyId: string | null = null,
): InternalEvent {
return {
pluginRefId: this.#workerData.pluginRefId,
pluginName: path.basename(this.#workerData.bootRequest.dir),
id: genId(),
replyId,
payload,
windowContext,
};
}
#sendPayload(
windowContext: PluginWindowContext,
payload: InternalEventPayload,
replyId: string | null,
): string {
const event = this.#buildEventToSend(windowContext, payload, replyId);
this.#sendEvent(event);
return event.id;
}
#sendEvent(event: InternalEvent) {
// if (event.payload.type !== 'empty_response') {
// console.log('Sending event to app', this.#pkg.name, event.id, event.payload.type);
// }
this.#pluginToAppEvents.emit(event);
}
#sendEmpty(windowContext: PluginWindowContext, replyId: string | null = null): string {
return this.#sendPayload(windowContext, { type: 'empty_response' }, replyId);
}
#sendAndWaitForReply<T extends Omit<InternalEventPayload, 'type'>>(
windowContext: PluginWindowContext,
payload: InternalEventPayload,
): Promise<T> {
// 1. Build event to send
const eventToSend = this.#buildEventToSend(windowContext, payload, null);
// 2. Spawn listener in background
const promise = new Promise<T>((resolve) => {
const cb = (event: InternalEvent) => {
if (event.replyId === eventToSend.id) {
this.#appToPluginEvents.unlisten(cb); // Unlisten, now that we're done
const { type: _, ...payload } = event.payload;
resolve(payload as T);
}
};
this.#appToPluginEvents.listen(cb);
});
// 3. Send the event after we start listening (to prevent race)
this.#sendEvent(eventToSend);
// 4. Return the listener promise
return promise as unknown as Promise<T>;
}
#sendAndListenForEvents(
windowContext: PluginWindowContext,
payload: InternalEventPayload,
onEvent: (event: InternalEventPayload) => void,
): void {
// 1. Build event to send
const eventToSend = this.#buildEventToSend(windowContext, payload, null);
// 2. Listen for replies in the background
this.#appToPluginEvents.listen((event: InternalEvent) => {
if (event.replyId === eventToSend.id) {
onEvent(event.payload);
}
});
// 3. Send the event after we start listening (to prevent race)
this.#sendEvent(eventToSend);
}
#newCtx(event: InternalEvent): Context {
return {
clipboard: {
copyText: async (text) => {
await this.#sendAndWaitForReply(event.windowContext, {
type: 'copy_text_request',
text,
});
},
},
toast: {
show: async (args) => {
await this.#sendAndWaitForReply(event.windowContext, {
type: 'show_toast_request',
...args,
});
},
},
window: {
openUrl: async ({ onNavigate, onClose, ...args }) => {
args.label = args.label || `${Math.random()}`;
const payload: InternalEventPayload = { type: 'open_window_request', ...args };
const onEvent = (event: InternalEventPayload) => {
if (event.type === 'window_navigate_event') {
onNavigate?.(event);
} else if (event.type === 'window_close_event') {
onClose?.();
}
};
this.#sendAndListenForEvents(event.windowContext, payload, onEvent);
return {
close: () => {
const closePayload: InternalEventPayload = {
type: 'close_window_request',
label: args.label,
};
this.#sendPayload(event.windowContext, closePayload, null);
},
};
},
},
prompt: {
text: async (args) => {
const reply: PromptTextResponse = await this.#sendAndWaitForReply(event.windowContext, {
type: 'prompt_text_request',
...args,
});
return reply.value;
},
},
httpResponse: {
find: async (args) => {
const payload = {
type: 'find_http_responses_request',
...args,
} as const;
const { httpResponses } = await this.#sendAndWaitForReply<FindHttpResponsesResponse>(
event.windowContext,
payload,
);
return httpResponses;
},
},
httpRequest: {
getById: async (args) => {
const payload = {
type: 'get_http_request_by_id_request',
...args,
} as const;
const { httpRequest } = await this.#sendAndWaitForReply<GetHttpRequestByIdResponse>(
event.windowContext,
payload,
);
return httpRequest;
},
send: async (args) => {
const payload = {
type: 'send_http_request_request',
...args,
} as const;
const { httpResponse } = await this.#sendAndWaitForReply<SendHttpRequestResponse>(
event.windowContext,
payload,
);
return httpResponse;
},
render: async (args) => {
const payload = {
type: 'render_http_request_request',
...args,
} as const;
const { httpRequest } = await this.#sendAndWaitForReply<RenderHttpRequestResponse>(
event.windowContext,
payload,
);
return httpRequest;
},
},
cookies: {
getValue: async (args: GetCookieValueRequest) => {
const payload = {
type: 'get_cookie_value_request',
...args,
} as const;
const { value } = await this.#sendAndWaitForReply<GetCookieValueResponse>(
event.windowContext,
payload,
);
return value;
},
listNames: async () => {
const payload = { type: 'list_cookie_names_request' } as const;
const { names } = await this.#sendAndWaitForReply<ListCookieNamesResponse>(
event.windowContext,
payload,
);
return names;
},
},
templates: {
/**
* Invoke Yaak's template engine to render a value. If the value is a nested type
* (eg. object), it will be recursively rendered.
*/
render: async (args) => {
const payload = { type: 'template_render_request', ...args } as const;
const result = await this.#sendAndWaitForReply<TemplateRenderResponse>(
event.windowContext,
payload,
);
return result.data;
},
},
store: {
get: async <T>(key: string) => {
const payload = { type: 'get_key_value_request', key } as const;
const result = await this.#sendAndWaitForReply<GetKeyValueResponse>(
event.windowContext,
payload,
);
return result.value ? (JSON.parse(result.value) as T) : undefined;
},
set: async <T>(key: string, value: T) => {
const valueStr = JSON.stringify(value);
const payload: InternalEventPayload = {
type: 'set_key_value_request',
key,
value: valueStr,
};
await this.#sendAndWaitForReply<GetKeyValueResponse>(event.windowContext, payload);
},
delete: async (key: string) => {
const payload = { type: 'delete_key_value_request', key } as const;
const result = await this.#sendAndWaitForReply<DeleteKeyValueResponse>(
event.windowContext,
payload,
);
return result.deleted;
},
},
};
}
}
function genId(len = 5): string {
const alphabet = '01234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
let id = '';
for (let i = 0; i < len; i++) {
id += alphabet[Math.floor(Math.random() * alphabet.length)];
}
return id;
}
/** Recursively apply form input defaults to a set of values */
function applyFormInputDefaults(
inputs: TemplateFunctionArg[],
values: { [p: string]: JsonValue | undefined },
) {
for (const input of inputs) {
if ('inputs' in input) {
applyFormInputDefaults(input.inputs ?? [], values);
} else if ('defaultValue' in input && values[input.name] === undefined) {
values[input.name] = input.defaultValue;
}
}
}
const watchedFiles: Record<string, Stats> = {};
/**
* Watch a file and trigger callback on change.
*
* We also track the stat for each file because fs.watch() will
* trigger a "change" event when the access date changes
*/
function watchFile(filepath: string, cb: (filepath: string) => void) {
watch(filepath, () => {
const stat = statSync(filepath);
if (stat.mtimeMs !== watchedFiles[filepath]?.mtimeMs) {
cb(filepath);
}
watchedFiles[filepath] = stat;
});
}

View File

@@ -1,55 +0,0 @@
import type { InternalEvent } from '@yaakapp/api';
import { EventChannel } from './EventChannel';
import { PluginHandle } from './PluginHandle';
import WebSocket from 'ws';
const port = process.env.PORT;
if (!port) {
throw new Error('Plugin runtime missing PORT')
}
const pluginToAppEvents = new EventChannel();
const plugins: Record<string, PluginHandle> = {};
const ws = new WebSocket(`ws://localhost:${port}`);
ws.on('message', async (e: Buffer) => {
try {
await handleIncoming(e.toString());
} catch (err) {
console.log('Failed to handle incoming plugin event', err);
}
});
ws.on('open', () => console.log('Plugin runtime connected to websocket'));
ws.on('error', (err: any) => console.error('Plugin runtime websocket error', err));
ws.on('close', (code: number) => console.log('Plugin runtime websocket closed', code));
// Listen for incoming events from plugins
pluginToAppEvents.listen((e) => {
const eventStr = JSON.stringify(e);
ws.send(eventStr);
});
async function handleIncoming(msg: string) {
const pluginEvent: InternalEvent = JSON.parse(msg);
// Handle special event to bootstrap plugin
if (pluginEvent.payload.type === 'boot_request') {
const plugin = new PluginHandle(pluginEvent.pluginRefId, pluginEvent.payload, pluginToAppEvents);
plugins[pluginEvent.pluginRefId] = plugin;
}
// Once booted, forward all events to the plugin worker
const plugin = plugins[pluginEvent.pluginRefId];
if (!plugin) {
console.warn('Failed to get plugin for event by', pluginEvent.pluginRefId);
return;
}
if (pluginEvent.payload.type === 'terminate_request') {
plugin.terminate();
console.log('Terminated plugin worker', pluginEvent.pluginRefId);
delete plugins[pluginEvent.pluginRefId];
}
plugin.sendToWorker(pluginEvent);
}

View File

@@ -1,37 +0,0 @@
import process from "node:process";
export function interceptStdout(
intercept: (text: string) => string,
) {
const old_stdout_write = process.stdout.write;
const old_stderr_write = process.stderr.write;
process.stdout.write = (function (write) {
return function (text: string) {
arguments[0] = interceptor(text, intercept);
// deno-lint-ignore no-explicit-any
write.apply(process.stdout, arguments as any);
return true;
};
})(process.stdout.write);
process.stderr.write = (function (write) {
return function (text: string) {
arguments[0] = interceptor(text, intercept);
// deno-lint-ignore no-explicit-any
write.apply(process.stderr, arguments as any);
return true;
};
})(process.stderr.write);
// puts back to original
return function unhook() {
process.stdout.write = old_stdout_write;
process.stderr.write = old_stderr_write;
};
}
function interceptor(text: string, fn: (text: string) => string) {
return fn(text).replace(/\n$/, "") +
(fn(text) && /\n$/.test(text) ? "\n" : "");
}

View File

@@ -1,18 +0,0 @@
import { TemplateFunction } from '@yaakapp/api';
export function migrateTemplateFunctionSelectOptions(f: TemplateFunction): TemplateFunction {
const migratedArgs = f.args.map((a) => {
if (a.type === 'select') {
a.options = a.options.map((o) => ({
...o,
label: o.label || (o as any).name,
}));
}
return a;
});
return {
...f,
args: migratedArgs,
};
}

View File

@@ -1,25 +0,0 @@
{
"compilerOptions": {
"module": "node16",
"strict": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"target": "es2021",
"lib": ["es2021"],
"noImplicitAny": false,
"moduleResolution": "node16",
"resolveJsonModule": true,
"sourceMap": true,
"outDir": "build",
"baseUrl": ".",
"paths": {
"*": [
"node_modules/*",
"src/types/*"
]
}
},
"include": [
"src"
]
}

1
plugins/.gitignore vendored
View File

@@ -1 +0,0 @@
*/build

View File

@@ -1,9 +0,0 @@
{
"name": "@yaakapp/auth-basic",
"private": true,
"version": "0.0.1",
"scripts": {
"build": "yaakcli build ./src/index.ts",
"dev": "yaakcli dev ./src/index.js"
}
}

View File

@@ -1,26 +0,0 @@
import { PluginDefinition } from '@yaakapp/api';
export const plugin: PluginDefinition = {
authentication: {
name: 'basic',
label: 'Basic Auth',
shortLabel: 'Basic',
args: [{
type: 'text',
name: 'username',
label: 'Username',
optional: true,
}, {
type: 'text',
name: 'password',
label: 'Password',
optional: true,
password: true,
}],
async onApply(_ctx, { values }) {
const { username, password } = values;
const value = 'Basic ' + Buffer.from(`${username}:${password}`).toString('base64');
return { setHeaders: [{ name: 'Authorization', value }] };
},
},
};

View File

@@ -1,9 +0,0 @@
{
"name": "@yaakapp/auth-bearer",
"private": true,
"version": "0.0.1",
"scripts": {
"build": "yaakcli build ./src/index.ts",
"dev": "yaakcli dev ./src/index.js"
}
}

View File

@@ -1,21 +0,0 @@
import { PluginDefinition } from '@yaakapp/api';
export const plugin: PluginDefinition = {
authentication: {
name: 'bearer',
label: 'Bearer Token',
shortLabel: 'Bearer',
args: [{
type: 'text',
name: 'token',
label: 'Token',
optional: true,
password: true,
}],
async onApply(_ctx, { values }) {
const { token } = values;
const value = `Bearer ${token}`.trim();
return { setHeaders: [{ name: 'Authorization', value }] };
},
},
};

View File

@@ -1,15 +0,0 @@
{
"name": "@yaakapp/auth-jwt",
"private": true,
"version": "0.0.1",
"scripts": {
"build": "yaakcli build ./src/index.ts",
"dev": "yaakcli dev ./src/index.js"
},
"dependencies": {
"jsonwebtoken": "^9.0.2"
},
"devDependencies": {
"@types/jsonwebtoken": "^9.0.7"
}
}

View File

@@ -1,68 +0,0 @@
import { PluginDefinition } from '@yaakapp/api';
import jwt from 'jsonwebtoken';
const algorithms = [
'HS256',
'HS384',
'HS512',
'RS256',
'RS384',
'RS512',
'PS256',
'PS384',
'PS512',
'ES256',
'ES384',
'ES512',
'none',
] as const;
const defaultAlgorithm = algorithms[0];
export const plugin: PluginDefinition = {
authentication: {
name: 'jwt',
label: 'JWT Bearer',
shortLabel: 'JWT',
args: [
{
type: 'select',
name: 'algorithm',
label: 'Algorithm',
hideLabel: true,
defaultValue: defaultAlgorithm,
options: algorithms.map(value => ({ label: value === 'none' ? 'None' : value, value })),
},
{
type: 'text',
name: 'secret',
label: 'Secret or Private Key',
password: true,
optional: true,
multiLine: true,
},
{
type: 'checkbox',
name: 'secretBase64',
label: 'Secret is base64 encoded',
},
{
type: 'editor',
name: 'payload',
label: 'Payload',
language: 'json',
defaultValue: '{\n "foo": "bar"\n}',
placeholder: '{ }',
},
],
async onApply(_ctx, { values }) {
const { algorithm, secret: _secret, secretBase64, payload } = values;
const secret = secretBase64 ? Buffer.from(`${_secret}`, 'base64') : `${_secret}`;
const token = jwt.sign(`${payload}`, secret, { algorithm: algorithm as any });
const value = `Bearer ${token}`;
return { setHeaders: [{ name: 'Authorization', value }] };
}
,
},
}
;

View File

@@ -1,9 +0,0 @@
{
"name": "@yaakapp/auth-oauth2",
"private": true,
"version": "0.0.1",
"scripts": {
"build": "yaakcli build ./src/index.ts",
"dev": "yaakcli dev ./src/index.js"
}
}

View File

@@ -1,78 +0,0 @@
import { Context, HttpRequest, HttpUrlParameter } from '@yaakapp/api';
import { readFileSync } from 'node:fs';
import { AccessTokenRawResponse } from './store';
export async function getAccessToken(
ctx: Context,
{
accessTokenUrl,
scope,
audience,
params,
grantType,
credentialsInBody,
clientId,
clientSecret,
}: {
clientId: string;
clientSecret: string;
grantType: string;
accessTokenUrl: string;
scope: string | null;
audience: string | null;
credentialsInBody: boolean;
params: HttpUrlParameter[];
},
): Promise<AccessTokenRawResponse> {
console.log('[oauth2] Getting access token', accessTokenUrl);
const httpRequest: Partial<HttpRequest> = {
method: 'POST',
url: accessTokenUrl,
bodyType: 'application/x-www-form-urlencoded',
body: {
form: [{ name: 'grant_type', value: grantType }, ...params],
},
headers: [
{ name: 'User-Agent', value: 'yaak' },
{ name: 'Accept', value: 'application/x-www-form-urlencoded, application/json' },
{ name: 'Content-Type', value: 'application/x-www-form-urlencoded' },
],
};
if (scope) httpRequest.body!.form.push({ name: 'scope', value: scope });
if (audience) httpRequest.body!.form.push({ name: 'audience', value: audience });
if (credentialsInBody) {
httpRequest.body!.form.push({ name: 'client_id', value: clientId });
httpRequest.body!.form.push({ name: 'client_secret', value: clientSecret });
} else {
const value = 'Basic ' + Buffer.from(`${clientId}:${clientSecret}`).toString('base64');
httpRequest.headers!.push({ name: 'Authorization', value });
}
httpRequest.authenticationType = 'none'; // Don't inherit workspace auth
const resp = await ctx.httpRequest.send({ httpRequest });
console.log('[oauth2] Got access token response', resp.status);
const body = resp.bodyPath ? readFileSync(resp.bodyPath, 'utf8') : '';
if (resp.status < 200 || resp.status >= 300) {
throw new Error(
'Failed to fetch access token with status=' + resp.status + ' and body=' + body,
);
}
let response;
try {
response = JSON.parse(body);
} catch {
response = Object.fromEntries(new URLSearchParams(body));
}
if (response.error) {
throw new Error('Failed to fetch access token with ' + response.error);
}
return response;
}

View File

@@ -1,103 +0,0 @@
import { Context, HttpRequest } from '@yaakapp/api';
import { readFileSync } from 'node:fs';
import { AccessToken, AccessTokenRawResponse, deleteToken, getToken, storeToken } from './store';
export async function getOrRefreshAccessToken(ctx: Context, contextId: string, {
scope,
accessTokenUrl,
credentialsInBody,
clientId,
clientSecret,
forceRefresh,
}: {
scope: string | null;
accessTokenUrl: string;
credentialsInBody: boolean;
clientId: string;
clientSecret: string;
forceRefresh?: boolean;
}): Promise<AccessToken | null> {
const token = await getToken(ctx, contextId);
if (token == null) {
return null;
}
const now = Date.now();
const isExpired = token.expiresAt && now > token.expiresAt;
// Return the current access token if it's still valid
if (!isExpired && !forceRefresh) {
return token;
}
// Token is expired, but there's no refresh token :(
if (!token.response.refresh_token) {
return null;
}
// Access token is expired, so get a new one
const httpRequest: Partial<HttpRequest> = {
method: 'POST',
url: accessTokenUrl,
bodyType: 'application/x-www-form-urlencoded',
body: {
form: [
{ name: 'grant_type', value: 'refresh_token' },
{ name: 'refresh_token', value: token.response.refresh_token },
],
},
headers: [
{ name: 'User-Agent', value: 'yaak' },
{ name: 'Accept', value: 'application/x-www-form-urlencoded, application/json' },
{ name: 'Content-Type', value: 'application/x-www-form-urlencoded' },
],
};
if (scope) httpRequest.body!.form.push({ name: 'scope', value: scope });
if (credentialsInBody) {
httpRequest.body!.form.push({ name: 'client_id', value: clientId });
httpRequest.body!.form.push({ name: 'client_secret', value: clientSecret });
} else {
const value = 'Basic ' + Buffer.from(`${clientId}:${clientSecret}`).toString('base64');
httpRequest.headers!.push({ name: 'Authorization', value });
}
httpRequest.authenticationType = 'none'; // Don't inherit workspace auth
const resp = await ctx.httpRequest.send({ httpRequest });
if (resp.status === 401) {
// Bad refresh token, so we'll force it to fetch a fresh access token by deleting
// and returning null;
console.log('[oauth2] Unauthorized refresh_token request');
await deleteToken(ctx, contextId);
return null;
}
const body = resp.bodyPath ? readFileSync(resp.bodyPath, 'utf8') : '';
console.log('[oauth2] Got refresh token response', resp.status);
if (resp.status < 200 || resp.status >= 300) {
throw new Error('Failed to refresh access token with status=' + resp.status + ' and body=' + body);
}
let response;
try {
response = JSON.parse(body);
} catch {
response = Object.fromEntries(new URLSearchParams(body));
}
if (response.error) {
throw new Error(`Failed to fetch access token with ${response.error} -> ${response.error_description}`);
}
const newResponse: AccessTokenRawResponse = {
...response,
// Assign a new one or keep the old one,
refresh_token: response.refresh_token ?? token.response.refresh_token,
};
return storeToken(ctx, contextId, newResponse);
}

View File

@@ -1,152 +0,0 @@
import { Context } from '@yaakapp/api';
import { createHash, randomBytes } from 'node:crypto';
import { getAccessToken } from '../getAccessToken';
import { getOrRefreshAccessToken } from '../getOrRefreshAccessToken';
import { AccessToken, getDataDirKey, storeToken } from '../store';
export const PKCE_SHA256 = 'S256';
export const PKCE_PLAIN = 'plain';
export const DEFAULT_PKCE_METHOD = PKCE_SHA256;
export async function getAuthorizationCode(
ctx: Context,
contextId: string,
{
authorizationUrl: authorizationUrlRaw,
accessTokenUrl,
clientId,
clientSecret,
redirectUri,
scope,
state,
audience,
credentialsInBody,
pkce,
tokenName,
}: {
authorizationUrl: string;
accessTokenUrl: string;
clientId: string;
clientSecret: string;
redirectUri: string | null;
scope: string | null;
state: string | null;
audience: string | null;
credentialsInBody: boolean;
pkce: {
challengeMethod: string | null;
codeVerifier: string | null;
} | null;
tokenName: 'access_token' | 'id_token';
},
): Promise<AccessToken> {
const token = await getOrRefreshAccessToken(ctx, contextId, {
accessTokenUrl,
scope,
clientId,
clientSecret,
credentialsInBody,
});
if (token != null) {
return token;
}
const authorizationUrl = new URL(`${authorizationUrlRaw ?? ''}`);
authorizationUrl.searchParams.set('response_type', 'code');
authorizationUrl.searchParams.set('client_id', clientId);
if (redirectUri) authorizationUrl.searchParams.set('redirect_uri', redirectUri);
if (scope) authorizationUrl.searchParams.set('scope', scope);
if (state) authorizationUrl.searchParams.set('state', state);
if (audience) authorizationUrl.searchParams.set('audience', audience);
if (pkce) {
const verifier = pkce.codeVerifier || createPkceCodeVerifier();
const challengeMethod = pkce.challengeMethod || DEFAULT_PKCE_METHOD;
authorizationUrl.searchParams.set(
'code_challenge',
createPkceCodeChallenge(verifier, challengeMethod),
);
authorizationUrl.searchParams.set('code_challenge_method', challengeMethod);
}
return new Promise(async (resolve, reject) => {
const authorizationUrlStr = authorizationUrl.toString();
const logsEnabled = (await ctx.store.get('enable_logs')) ?? false;
console.log('[oauth2] Authorizing', authorizationUrlStr);
let foundCode = false;
let { close } = await ctx.window.openUrl({
url: authorizationUrlStr,
label: 'oauth-authorization-url',
dataDirKey: await getDataDirKey(ctx, contextId),
async onClose() {
if (!foundCode) {
reject(new Error('Authorization window closed'));
}
},
async onNavigate({ url: urlStr }) {
const url = new URL(urlStr);
if (logsEnabled) console.log('[oauth2] Navigated to', urlStr);
if (url.searchParams.has('error')) {
return reject(new Error(`Failed to authorize: ${url.searchParams.get('error')}`));
}
const code = url.searchParams.get('code');
if (!code) {
console.log('[oauth2] Code not found');
return; // Could be one of many redirects in a chain, so skip it
}
// Close the window here, because we don't need it anymore!
foundCode = true;
close();
console.log('[oauth2] Code found');
const response = await getAccessToken(ctx, {
grantType: 'authorization_code',
accessTokenUrl,
clientId,
clientSecret,
scope,
audience,
credentialsInBody,
params: [
{ name: 'code', value: code },
...(redirectUri ? [{ name: 'redirect_uri', value: redirectUri }] : []),
],
});
try {
resolve(await storeToken(ctx, contextId, response, tokenName));
} catch (err) {
reject(err);
}
},
});
});
}
function createPkceCodeVerifier() {
return encodeForPkce(randomBytes(32));
}
function createPkceCodeChallenge(verifier: string, method: string) {
if (method === 'plain') {
return verifier;
}
const hash = encodeForPkce(createHash('sha256').update(verifier).digest());
return hash
.replace(/=/g, '') // Remove padding '='
.replace(/\+/g, '-') // Replace '+' with '-'
.replace(/\//g, '_'); // Replace '/' with '_'
}
function encodeForPkce(bytes: Buffer) {
return bytes
.toString('base64')
.replace(/=/g, '') // Remove padding '='
.replace(/\+/g, '-') // Replace '+' with '-'
.replace(/\//g, '_'); // Replace '/' with '_'
}

View File

@@ -1,43 +0,0 @@
import { Context } from '@yaakapp/api';
import { getAccessToken } from '../getAccessToken';
import { getToken, storeToken } from '../store';
export async function getClientCredentials(
ctx: Context,
contextId: string,
{
accessTokenUrl,
clientId,
clientSecret,
scope,
audience,
credentialsInBody,
}: {
accessTokenUrl: string;
clientId: string;
clientSecret: string;
scope: string | null;
audience: string | null;
credentialsInBody: boolean;
},
) {
const token = await getToken(ctx, contextId);
if (token) {
// resolve(token.response.access_token);
// TODO: Refresh token if expired
// return;
}
const response = await getAccessToken(ctx, {
grantType: 'client_credentials',
accessTokenUrl,
audience,
clientId,
clientSecret,
scope,
credentialsInBody,
params: [],
});
return storeToken(ctx, contextId, response);
}

View File

@@ -1,86 +0,0 @@
import { Context } from '@yaakapp/api';
import { AccessToken, AccessTokenRawResponse, getToken, storeToken } from '../store';
export function getImplicit(
ctx: Context,
contextId: string,
{
authorizationUrl: authorizationUrlRaw,
responseType,
clientId,
redirectUri,
scope,
state,
audience,
tokenName,
}: {
authorizationUrl: string;
responseType: string;
clientId: string;
redirectUri: string | null;
scope: string | null;
state: string | null;
audience: string | null;
tokenName: 'access_token' | 'id_token';
},
): Promise<AccessToken> {
return new Promise(async (resolve, reject) => {
const token = await getToken(ctx, contextId);
if (token) {
// resolve(token.response.access_token);
// TODO: Refresh token if expired
// return;
}
const authorizationUrl = new URL(`${authorizationUrlRaw ?? ''}`);
authorizationUrl.searchParams.set('response_type', 'token');
authorizationUrl.searchParams.set('client_id', clientId);
if (redirectUri) authorizationUrl.searchParams.set('redirect_uri', redirectUri);
if (scope) authorizationUrl.searchParams.set('scope', scope);
if (state) authorizationUrl.searchParams.set('state', state);
if (audience) authorizationUrl.searchParams.set('audience', audience);
if (responseType.includes('id_token')) {
authorizationUrl.searchParams.set(
'nonce',
String(Math.floor(Math.random() * 9999999999999) + 1),
);
}
const authorizationUrlStr = authorizationUrl.toString();
let foundAccessToken = false;
let { close } = await ctx.window.openUrl({
url: authorizationUrlStr,
label: 'oauth-authorization-url',
async onClose() {
if (!foundAccessToken) {
reject(new Error('Authorization window closed'));
}
},
async onNavigate({ url: urlStr }) {
const url = new URL(urlStr);
if (url.searchParams.has('error')) {
return reject(Error(`Failed to authorize: ${url.searchParams.get('error')}`));
}
const hash = url.hash.slice(1);
const params = new URLSearchParams(hash);
const accessToken = params.get(tokenName);
if (!accessToken) {
return;
}
foundAccessToken = true;
// Close the window here, because we don't need it anymore
close();
const response = Object.fromEntries(params) as unknown as AccessTokenRawResponse;
try {
resolve(await storeToken(ctx, contextId, response));
} catch (err) {
reject(err);
}
},
});
});
}

View File

@@ -1,55 +0,0 @@
import { Context } from '@yaakapp/api';
import { getAccessToken } from '../getAccessToken';
import { getOrRefreshAccessToken } from '../getOrRefreshAccessToken';
import { AccessToken, storeToken } from '../store';
export async function getPassword(
ctx: Context,
contextId: string,
{
accessTokenUrl,
clientId,
clientSecret,
username,
password,
credentialsInBody,
audience,
scope,
}: {
accessTokenUrl: string;
clientId: string;
clientSecret: string;
username: string;
password: string;
scope: string | null;
audience: string | null;
credentialsInBody: boolean;
},
): Promise<AccessToken> {
const token = await getOrRefreshAccessToken(ctx, contextId, {
accessTokenUrl,
scope,
clientId,
clientSecret,
credentialsInBody,
});
if (token != null) {
return token;
}
const response = await getAccessToken(ctx, {
accessTokenUrl,
clientId,
clientSecret,
scope,
audience,
grantType: 'password',
credentialsInBody,
params: [
{ name: 'username', value: username },
{ name: 'password', value: password },
],
});
return storeToken(ctx, contextId, response);
}

View File

@@ -1,404 +0,0 @@
import {
Context,
FormInputSelectOption,
GetHttpAuthenticationConfigRequest,
JsonPrimitive,
PluginDefinition,
} from '@yaakapp/api';
import {
DEFAULT_PKCE_METHOD,
getAuthorizationCode,
PKCE_PLAIN,
PKCE_SHA256,
} from './grants/authorizationCode';
import { getClientCredentials } from './grants/clientCredentials';
import { getImplicit } from './grants/implicit';
import { getPassword } from './grants/password';
import { AccessToken, deleteToken, getToken, resetDataDirKey } from './store';
type GrantType = 'authorization_code' | 'implicit' | 'password' | 'client_credentials';
const grantTypes: FormInputSelectOption[] = [
{ label: 'Authorization Code', value: 'authorization_code' },
{ label: 'Implicit', value: 'implicit' },
{ label: 'Resource Owner Password Credential', value: 'password' },
{ label: 'Client Credentials', value: 'client_credentials' },
];
const defaultGrantType = grantTypes[0]!.value;
function hiddenIfNot(
grantTypes: GrantType[],
...other: ((values: GetHttpAuthenticationConfigRequest['values']) => boolean)[]
) {
return (_ctx: Context, { values }: GetHttpAuthenticationConfigRequest) => {
const hasGrantType = grantTypes.find((t) => t === String(values.grantType ?? defaultGrantType));
const hasOtherBools = other.every((t) => t(values));
const show = hasGrantType && hasOtherBools;
return { hidden: !show };
};
}
const authorizationUrls = [
'https://github.com/login/oauth/authorize',
'https://account.box.com/api/oauth2/authorize',
'https://accounts.google.com/o/oauth2/v2/auth',
'https://api.imgur.com/oauth2/authorize',
'https://bitly.com/oauth/authorize',
'https://gitlab.example.com/oauth/authorize',
'https://medium.com/m/oauth/authorize',
'https://public-api.wordpress.com/oauth2/authorize',
'https://slack.com/oauth/authorize',
'https://todoist.com/oauth/authorize',
'https://www.dropbox.com/oauth2/authorize',
'https://www.linkedin.com/oauth/v2/authorization',
'https://MY_SHOP.myshopify.com/admin/oauth/access_token',
'https://appcenter.intuit.com/app/connect/oauth2/authorize',
];
const accessTokenUrls = [
'https://github.com/login/oauth/access_token',
'https://api-ssl.bitly.com/oauth/access_token',
'https://api.box.com/oauth2/token',
'https://api.dropboxapi.com/oauth2/token',
'https://api.imgur.com/oauth2/token',
'https://api.medium.com/v1/tokens',
'https://gitlab.example.com/oauth/token',
'https://public-api.wordpress.com/oauth2/token',
'https://slack.com/api/oauth.access',
'https://todoist.com/oauth/access_token',
'https://www.googleapis.com/oauth2/v4/token',
'https://www.linkedin.com/oauth/v2/accessToken',
'https://MY_SHOP.myshopify.com/admin/oauth/authorize',
'https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer',
];
export const plugin: PluginDefinition = {
authentication: {
name: 'oauth2',
label: 'OAuth 2.0',
shortLabel: 'OAuth 2',
actions: [
{
label: 'Copy Current Token',
async onSelect(ctx, { contextId }) {
const token = await getToken(ctx, contextId);
if (token == null) {
await ctx.toast.show({ message: 'No token to copy', color: 'warning' });
} else {
await ctx.clipboard.copyText(token.response.access_token);
await ctx.toast.show({
message: 'Token copied to clipboard',
icon: 'copy',
color: 'success',
});
}
},
},
{
label: 'Delete Token',
async onSelect(ctx, { contextId }) {
if (await deleteToken(ctx, contextId)) {
await ctx.toast.show({ message: 'Token deleted', color: 'success' });
} else {
await ctx.toast.show({ message: 'No token to delete', color: 'warning' });
}
},
},
{
label: 'Clear Window Session',
async onSelect(ctx, { contextId }) {
await resetDataDirKey(ctx, contextId);
},
},
{
label: 'Toggle Debug Logs',
async onSelect(ctx) {
const enableLogs = !(await ctx.store.get('enable_logs'));
await ctx.store.set('enable_logs', enableLogs);
await ctx.toast.show({
message: `Debug logs ${enableLogs ? 'enabled' : 'disabled'}`,
color: 'info',
});
},
},
],
args: [
{
type: 'select',
name: 'grantType',
label: 'Grant Type',
hideLabel: true,
defaultValue: defaultGrantType,
options: grantTypes,
},
// Always-present fields
{
type: 'text',
name: 'clientId',
label: 'Client ID',
optional: true,
},
{
type: 'text',
name: 'clientSecret',
label: 'Client Secret',
optional: true,
password: true,
dynamic: hiddenIfNot(['authorization_code', 'password', 'client_credentials']),
},
{
type: 'text',
name: 'authorizationUrl',
optional: true,
label: 'Authorization URL',
dynamic: hiddenIfNot(['authorization_code', 'implicit']),
placeholder: authorizationUrls[0],
completionOptions: authorizationUrls.map((url) => ({ label: url, value: url })),
},
{
type: 'text',
name: 'accessTokenUrl',
optional: true,
label: 'Access Token URL',
placeholder: accessTokenUrls[0],
dynamic: hiddenIfNot(['authorization_code', 'password', 'client_credentials']),
completionOptions: accessTokenUrls.map((url) => ({ label: url, value: url })),
},
{
type: 'text',
name: 'redirectUri',
label: 'Redirect URI',
optional: true,
dynamic: hiddenIfNot(['authorization_code', 'implicit']),
},
{
type: 'text',
name: 'state',
label: 'State',
optional: true,
dynamic: hiddenIfNot(['authorization_code', 'implicit']),
},
{
type: 'text',
name: 'audience',
label: 'Audience',
optional: true,
},
{
type: 'select',
name: 'tokenName',
label: 'Token for authorization',
description:
'Select which token to send in the "Authorization: Bearer" header. Most APIs expect ' +
'access_token, but some (like OpenID Connect) require id_token.',
defaultValue: 'access_token',
options: [
{ label: 'access_token', value: 'access_token' },
{ label: 'id_token', value: 'id_token' },
],
dynamic: hiddenIfNot(['authorization_code', 'implicit']),
},
{
type: 'checkbox',
name: 'usePkce',
label: 'Use PKCE',
dynamic: hiddenIfNot(['authorization_code']),
},
{
type: 'select',
name: 'pkceChallengeMethod',
label: 'Code Challenge Method',
options: [
{ label: 'SHA-256', value: PKCE_SHA256 },
{ label: 'Plain', value: PKCE_PLAIN },
],
defaultValue: DEFAULT_PKCE_METHOD,
dynamic: hiddenIfNot(['authorization_code'], ({ usePkce }) => !!usePkce),
},
{
type: 'text',
name: 'pkceCodeVerifier',
label: 'Code Verifier',
placeholder: 'Automatically generated if not provided',
optional: true,
dynamic: hiddenIfNot(['authorization_code'], ({ usePkce }) => !!usePkce),
},
{
type: 'text',
name: 'username',
label: 'Username',
optional: true,
dynamic: hiddenIfNot(['password']),
},
{
type: 'text',
name: 'password',
label: 'Password',
password: true,
optional: true,
dynamic: hiddenIfNot(['password']),
},
{
type: 'select',
name: 'responseType',
label: 'Response Type',
defaultValue: 'token',
options: [
{ label: 'Access Token', value: 'token' },
{ label: 'ID Token', value: 'id_token' },
{ label: 'ID and Access Token', value: 'id_token token' },
],
dynamic: hiddenIfNot(['implicit']),
},
{
type: 'accordion',
label: 'Advanced',
inputs: [
{ type: 'text', name: 'scope', label: 'Scope', optional: true },
{
type: 'text',
name: 'headerPrefix',
label: 'Header Prefix',
optional: true,
defaultValue: 'Bearer',
},
{
type: 'select',
name: 'credentials',
label: 'Send Credentials',
defaultValue: 'body',
options: [
{ label: 'In Request Body', value: 'body' },
{ label: 'As Basic Authentication', value: 'basic' },
],
},
],
},
{
type: 'accordion',
label: 'Access Token Response',
async dynamic(ctx, { contextId }) {
const token = await getToken(ctx, contextId);
if (token == null) {
return { hidden: true };
}
return {
label: 'Access Token Response',
inputs: [
{
type: 'editor',
defaultValue: JSON.stringify(token.response, null, 2),
hideLabel: true,
readOnly: true,
language: 'json',
},
],
};
},
},
],
async onApply(ctx, { values, contextId }) {
const headerPrefix = stringArg(values, 'headerPrefix');
const grantType = stringArg(values, 'grantType') as GrantType;
const credentialsInBody = values.credentials === 'body';
const tokenName = values.tokenName === 'id_token' ? 'id_token' : 'access_token';
let token: AccessToken;
if (grantType === 'authorization_code') {
const authorizationUrl = stringArg(values, 'authorizationUrl');
const accessTokenUrl = stringArg(values, 'accessTokenUrl');
token = await getAuthorizationCode(ctx, contextId, {
accessTokenUrl: accessTokenUrl.match(/^https?:\/\//)
? accessTokenUrl
: `https://${accessTokenUrl}`,
authorizationUrl: authorizationUrl.match(/^https?:\/\//)
? authorizationUrl
: `https://${authorizationUrl}`,
clientId: stringArg(values, 'clientId'),
clientSecret: stringArg(values, 'clientSecret'),
redirectUri: stringArgOrNull(values, 'redirectUri'),
scope: stringArgOrNull(values, 'scope'),
audience: stringArgOrNull(values, 'audience'),
state: stringArgOrNull(values, 'state'),
credentialsInBody,
pkce: values.usePkce
? {
challengeMethod: stringArg(values, 'pkceChallengeMethod'),
codeVerifier: stringArgOrNull(values, 'pkceCodeVerifier'),
}
: null,
tokenName: tokenName,
});
} else if (grantType === 'implicit') {
const authorizationUrl = stringArg(values, 'authorizationUrl');
token = await getImplicit(ctx, contextId, {
authorizationUrl: authorizationUrl.match(/^https?:\/\//)
? authorizationUrl
: `https://${authorizationUrl}`,
clientId: stringArg(values, 'clientId'),
redirectUri: stringArgOrNull(values, 'redirectUri'),
responseType: stringArg(values, 'responseType'),
scope: stringArgOrNull(values, 'scope'),
audience: stringArgOrNull(values, 'audience'),
state: stringArgOrNull(values, 'state'),
tokenName: tokenName,
});
} else if (grantType === 'client_credentials') {
const accessTokenUrl = stringArg(values, 'accessTokenUrl');
token = await getClientCredentials(ctx, contextId, {
accessTokenUrl: accessTokenUrl.match(/^https?:\/\//)
? accessTokenUrl
: `https://${accessTokenUrl}`,
clientId: stringArg(values, 'clientId'),
clientSecret: stringArg(values, 'clientSecret'),
scope: stringArgOrNull(values, 'scope'),
audience: stringArgOrNull(values, 'audience'),
credentialsInBody,
});
} else if (grantType === 'password') {
const accessTokenUrl = stringArg(values, 'accessTokenUrl');
token = await getPassword(ctx, contextId, {
accessTokenUrl: accessTokenUrl.match(/^https?:\/\//)
? accessTokenUrl
: `https://${accessTokenUrl}`,
clientId: stringArg(values, 'clientId'),
clientSecret: stringArg(values, 'clientSecret'),
username: stringArg(values, 'username'),
password: stringArg(values, 'password'),
scope: stringArgOrNull(values, 'scope'),
audience: stringArgOrNull(values, 'audience'),
credentialsInBody,
});
} else {
throw new Error('Invalid grant type ' + grantType);
}
const headerValue = `${headerPrefix} ${token.response[tokenName]}`.trim();
return {
setHeaders: [
{
name: 'Authorization',
value: headerValue,
},
],
};
},
},
};
function stringArgOrNull(
values: Record<string, JsonPrimitive | undefined>,
name: string,
): string | null {
const arg = values[name];
if (arg == null || arg == '') return null;
return `${arg}`;
}
function stringArg(values: Record<string, JsonPrimitive | undefined>, name: string): string {
const arg = stringArgOrNull(values, name);
if (!arg) return '';
return arg;
}

View File

@@ -1,62 +0,0 @@
import { Context } from '@yaakapp/api';
export async function storeToken(
ctx: Context,
contextId: string,
response: AccessTokenRawResponse,
tokenName: 'access_token' | 'id_token' = 'access_token',
) {
if (!response[tokenName]) {
throw new Error(`${tokenName} not found in response ${Object.keys(response).join(', ')}`);
}
const expiresAt = response.expires_in ? Date.now() + response.expires_in * 1000 : null;
const token: AccessToken = {
response,
expiresAt,
};
await ctx.store.set<AccessToken>(tokenStoreKey(contextId), token);
return token;
}
export async function getToken(ctx: Context, contextId: string) {
return ctx.store.get<AccessToken>(tokenStoreKey(contextId));
}
export async function deleteToken(ctx: Context, contextId: string) {
return ctx.store.delete(tokenStoreKey(contextId));
}
export async function resetDataDirKey(ctx: Context, contextId: string) {
const key = new Date().toISOString();
return ctx.store.set<string>(dataDirStoreKey(contextId), key);
}
export async function getDataDirKey(ctx: Context, contextId: string) {
const key = (await ctx.store.get<string>(dataDirStoreKey(contextId))) ?? 'default';
return `${contextId}::${key}`;
}
function tokenStoreKey(contextId: string) {
return ['token', contextId].join('::');
}
function dataDirStoreKey(contextId: string) {
return ['data_dir', contextId].join('::');
}
export interface AccessToken {
response: AccessTokenRawResponse;
expiresAt: number | null;
}
export interface AccessTokenRawResponse {
access_token: string;
id_token?: string;
token_type?: string;
expires_in?: number;
refresh_token?: string;
error?: string;
error_description?: string;
scope?: string;
}

View File

@@ -1,9 +0,0 @@
{
"name": "@yaakapp/exporter-curl",
"private": true,
"version": "0.0.1",
"scripts": {
"build": "yaakcli build ./src/index.js",
"dev": "yaakcli dev ./src/index.js"
}
}

View File

@@ -1,101 +0,0 @@
import { HttpRequest, PluginDefinition } from '@yaakapp/api';
const NEWLINE = '\\\n ';
export const plugin: PluginDefinition = {
httpRequestActions: [{
label: 'Copy as Curl',
icon: 'copy',
async onSelect(ctx, args) {
const rendered_request = await ctx.httpRequest.render({ httpRequest: args.httpRequest, purpose: 'preview' });
const data = await convertToCurl(rendered_request);
await ctx.clipboard.copyText(data);
await ctx.toast.show({ message: 'Curl copied to clipboard', icon: 'copy', color: 'success' });
},
}],
};
export async function convertToCurl(request: Partial<HttpRequest>) {
const xs = ['curl'];
// Add method and URL all on first line
if (request.method) xs.push('-X', request.method);
if (request.url) xs.push(quote(request.url));
xs.push(NEWLINE);
// Add URL params
for (const p of (request.urlParameters ?? []).filter(onlyEnabled)) {
xs.push('--url-query', quote(`${p.name}=${p.value}`));
xs.push(NEWLINE);
}
// Add headers
for (const h of (request.headers ?? []).filter(onlyEnabled)) {
xs.push('--header', quote(`${h.name}: ${h.value}`));
xs.push(NEWLINE);
}
// Add form params
if (Array.isArray(request.body?.form)) {
const flag = request.bodyType === 'multipart/form-data' ? '--form' : '--data';
for (const p of (request.body?.form ?? []).filter(onlyEnabled)) {
if (p.file) {
let v = `${p.name}=@${p.file}`;
v += p.contentType ? `;type=${p.contentType}` : '';
xs.push(flag, v);
} else {
xs.push(flag, quote(`${p.name}=${p.value}`));
}
xs.push(NEWLINE);
}
} else if (typeof request.body?.query === 'string') {
const body = { query: request.body.query || '', variables: maybeParseJSON(request.body.variables, undefined) };
xs.push('--data-raw', `${quote(JSON.stringify(body))}`);
xs.push(NEWLINE);
} else if (typeof request.body?.text === 'string') {
xs.push('--data-raw', `${quote(request.body.text)}`);
xs.push(NEWLINE);
}
// Add basic/digest authentication
if (request.authenticationType === 'basic' || request.authenticationType === 'digest') {
if (request.authenticationType === 'digest') xs.push('--digest');
xs.push(
'--user',
quote(`${request.authentication?.username ?? ''}:${request.authentication?.password ?? ''}`),
);
xs.push(NEWLINE);
}
// Add bearer authentication
if (request.authenticationType === 'bearer') {
xs.push('--header', quote(`Authorization: Bearer ${request.authentication?.token ?? ''}`));
xs.push(NEWLINE);
}
// Remove trailing newline
if (xs[xs.length - 1] === NEWLINE) {
xs.splice(xs.length - 1, 1);
}
return xs.join(' ');
}
function quote(arg: string): string {
const escaped = arg.replace(/'/g, '\\\'');
return `'${escaped}'`;
}
function onlyEnabled(v: { name?: string; enabled?: boolean }): boolean {
return v.enabled !== false && !!v.name;
}
function maybeParseJSON(v: any, fallback: any): string {
try {
return JSON.parse(v);
} catch (err) {
return fallback;
}
}

View File

@@ -1,206 +0,0 @@
import { describe, expect, test } from 'vitest';
import { convertToCurl } from '../src';
describe('exporter-curl', () => {
test('Exports GET with params', async () => {
expect(
await convertToCurl({
url: 'https://yaak.app',
urlParameters: [
{ name: 'a', value: 'aaa' },
{ name: 'b', value: 'bbb', enabled: true },
{ name: 'c', value: 'ccc', enabled: false },
],
}),
).toEqual(
[`curl 'https://yaak.app'`, `--url-query 'a=aaa'`, `--url-query 'b=bbb'`].join(` \\\n `),
);
});
test('Exports POST with url form data', async () => {
expect(
await convertToCurl({
url: 'https://yaak.app',
method: 'POST',
bodyType: 'application/x-www-form-urlencoded',
body: {
form: [
{ name: 'a', value: 'aaa' },
{ name: 'b', value: 'bbb', enabled: true },
{ name: 'c', value: 'ccc', enabled: false },
],
},
}),
).toEqual(
[`curl -X POST 'https://yaak.app'`, `--data 'a=aaa'`, `--data 'b=bbb'`].join(` \\\n `),
);
});
test('Exports POST with GraphQL data', async () => {
expect(
await convertToCurl({
url: 'https://yaak.app',
method: 'POST',
bodyType: 'graphql',
body: {
query: '{foo,bar}',
variables: '{"a": "aaa", "b": "bbb"}',
},
}),
).toEqual(
[`curl -X POST 'https://yaak.app'`, `--data-raw '{"query":"{foo,bar}","variables":{"a":"aaa","b":"bbb"}}'`].join(` \\\n `),
);
});
test('Exports POST with GraphQL data no variables', async () => {
expect(
await convertToCurl({
url: 'https://yaak.app',
method: 'POST',
bodyType: 'graphql',
body: {
query: '{foo,bar}',
},
}),
).toEqual(
[`curl -X POST 'https://yaak.app'`, `--data-raw '{"query":"{foo,bar}"}'`].join(` \\\n `),
);
});
test('Exports PUT with multipart form', async () => {
expect(
await convertToCurl({
url: 'https://yaak.app',
method: 'PUT',
bodyType: 'multipart/form-data',
body: {
form: [
{ name: 'a', value: 'aaa' },
{ name: 'b', value: 'bbb', enabled: true },
{ name: 'c', value: 'ccc', enabled: false },
{ name: 'f', file: '/foo/bar.png', contentType: 'image/png' },
],
},
}),
).toEqual(
[
`curl -X PUT 'https://yaak.app'`,
`--form 'a=aaa'`,
`--form 'b=bbb'`,
`--form f=@/foo/bar.png;type=image/png`,
].join(` \\\n `),
);
});
test('Exports JSON body', async () => {
expect(
await convertToCurl({
url: 'https://yaak.app',
method: 'POST',
bodyType: 'application/json',
body: {
text: `{"foo":"bar's"}`,
},
headers: [{ name: 'Content-Type', value: 'application/json' }],
}),
).toEqual(
[
`curl -X POST 'https://yaak.app'`,
`--header 'Content-Type: application/json'`,
`--data-raw '{"foo":"bar\\'s"}'`,
].join(` \\\n `),
);
});
test('Exports multi-line JSON body', async () => {
expect(
await convertToCurl({
url: 'https://yaak.app',
method: 'POST',
bodyType: 'application/json',
body: {
text: `{"foo":"bar",\n"baz":"qux"}`,
},
headers: [{ name: 'Content-Type', value: 'application/json' }],
}),
).toEqual(
[
`curl -X POST 'https://yaak.app'`,
`--header 'Content-Type: application/json'`,
`--data-raw '{"foo":"bar",\n"baz":"qux"}'`,
].join(` \\\n `),
);
});
test('Exports headers', async () => {
expect(
await convertToCurl({
headers: [
{ name: 'a', value: 'aaa' },
{ name: 'b', value: 'bbb', enabled: true },
{ name: 'c', value: 'ccc', enabled: false },
],
}),
).toEqual([`curl`, `--header 'a: aaa'`, `--header 'b: bbb'`].join(` \\\n `));
});
test('Basic auth', async () => {
expect(
await convertToCurl({
url: 'https://yaak.app',
authenticationType: 'basic',
authentication: {
username: 'user',
password: 'pass',
},
}),
).toEqual([`curl 'https://yaak.app'`, `--user 'user:pass'`].join(` \\\n `));
});
test('Broken basic auth', async () => {
expect(
await convertToCurl({
url: 'https://yaak.app',
authenticationType: 'basic',
authentication: {},
}),
).toEqual([`curl 'https://yaak.app'`, `--user ':'`].join(` \\\n `));
});
test('Digest auth', async () => {
expect(
await convertToCurl({
url: 'https://yaak.app',
authenticationType: 'digest',
authentication: {
username: 'user',
password: 'pass',
},
}),
).toEqual([`curl 'https://yaak.app'`, `--digest --user 'user:pass'`].join(` \\\n `));
});
test('Bearer auth', async () => {
expect(
await convertToCurl({
url: 'https://yaak.app',
authenticationType: 'bearer',
authentication: {
token: 'tok',
},
}),
).toEqual([`curl 'https://yaak.app'`, `--header 'Authorization: Bearer tok'`].join(` \\\n `));
});
test('Broken bearer auth', async () => {
expect(
await convertToCurl({
url: 'https://yaak.app',
authenticationType: 'bearer',
authentication: {
username: 'user',
password: 'pass',
},
}),
).toEqual([`curl 'https://yaak.app'`, `--header 'Authorization: Bearer '`].join(` \\\n `));
});
});

View File

@@ -1,15 +0,0 @@
{
"name": "@yaakapp/filter-jsonpath",
"private": true,
"version": "0.0.1",
"scripts": {
"build": "yaakcli build ./src/index.ts",
"dev": "yaakcli dev ./src/index.js"
},
"dependencies": {
"jsonpath-plus": "^10.3.0"
},
"devDependencies": {
"@types/jsonpath": "^0.2.4"
}
}

View File

@@ -1,14 +0,0 @@
import { PluginDefinition } from '@yaakapp/api';
import { JSONPath } from 'jsonpath-plus';
export const plugin: PluginDefinition = {
filter: {
name: 'JSONPath',
description: 'Filter JSONPath',
onFilter(_ctx, args) {
const parsed = JSON.parse(args.payload);
const filtered = JSONPath({ path: args.filter, json: parsed });
return { filtered: JSON.stringify(filtered, null, 2) };
},
},
};

View File

@@ -1,13 +0,0 @@
{
"name": "@yaakapp/filter-xpath",
"private": true,
"version": "0.0.1",
"scripts": {
"build": "yaakcli build ./src/index.js",
"dev": "yaakcli dev ./src/index.js"
},
"dependencies": {
"@xmldom/xmldom": "^0.8.10",
"xpath": "^0.0.34"
}
}

View File

@@ -1,21 +0,0 @@
import { DOMParser } from '@xmldom/xmldom';
import { PluginDefinition } from '@yaakapp/api';
import xpath from 'xpath';
export const plugin: PluginDefinition = {
filter: {
name: 'XPath',
description: 'Filter XPath',
onFilter(_ctx, args) {
const doc = new DOMParser().parseFromString(args.payload, 'text/xml');
const result = xpath.select(args.filter, doc, false);
if (Array.isArray(result)) {
return { filtered: result.map(r => String(r)).join('\n') };
} else {
// Not sure what cases this happens in (?)
return { filtered: String(result) };
}
},
},
};

View File

@@ -1,15 +0,0 @@
{
"name": "@yaakapp/importer-curl",
"private": true,
"version": "0.0.1",
"scripts": {
"build": "yaakcli build ./src/index.js",
"dev": "yaakcli dev ./src/index.js"
},
"dependencies": {
"shell-quote": "^1.8.1"
},
"devDependencies": {
"@types/shell-quote": "^1.7.5"
}
}

View File

@@ -1,441 +0,0 @@
import { Context, Environment, Folder, HttpRequest, HttpUrlParameter, PluginDefinition, Workspace } from '@yaakapp/api';
import { ControlOperator, parse, ParseEntry } from 'shell-quote';
type AtLeast<T, K extends keyof T> = Partial<T> & Pick<T, K>;
interface ExportResources {
workspaces: AtLeast<Workspace, 'name' | 'id' | 'model'>[];
environments: AtLeast<Environment, 'name' | 'id' | 'model' | 'workspaceId'>[];
httpRequests: AtLeast<HttpRequest, 'name' | 'id' | 'model' | 'workspaceId'>[];
folders: AtLeast<Folder, 'name' | 'id' | 'model' | 'workspaceId'>[];
}
const DATA_FLAGS = ['d', 'data', 'data-raw', 'data-urlencode', 'data-binary', 'data-ascii'];
const SUPPORTED_FLAGS = [
['cookie', 'b'],
['d', 'data'], // Add url encoded data
['data-ascii'],
['data-binary'],
['data-raw'],
['data-urlencode'],
['digest'], // Apply auth as digest
['form', 'F'], // Add multipart data
['get', 'G'], // Put the post data in the URL
['header', 'H'],
['request', 'X'], // Request method
['url'], // Specify the URL explicitly
['url-query'],
['user', 'u'], // Authentication
DATA_FLAGS,
].flatMap((v) => v);
const BOOLEAN_FLAGS = ['G', 'get', 'digest'];
type FlagValue = string | boolean;
type FlagsByName = Record<string, FlagValue[]>;
export const plugin: PluginDefinition = {
importer: {
name: 'cURL',
description: 'Import cURL commands',
onImport(_ctx: Context, args: { text: string }) {
return convertCurl(args.text) as any;
},
},
};
export function convertCurl(rawData: string) {
if (!rawData.match(/^\s*curl /)) {
return null;
}
const commands: ParseEntry[][] = [];
// Replace non-escaped newlines with semicolons to make parsing easier
// NOTE: This is really slow in debug build but fast in release mode
const normalizedData = rawData.replace(/\ncurl/g, '; curl');
let currentCommand: ParseEntry[] = [];
const parsed = parse(normalizedData);
// Break up `-XPOST` into `-X POST`
const normalizedParseEntries = parsed.flatMap((entry) => {
if (
typeof entry === 'string' &&
entry.startsWith('-') &&
!entry.startsWith('--') &&
entry.length > 2
) {
return [entry.slice(0, 2), entry.slice(2)];
}
return entry;
});
for (const parseEntry of normalizedParseEntries) {
if (typeof parseEntry === 'string') {
if (parseEntry.startsWith('$')) {
currentCommand.push(parseEntry.slice(1));
} else {
currentCommand.push(parseEntry);
}
continue;
}
if ('comment' in parseEntry) {
continue;
}
const { op } = parseEntry as { op: 'glob'; pattern: string } | { op: ControlOperator };
// `;` separates commands
if (op === ';') {
commands.push(currentCommand);
currentCommand = [];
continue;
}
if (op?.startsWith('$')) {
// Handle the case where literal like -H $'Header: \'Some Quoted Thing\''
const str = op.slice(2, op.length - 1).replace(/\\'/g, '\'');
currentCommand.push(str);
continue;
}
if (op === 'glob') {
currentCommand.push((parseEntry as { op: 'glob'; pattern: string }).pattern);
}
}
commands.push(currentCommand);
const workspace: ExportResources['workspaces'][0] = {
model: 'workspace',
id: generateId('workspace'),
name: 'Curl Import',
};
const requests: ExportResources['httpRequests'] = commands
.filter((command) => command[0] === 'curl')
.map((v) => importCommand(v, workspace.id));
return {
resources: {
httpRequests: requests,
workspaces: [workspace],
},
};
}
function importCommand(parseEntries: ParseEntry[], workspaceId: string) {
// ~~~~~~~~~~~~~~~~~~~~~ //
// Collect all the flags //
// ~~~~~~~~~~~~~~~~~~~~~ //
const flagsByName: FlagsByName = {};
const singletons: ParseEntry[] = [];
// Start at 1 so we can skip the ^curl part
for (let i = 1; i < parseEntries.length; i++) {
let parseEntry = parseEntries[i];
if (typeof parseEntry === 'string') {
parseEntry = parseEntry.trim();
}
if (typeof parseEntry === 'string' && parseEntry.match(/^-{1,2}[\w-]+/)) {
const isSingleDash = parseEntry[0] === '-' && parseEntry[1] !== '-';
let name = parseEntry.replace(/^-{1,2}/, '');
if (!SUPPORTED_FLAGS.includes(name)) {
continue;
}
let value;
const nextEntry = parseEntries[i + 1];
const hasValue = !BOOLEAN_FLAGS.includes(name);
if (isSingleDash && name.length > 1) {
// Handle squished arguments like -XPOST
value = name.slice(1);
name = name.slice(0, 1);
} else if (typeof nextEntry === 'string' && hasValue && !nextEntry.startsWith('-')) {
// Next arg is not a flag, so assign it as the value
value = nextEntry;
i++; // Skip next one
} else {
value = true;
}
flagsByName[name] = flagsByName[name] || [];
flagsByName[name]!.push(value);
} else if (parseEntry) {
singletons.push(parseEntry);
}
}
// ~~~~~~~~~~~~~~~~~ //
// Build the request //
// ~~~~~~~~~~~~~~~~~ //
// Url and Parameters
let urlParameters: HttpUrlParameter[];
let url: string;
const urlArg = getPairValue(flagsByName, (singletons[0] as string) || '', ['url']);
const [baseUrl, search] = splitOnce(urlArg, '?');
urlParameters =
search?.split('&').map((p) => {
const v = splitOnce(p, '=');
return { name: decodeURIComponent(v[0] ?? ''), value: decodeURIComponent(v[1] ?? ''), enabled: true };
}) ?? [];
url = baseUrl ?? urlArg;
// Query params
for (const p of flagsByName['url-query'] ?? []) {
if (typeof p !== 'string') {
continue;
}
const [name, value] = p.split('=');
urlParameters.push({
name: name ?? '',
value: value ?? '',
enabled: true,
});
}
// Authentication
const [username, password] = getPairValue(flagsByName, '', ['u', 'user']).split(/:(.*)$/);
const isDigest = getPairValue(flagsByName, false, ['digest']);
const authenticationType = username ? (isDigest ? 'digest' : 'basic') : null;
const authentication = username
? {
username: username.trim(),
password: (password ?? '').trim(),
}
: {};
// Headers
const headers = [
...((flagsByName['header'] as string[] | undefined) || []),
...((flagsByName['H'] as string[] | undefined) || []),
].map((header) => {
const [name, value] = header.split(/:(.*)$/);
// remove final colon from header name if present
if (!value) {
return {
name: (name ?? '').trim().replace(/;$/, ''),
value: '',
enabled: true,
};
}
return {
name: (name ?? '').trim(),
value: value.trim(),
enabled: true,
};
});
// Cookies
const cookieHeaderValue = [
...((flagsByName['cookie'] as string[] | undefined) || []),
...((flagsByName['b'] as string[] | undefined) || []),
]
.map((str) => {
const name = str.split('=', 1)[0];
const value = str.replace(`${name}=`, '');
return `${name}=${value}`;
})
.join('; ');
// Convert cookie value to header
const existingCookieHeader = headers.find((header) => header.name.toLowerCase() === 'cookie');
if (cookieHeaderValue && existingCookieHeader) {
// Has existing cookie header, so let's update it
existingCookieHeader.value += `; ${cookieHeaderValue}`;
} else if (cookieHeaderValue) {
// No existing cookie header, so let's make a new one
headers.push({
name: 'Cookie',
value: cookieHeaderValue,
enabled: true,
});
}
// Body (Text or Blob)
const dataParameters = pairsToDataParameters(flagsByName);
const contentTypeHeader = headers.find((header) => header.name.toLowerCase() === 'content-type');
const mimeType = contentTypeHeader ? contentTypeHeader.value.split(';')[0] : null;
// Body (Multipart Form Data)
const formDataParams = [
...((flagsByName['form'] as string[] | undefined) || []),
...((flagsByName['F'] as string[] | undefined) || []),
].map((str) => {
const parts = str.split('=');
const name = parts[0] ?? '';
const value = parts[1] ?? '';
const item: { name: string; value?: string; file?: string; enabled: boolean } = {
name,
enabled: true,
};
if (value.indexOf('@') === 0) {
item['file'] = value.slice(1);
} else {
item['value'] = value;
}
return item;
});
// Body
let body = {};
let bodyType: string | null = null;
const bodyAsGET = getPairValue(flagsByName, false, ['G', 'get']);
if (dataParameters.length > 0 && bodyAsGET) {
urlParameters.push(...dataParameters);
} else if (
dataParameters.length > 0 &&
(mimeType == null || mimeType === 'application/x-www-form-urlencoded')
) {
bodyType = mimeType ?? 'application/x-www-form-urlencoded';
body = {
form: dataParameters.map((parameter) => ({
...parameter,
name: decodeURIComponent(parameter.name || ''),
value: decodeURIComponent(parameter.value || ''),
})),
};
headers.push({
name: 'Content-Type',
value: 'application/x-www-form-urlencoded',
enabled: true,
});
} else if (dataParameters.length > 0) {
bodyType =
mimeType === 'application/json' || mimeType === 'text/xml' || mimeType === 'text/plain'
? mimeType
: 'other';
body = {
text: dataParameters
.map(({ name, value }) => (name && value ? `${name}=${value}` : name || value))
.join('&'),
};
} else if (formDataParams.length) {
bodyType = mimeType ?? 'multipart/form-data';
body = {
form: formDataParams,
};
if (mimeType == null) {
headers.push({
name: 'Content-Type',
value: 'multipart/form-data',
enabled: true,
});
}
}
// Method
let method = getPairValue(flagsByName, '', ['X', 'request']).toUpperCase();
if (method === '' && body) {
method = 'text' in body || 'form' in body ? 'POST' : 'GET';
}
const request: ExportResources['httpRequests'][0] = {
id: generateId('http_request'),
model: 'http_request',
workspaceId,
name: '',
urlParameters,
url,
method,
headers,
authentication,
authenticationType,
body,
bodyType,
folderId: null,
sortPriority: 0,
};
return request;
}
interface DataParameter {
name: string;
value: string;
contentType?: string;
filePath?: string;
enabled?: boolean;
}
function pairsToDataParameters(keyedPairs: FlagsByName): DataParameter[] {
let dataParameters: DataParameter[] = [];
for (const flagName of DATA_FLAGS) {
const pairs = keyedPairs[flagName];
if (!pairs || pairs.length === 0) {
continue;
}
for (const p of pairs) {
if (typeof p !== 'string') continue;
let params = p.split("&");
for (const param of params) {
const [name, value] = param.split('=');
if (param.startsWith('@')) {
// Yaak doesn't support files in url-encoded data, so
dataParameters.push({
name: name ?? '',
value: '',
filePath: param.slice(1),
enabled: true,
});
} else {
dataParameters.push({
name: name ?? '',
value: flagName === 'data-urlencode' ? encodeURIComponent(value ?? '') : value ?? '',
enabled: true,
});
}
}
}
}
return dataParameters;
}
const getPairValue = <T extends string | boolean>(
pairsByName: FlagsByName,
defaultValue: T,
names: string[],
) => {
for (const name of names) {
if (pairsByName[name] && pairsByName[name]!.length) {
return pairsByName[name]![0] as T;
}
}
return defaultValue;
};
function splitOnce(str: string, sep: string): string[] {
const index = str.indexOf(sep);
if (index > -1) {
return [str.slice(0, index), str.slice(index + 1)];
}
return [str];
}
const idCount: Partial<Record<string, number>> = {};
function generateId(model: string): string {
idCount[model] = (idCount[model] ?? -1) + 1;
return `GENERATE_ID::${model.toUpperCase()}_${idCount[model]}`;
}

View File

@@ -1,400 +0,0 @@
import { HttpRequest, Workspace } from '@yaakapp/api';
import { describe, expect, test } from 'vitest';
import { convertCurl } from '../src';
describe('importer-curl', () => {
test('Imports basic GET', () => {
expect(convertCurl('curl https://yaak.app')).toEqual({
resources: {
workspaces: [baseWorkspace()],
httpRequests: [
baseRequest({
url: 'https://yaak.app',
}),
],
},
});
});
test('Explicit URL', () => {
expect(convertCurl('curl --url https://yaak.app')).toEqual({
resources: {
workspaces: [baseWorkspace()],
httpRequests: [
baseRequest({
url: 'https://yaak.app',
}),
],
},
});
});
test('Missing URL', () => {
expect(convertCurl('curl -X POST')).toEqual({
resources: {
workspaces: [baseWorkspace()],
httpRequests: [
baseRequest({
method: 'POST',
}),
],
},
});
});
test('URL between', () => {
expect(convertCurl('curl -v https://yaak.app -X POST')).toEqual({
resources: {
workspaces: [baseWorkspace()],
httpRequests: [
baseRequest({
url: 'https://yaak.app',
method: 'POST',
}),
],
},
});
});
test('Random flags', () => {
expect(convertCurl('curl --random -Z -Y -S --foo https://yaak.app')).toEqual({
resources: {
workspaces: [baseWorkspace()],
httpRequests: [
baseRequest({
url: 'https://yaak.app',
}),
],
},
});
});
test('Imports --request method', () => {
expect(convertCurl('curl --request POST https://yaak.app')).toEqual({
resources: {
workspaces: [baseWorkspace()],
httpRequests: [
baseRequest({
url: 'https://yaak.app',
method: 'POST',
}),
],
},
});
});
test('Imports -XPOST method', () => {
expect(convertCurl('curl -XPOST --request POST https://yaak.app')).toEqual({
resources: {
workspaces: [baseWorkspace()],
httpRequests: [
baseRequest({
url: 'https://yaak.app',
method: 'POST',
}),
],
},
});
});
test('Imports multiple requests', () => {
expect(
convertCurl('curl \\\n https://yaak.app\necho "foo"\ncurl example.com;curl foo.com'),
).toEqual({
resources: {
workspaces: [baseWorkspace()],
httpRequests: [
baseRequest({ url: 'https://yaak.app' }),
baseRequest({ url: 'example.com' }),
baseRequest({ url: 'foo.com' }),
],
},
});
});
test('Imports form data', () => {
expect(
convertCurl('curl -X POST -F "a=aaa" -F b=bbb" -F f=@filepath https://yaak.app'),
).toEqual({
resources: {
workspaces: [baseWorkspace()],
httpRequests: [
baseRequest({
method: 'POST',
url: 'https://yaak.app',
headers: [
{
name: 'Content-Type',
value: 'multipart/form-data',
enabled: true,
},
],
bodyType: 'multipart/form-data',
body: {
form: [
{ enabled: true, name: 'a', value: 'aaa' },
{ enabled: true, name: 'b', value: 'bbb' },
{ enabled: true, name: 'f', file: 'filepath' },
],
},
}),
],
},
});
});
test('Imports data params as form url-encoded', () => {
expect(convertCurl('curl -d a -d b -d c=ccc https://yaak.app')).toEqual({
resources: {
workspaces: [baseWorkspace()],
httpRequests: [
baseRequest({
method: 'POST',
url: 'https://yaak.app',
bodyType: 'application/x-www-form-urlencoded',
headers: [
{
name: 'Content-Type',
value: 'application/x-www-form-urlencoded',
enabled: true,
},
],
body: {
form: [
{ name: 'a', value: '', enabled: true },
{ name: 'b', value: '', enabled: true },
{ name: 'c', value: 'ccc', enabled: true },
],
},
}),
],
},
});
});
test('Imports combined data params as form url-encoded', () => {
expect(convertCurl(`curl -d 'a=aaa&b=bbb&c' https://yaak.app`)).toEqual({
resources: {
workspaces: [baseWorkspace()],
httpRequests: [
baseRequest({
method: 'POST',
url: 'https://yaak.app',
bodyType: 'application/x-www-form-urlencoded',
headers: [
{
name: 'Content-Type',
value: 'application/x-www-form-urlencoded',
enabled: true,
},
],
body: {
form: [
{ name: 'a', value: 'aaa', enabled: true },
{ name: 'b', value: 'bbb', enabled: true },
{ name: 'c', value: '', enabled: true },
],
},
}),
],
},
});
});
test('Imports data params as text', () => {
expect(
convertCurl('curl -H Content-Type:text/plain -d a -d b -d c=ccc https://yaak.app'),
).toEqual({
resources: {
workspaces: [baseWorkspace()],
httpRequests: [
baseRequest({
method: 'POST',
url: 'https://yaak.app',
headers: [{ name: 'Content-Type', value: 'text/plain', enabled: true }],
bodyType: 'text/plain',
body: { text: 'a&b&c=ccc' },
}),
],
},
});
});
test('Imports post data into URL', () => {
expect(
convertCurl('curl -G https://api.stripe.com/v1/payment_links -d limit=3'),
).toEqual({
resources: {
workspaces: [baseWorkspace()],
httpRequests: [
baseRequest({
method: 'GET',
url: 'https://api.stripe.com/v1/payment_links',
urlParameters: [{
enabled: true,
name: 'limit',
value: '3',
}],
}),
],
},
});
});
test('Imports multi-line JSON', () => {
expect(
convertCurl(`curl -H Content-Type:application/json -d $'{\n "foo":"bar"\n}' https://yaak.app`),
).toEqual({
resources: {
workspaces: [baseWorkspace()],
httpRequests: [
baseRequest({
method: 'POST',
url: 'https://yaak.app',
headers: [{ name: 'Content-Type', value: 'application/json', enabled: true }],
bodyType: 'application/json',
body: { text: '{\n "foo":"bar"\n}' },
}),
],
},
});
});
test('Imports multiple headers', () => {
expect(
convertCurl('curl -H Foo:bar --header Name -H AAA:bbb -H :ccc https://yaak.app'),
).toEqual({
resources: {
workspaces: [baseWorkspace()],
httpRequests: [
baseRequest({
url: 'https://yaak.app',
headers: [
{ name: 'Name', value: '', enabled: true },
{ name: 'Foo', value: 'bar', enabled: true },
{ name: 'AAA', value: 'bbb', enabled: true },
{ name: '', value: 'ccc', enabled: true },
],
}),
],
},
});
});
test('Imports basic auth', () => {
expect(convertCurl('curl --user user:pass https://yaak.app')).toEqual({
resources: {
workspaces: [baseWorkspace()],
httpRequests: [
baseRequest({
url: 'https://yaak.app',
authenticationType: 'basic',
authentication: {
username: 'user',
password: 'pass',
},
}),
],
},
});
});
test('Imports digest auth', () => {
expect(convertCurl('curl --digest --user user:pass https://yaak.app')).toEqual({
resources: {
workspaces: [baseWorkspace()],
httpRequests: [
baseRequest({
url: 'https://yaak.app',
authenticationType: 'digest',
authentication: {
username: 'user',
password: 'pass',
},
}),
],
},
});
});
test('Imports cookie as header', () => {
expect(convertCurl('curl --cookie "foo=bar" https://yaak.app')).toEqual({
resources: {
workspaces: [baseWorkspace()],
httpRequests: [
baseRequest({
url: 'https://yaak.app',
headers: [{ name: 'Cookie', value: 'foo=bar', enabled: true }],
}),
],
},
});
});
test('Imports query params', () => {
expect(convertCurl('curl "https://yaak.app" --url-query foo=bar --url-query baz=qux')).toEqual({
resources: {
workspaces: [baseWorkspace()],
httpRequests: [
baseRequest({
url: 'https://yaak.app',
urlParameters: [
{ name: 'foo', value: 'bar', enabled: true },
{ name: 'baz', value: 'qux', enabled: true },
],
}),
],
},
});
});
test('Imports query params from the URL', () => {
expect(convertCurl('curl "https://yaak.app?foo=bar&baz=a%20a"')).toEqual({
resources: {
workspaces: [baseWorkspace()],
httpRequests: [
baseRequest({
url: 'https://yaak.app',
urlParameters: [
{ name: 'foo', value: 'bar', enabled: true },
{ name: 'baz', value: 'a a', enabled: true },
],
}),
],
},
});
});
});
const idCount: Partial<Record<string, number>> = {};
function baseRequest(mergeWith: Partial<HttpRequest>) {
idCount.http_request = (idCount.http_request ?? -1) + 1;
return {
id: `GENERATE_ID::HTTP_REQUEST_${idCount.http_request}`,
model: 'http_request',
authentication: {},
authenticationType: null,
body: {},
bodyType: null,
folderId: null,
headers: [],
method: 'GET',
name: '',
sortPriority: 0,
url: '',
urlParameters: [],
workspaceId: `GENERATE_ID::WORKSPACE_${idCount.workspace}`,
...mergeWith,
};
}
function baseWorkspace(mergeWith: Partial<Workspace> = {}) {
idCount.workspace = (idCount.workspace ?? -1) + 1;
return {
id: `GENERATE_ID::WORKSPACE_${idCount.workspace}`,
model: 'workspace',
name: 'Curl Import',
...mergeWith,
};
}

View File

@@ -1,12 +0,0 @@
{
"name": "@yaakapp/importer-insomnia",
"private": true,
"version": "0.0.1",
"scripts": {
"build": "yaakcli build ./src/index.js",
"dev": "yaakcli dev ./src/index.js"
},
"dependencies": {
"yaml": "^2.4.2"
}
}

View File

@@ -1,34 +0,0 @@
export function convertSyntax(variable: string): string {
if (!isJSString(variable)) return variable;
return variable.replaceAll(/{{\s*(_\.)?([^}]+)\s*}}/g, '${[$2]}');
}
export function isJSObject(obj: any) {
return Object.prototype.toString.call(obj) === '[object Object]';
}
export function isJSString(obj: any) {
return Object.prototype.toString.call(obj) === '[object String]';
}
export function convertId(id: string): string {
if (id.startsWith('GENERATE_ID::')) {
return id;
}
return `GENERATE_ID::${id}`;
}
export function deleteUndefinedAttrs<T>(obj: T): T {
if (Array.isArray(obj) && obj != null) {
return obj.map(deleteUndefinedAttrs) as T;
} else if (typeof obj === 'object' && obj != null) {
return Object.fromEntries(
Object.entries(obj)
.filter(([, v]) => v !== undefined)
.map(([k, v]) => [k, deleteUndefinedAttrs(v)]),
) as T;
} else {
return obj;
}
}

View File

@@ -1,35 +0,0 @@
import { Context, PluginDefinition } from '@yaakapp/api';
import YAML from 'yaml';
import { deleteUndefinedAttrs, isJSObject } from './common';
import { convertInsomniaV4 } from './v4';
import { convertInsomniaV5 } from './v5';
export const plugin: PluginDefinition = {
importer: {
name: 'Insomnia',
description: 'Import Insomnia workspaces',
async onImport(_ctx: Context, args: { text: string }) {
return convertInsomnia(args.text);
},
},
};
export function convertInsomnia(contents: string) {
let parsed: any;
try {
parsed = JSON.parse(contents);
} catch (e) {
}
try {
parsed = parsed ?? YAML.parse(contents);
} catch (e) {
}
if (!isJSObject(parsed)) return null;
const result = convertInsomniaV5(parsed) ?? convertInsomniaV4(parsed);
return deleteUndefinedAttrs(result);
}

View File

@@ -1,206 +0,0 @@
import { PartialImportResources } from '@yaakapp/api';
import { convertId, convertSyntax, isJSObject } from './common';
export function convertInsomniaV4(parsed: Record<string, any>) {
if (!Array.isArray(parsed.resources)) return null;
const resources: PartialImportResources = {
environments: [],
folders: [],
grpcRequests: [],
httpRequests: [],
websocketRequests: [],
workspaces: [],
};
// Import workspaces
const workspacesToImport = parsed.resources.filter(r => isJSObject(r) && r._type === 'workspace');
for (const w of workspacesToImport) {
resources.workspaces.push({
id: convertId(w._id),
createdAt: w.created ? new Date(w.created).toISOString().replace('Z', '') : undefined,
updatedAt: w.updated ? new Date(w.updated).toISOString().replace('Z', '') : undefined,
model: 'workspace',
name: w.name,
description: w.description || undefined,
});
const environmentsToImport = parsed.resources.filter(
(r: any) => isJSObject(r) && r._type === 'environment',
);
resources.environments.push(
...environmentsToImport.map((r: any) => importEnvironment(r, w._id)),
);
const nextFolder = (parentId: string) => {
const children = parsed.resources.filter((r: any) => r.parentId === parentId);
for (const child of children) {
if (!isJSObject(child)) continue;
if (child._type === 'request_group') {
resources.folders.push(importFolder(child, w._id));
nextFolder(child._id);
} else if (child._type === 'request') {
resources.httpRequests.push(
importHttpRequest(child, w._id),
);
} else if (child._type === 'grpc_request') {
resources.grpcRequests.push(
importGrpcRequest(child, w._id),
);
}
}
};
// Import folders
nextFolder(w._id);
}
// Filter out any `null` values
resources.httpRequests = resources.httpRequests.filter(Boolean);
resources.grpcRequests = resources.grpcRequests.filter(Boolean);
resources.environments = resources.environments.filter(Boolean);
resources.workspaces = resources.workspaces.filter(Boolean);
return { resources };
}
function importHttpRequest(
r: any,
workspaceId: string,
): PartialImportResources['httpRequests'][0] {
let bodyType: string | null = null;
let body = {};
if (r.body.mimeType === 'application/octet-stream') {
bodyType = 'binary';
body = { filePath: r.body.fileName ?? '' };
} else if (r.body?.mimeType === 'application/x-www-form-urlencoded') {
bodyType = 'application/x-www-form-urlencoded';
body = {
form: (r.body.params ?? []).map((p: any) => ({
enabled: !p.disabled,
name: p.name ?? '',
value: p.value ?? '',
})),
};
} else if (r.body?.mimeType === 'multipart/form-data') {
bodyType = 'multipart/form-data';
body = {
form: (r.body.params ?? []).map((p: any) => ({
enabled: !p.disabled,
name: p.name ?? '',
value: p.value ?? '',
file: p.fileName ?? null,
})),
};
} else if (r.body?.mimeType === 'application/graphql') {
bodyType = 'graphql';
body = { text: convertSyntax(r.body.text ?? '') };
} else if (r.body?.mimeType === 'application/json') {
bodyType = 'application/json';
body = { text: convertSyntax(r.body.text ?? '') };
}
let authenticationType: string | null = null;
let authentication = {};
if (r.authentication.type === 'bearer') {
authenticationType = 'bearer';
authentication = {
token: convertSyntax(r.authentication.token),
};
} else if (r.authentication.type === 'basic') {
authenticationType = 'basic';
authentication = {
username: convertSyntax(r.authentication.username),
password: convertSyntax(r.authentication.password),
};
}
return {
id: convertId(r.meta?.id ?? r._id),
createdAt: r.created ? new Date(r.created).toISOString().replace('Z', '') : undefined,
updatedAt: r.modified ? new Date(r.modified).toISOString().replace('Z', '') : undefined,
workspaceId: convertId(workspaceId),
folderId: r.parentId === workspaceId ? null : convertId(r.parentId),
model: 'http_request',
sortPriority: r.metaSortKey,
name: r.name,
description: r.description || undefined,
url: convertSyntax(r.url),
body,
bodyType,
authentication,
authenticationType,
method: r.method,
headers: (r.headers ?? [])
.map((h: any) => ({
enabled: !h.disabled,
name: h.name ?? '',
value: h.value ?? '',
}))
.filter(({ name, value }: any) => name !== '' || value !== ''),
};
}
function importGrpcRequest(
r: any,
workspaceId: string,
): PartialImportResources['grpcRequests'][0] {
const parts = r.protoMethodName.split('/').filter((p: any) => p !== '');
const service = parts[0] ?? null;
const method = parts[1] ?? null;
return {
id: convertId(r.meta?.id ?? r._id),
createdAt: r.created ? new Date(r.created).toISOString().replace('Z', '') : undefined,
updatedAt: r.modified ? new Date(r.modified).toISOString().replace('Z', '') : undefined,
workspaceId: convertId(workspaceId),
folderId: r.parentId === workspaceId ? null : convertId(r.parentId),
model: 'grpc_request',
sortPriority: r.metaSortKey,
name: r.name,
description: r.description || undefined,
url: convertSyntax(r.url),
service,
method,
message: r.body?.text ?? '',
metadata: (r.metadata ?? [])
.map((h: any) => ({
enabled: !h.disabled,
name: h.name ?? '',
value: h.value ?? '',
}))
.filter(({ name, value }: any) => name !== '' || value !== ''),
};
}
function importFolder(f: any, workspaceId: string): PartialImportResources['folders'][0] {
return {
id: convertId(f._id),
createdAt: f.created ? new Date(f.created).toISOString().replace('Z', '') : undefined,
updatedAt: f.modified ? new Date(f.modified).toISOString().replace('Z', '') : undefined,
folderId: f.parentId === workspaceId ? null : convertId(f.parentId),
workspaceId: convertId(workspaceId),
description: f.description || undefined,
model: 'folder',
name: f.name,
};
}
function importEnvironment(e: any, workspaceId: string, isParent?: boolean): PartialImportResources['environments'][0] {
return {
id: convertId(e._id),
createdAt: e.created ? new Date(e.created).toISOString().replace('Z', '') : undefined,
updatedAt: e.modified ? new Date(e.modified).toISOString().replace('Z', '') : undefined,
workspaceId: convertId(workspaceId),
// @ts-ignore
sortPriority: e.metaSortKey, // Will be added to Yaak later
base: isParent ?? e.parentId === workspaceId,
model: 'environment',
name: e.name,
variables: Object.entries(e.data).map(([name, value]) => ({
enabled: true,
name,
value: `${value}`,
})),
};
}

View File

@@ -1,265 +0,0 @@
import { PartialImportResources } from '@yaakapp/api';
import { convertId, convertSyntax, isJSObject } from './common';
export function convertInsomniaV5(parsed: Record<string, any>) {
if (!Array.isArray(parsed.collection)) return null;
const resources: PartialImportResources = {
environments: [],
folders: [],
grpcRequests: [],
httpRequests: [],
websocketRequests: [],
workspaces: [],
};
// Import workspaces
const meta: Record<string, any> = parsed.meta ?? {};
resources.workspaces.push({
id: convertId(meta.id ?? 'collection'),
createdAt: meta.created ? new Date(meta.created).toISOString().replace('Z', '') : undefined,
updatedAt: meta.modified ? new Date(meta.modified).toISOString().replace('Z', '') : undefined,
model: 'workspace',
name: parsed.name,
description: meta.description || undefined,
});
resources.environments.push(
importEnvironment(parsed.environments, meta.id, true),
...(parsed.environments.subEnvironments ?? []).map((r: any) => importEnvironment(r, meta.id)),
);
const nextFolder = (children: any[], parentId: string) => {
for (const child of children ?? []) {
if (!isJSObject(child)) continue;
if (Array.isArray(child.children)) {
resources.folders.push(importFolder(child, meta.id, parentId));
nextFolder(child.children, child.meta.id);
} else if (child.method) {
resources.httpRequests.push(
importHttpRequest(child, meta.id, parentId),
);
} else if (child.protoFileId) {
resources.grpcRequests.push(
importGrpcRequest(child, meta.id, parentId),
);
} else if (child.url) {
resources.websocketRequests.push(
importWebsocketRequest(child, meta.id, parentId),
);
}
}
};
// Import folders
nextFolder(parsed.collection ?? [], meta.id);
// Filter out any `null` values
resources.httpRequests = resources.httpRequests.filter(Boolean);
resources.grpcRequests = resources.grpcRequests.filter(Boolean);
resources.environments = resources.environments.filter(Boolean);
resources.workspaces = resources.workspaces.filter(Boolean);
return { resources };
}
function importHttpRequest(
r: any,
workspaceId: string,
parentId: string,
): PartialImportResources['httpRequests'][0] {
const id = r.meta?.id ?? r._id;
const created = r.meta?.created ?? r.created;
const updated = r.meta?.modified ?? r.updated;
const sortKey = r.meta?.sortKey ?? r.sortKey;
let bodyType: string | null = null;
let body = {};
if (r.body?.mimeType === 'application/octet-stream') {
bodyType = 'binary';
body = { filePath: r.body.fileName ?? '' };
} else if (r.body?.mimeType === 'application/x-www-form-urlencoded') {
bodyType = 'application/x-www-form-urlencoded';
body = {
form: (r.body.params ?? []).map((p: any) => ({
enabled: !p.disabled,
name: p.name ?? '',
value: p.value ?? '',
})),
};
} else if (r.body?.mimeType === 'multipart/form-data') {
bodyType = 'multipart/form-data';
body = {
form: (r.body.params ?? []).map((p: any) => ({
enabled: !p.disabled,
name: p.name ?? '',
value: p.value ?? '',
file: p.fileName ?? null,
})),
};
} else if (r.body?.mimeType === 'application/graphql') {
bodyType = 'graphql';
body = { text: convertSyntax(r.body.text ?? '') };
} else if (r.body?.mimeType === 'application/json') {
bodyType = 'application/json';
body = { text: convertSyntax(r.body.text ?? '') };
}
return {
id: convertId(id),
workspaceId: convertId(workspaceId),
createdAt: created ? new Date(created).toISOString().replace('Z', '') : undefined,
updatedAt: updated ? new Date(updated).toISOString().replace('Z', '') : undefined,
folderId: parentId === workspaceId ? null : convertId(parentId),
sortPriority: sortKey,
model: 'http_request',
name: r.name,
description: r.meta?.description || undefined,
url: convertSyntax(r.url),
body,
bodyType,
method: r.method,
...importHeaders(r),
...importAuthentication(r),
};
}
function importGrpcRequest(
r: any,
workspaceId: string,
parentId: string,
): PartialImportResources['grpcRequests'][0] {
const id = r.meta?.id ?? r._id;
const created = r.meta?.created ?? r.created;
const updated = r.meta?.modified ?? r.updated;
const sortKey = r.meta?.sortKey ?? r.sortKey;
const parts = r.protoMethodName.split('/').filter((p: any) => p !== '');
const service = parts[0] ?? null;
const method = parts[1] ?? null;
return {
model: 'grpc_request',
id: convertId(id),
workspaceId: convertId(workspaceId),
createdAt: created ? new Date(created).toISOString().replace('Z', '') : undefined,
updatedAt: updated ? new Date(updated).toISOString().replace('Z', '') : undefined,
folderId: parentId === workspaceId ? null : convertId(parentId),
sortPriority: sortKey,
name: r.name,
description: r.description || undefined,
url: convertSyntax(r.url),
service,
method,
message: r.body?.text ?? '',
metadata: (r.metadata ?? [])
.map((h: any) => ({
enabled: !h.disabled,
name: h.name ?? '',
value: h.value ?? '',
}))
.filter(({ name, value }: any) => name !== '' || value !== ''),
};
}
function importWebsocketRequest(
r: any,
workspaceId: string,
parentId: string,
): PartialImportResources['websocketRequests'][0] {
const id = r.meta?.id ?? r._id;
const created = r.meta?.created ?? r.created;
const updated = r.meta?.modified ?? r.updated;
const sortKey = r.meta?.sortKey ?? r.sortKey;
return {
model: 'websocket_request',
id: convertId(id),
workspaceId: convertId(workspaceId),
createdAt: created ? new Date(created).toISOString().replace('Z', '') : undefined,
updatedAt: updated ? new Date(updated).toISOString().replace('Z', '') : undefined,
folderId: parentId === workspaceId ? null : convertId(parentId),
sortPriority: sortKey,
name: r.name,
description: r.description || undefined,
url: convertSyntax(r.url),
message: r.body?.text ?? '',
...importHeaders(r),
...importAuthentication(r),
};
}
function importHeaders(r: any) {
const headers = (r.headers ?? [])
.map((h: any) => ({
enabled: !h.disabled,
name: h.name ?? '',
value: h.value ?? '',
}))
.filter(({ name, value }: any) => name !== '' || value !== '');
return { headers } as const;
}
function importAuthentication(r: any) {
let authenticationType: string | null = null;
let authentication = {};
if (r.authentication?.type === 'bearer') {
authenticationType = 'bearer';
authentication = {
token: convertSyntax(r.authentication.token),
};
} else if (r.authentication?.type === 'basic') {
authenticationType = 'basic';
authentication = {
username: convertSyntax(r.authentication.username),
password: convertSyntax(r.authentication.password),
};
}
return { authenticationType, authentication } as const;
}
function importFolder(f: any, workspaceId: string, parentId: string): PartialImportResources['folders'][0] {
const id = f.meta?.id ?? f._id;
const created = f.meta?.created ?? f.created;
const updated = f.meta?.modified ?? f.updated;
const sortKey = f.meta?.sortKey ?? f.sortKey;
return {
model: 'folder',
id: convertId(id),
createdAt: created ? new Date(created).toISOString().replace('Z', '') : undefined,
updatedAt: updated ? new Date(updated).toISOString().replace('Z', '') : undefined,
folderId: parentId === workspaceId ? null : convertId(parentId),
sortPriority: sortKey,
workspaceId: convertId(workspaceId),
description: f.description || undefined,
name: f.name,
};
}
function importEnvironment(e: any, workspaceId: string, isParent?: boolean): PartialImportResources['environments'][0] {
const id = e.meta?.id ?? e._id;
const created = e.meta?.created ?? e.created;
const updated = e.meta?.modified ?? e.updated;
const sortKey = e.meta?.sortKey ?? e.sortKey;
return {
id: convertId(id),
createdAt: created ? new Date(created).toISOString().replace('Z', '') : undefined,
updatedAt: updated ? new Date(updated).toISOString().replace('Z', '') : undefined,
workspaceId: convertId(workspaceId),
public: !e.isPrivate,
// @ts-ignore
sortPriority: sortKey, // Will be added to Yaak later
base: isParent ?? e.parentId === workspaceId,
model: 'environment',
name: e.name,
variables: Object.entries(e.data ?? {}).map(([name, value]) => ({
enabled: true,
name,
value: `${value}`,
})),
};
}

View File

@@ -1,187 +0,0 @@
{
"_type": "export",
"__export_format": 4,
"__export_date": "2025-01-13T15:19:18.330Z",
"__export_source": "insomnia.desktop.app:v10.3.0",
"resources": [
{
"_id": "req_84cd9ae4bd034dd8bb730e856a665cbb",
"parentId": "fld_859d1df78261463480b6a3a1419517e3",
"modified": 1736781473176,
"created": 1736781406672,
"url": "{{ _.BASE_URL }}/foo/:id",
"name": "New Request",
"description": "My description of the request",
"method": "GET",
"body": {
"mimeType": "multipart/form-data",
"params": [
{
"id": "pair_7c86036ae8ef499dbbc0b43d0800c5a3",
"name": "form",
"value": "data",
"description": "",
"disabled": false
}
]
},
"parameters": [
{
"id": "pair_b22f6ff611cd4250a6e405ca7b713d09",
"name": "query",
"value": "qqq",
"description": "",
"disabled": false
}
],
"headers": [
{
"name": "Content-Type",
"value": "multipart/form-data",
"id": "pair_4af845963bd14256b98716617971eecd"
},
{
"name": "User-Agent",
"value": "insomnia/10.3.0",
"id": "pair_535ffd00ce48462cb1b7258832ade65a"
},
{
"id": "pair_ab4b870278e943cba6babf5a73e213e3",
"name": "X-Header",
"value": "xxxx",
"description": "",
"disabled": false
}
],
"authentication": {
"type": "basic",
"useISO88591": false,
"disabled": false,
"username": "user",
"password": "pass"
},
"metaSortKey": -1736781406672,
"isPrivate": false,
"pathParameters": [
{
"name": "id",
"value": "iii"
}
],
"settingStoreCookies": true,
"settingSendCookies": true,
"settingDisableRenderRequestBody": false,
"settingEncodeUrl": true,
"settingRebuildPath": true,
"settingFollowRedirects": "global",
"_type": "request"
},
{
"_id": "fld_859d1df78261463480b6a3a1419517e3",
"parentId": "wrk_d4d92f7c0ee947b89159243506687019",
"modified": 1736781404718,
"created": 1736781404718,
"name": "Top Level",
"description": "",
"environment": {},
"environmentPropertyOrder": null,
"metaSortKey": -1736781404718,
"environmentType": "kv",
"_type": "request_group"
},
{
"_id": "wrk_d4d92f7c0ee947b89159243506687019",
"parentId": null,
"modified": 1736781343765,
"created": 1736781343765,
"name": "Dummy",
"description": "",
"scope": "collection",
"_type": "workspace"
},
{
"_id": "env_16c0dec5b77c414ae0e419b8f10c3701300c5900",
"parentId": "wrk_d4d92f7c0ee947b89159243506687019",
"modified": 1736781355209,
"created": 1736781343767,
"name": "Base Environment",
"data": {
"BASE_VAR": "hello"
},
"dataPropertyOrder": null,
"color": null,
"isPrivate": false,
"metaSortKey": 1736781343767,
"environmentType": "kv",
"kvPairData": [
{
"id": "envPair_61c1be66d42241b5a28306d2cd92d3e3",
"name": "BASE_VAR",
"value": "hello",
"type": "str",
"enabled": true
}
],
"_type": "environment"
},
{
"_id": "jar_16c0dec5b77c414ae0e419b8f10c3701300c5900",
"parentId": "wrk_d4d92f7c0ee947b89159243506687019",
"modified": 1736781343768,
"created": 1736781343768,
"name": "Default Jar",
"cookies": [],
"_type": "cookie_jar"
},
{
"_id": "env_799ae3d723ef44af91b4817e5d057e6d",
"parentId": "env_16c0dec5b77c414ae0e419b8f10c3701300c5900",
"modified": 1736781394705,
"created": 1736781358515,
"name": "Production",
"data": {
"BASE_URL": "https://api.yaak.app"
},
"dataPropertyOrder": null,
"color": "#f22c2c",
"isPrivate": false,
"metaSortKey": 1736781358515,
"environmentType": "kv",
"kvPairData": [
{
"id": "envPair_4d97b569b7e845ccbf488e1b26637cbc",
"name": "BASE_URL",
"value": "https://api.yaak.app",
"type": "str",
"enabled": true
}
],
"_type": "environment"
},
{
"_id": "env_030fbfdbb274426ebd78e2e6518f8553",
"parentId": "env_16c0dec5b77c414ae0e419b8f10c3701300c5900",
"modified": 1736781391078,
"created": 1736781374707,
"name": "Staging",
"data": {
"BASE_URL": "https://api.staging.yaak.app"
},
"dataPropertyOrder": null,
"color": "#206fac",
"isPrivate": false,
"metaSortKey": 1736781358565,
"environmentType": "kv",
"kvPairData": [
{
"id": "envPair_4d97b569b7e845ccbf488e1b26637cbc",
"name": "BASE_URL",
"value": "https://api.staging.yaak.app",
"type": "str",
"enabled": true
}
],
"_type": "environment"
}
]
}

View File

@@ -1,126 +0,0 @@
{
"resources": {
"environments": [
{
"createdAt": "2025-01-13T15:15:43.767",
"updatedAt": "2025-01-13T15:15:55.209",
"sortPriority": 1736781343767,
"base": true,
"id": "GENERATE_ID::env_16c0dec5b77c414ae0e419b8f10c3701300c5900",
"model": "environment",
"name": "Base Environment",
"variables": [
{
"enabled": true,
"name": "BASE_VAR",
"value": "hello"
}
],
"workspaceId": "GENERATE_ID::wrk_d4d92f7c0ee947b89159243506687019"
},
{
"createdAt": "2025-01-13T15:15:58.515",
"updatedAt": "2025-01-13T15:16:34.705",
"sortPriority": 1736781358515,
"base": false,
"id": "GENERATE_ID::env_799ae3d723ef44af91b4817e5d057e6d",
"model": "environment",
"name": "Production",
"variables": [
{
"enabled": true,
"name": "BASE_URL",
"value": "https://api.yaak.app"
}
],
"workspaceId": "GENERATE_ID::wrk_d4d92f7c0ee947b89159243506687019"
},
{
"createdAt": "2025-01-13T15:16:14.707",
"updatedAt": "2025-01-13T15:16:31.078",
"sortPriority": 1736781358565,
"base": false,
"id": "GENERATE_ID::env_030fbfdbb274426ebd78e2e6518f8553",
"model": "environment",
"name": "Staging",
"variables": [
{
"enabled": true,
"name": "BASE_URL",
"value": "https://api.staging.yaak.app"
}
],
"workspaceId": "GENERATE_ID::wrk_d4d92f7c0ee947b89159243506687019"
}
],
"folders": [
{
"createdAt": "2025-01-13T15:16:44.718",
"updatedAt": "2025-01-13T15:16:44.718",
"folderId": null,
"id": "GENERATE_ID::fld_859d1df78261463480b6a3a1419517e3",
"model": "folder",
"name": "Top Level",
"workspaceId": "GENERATE_ID::wrk_d4d92f7c0ee947b89159243506687019"
}
],
"grpcRequests": [],
"httpRequests": [
{
"authentication": {
"password": "pass",
"username": "user"
},
"authenticationType": "basic",
"body": {
"form": [
{
"enabled": true,
"file": null,
"name": "form",
"value": "data"
}
]
},
"bodyType": "multipart/form-data",
"createdAt": "2025-01-13T15:16:46.672",
"sortPriority": -1736781406672,
"updatedAt": "2025-01-13T15:17:53.176",
"description": "My description of the request",
"folderId": "GENERATE_ID::fld_859d1df78261463480b6a3a1419517e3",
"headers": [
{
"enabled": true,
"name": "Content-Type",
"value": "multipart/form-data"
},
{
"enabled": true,
"name": "User-Agent",
"value": "insomnia/10.3.0"
},
{
"enabled": true,
"name": "X-Header",
"value": "xxxx"
}
],
"id": "GENERATE_ID::req_84cd9ae4bd034dd8bb730e856a665cbb",
"method": "GET",
"model": "http_request",
"name": "New Request",
"url": "${[BASE_URL ]}/foo/:id",
"workspaceId": "GENERATE_ID::wrk_d4d92f7c0ee947b89159243506687019"
}
],
"websocketRequests": [],
"workspaces": [
{
"createdAt": "2025-01-13T15:15:43.765",
"id": "GENERATE_ID::wrk_d4d92f7c0ee947b89159243506687019",
"model": "workspace",
"name": "Dummy"
}
]
}
}

View File

@@ -1,72 +0,0 @@
type: collection.insomnia.rest/5.0
name: Debugging
meta:
id: wrk_9717dd1c9e0c4b2e9ed6d2abcf3bd45c
created: 1747197924902
modified: 1747197924902
collection:
- name: My Folder
meta:
id: fld_296933ea4ea84783a775d199997e9be7
created: 1747414092298
modified: 1747414142427
sortKey: -1747414092298
children:
- url: https://httpbin.org/post
name: New Request
meta:
id: req_9a80320365ac4509ade406359dbc6a71
created: 1747197928502
modified: 1747414129313
isPrivate: false
sortKey: -1747414129276
method: GET
headers:
- name: User-Agent
value: insomnia/11.1.0
id: pair_6ae87d1620a9494f8e5b29cd9f92d087
settings:
renderRequestBody: true
encodeUrl: true
followRedirects: global
cookies:
send: true
store: true
rebuildPath: true
headers:
- id: pair_f2b330e3914f4c11b209318aef94325c
name: foo
value: bar
disabled: false
- name: New Request
meta:
id: req_e3f8cdbd58784a539dd4c1e127d73451
created: 1747414160497
modified: 1747414160497
isPrivate: false
sortKey: -1747414160498
method: GET
headers:
- name: User-Agent
value: insomnia/11.1.0
settings:
renderRequestBody: true
encodeUrl: true
followRedirects: global
cookies:
send: true
store: true
rebuildPath: true
cookieJar:
name: Default Jar
meta:
id: jar_e46dc73e8ccda30ca132153e8f11183bd08119ce
created: 1747197924904
modified: 1747197924904
environments:
name: Base Environment
meta:
id: env_e46dc73e8ccda30ca132153e8f11183bd08119ce
created: 1747197924903
modified: 1747197924903
isPrivate: false

View File

@@ -1,87 +0,0 @@
{
"resources": {
"environments": [
{
"base": true,
"createdAt": "2025-05-14T04:45:24.903",
"id": "GENERATE_ID::env_e46dc73e8ccda30ca132153e8f11183bd08119ce",
"model": "environment",
"name": "Base Environment",
"public": true,
"updatedAt": "2025-05-14T04:45:24.903",
"variables": [],
"workspaceId": "GENERATE_ID::wrk_9717dd1c9e0c4b2e9ed6d2abcf3bd45c"
}
],
"folders": [
{
"createdAt": "2025-05-16T16:48:12.298",
"folderId": null,
"id": "GENERATE_ID::fld_296933ea4ea84783a775d199997e9be7",
"model": "folder",
"name": "My Folder",
"sortPriority": -1747414092298,
"updatedAt": "2025-05-16T16:49:02.427",
"workspaceId": "GENERATE_ID::wrk_9717dd1c9e0c4b2e9ed6d2abcf3bd45c"
}
],
"grpcRequests": [],
"httpRequests": [
{
"authentication": {},
"authenticationType": null,
"body": {},
"bodyType": null,
"createdAt": "2025-05-14T04:45:28.502",
"folderId": "GENERATE_ID::fld_296933ea4ea84783a775d199997e9be7",
"headers": [
{
"enabled": true,
"name": "User-Agent",
"value": "insomnia/11.1.0"
}
],
"id": "GENERATE_ID::req_9a80320365ac4509ade406359dbc6a71",
"method": "GET",
"model": "http_request",
"name": "New Request",
"sortPriority": -1747414129276,
"updatedAt": "2025-05-16T16:48:49.313",
"url": "https://httpbin.org/post",
"workspaceId": "GENERATE_ID::wrk_9717dd1c9e0c4b2e9ed6d2abcf3bd45c"
},
{
"authentication": {},
"authenticationType": null,
"body": {},
"bodyType": null,
"createdAt": "2025-05-16T16:49:20.497",
"folderId": null,
"headers": [
{
"enabled": true,
"name": "User-Agent",
"value": "insomnia/11.1.0"
}
],
"id": "GENERATE_ID::req_e3f8cdbd58784a539dd4c1e127d73451",
"method": "GET",
"model": "http_request",
"name": "New Request",
"sortPriority": -1747414160498,
"updatedAt": "2025-05-16T16:49:20.497",
"workspaceId": "GENERATE_ID::wrk_9717dd1c9e0c4b2e9ed6d2abcf3bd45c"
}
],
"websocketRequests": [],
"workspaces": [
{
"createdAt": "2025-05-14T04:45:24.902",
"id": "GENERATE_ID::wrk_9717dd1c9e0c4b2e9ed6d2abcf3bd45c",
"model": "workspace",
"name": "Debugging",
"updatedAt": "2025-05-14T04:45:24.902"
}
]
}
}

View File

@@ -1,142 +0,0 @@
type: collection.insomnia.rest/5.0
name: Dummy
meta:
id: wrk_c1eacfa750a04f3ea9985ef28043fa53
created: 1746799305927
modified: 1746843054272
description: This is the description
collection:
- name: Top Level
meta:
id: fld_42eb2e2bb22b4cedacbd3d057634e80c
created: 1736781404718
modified: 1736781404718
sortKey: -1736781404718
children:
- url: "{{ _.BASE_URL }}/foo/:id"
name: New Request
meta:
id: req_d72fff2a6b104b91a2ebe9de9edd2785
created: 1736781406672
modified: 1736781473176
isPrivate: false
description: My description of the request
sortKey: -1736781406672
method: GET
body:
mimeType: multipart/form-data
params:
- id: pair_7c86036ae8ef499dbbc0b43d0800c5a3
name: form
value: data
disabled: false
parameters:
- id: pair_b22f6ff611cd4250a6e405ca7b713d09
name: query
value: qqq
disabled: false
headers:
- name: Content-Type
value: multipart/form-data
id: pair_4af845963bd14256b98716617971eecd
- name: User-Agent
value: insomnia/10.3.0
id: pair_535ffd00ce48462cb1b7258832ade65a
- id: pair_ab4b870278e943cba6babf5a73e213e3
name: X-Header
value: xxxx
disabled: false
authentication:
type: basic
useISO88591: false
disabled: false
username: user
password: pass
settings:
renderRequestBody: true
encodeUrl: true
followRedirects: global
cookies:
send: true
store: true
rebuildPath: true
pathParameters:
- name: id
value: iii
- url: grpcb.in:9000
name: New Request
meta:
id: greq_06d659324df94504a4d64632be7106b3
created: 1746799344864
modified: 1746799544082
isPrivate: false
sortKey: -1746799344864
body:
text: |-
{
"greeting": "Greg"
}
protoFileId: pf_9d45b0dfaccc4bcc9d930746716786c5
protoMethodName: /hello.HelloService/SayHello
reflectionApi:
enabled: false
url: https://buf.build
module: buf.build/connectrpc/eliza
- url: wss://echo.websocket.org
name: New WebSocket Request
meta:
id: ws-req_5d1a4c7c79494743962e5176f6add270
created: 1746799553909
modified: 1746887120958
sortKey: -1746799553909
settings:
encodeUrl: true
followRedirects: global
cookies:
send: true
store: true
authentication:
type: basic
useISO88591: false
disabled: false
username: user
password: password
headers:
- name: User-Agent
value: insomnia/11.1.0
cookieJar:
name: Default Jar
meta:
id: jar_663d5741b072441aa2709a6113371510
created: 1736781343768
modified: 1736781343768
environments:
name: Base Environment
meta:
id: env_20945044d3c8497ca8b717bef750987e
created: 1736781343767
modified: 1736781355209
isPrivate: false
data:
BASE_VAR: hello
subEnvironments:
- name: Production
meta:
id: env_6f7728bb7fc04d558d668e954d756ea2
created: 1736781358515
modified: 1736781394705
isPrivate: false
sortKey: 1736781358515
data:
BASE_URL: https://api.yaak.app
color: "#f22c2c"
- name: Staging
meta:
id: env_976a8b6eb5d44fb6a20150f65c32d243
created: 1736781374707
modified: 1736781391078
isPrivate: false
sortKey: 1736781358565
data:
BASE_URL: https://api.staging.yaak.app
color: "#206fac"

View File

@@ -1,172 +0,0 @@
{
"resources": {
"environments": [
{
"createdAt": "2025-01-13T15:15:43.767",
"updatedAt": "2025-01-13T15:15:55.209",
"base": true,
"public": true,
"id": "GENERATE_ID::env_20945044d3c8497ca8b717bef750987e",
"model": "environment",
"name": "Base Environment",
"variables": [
{
"enabled": true,
"name": "BASE_VAR",
"value": "hello"
}
],
"workspaceId": "GENERATE_ID::wrk_c1eacfa750a04f3ea9985ef28043fa53"
},
{
"createdAt": "2025-01-13T15:15:58.515",
"updatedAt": "2025-01-13T15:16:34.705",
"base": false,
"public": true,
"id": "GENERATE_ID::env_6f7728bb7fc04d558d668e954d756ea2",
"model": "environment",
"name": "Production",
"sortPriority": 1736781358515,
"variables": [
{
"enabled": true,
"name": "BASE_URL",
"value": "https://api.yaak.app"
}
],
"workspaceId": "GENERATE_ID::wrk_c1eacfa750a04f3ea9985ef28043fa53"
},
{
"createdAt": "2025-01-13T15:16:14.707",
"updatedAt": "2025-01-13T15:16:31.078",
"base": false,
"public": true,
"id": "GENERATE_ID::env_976a8b6eb5d44fb6a20150f65c32d243",
"model": "environment",
"name": "Staging",
"sortPriority": 1736781358565,
"variables": [
{
"enabled": true,
"name": "BASE_URL",
"value": "https://api.staging.yaak.app"
}
],
"workspaceId": "GENERATE_ID::wrk_c1eacfa750a04f3ea9985ef28043fa53"
}
],
"folders": [
{
"createdAt": "2025-01-13T15:16:44.718",
"updatedAt": "2025-01-13T15:16:44.718",
"folderId": null,
"id": "GENERATE_ID::fld_42eb2e2bb22b4cedacbd3d057634e80c",
"model": "folder",
"name": "Top Level",
"sortPriority": -1736781404718,
"workspaceId": "GENERATE_ID::wrk_c1eacfa750a04f3ea9985ef28043fa53"
}
],
"grpcRequests": [
{
"model": "grpc_request",
"createdAt": "2025-05-09T14:02:24.864",
"folderId": null,
"id": "GENERATE_ID::greq_06d659324df94504a4d64632be7106b3",
"message": "{\n\t\"greeting\": \"Greg\"\n}",
"metadata": [],
"method": "SayHello",
"name": "New Request",
"service": "hello.HelloService",
"sortPriority": -1746799344864,
"updatedAt": "2025-05-09T14:05:44.082",
"url": "grpcb.in:9000",
"workspaceId": "GENERATE_ID::wrk_c1eacfa750a04f3ea9985ef28043fa53"
}
],
"httpRequests": [
{
"authentication": {
"password": "pass",
"username": "user"
},
"authenticationType": "basic",
"body": {
"form": [
{
"enabled": true,
"file": null,
"name": "form",
"value": "data"
}
]
},
"bodyType": "multipart/form-data",
"createdAt": "2025-01-13T15:16:46.672",
"updatedAt": "2025-01-13T15:17:53.176",
"description": "My description of the request",
"folderId": "GENERATE_ID::fld_42eb2e2bb22b4cedacbd3d057634e80c",
"headers": [
{
"enabled": true,
"name": "Content-Type",
"value": "multipart/form-data"
},
{
"enabled": true,
"name": "User-Agent",
"value": "insomnia/10.3.0"
},
{
"enabled": true,
"name": "X-Header",
"value": "xxxx"
}
],
"id": "GENERATE_ID::req_d72fff2a6b104b91a2ebe9de9edd2785",
"method": "GET",
"model": "http_request",
"name": "New Request",
"sortPriority": -1736781406672,
"url": "${[BASE_URL ]}/foo/:id",
"workspaceId": "GENERATE_ID::wrk_c1eacfa750a04f3ea9985ef28043fa53"
}
],
"websocketRequests": [
{
"id": "GENERATE_ID::ws-req_5d1a4c7c79494743962e5176f6add270",
"createdAt": "2025-05-09T14:05:53.909",
"updatedAt": "2025-05-10T14:25:20.958",
"message": "",
"model": "websocket_request",
"name": "New WebSocket Request",
"sortPriority": -1746799553909,
"authenticationType": "basic",
"authentication": {
"password": "password",
"username": "user"
},
"folderId": null,
"headers": [
{
"enabled": true,
"name": "User-Agent",
"value": "insomnia/11.1.0"
}
],
"url": "wss://echo.websocket.org",
"workspaceId": "GENERATE_ID::wrk_c1eacfa750a04f3ea9985ef28043fa53"
}
],
"workspaces": [
{
"createdAt": "2025-05-09T14:01:45.927",
"updatedAt": "2025-05-10T02:10:54.272",
"description": "This is the description",
"id": "GENERATE_ID::wrk_c1eacfa750a04f3ea9985ef28043fa53",
"model": "workspace",
"name": "Dummy"
}
]
}
}

View File

@@ -1,32 +0,0 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import { describe, expect, test } from 'vitest';
import YAML from 'yaml';
import { convertInsomnia } from '../src';
describe('importer-yaak', () => {
const p = path.join(__dirname, 'fixtures');
const fixtures = fs.readdirSync(p);
for (const fixture of fixtures) {
if (fixture.includes('.output')) {
continue;
}
test('Imports ' + fixture, () => {
const contents = fs.readFileSync(path.join(p, fixture), 'utf-8');
const expected = fs.readFileSync(path.join(p, fixture.replace(/.input\..*/, '.output.json')), 'utf-8');
const result = convertInsomnia(contents);
// console.log(JSON.stringify(result, null, 2))
expect(result).toEqual(parseJsonOrYaml(expected));
});
}
});
function parseJsonOrYaml(text: string): unknown {
try {
return JSON.parse(text);
} catch {
return YAML.parse(text);
}
}

View File

@@ -1,16 +0,0 @@
{
"name": "@yaakapp/importer-openapi",
"private": true,
"version": "0.0.1",
"scripts": {
"build": "yaakcli build ./src/index.js",
"dev": "yaakcli dev ./src/index.js"
},
"dependencies": {
"openapi-to-postmanv2": "^5.0.0",
"yaml": "^2.4.2"
},
"devDependencies": {
"@types/openapi-to-postmanv2": "^3.2.4"
}
}

View File

@@ -1,44 +0,0 @@
import { Context, Environment, Folder, HttpRequest, PluginDefinition, Workspace } from '@yaakapp/api';
import { convert } from 'openapi-to-postmanv2';
import { convertPostman } from '@yaakapp/importer-postman/src';
type AtLeast<T, K extends keyof T> = Partial<T> & Pick<T, K>;
interface ExportResources {
workspaces: AtLeast<Workspace, 'name' | 'id' | 'model'>[];
environments: AtLeast<Environment, 'name' | 'id' | 'model' | 'workspaceId'>[];
httpRequests: AtLeast<HttpRequest, 'name' | 'id' | 'model' | 'workspaceId'>[];
folders: AtLeast<Folder, 'name' | 'id' | 'model' | 'workspaceId'>[];
}
export const plugin: PluginDefinition = {
importer: {
name: 'OpenAPI',
description: 'Import OpenAPI collections',
onImport(_ctx: Context, args: { text: string }) {
return convertOpenApi(args.text) as any;
},
},
};
export async function convertOpenApi(
contents: string,
): Promise<{ resources: ExportResources } | undefined> {
let postmanCollection;
try {
postmanCollection = await new Promise((resolve, reject) => {
convert({ type: 'string', data: contents }, {}, (err, result: any) => {
if (err != null) reject(err);
if (Array.isArray(result.output) && result.output.length > 0) {
resolve(result.output[0].data);
}
});
});
} catch (err) {
// Probably not an OpenAPI file, so skip it
return undefined;
}
return convertPostman(JSON.stringify(postmanCollection));
}

View File

@@ -1,819 +0,0 @@
openapi: 3.0.2
servers:
- url: /v3
info:
description: |-
This is a sample Pet Store Server based on the OpenAPI 3.0 specification. You can find out more about
Swagger at [http://swagger.io](http://swagger.io). In the third iteration of the pet store, we've switched to the design first approach!
You can now help us improve the API whether it's by making changes to the definition itself or to the code.
That way, with time, we can improve the API in general, and expose some of the new features in OAS3.
Some useful links:
- [The Pet Store repository](https://github.com/swagger-api/swagger-petstore)
- [The source API definition for the Pet Store](https://github.com/swagger-api/swagger-petstore/blob/master/src/main/resources/openapi.yaml)
version: 1.0.20-SNAPSHOT
title: Swagger Petstore - OpenAPI 3.0
termsOfService: 'http://swagger.io/terms/'
contact:
email: apiteam@swagger.io
license:
name: Apache 2.0
url: 'http://www.apache.org/licenses/LICENSE-2.0.html'
tags:
- name: pet
description: Everything about your Pets
externalDocs:
description: Find out more
url: 'http://swagger.io'
- name: store
description: Access to Petstore orders
externalDocs:
description: Find out more about our store
url: 'http://swagger.io'
- name: user
description: Operations about user
paths:
/pet:
post:
tags:
- pet
summary: Add a new pet to the store
description: Add a new pet to the store
operationId: addPet
responses:
'200':
description: Successful operation
content:
application/xml:
schema:
$ref: '#/components/schemas/Pet'
application/json:
schema:
$ref: '#/components/schemas/Pet'
'405':
description: Invalid input
security:
- petstore_auth:
- 'write:pets'
- 'read:pets'
requestBody:
description: Create a new pet in the store
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/Pet'
application/xml:
schema:
$ref: '#/components/schemas/Pet'
application/x-www-form-urlencoded:
schema:
$ref: '#/components/schemas/Pet'
put:
tags:
- pet
summary: Update an existing pet
description: Update an existing pet by Id
operationId: updatePet
responses:
'200':
description: Successful operation
content:
application/xml:
schema:
$ref: '#/components/schemas/Pet'
application/json:
schema:
$ref: '#/components/schemas/Pet'
'400':
description: Invalid ID supplied
'404':
description: Pet not found
'405':
description: Validation exception
security:
- petstore_auth:
- 'write:pets'
- 'read:pets'
requestBody:
description: Update an existent pet in the store
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/Pet'
application/xml:
schema:
$ref: '#/components/schemas/Pet'
application/x-www-form-urlencoded:
schema:
$ref: '#/components/schemas/Pet'
/pet/findByStatus:
get:
tags:
- pet
summary: Finds Pets by status
description: Multiple status values can be provided with comma separated strings
operationId: findPetsByStatus
parameters:
- name: status
in: query
description: Status values that need to be considered for filter
required: false
explode: true
schema:
type: string
enum:
- available
- pending
- sold
default: available
responses:
'200':
description: successful operation
content:
application/xml:
schema:
type: array
items:
$ref: '#/components/schemas/Pet'
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Pet'
'400':
description: Invalid status value
security:
- petstore_auth:
- 'write:pets'
- 'read:pets'
/pet/findByTags:
get:
tags:
- pet
summary: Finds Pets by tags
description: >-
Multiple tags can be provided with comma separated strings. Use tag1,
tag2, tag3 for testing.
operationId: findPetsByTags
parameters:
- name: tags
in: query
description: Tags to filter by
required: false
explode: true
schema:
type: array
items:
type: string
responses:
'200':
description: successful operation
content:
application/xml:
schema:
type: array
items:
$ref: '#/components/schemas/Pet'
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Pet'
'400':
description: Invalid tag value
security:
- petstore_auth:
- 'write:pets'
- 'read:pets'
'/pet/{petId}':
get:
tags:
- pet
summary: Find pet by ID
description: Returns a single pet
operationId: getPetById
parameters:
- name: petId
in: path
description: ID of pet to return
required: true
schema:
type: integer
format: int64
responses:
'200':
description: successful operation
content:
application/xml:
schema:
$ref: '#/components/schemas/Pet'
application/json:
schema:
$ref: '#/components/schemas/Pet'
'400':
description: Invalid ID supplied
'404':
description: Pet not found
security:
- api_key: []
- petstore_auth:
- 'write:pets'
- 'read:pets'
post:
tags:
- pet
summary: Updates a pet in the store with form data
description: ''
operationId: updatePetWithForm
parameters:
- name: petId
in: path
description: ID of pet that needs to be updated
required: true
schema:
type: integer
format: int64
- name: name
in: query
description: Name of pet that needs to be updated
schema:
type: string
- name: status
in: query
description: Status of pet that needs to be updated
schema:
type: string
responses:
'405':
description: Invalid input
security:
- petstore_auth:
- 'write:pets'
- 'read:pets'
delete:
tags:
- pet
summary: Deletes a pet
description: ''
operationId: deletePet
parameters:
- name: api_key
in: header
description: ''
required: false
schema:
type: string
- name: petId
in: path
description: Pet id to delete
required: true
schema:
type: integer
format: int64
responses:
'400':
description: Invalid pet value
security:
- petstore_auth:
- 'write:pets'
- 'read:pets'
'/pet/{petId}/uploadImage':
post:
tags:
- pet
summary: uploads an image
description: ''
operationId: uploadFile
parameters:
- name: petId
in: path
description: ID of pet to update
required: true
schema:
type: integer
format: int64
- name: additionalMetadata
in: query
description: Additional Metadata
required: false
schema:
type: string
responses:
'200':
description: successful operation
content:
application/json:
schema:
$ref: '#/components/schemas/ApiResponse'
security:
- petstore_auth:
- 'write:pets'
- 'read:pets'
requestBody:
content:
application/octet-stream:
schema:
type: string
format: binary
/store/inventory:
get:
tags:
- store
summary: Returns pet inventories by status
description: Returns a map of status codes to quantities
operationId: getInventory
x-swagger-router-controller: OrderController
responses:
'200':
description: successful operation
content:
application/json:
schema:
type: object
additionalProperties:
type: integer
format: int32
security:
- api_key: []
/store/order:
post:
tags:
- store
summary: Place an order for a pet
description: Place a new order in the store
operationId: placeOrder
x-swagger-router-controller: OrderController
responses:
'200':
description: successful operation
content:
application/json:
schema:
$ref: '#/components/schemas/Order'
'405':
description: Invalid input
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/Order'
application/xml:
schema:
$ref: '#/components/schemas/Order'
application/x-www-form-urlencoded:
schema:
$ref: '#/components/schemas/Order'
'/store/order/{orderId}':
get:
tags:
- store
summary: Find purchase order by ID
x-swagger-router-controller: OrderController
description: >-
For valid response try integer IDs with value <= 5 or > 10. Other values
will generate exceptions.
operationId: getOrderById
parameters:
- name: orderId
in: path
description: ID of order that needs to be fetched
required: true
schema:
type: integer
format: int64
responses:
'200':
description: successful operation
content:
application/xml:
schema:
$ref: '#/components/schemas/Order'
application/json:
schema:
$ref: '#/components/schemas/Order'
'400':
description: Invalid ID supplied
'404':
description: Order not found
delete:
tags:
- store
summary: Delete purchase order by ID
x-swagger-router-controller: OrderController
description: >-
For valid response try integer IDs with value < 1000. Anything above
1000 or nonintegers will generate API errors
operationId: deleteOrder
parameters:
- name: orderId
in: path
description: ID of the order that needs to be deleted
required: true
schema:
type: integer
format: int64
responses:
'400':
description: Invalid ID supplied
'404':
description: Order not found
/user:
post:
tags:
- user
summary: Create user
description: This can only be done by the logged in user.
operationId: createUser
responses:
default:
description: successful operation
content:
application/json:
schema:
$ref: '#/components/schemas/User'
application/xml:
schema:
$ref: '#/components/schemas/User'
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/User'
application/xml:
schema:
$ref: '#/components/schemas/User'
application/x-www-form-urlencoded:
schema:
$ref: '#/components/schemas/User'
description: Created user object
/user/createWithList:
post:
tags:
- user
summary: Creates list of users with given input array
description: 'Creates list of users with given input array'
x-swagger-router-controller: UserController
operationId: createUsersWithListInput
responses:
'200':
description: Successful operation
content:
application/xml:
schema:
$ref: '#/components/schemas/User'
application/json:
schema:
$ref: '#/components/schemas/User'
default:
description: successful operation
requestBody:
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/User'
/user/login:
get:
tags:
- user
summary: Logs user into the system
description: ''
operationId: loginUser
parameters:
- name: username
in: query
description: The user name for login
required: false
schema:
type: string
- name: password
in: query
description: The password for login in clear text
required: false
schema:
type: string
responses:
'200':
description: successful operation
headers:
X-Rate-Limit:
description: calls per hour allowed by the user
schema:
type: integer
format: int32
X-Expires-After:
description: date in UTC when token expires
schema:
type: string
format: date-time
content:
application/xml:
schema:
type: string
application/json:
schema:
type: string
'400':
description: Invalid username/password supplied
/user/logout:
get:
tags:
- user
summary: Logs out current logged in user session
description: ''
operationId: logoutUser
parameters: []
responses:
default:
description: successful operation
'/user/{username}':
get:
tags:
- user
summary: Get user by user name
description: ''
operationId: getUserByName
parameters:
- name: username
in: path
description: 'The name that needs to be fetched. Use user1 for testing. '
required: true
schema:
type: string
responses:
'200':
description: successful operation
content:
application/xml:
schema:
$ref: '#/components/schemas/User'
application/json:
schema:
$ref: '#/components/schemas/User'
'400':
description: Invalid username supplied
'404':
description: User not found
put:
tags:
- user
summary: Update user
x-swagger-router-controller: UserController
description: This can only be done by the logged in user.
operationId: updateUser
parameters:
- name: username
in: path
description: name that needs to be updated
required: true
schema:
type: string
responses:
default:
description: successful operation
requestBody:
description: Update an existent user in the store
content:
application/json:
schema:
$ref: '#/components/schemas/User'
application/xml:
schema:
$ref: '#/components/schemas/User'
application/x-www-form-urlencoded:
schema:
$ref: '#/components/schemas/User'
delete:
tags:
- user
summary: Delete user
description: This can only be done by the logged in user.
operationId: deleteUser
parameters:
- name: username
in: path
description: The name that needs to be deleted
required: true
schema:
type: string
responses:
'400':
description: Invalid username supplied
'404':
description: User not found
externalDocs:
description: Find out more about Swagger
url: 'http://swagger.io'
components:
schemas:
Order:
x-swagger-router-model: io.swagger.petstore.model.Order
properties:
id:
type: integer
format: int64
example: 10
petId:
type: integer
format: int64
example: 198772
quantity:
type: integer
format: int32
example: 7
shipDate:
type: string
format: date-time
status:
type: string
description: Order Status
enum:
- placed
- approved
- delivered
example: approved
complete:
type: boolean
xml:
name: order
type: object
Customer:
properties:
id:
type: integer
format: int64
example: 100000
username:
type: string
example: fehguy
address:
type: array
items:
$ref: '#/components/schemas/Address'
xml:
wrapped: true
name: addresses
xml:
name: customer
type: object
Address:
properties:
street:
type: string
example: 437 Lytton
city:
type: string
example: Palo Alto
state:
type: string
example: CA
zip:
type: string
example: 94301
xml:
name: address
type: object
Category:
x-swagger-router-model: io.swagger.petstore.model.Category
properties:
id:
type: integer
format: int64
example: 1
name:
type: string
example: Dogs
xml:
name: category
type: object
User:
x-swagger-router-model: io.swagger.petstore.model.User
properties:
id:
type: integer
format: int64
example: 10
username:
type: string
example: theUser
firstName:
type: string
example: John
lastName:
type: string
example: James
email:
type: string
example: john@email.com
password:
type: string
example: 12345
phone:
type: string
example: 12345
userStatus:
type: integer
format: int32
example: 1
description: User Status
xml:
name: user
type: object
Tag:
x-swagger-router-model: io.swagger.petstore.model.Tag
properties:
id:
type: integer
format: int64
name:
type: string
xml:
name: tag
type: object
Pet:
x-swagger-router-model: io.swagger.petstore.model.Pet
required:
- name
- photoUrls
properties:
id:
type: integer
format: int64
example: 10
name:
type: string
example: doggie
category:
$ref: '#/components/schemas/Category'
photoUrls:
type: array
xml:
wrapped: true
items:
type: string
xml:
name: photoUrl
tags:
type: array
xml:
wrapped: true
items:
$ref: '#/components/schemas/Tag'
xml:
name: tag
status:
type: string
description: pet status in the store
enum:
- available
- pending
- sold
xml:
name: pet
type: object
ApiResponse:
properties:
code:
type: integer
format: int32
type:
type: string
message:
type: string
xml:
name: '##default'
type: object
requestBodies:
Pet:
content:
application/json:
schema:
$ref: '#/components/schemas/Pet'
application/xml:
schema:
$ref: '#/components/schemas/Pet'
description: Pet object that needs to be added to the store
UserArray:
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/User'
description: List of user object
securitySchemes:
petstore_auth:
type: oauth2
flows:
implicit:
authorizationUrl: 'https://petstore.swagger.io/oauth/authorize'
scopes:
'write:pets': modify pets in your account
'read:pets': read your pets
api_key:
type: apiKey
name: api_key
in: header

View File

@@ -1,29 +0,0 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import { describe, expect, test } from 'vitest';
import { convertOpenApi } from '../src';
describe('importer-openapi', () => {
const p = path.join(__dirname, 'fixtures');
const fixtures = fs.readdirSync(p);
test('Skips invalid file', async () => {
const imported = await convertOpenApi('{}');
expect(imported).toBeUndefined();
});
for (const fixture of fixtures) {
test('Imports ' + fixture, async () => {
const contents = fs.readFileSync(path.join(p, fixture), 'utf-8');
const imported = await convertOpenApi(contents);
expect(imported?.resources.workspaces).toEqual([
expect.objectContaining({
name: 'Swagger Petstore - OpenAPI 3.0',
description: expect.stringContaining('This is a sample Pet Store Server'),
}),
]);
expect(imported?.resources.httpRequests.length).toBe(19);
expect(imported?.resources.folders.length).toBe(7);
});
}
});

View File

@@ -1,10 +0,0 @@
{
"name": "@yaakapp/importer-postman",
"private": true,
"version": "0.0.1",
"main": "./build/index.js",
"scripts": {
"build": "yaakcli build ./src/index.js",
"dev": "yaakcli dev ./src/index.js"
}
}

View File

@@ -1,370 +0,0 @@
import {
Context,
Environment,
Folder,
HttpRequest,
HttpRequestHeader,
HttpUrlParameter,
PluginDefinition,
Workspace,
} from '@yaakapp/api';
const POSTMAN_2_1_0_SCHEMA = 'https://schema.getpostman.com/json/collection/v2.1.0/collection.json';
const POSTMAN_2_0_0_SCHEMA = 'https://schema.getpostman.com/json/collection/v2.0.0/collection.json';
const VALID_SCHEMAS = [POSTMAN_2_0_0_SCHEMA, POSTMAN_2_1_0_SCHEMA];
type AtLeast<T, K extends keyof T> = Partial<T> & Pick<T, K>;
interface ExportResources {
workspaces: AtLeast<Workspace, 'name' | 'id' | 'model'>[];
environments: AtLeast<Environment, 'name' | 'id' | 'model' | 'workspaceId'>[];
httpRequests: AtLeast<HttpRequest, 'name' | 'id' | 'model' | 'workspaceId'>[];
folders: AtLeast<Folder, 'name' | 'id' | 'model' | 'workspaceId'>[];
}
export const plugin: PluginDefinition = {
importer: {
name: 'Postman',
description: 'Import postman collections',
onImport(_ctx: Context, args: { text: string }) {
return convertPostman(args.text) as any;
},
},
};
export function convertPostman(
contents: string,
): { resources: ExportResources } | undefined {
const root = parseJSONToRecord(contents);
if (root == null) return;
const info = toRecord(root.info);
const isValidSchema = VALID_SCHEMAS.includes(info.schema);
if (!isValidSchema || !Array.isArray(root.item)) {
return;
}
const globalAuth = importAuth(root.auth);
const exportResources: ExportResources = {
workspaces: [],
environments: [],
httpRequests: [],
folders: [],
};
const workspace: ExportResources['workspaces'][0] = {
model: 'workspace',
id: generateId('workspace'),
name: info.name || 'Postman Import',
description: info.description?.content ?? info.description,
};
exportResources.workspaces.push(workspace);
// Create the base environment
const environment: ExportResources['environments'][0] = {
model: 'environment',
id: generateId('environment'),
name: 'Global Variables',
workspaceId: workspace.id,
variables:
root.variable?.map((v: any) => ({
name: v.key,
value: v.value,
})) ?? [],
};
exportResources.environments.push(environment);
const importItem = (v: Record<string, any>, folderId: string | null = null) => {
if (typeof v.name === 'string' && Array.isArray(v.item)) {
const folder: ExportResources['folders'][0] = {
model: 'folder',
workspaceId: workspace.id,
id: generateId('folder'),
name: v.name,
folderId,
};
exportResources.folders.push(folder);
for (const child of v.item) {
importItem(child, folder.id);
}
} else if (typeof v.name === 'string' && 'request' in v) {
const r = toRecord(v.request);
const bodyPatch = importBody(r.body);
const requestAuthPath = importAuth(r.auth);
const authPatch = requestAuthPath.authenticationType == null ? globalAuth : requestAuthPath;
const headers: HttpRequestHeader[] = toArray(r.header).map((h) => {
return {
name: h.key,
value: h.value,
enabled: !h.disabled,
};
});
// Add body headers only if they don't already exist
for (const bodyPatchHeader of bodyPatch.headers) {
const existingHeader = headers.find(h => h.name.toLowerCase() === bodyPatchHeader.name.toLowerCase());
if (existingHeader) {
continue;
}
headers.push(bodyPatchHeader);
}
const { url, urlParameters } = convertUrl(r.url);
const request: ExportResources['httpRequests'][0] = {
model: 'http_request',
id: generateId('http_request'),
workspaceId: workspace.id,
folderId,
name: v.name,
description: v.description || undefined,
method: r.method || 'GET',
url,
urlParameters,
body: bodyPatch.body,
bodyType: bodyPatch.bodyType,
authentication: authPatch.authentication,
authenticationType: authPatch.authenticationType,
headers,
};
exportResources.httpRequests.push(request);
} else {
console.log('Unknown item', v, folderId);
}
};
for (const item of root.item) {
importItem(item);
}
const resources = deleteUndefinedAttrs(convertTemplateSyntax(exportResources));
return { resources };
}
function convertUrl(url: string | any): Pick<HttpRequest, 'url' | 'urlParameters'> {
if (typeof url === 'string') {
return { url, urlParameters: [] };
}
url = toRecord(url);
let v = '';
if ('protocol' in url && typeof url.protocol === 'string') {
v += `${url.protocol}://`;
}
if ('host' in url) {
v += `${Array.isArray(url.host) ? url.host.join('.') : url.host}`;
}
if ('port' in url && typeof url.port === 'string') {
v += `:${url.port}`;
}
if ('path' in url && Array.isArray(url.path) && url.path.length > 0) {
v += `/${Array.isArray(url.path) ? url.path.join('/') : url.path}`;
}
const params: HttpUrlParameter[] = [];
if ('query' in url && Array.isArray(url.query) && url.query.length > 0) {
for (const query of url.query) {
params.push({
name: query.key ?? '',
value: query.value ?? '',
enabled: !query.disabled,
});
}
}
if ('variable' in url && Array.isArray(url.variable) && url.variable.length > 0) {
for (const v of url.variable) {
params.push({
name: ':' + (v.key ?? ''),
value: v.value ?? '',
enabled: !v.disabled,
});
}
}
if ('hash' in url && typeof url.hash === 'string') {
v += `#${url.hash}`;
}
// TODO: Implement url.variables (path variables)
return { url: v, urlParameters: params };
}
function importAuth(
rawAuth: any,
): Pick<HttpRequest, 'authentication' | 'authenticationType'> {
const auth = toRecord(rawAuth);
if ('basic' in auth) {
return {
authenticationType: 'basic',
authentication: {
username: auth.basic.username || '',
password: auth.basic.password || '',
},
};
} else if ('bearer' in auth) {
return {
authenticationType: 'bearer',
authentication: {
token: auth.bearer.token || '',
},
};
} else {
return { authenticationType: null, authentication: {} };
}
}
function importBody(rawBody: any): Pick<HttpRequest, 'body' | 'bodyType' | 'headers'> {
const body = toRecord(rawBody);
if (body.mode === 'graphql') {
return {
headers: [
{
name: 'Content-Type',
value: 'application/json',
enabled: true,
},
],
bodyType: 'graphql',
body: {
text: JSON.stringify(
{ query: body.graphql.query, variables: parseJSONToRecord(body.graphql.variables) },
null,
2,
),
},
};
} else if (body.mode === 'urlencoded') {
return {
headers: [
{
name: 'Content-Type',
value: 'application/x-www-form-urlencoded',
enabled: true,
},
],
bodyType: 'application/x-www-form-urlencoded',
body: {
form: toArray(body.urlencoded).map((f) => ({
enabled: !f.disabled,
name: f.key ?? '',
value: f.value ?? '',
})),
},
};
} else if (body.mode === 'formdata') {
return {
headers: [
{
name: 'Content-Type',
value: 'multipart/form-data',
enabled: true,
},
],
bodyType: 'multipart/form-data',
body: {
form: toArray(body.formdata).map((f) =>
f.src != null
? {
enabled: !f.disabled,
contentType: f.contentType ?? null,
name: f.key ?? '',
file: f.src ?? '',
}
: {
enabled: !f.disabled,
name: f.key ?? '',
value: f.value ?? '',
},
),
},
};
} else if (body.mode === 'raw') {
return {
headers: [
{
name: 'Content-Type',
value: body.options?.raw?.language === 'json' ? 'application/json' : '',
enabled: true,
},
],
bodyType: body.options?.raw?.language === 'json' ? 'application/json' : 'other',
body: {
text: body.raw ?? '',
},
};
} else if (body.mode === 'file') {
return {
headers: [],
bodyType: 'binary',
body: {
filePath: body.file?.src,
},
};
} else {
return { headers: [], bodyType: null, body: {} };
}
}
function parseJSONToRecord(jsonStr: string): Record<string, any> | null {
try {
return toRecord(JSON.parse(jsonStr));
} catch (err) {
}
return null;
}
function toRecord(value: any): Record<string, any> {
if (Object.prototype.toString.call(value) === '[object Object]') return value;
else return {};
}
function toArray(value: any): any[] {
if (Object.prototype.toString.call(value) === '[object Array]') return value;
else return [];
}
/** Recursively render all nested object properties */
function convertTemplateSyntax<T>(obj: T): T {
if (typeof obj === 'string') {
return obj.replace(/{{\s*(_\.)?([^}]+)\s*}}/g, '${[$2]}') as T;
} else if (Array.isArray(obj) && obj != null) {
return obj.map(convertTemplateSyntax) as T;
} else if (typeof obj === 'object' && obj != null) {
return Object.fromEntries(
Object.entries(obj).map(([k, v]) => [k, convertTemplateSyntax(v)]),
) as T;
} else {
return obj;
}
}
function deleteUndefinedAttrs<T>(obj: T): T {
if (Array.isArray(obj) && obj != null) {
return obj.map(deleteUndefinedAttrs) as T;
} else if (typeof obj === 'object' && obj != null) {
return Object.fromEntries(
Object.entries(obj)
.filter(([, v]) => v !== undefined)
.map(([k, v]) => [k, deleteUndefinedAttrs(v)]),
) as T;
} else {
return obj;
}
}
const idCount: Partial<Record<string, number>> = {};
function generateId(model: string): string {
idCount[model] = (idCount[model] ?? -1) + 1;
return `GENERATE_ID::${model.toUpperCase()}_${idCount[model]}`;
}

View File

@@ -1,38 +0,0 @@
{
"info": {
"_postman_id": "9e6dfada-256c-49ea-a38f-7d1b05b7ca2d",
"name": "New Collection",
"schema": "https://schema.getpostman.com/json/collection/v2.0.0/collection.json",
"_exporter_id": "18798"
},
"item": [
{
"name": "Top Folder",
"item": [
{
"name": "Nested Folder",
"item": [
{
"name": "Request 1",
"request": {
"method": "GET"
}
}
]
},
{
"name": "Request 2",
"request": {
"method": "GET"
}
}
]
},
{
"name": "Request 3",
"request": {
"method": "GET"
}
}
]
}

View File

@@ -1,83 +0,0 @@
{
"resources": {
"workspaces": [
{
"model": "workspace",
"id": "GENERATE_ID::WORKSPACE_0",
"name": "New Collection"
}
],
"environments": [
{
"id": "GENERATE_ID::ENVIRONMENT_0",
"model": "environment",
"name": "Global Variables",
"variables": [],
"workspaceId": "GENERATE_ID::WORKSPACE_0"
}
],
"httpRequests": [
{
"model": "http_request",
"id": "GENERATE_ID::HTTP_REQUEST_0",
"workspaceId": "GENERATE_ID::WORKSPACE_0",
"folderId": "GENERATE_ID::FOLDER_1",
"name": "Request 1",
"method": "GET",
"url": "",
"urlParameters": [],
"body": {},
"bodyType": null,
"authentication": {},
"authenticationType": null,
"headers": []
},
{
"model": "http_request",
"id": "GENERATE_ID::HTTP_REQUEST_1",
"workspaceId": "GENERATE_ID::WORKSPACE_0",
"folderId": "GENERATE_ID::FOLDER_0",
"name": "Request 2",
"method": "GET",
"url": "",
"urlParameters": [],
"body": {},
"bodyType": null,
"authentication": {},
"authenticationType": null,
"headers": []
},
{
"model": "http_request",
"id": "GENERATE_ID::HTTP_REQUEST_2",
"workspaceId": "GENERATE_ID::WORKSPACE_0",
"folderId": null,
"name": "Request 3",
"method": "GET",
"url": "",
"urlParameters": [],
"body": {},
"bodyType": null,
"authentication": {},
"authenticationType": null,
"headers": []
}
],
"folders": [
{
"model": "folder",
"workspaceId": "GENERATE_ID::WORKSPACE_0",
"id": "GENERATE_ID::FOLDER_0",
"name": "Top Folder",
"folderId": null
},
{
"model": "folder",
"workspaceId": "GENERATE_ID::WORKSPACE_0",
"id": "GENERATE_ID::FOLDER_1",
"name": "Nested Folder",
"folderId": "GENERATE_ID::FOLDER_0"
}
]
}
}

View File

@@ -1,136 +0,0 @@
{
"info": {
"_postman_id": "9e6dfada-256c-49ea-a38f-7d1b05b7ca2d",
"name": "New Collection",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
"_exporter_id": "18798"
},
"item": [
{
"name": "Form URL",
"request": {
"auth": {
"type": "bearer",
"bearer": [
{
"key": "token",
"value": "baeare",
"type": "string"
}
]
},
"method": "POST",
"header": [
{
"key": "X-foo",
"value": "bar",
"description": "description"
},
{
"key": "Disabled",
"value": "tnroant",
"description": "ntisorantosra",
"disabled": true
}
],
"body": {
"mode": "formdata",
"formdata": [
{
"key": "Key",
"contentType": "Custom/COntent",
"description": "DEscription",
"type": "file",
"src": "/Users/gschier/Desktop/Screenshot 2024-05-31 at 12.05.11PM.png"
}
]
},
"url": {
"raw": "example.com/:foo/:bar?q=qqq&",
"host": [
"example",
"com"
],
"path": [
":foo",
":bar"
],
"query": [
{
"key": "disabled",
"value": "secondvalue",
"description": "this is disabled",
"disabled": true
},
{
"key": "q",
"value": "qqq",
"description": "hello"
},
{
"key": "",
"value": null
}
],
"variable": [
{
"key": "foo",
"value": "fff",
"description": "Description"
},
{
"key": "bar",
"value": "bbb",
"description": "bbb description"
}
]
}
},
"response": []
}
],
"auth": {
"type": "basic",
"basic": [
{
"key": "password",
"value": "globalpass",
"type": "string"
},
{
"key": "username",
"value": "globaluser",
"type": "string"
}
]
},
"event": [
{
"listen": "prerequest",
"script": {
"type": "text/javascript",
"packages": {},
"exec": [
""
]
}
},
{
"listen": "test",
"script": {
"type": "text/javascript",
"packages": {},
"exec": [
""
]
}
}
],
"variable": [
{
"key": "COLLECTION VARIABLE",
"value": "collection variable",
"type": "string"
}
]
}

View File

@@ -1,96 +0,0 @@
{
"resources": {
"workspaces": [
{
"model": "workspace",
"id": "GENERATE_ID::WORKSPACE_1",
"name": "New Collection"
}
],
"environments": [
{
"id": "GENERATE_ID::ENVIRONMENT_1",
"workspaceId": "GENERATE_ID::WORKSPACE_1",
"model": "environment",
"name": "Global Variables",
"variables": [
{
"name": "COLLECTION VARIABLE",
"value": "collection variable"
}
]
}
],
"httpRequests": [
{
"model": "http_request",
"id": "GENERATE_ID::HTTP_REQUEST_3",
"workspaceId": "GENERATE_ID::WORKSPACE_1",
"folderId": null,
"name": "Form URL",
"method": "POST",
"url": "example.com/:foo/:bar",
"urlParameters": [
{
"name": "disabled",
"value": "secondvalue",
"enabled": false
},
{
"name": "q",
"value": "qqq",
"enabled": true
},
{
"name": "",
"value": "",
"enabled": true
},
{
"name": ":foo",
"value": "fff",
"enabled": true
},
{
"name": ":bar",
"value": "bbb",
"enabled": true
}
],
"body": {
"form": [
{
"enabled": true,
"contentType": "Custom/COntent",
"name": "Key",
"file": "/Users/gschier/Desktop/Screenshot 2024-05-31 at 12.05.11PM.png"
}
]
},
"bodyType": "multipart/form-data",
"authentication": {
"token": ""
},
"authenticationType": "bearer",
"headers": [
{
"name": "X-foo",
"value": "bar",
"enabled": true
},
{
"name": "Disabled",
"value": "tnroant",
"enabled": false
},
{
"name": "Content-Type",
"value": "multipart/form-data",
"enabled": true
}
]
}
],
"folders": []
}
}

Some files were not shown because too many files have changed in this diff Show More