Repository: mountain-loop/yaak Branch: main Commit: b4a1c418bb3f Files: 1118 Total size: 3.2 MB Directory structure: gitextract_zes9y_de/ ├── .claude/ │ ├── commands/ │ │ └── release/ │ │ └── generate-release-notes.md │ └── rules.md ├── .claude-context.md ├── .codex/ │ └── skills/ │ └── release-generate-release-notes/ │ └── SKILL.md ├── .gitattributes ├── .github/ │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.md │ │ └── config.yml │ ├── pull_request_template.md │ └── workflows/ │ ├── ci.yml │ ├── claude.yml │ ├── flathub.yml │ ├── release-api-npm.yml │ ├── release-app.yml │ ├── release-cli-npm.yml │ └── sponsors.yml ├── .gitignore ├── .node-version ├── .npmrc ├── .nvmrc ├── .oxfmtignore ├── .vite-hooks/ │ ├── post-checkout │ └── pre-commit ├── .vscode/ │ ├── extensions.json │ ├── launch.json │ └── settings.json ├── AGENTS.md ├── CONTRIBUTING.md ├── Cargo.toml ├── DEVELOPMENT.md ├── LICENSE ├── README.md ├── crates/ │ ├── yaak/ │ │ ├── Cargo.toml │ │ └── src/ │ │ ├── error.rs │ │ ├── lib.rs │ │ ├── plugin_events.rs │ │ ├── render.rs │ │ └── send.rs │ ├── yaak-api/ │ │ ├── Cargo.toml │ │ └── src/ │ │ ├── error.rs │ │ └── lib.rs │ ├── yaak-common/ │ │ ├── Cargo.toml │ │ └── src/ │ │ ├── command.rs │ │ ├── lib.rs │ │ ├── platform.rs │ │ └── serde.rs │ ├── yaak-core/ │ │ ├── Cargo.toml │ │ └── src/ │ │ ├── context.rs │ │ ├── error.rs │ │ └── lib.rs │ ├── yaak-crypto/ │ │ ├── Cargo.toml │ │ ├── index.ts │ │ ├── package.json │ │ └── src/ │ │ ├── encryption.rs │ │ ├── error.rs │ │ ├── lib.rs │ │ ├── manager.rs │ │ ├── master_key.rs │ │ └── workspace_key.rs │ ├── yaak-git/ │ │ ├── Cargo.toml │ │ ├── bindings/ │ │ │ ├── gen_git.ts │ │ │ └── gen_models.ts │ │ ├── index.ts │ │ ├── package.json │ │ └── src/ │ │ ├── add.rs │ │ ├── binary.rs │ │ ├── branch.rs │ │ ├── clone.rs │ │ ├── commit.rs │ │ ├── credential.rs │ │ ├── error.rs │ │ ├── fetch.rs │ │ ├── init.rs │ │ ├── lib.rs │ │ ├── log.rs │ │ ├── pull.rs │ │ ├── push.rs │ │ ├── remotes.rs │ │ ├── repository.rs │ │ ├── reset.rs │ │ ├── status.rs │ │ ├── unstage.rs │ │ └── util.rs │ ├── yaak-grpc/ │ │ ├── Cargo.toml │ │ └── src/ │ │ ├── any.rs │ │ ├── client.rs │ │ ├── codec.rs │ │ ├── error.rs │ │ ├── json_schema.rs │ │ ├── lib.rs │ │ ├── manager.rs │ │ ├── reflection.rs │ │ └── transport.rs │ ├── yaak-http/ │ │ ├── Cargo.toml │ │ ├── src/ │ │ │ ├── chained_reader.rs │ │ │ ├── client.rs │ │ │ ├── cookies.rs │ │ │ ├── decompress.rs │ │ │ ├── dns.rs │ │ │ ├── error.rs │ │ │ ├── lib.rs │ │ │ ├── manager.rs │ │ │ ├── path_placeholders.rs │ │ │ ├── proto.rs │ │ │ ├── sender.rs │ │ │ ├── tee_reader.rs │ │ │ ├── transaction.rs │ │ │ └── types.rs │ │ └── tests/ │ │ └── test.txt │ ├── yaak-models/ │ │ ├── Cargo.toml │ │ ├── bindings/ │ │ │ ├── gen_models.ts │ │ │ └── gen_util.ts │ │ ├── blob_migrations/ │ │ │ └── 00000000000000_init.sql │ │ ├── build.rs │ │ ├── guest-js/ │ │ │ ├── atoms.ts │ │ │ ├── index.ts │ │ │ ├── store.ts │ │ │ ├── types.ts │ │ │ └── util.ts │ │ ├── migrations/ │ │ │ ├── 20230225181302_init.sql │ │ │ ├── 20230319042610_sort-priority.sql │ │ │ ├── 20230330143214_request-auth.sql │ │ │ ├── 20230413232435_response-body-blob.sql │ │ │ ├── 20231022205109_environments.sql │ │ │ ├── 20231028161007_variables.sql │ │ │ ├── 20231103004111_workspace-variables.sql │ │ │ ├── 20231103142807_folders.sql │ │ │ ├── 20231112180500_body_object.sql │ │ │ ├── 20231113183810_url_params.sql │ │ │ ├── 20231122055216_remove_body.sql │ │ │ ├── 20240111221224_settings.sql │ │ │ ├── 20240115193751_workspace_settings.sql │ │ │ ├── 20240118181105_channel_setting.sql │ │ │ ├── 20240127013915_cookies.sql │ │ │ ├── 20240128230717_more_response_attrs.sql │ │ │ ├── 20240203164833_grpc.sql │ │ │ ├── 20240522031045_theme-settings.sql │ │ │ ├── 20240529143147_more-settings.sql │ │ │ ├── 20240607151115_open-workspace-setting.sql │ │ │ ├── 20240814013812_fix-env-model.sql │ │ │ ├── 20240826184943_disable-telemetry.sql │ │ │ ├── 20240829131004_plugins.sql │ │ │ ├── 20241003134208_response-state.sql │ │ │ ├── 20241012181547_proxy-setting.sql │ │ │ ├── 20241217204951_docs.sql │ │ │ ├── 20241219140051_base-environments.sql │ │ │ ├── 20250102141937_sync.sql │ │ │ ├── 20250108035425_editor-keymap.sql │ │ │ ├── 20250108205117_workspace-meta.sql │ │ │ ├── 20250114160022_remove-workspace-sync-setting.sql │ │ │ ├── 20250123192023_plugin-kv.sql │ │ │ ├── 20250128155623_websockets.sql │ │ │ ├── 20250302041707_hide-window-controls.sql │ │ │ ├── 20250326193143_key-value-id.sql │ │ │ ├── 20250401122407_encrypted-key.sql │ │ │ ├── 20250402144842_encryption-key-challenge.sql │ │ │ ├── 20250424152740_remove-fks.sql │ │ │ ├── 20250507140702_remove-ev-sync-states.sql │ │ │ ├── 20250508161145_public-environments.sql │ │ │ ├── 20250516182745_default-attrs.sql │ │ │ ├── 20250530174021_graphql-introspection.sql │ │ │ ├── 20250531193722_sync-state-index.sql │ │ │ ├── 20250604102922_colored-methods-setting.sql │ │ │ ├── 20250608150053_font-settings.sql │ │ │ ├── 20250611120000_environment-color.sql │ │ │ ├── 20250727190746_autoupdate_setting.sql │ │ │ ├── 20250918141129_request-folder-environments.sql │ │ │ ├── 20250929132954_dismiss-license-badge.sql │ │ │ ├── 20251001082054_auto-download.sql │ │ │ ├── 20251028060300_check_notifications_setting.sql │ │ │ ├── 20251029062024_aws-auth-name.sql │ │ │ ├── 20251031070515_environment-sort-priority.sql │ │ │ ├── 20251202080000_use-native-titlebar.sql │ │ │ ├── 20251209000000_client-certificates.sql │ │ │ ├── 20251219074602_default-workspace-headers.sql │ │ │ ├── 20251220000000_response-request-headers.sql │ │ │ ├── 20251221000000_http-response-events.sql │ │ │ ├── 20251221100000_request-content-length.sql │ │ │ ├── 20260104000000_hotkeys.sql │ │ │ ├── 20260111000000_dns-timing.sql │ │ │ ├── 20260112000000_dns-overrides.sql │ │ │ ├── 20260119045146_remove-default-workspace-headers.sql │ │ │ ├── 20260216000000_model-changes.sql │ │ │ ├── 20260217000000_remove-legacy-faker-plugin.sql │ │ │ └── 20260301000000_plugin-source-and-unique-directory.sql │ │ ├── package.json │ │ └── src/ │ │ ├── blob_manager.rs │ │ ├── connection_or_tx.rs │ │ ├── db_context.rs │ │ ├── error.rs │ │ ├── lib.rs │ │ ├── migrate.rs │ │ ├── models.rs │ │ ├── queries/ │ │ │ ├── any_request.rs │ │ │ ├── batch.rs │ │ │ ├── cookie_jars.rs │ │ │ ├── environments.rs │ │ │ ├── folders.rs │ │ │ ├── graphql_introspections.rs │ │ │ ├── grpc_connections.rs │ │ │ ├── grpc_events.rs │ │ │ ├── grpc_requests.rs │ │ │ ├── http_requests.rs │ │ │ ├── http_response_events.rs │ │ │ ├── http_responses.rs │ │ │ ├── key_values.rs │ │ │ ├── mod.rs │ │ │ ├── model_changes.rs │ │ │ ├── plugin_key_values.rs │ │ │ ├── plugins.rs │ │ │ ├── settings.rs │ │ │ ├── sync_states.rs │ │ │ ├── websocket_connections.rs │ │ │ ├── websocket_events.rs │ │ │ ├── websocket_requests.rs │ │ │ ├── workspace_metas.rs │ │ │ └── workspaces.rs │ │ ├── query_manager.rs │ │ ├── render.rs │ │ └── util.rs │ ├── yaak-plugins/ │ │ ├── Cargo.toml │ │ ├── bindings/ │ │ │ ├── gen_api.ts │ │ │ ├── gen_events.ts │ │ │ ├── gen_models.ts │ │ │ ├── gen_search.ts │ │ │ └── serde_json/ │ │ │ └── JsonValue.ts │ │ ├── index.ts │ │ ├── package.json │ │ └── src/ │ │ ├── api.rs │ │ ├── checksum.rs │ │ ├── error.rs │ │ ├── events.rs │ │ ├── install.rs │ │ ├── lib.rs │ │ ├── manager.rs │ │ ├── native_template_functions.rs │ │ ├── nodejs.rs │ │ ├── plugin_handle.rs │ │ ├── plugin_meta.rs │ │ ├── server_ws.rs │ │ ├── template_callback.rs │ │ └── util.rs │ ├── yaak-sse/ │ │ ├── Cargo.toml │ │ ├── bindings/ │ │ │ └── sse.ts │ │ ├── index.ts │ │ ├── package.json │ │ └── src/ │ │ ├── lib.rs │ │ └── sse.rs │ ├── yaak-sync/ │ │ ├── Cargo.toml │ │ ├── bindings/ │ │ │ ├── gen_models.ts │ │ │ ├── gen_sync.ts │ │ │ ├── gen_watch.ts │ │ │ └── git.ts │ │ ├── index.ts │ │ ├── package.json │ │ └── src/ │ │ ├── error.rs │ │ ├── lib.rs │ │ ├── models.rs │ │ ├── sync.rs │ │ └── watch.rs │ ├── yaak-templates/ │ │ ├── Cargo.toml │ │ ├── bindings/ │ │ │ └── parser.ts │ │ ├── build-wasm.cjs │ │ ├── index.ts │ │ ├── package.json │ │ ├── pkg/ │ │ │ ├── package.json │ │ │ ├── yaak_templates.d.ts │ │ │ ├── yaak_templates.js │ │ │ ├── yaak_templates_bg.js │ │ │ ├── yaak_templates_bg.wasm │ │ │ └── yaak_templates_bg.wasm.d.ts │ │ └── src/ │ │ ├── error.rs │ │ ├── escape.rs │ │ ├── format_json.rs │ │ ├── lib.rs │ │ ├── parser.rs │ │ ├── renderer.rs │ │ ├── strip_json_comments.rs │ │ └── wasm.rs │ ├── yaak-tls/ │ │ ├── Cargo.toml │ │ └── src/ │ │ ├── error.rs │ │ └── lib.rs │ └── yaak-ws/ │ ├── Cargo.toml │ ├── index.ts │ ├── package.json │ └── src/ │ ├── connect.rs │ ├── error.rs │ ├── lib.rs │ ├── manager.rs │ └── render.rs ├── crates-cli/ │ └── yaak-cli/ │ ├── Cargo.toml │ ├── README.md │ ├── src/ │ │ ├── cli.rs │ │ ├── commands/ │ │ │ ├── auth.rs │ │ │ ├── cookie_jar.rs │ │ │ ├── environment.rs │ │ │ ├── folder.rs │ │ │ ├── mod.rs │ │ │ ├── plugin.rs │ │ │ ├── request.rs │ │ │ ├── send.rs │ │ │ └── workspace.rs │ │ ├── context.rs │ │ ├── main.rs │ │ ├── plugin_events.rs │ │ ├── ui.rs │ │ ├── utils/ │ │ │ ├── confirm.rs │ │ │ ├── http.rs │ │ │ ├── json.rs │ │ │ ├── mod.rs │ │ │ ├── schema.rs │ │ │ └── workspace.rs │ │ ├── version.rs │ │ └── version_check.rs │ └── tests/ │ ├── common/ │ │ ├── http_server.rs │ │ └── mod.rs │ ├── environment_commands.rs │ ├── folder_commands.rs │ ├── request_commands.rs │ ├── send_commands.rs │ └── workspace_commands.rs ├── crates-tauri/ │ ├── yaak-app/ │ │ ├── .gitignore │ │ ├── Cargo.toml │ │ ├── bindings/ │ │ │ ├── gen_watch.ts │ │ │ ├── index.ts │ │ │ └── plugins_ext.ts │ │ ├── build.rs │ │ ├── capabilities/ │ │ │ └── default.json │ │ ├── icons/ │ │ │ ├── dev/ │ │ │ │ └── icon.icns │ │ │ └── release/ │ │ │ └── icon.icns │ │ ├── macos/ │ │ │ ├── entitlements.plist │ │ │ ├── entitlements.yaaknode.plist │ │ │ └── entitlements.yaakprotoc.plist │ │ ├── package.json │ │ ├── src/ │ │ │ ├── commands.rs │ │ │ ├── encoding.rs │ │ │ ├── error.rs │ │ │ ├── git_ext.rs │ │ │ ├── grpc.rs │ │ │ ├── history.rs │ │ │ ├── http_request.rs │ │ │ ├── import.rs │ │ │ ├── lib.rs │ │ │ ├── main.rs │ │ │ ├── models_ext.rs │ │ │ ├── notifications.rs │ │ │ ├── plugin_events.rs │ │ │ ├── plugins_ext.rs │ │ │ ├── render.rs │ │ │ ├── sync_ext.rs │ │ │ ├── updates.rs │ │ │ ├── uri_scheme.rs │ │ │ ├── window.rs │ │ │ ├── window_menu.rs │ │ │ └── ws_ext.rs │ │ ├── tauri.conf.json │ │ ├── tauri.development.conf.json │ │ ├── tauri.linux.conf.json │ │ ├── tauri.release.conf.json │ │ └── template.desktop │ ├── yaak-fonts/ │ │ ├── Cargo.toml │ │ ├── bindings/ │ │ │ └── gen_fonts.ts │ │ ├── build.rs │ │ ├── index.ts │ │ ├── package.json │ │ ├── permissions/ │ │ │ └── default.toml │ │ └── src/ │ │ ├── commands.rs │ │ ├── error.rs │ │ └── lib.rs │ ├── yaak-license/ │ │ ├── Cargo.toml │ │ ├── bindings/ │ │ │ ├── gen_models.ts │ │ │ ├── license.ts │ │ │ └── models.ts │ │ ├── build.rs │ │ ├── index.ts │ │ ├── package.json │ │ ├── permissions/ │ │ │ └── default.toml │ │ └── src/ │ │ ├── commands.rs │ │ ├── error.rs │ │ ├── lib.rs │ │ └── license.rs │ ├── yaak-mac-window/ │ │ ├── Cargo.toml │ │ ├── build.rs │ │ ├── index.ts │ │ ├── package.json │ │ ├── permissions/ │ │ │ └── default.toml │ │ └── src/ │ │ ├── commands.rs │ │ ├── lib.rs │ │ └── mac.rs │ └── yaak-tauri-utils/ │ ├── Cargo.toml │ └── src/ │ ├── lib.rs │ └── window.rs ├── flatpak/ │ ├── app.yaak.Yaak.metainfo.xml │ ├── fix-lockfile.mjs │ ├── generate-sources.sh │ └── update-manifest.sh ├── npm/ │ ├── cli/ │ │ ├── .gitignore │ │ ├── bin/ │ │ │ └── cli.js │ │ ├── common.js │ │ ├── index.js │ │ ├── install.js │ │ ├── package.json │ │ └── prepublish.js │ ├── cli-darwin-arm64/ │ │ ├── bin/ │ │ │ └── .gitkeep │ │ └── package.json │ ├── cli-darwin-x64/ │ │ ├── bin/ │ │ │ └── .gitkeep │ │ └── package.json │ ├── cli-linux-arm64/ │ │ ├── bin/ │ │ │ └── .gitkeep │ │ └── package.json │ ├── cli-linux-x64/ │ │ ├── bin/ │ │ │ └── .gitkeep │ │ └── package.json │ ├── cli-win32-arm64/ │ │ ├── bin/ │ │ │ └── .gitkeep │ │ └── package.json │ ├── cli-win32-x64/ │ │ ├── bin/ │ │ │ └── .gitkeep │ │ └── package.json │ └── prepare-publish.js ├── package.json ├── packages/ │ ├── common-lib/ │ │ ├── debounce.ts │ │ ├── formatSize.ts │ │ ├── index.ts │ │ ├── package.json │ │ └── templateFunction.ts │ ├── plugin-runtime/ │ │ ├── package.json │ │ ├── src/ │ │ │ ├── EventChannel.ts │ │ │ ├── PluginHandle.ts │ │ │ ├── PluginInstance.ts │ │ │ ├── common.ts │ │ │ ├── index.ts │ │ │ ├── interceptStdout.ts │ │ │ └── migrations.ts │ │ ├── tests/ │ │ │ └── common.test.ts │ │ └── tsconfig.json │ └── plugin-runtime-types/ │ ├── .gitignore │ ├── README.md │ ├── package.json │ ├── src/ │ │ ├── bindings/ │ │ │ ├── gen_api.ts │ │ │ ├── gen_events.ts │ │ │ ├── gen_models.ts │ │ │ ├── gen_search.ts │ │ │ └── serde_json/ │ │ │ └── JsonValue.ts │ │ ├── helpers.ts │ │ ├── index.ts │ │ ├── plugins/ │ │ │ ├── AuthenticationPlugin.ts │ │ │ ├── Context.ts │ │ │ ├── FilterPlugin.ts │ │ │ ├── FolderActionPlugin.ts │ │ │ ├── GrpcRequestActionPlugin.ts │ │ │ ├── HttpRequestActionPlugin.ts │ │ │ ├── ImporterPlugin.ts │ │ │ ├── TemplateFunctionPlugin.ts │ │ │ ├── ThemePlugin.ts │ │ │ ├── WebsocketRequestActionPlugin.ts │ │ │ ├── WorkspaceActionPlugin.ts │ │ │ └── index.ts │ │ └── themes/ │ │ └── index.ts │ └── tsconfig.json ├── plugins/ │ ├── .gitignore │ ├── action-copy-curl/ │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ └── index.ts │ │ ├── tests/ │ │ │ └── index.test.ts │ │ └── tsconfig.json │ ├── action-copy-grpcurl/ │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ └── index.ts │ │ ├── tests/ │ │ │ └── index.test.ts │ │ └── tsconfig.json │ ├── action-send-folder/ │ │ ├── package.json │ │ ├── src/ │ │ │ └── index.ts │ │ └── tsconfig.json │ ├── auth-apikey/ │ │ ├── package.json │ │ ├── src/ │ │ │ └── index.ts │ │ └── tsconfig.json │ ├── auth-aws/ │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ └── index.ts │ │ └── tsconfig.json │ ├── auth-basic/ │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ └── index.ts │ │ ├── tests/ │ │ │ └── index.test.ts │ │ └── tsconfig.json │ ├── auth-bearer/ │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ └── index.ts │ │ ├── tests/ │ │ │ └── index.test.ts │ │ └── tsconfig.json │ ├── auth-jwt/ │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ └── index.ts │ │ └── tsconfig.json │ ├── auth-ntlm/ │ │ ├── package.json │ │ ├── src/ │ │ │ ├── index.ts │ │ │ └── modules.d.ts │ │ ├── tests/ │ │ │ └── index.test.ts │ │ └── tsconfig.json │ ├── auth-oauth1/ │ │ ├── package.json │ │ ├── src/ │ │ │ └── index.ts │ │ └── tsconfig.json │ ├── auth-oauth2/ │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── callbackServer.ts │ │ │ ├── fetchAccessToken.ts │ │ │ ├── getOrRefreshAccessToken.ts │ │ │ ├── grants/ │ │ │ │ ├── authorizationCode.ts │ │ │ │ ├── clientCredentials.ts │ │ │ │ ├── implicit.ts │ │ │ │ └── password.ts │ │ │ ├── index.ts │ │ │ ├── store.ts │ │ │ └── util.ts │ │ ├── tests/ │ │ │ └── util.test.ts │ │ └── tsconfig.json │ ├── filter-jsonpath/ │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ └── index.ts │ │ └── tsconfig.json │ ├── filter-xpath/ │ │ ├── package.json │ │ ├── src/ │ │ │ └── index.ts │ │ └── tsconfig.json │ ├── importer-curl/ │ │ ├── package.json │ │ ├── src/ │ │ │ └── index.ts │ │ ├── tests/ │ │ │ └── index.test.ts │ │ └── tsconfig.json │ ├── importer-insomnia/ │ │ ├── package.json │ │ ├── src/ │ │ │ ├── common.ts │ │ │ ├── index.ts │ │ │ ├── v4.ts │ │ │ └── v5.ts │ │ ├── tests/ │ │ │ ├── fixtures/ │ │ │ │ ├── basic.input.json │ │ │ │ ├── basic.output.json │ │ │ │ ├── version-5-minimal.input.yaml │ │ │ │ ├── version-5-minimal.output.json │ │ │ │ ├── version-5.input.yaml │ │ │ │ └── version-5.output.json │ │ │ └── index.test.ts │ │ └── tsconfig.json │ ├── importer-openapi/ │ │ ├── package.json │ │ ├── src/ │ │ │ └── index.ts │ │ ├── tests/ │ │ │ ├── fixtures/ │ │ │ │ └── petstore.yaml │ │ │ └── index.test.ts │ │ └── tsconfig.json │ ├── importer-postman/ │ │ ├── package.json │ │ ├── src/ │ │ │ └── index.ts │ │ ├── tests/ │ │ │ ├── fixtures/ │ │ │ │ ├── auth.input.json │ │ │ │ ├── auth.output.json │ │ │ │ ├── nested.input.json │ │ │ │ ├── nested.output.json │ │ │ │ ├── params.input.json │ │ │ │ └── params.output.json │ │ │ └── index.test.ts │ │ └── tsconfig.json │ ├── importer-postman-environment/ │ │ ├── package.json │ │ ├── src/ │ │ │ └── index.ts │ │ ├── tests/ │ │ │ ├── fixtures/ │ │ │ │ ├── environment.input.json │ │ │ │ └── environment.output.json │ │ │ └── index.test.ts │ │ └── tsconfig.json │ ├── importer-yaak/ │ │ ├── package.json │ │ ├── src/ │ │ │ └── index.ts │ │ ├── tests/ │ │ │ └── index.test.ts │ │ └── tsconfig.json │ ├── template-function-1password/ │ │ ├── package.json │ │ ├── src/ │ │ │ └── index.ts │ │ └── tsconfig.json │ ├── template-function-cookie/ │ │ ├── package.json │ │ ├── src/ │ │ │ └── index.ts │ │ └── tsconfig.json │ ├── template-function-ctx/ │ │ ├── package.json │ │ ├── src/ │ │ │ └── index.ts │ │ └── tsconfig.json │ ├── template-function-encode/ │ │ ├── package.json │ │ ├── src/ │ │ │ └── index.ts │ │ └── tsconfig.json │ ├── template-function-fs/ │ │ ├── package.json │ │ ├── src/ │ │ │ └── index.ts │ │ └── tsconfig.json │ ├── template-function-hash/ │ │ ├── package.json │ │ ├── src/ │ │ │ └── index.ts │ │ └── tsconfig.json │ ├── template-function-json/ │ │ ├── package.json │ │ ├── src/ │ │ │ └── index.ts │ │ └── tsconfig.json │ ├── template-function-prompt/ │ │ ├── package.json │ │ ├── src/ │ │ │ └── index.ts │ │ └── tsconfig.json │ ├── template-function-random/ │ │ ├── package.json │ │ ├── src/ │ │ │ └── index.ts │ │ └── tsconfig.json │ ├── template-function-regex/ │ │ ├── package.json │ │ ├── src/ │ │ │ └── index.ts │ │ ├── tests/ │ │ │ └── regex.test.ts │ │ └── tsconfig.json │ ├── template-function-request/ │ │ ├── package.json │ │ ├── src/ │ │ │ └── index.ts │ │ └── tsconfig.json │ ├── template-function-response/ │ │ ├── package.json │ │ ├── src/ │ │ │ └── index.ts │ │ └── tsconfig.json │ ├── template-function-timestamp/ │ │ ├── package.json │ │ ├── src/ │ │ │ └── index.ts │ │ ├── tests/ │ │ │ └── formatDatetime.test.ts │ │ └── tsconfig.json │ ├── template-function-uuid/ │ │ ├── package.json │ │ ├── src/ │ │ │ └── index.ts │ │ └── tsconfig.json │ ├── template-function-xml/ │ │ ├── package.json │ │ ├── src/ │ │ │ └── index.ts │ │ └── tsconfig.json │ └── themes-yaak/ │ ├── package.json │ ├── src/ │ │ ├── index.ts │ │ └── themes/ │ │ ├── andromeda.ts │ │ ├── atom-one-dark.ts │ │ ├── ayu.ts │ │ ├── bluloco.ts │ │ ├── catppuccin.ts │ │ ├── cobalt2.ts │ │ ├── dracula.ts │ │ ├── everforest.ts │ │ ├── fleet.ts │ │ ├── github-dimmed.ts │ │ ├── github.ts │ │ ├── gruvbox.ts │ │ ├── high-contrast.ts │ │ ├── horizon.ts │ │ ├── hotdog-stand.ts │ │ ├── material-darker.ts │ │ ├── material-ocean.ts │ │ ├── material-palenight.ts │ │ ├── monokai-pro.ts │ │ ├── moonlight.ts │ │ ├── night-owl.ts │ │ ├── noctis.ts │ │ ├── nord.ts │ │ ├── one-dark-pro.ts │ │ ├── panda.ts │ │ ├── relaxing.ts │ │ ├── rose-pine.ts │ │ ├── shades-of-purple.ts │ │ ├── slack.ts │ │ ├── solarized.ts │ │ ├── synthwave-84.ts │ │ ├── tokyo-night.ts │ │ ├── triangle.ts │ │ ├── vitesse.ts │ │ └── winter-is-coming.ts │ └── tsconfig.json ├── plugins-external/ │ ├── .gitignore │ ├── faker/ │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ └── index.ts │ │ ├── tests/ │ │ │ ├── __snapshots__/ │ │ │ │ └── init.test.ts.snap │ │ │ └── init.test.ts │ │ └── tsconfig.json │ ├── httpsnippet/ │ │ ├── README.md │ │ ├── package.json │ │ └── src/ │ │ └── index.ts │ └── mcp-server/ │ ├── README.md │ ├── package.json │ ├── src/ │ │ ├── index.ts │ │ ├── server.ts │ │ ├── tools/ │ │ │ ├── folder.ts │ │ │ ├── helpers.ts │ │ │ ├── httpRequest.ts │ │ │ ├── schemas.ts │ │ │ ├── toast.ts │ │ │ ├── window.ts │ │ │ └── workspace.ts │ │ └── types.ts │ └── tsconfig.json ├── rustfmt.toml ├── scripts/ │ ├── .gitignore │ ├── create-migration.cjs │ ├── git-hooks/ │ │ └── post-checkout.mjs │ ├── install-wasm-pack.cjs │ ├── publish-core-plugins.cjs │ ├── replace-version.cjs │ ├── run-dev.mjs │ ├── run-workspaces-dev.mjs │ ├── vendor-node.cjs │ ├── vendor-plugins.cjs │ └── vendor-protoc.cjs ├── src-web/ │ ├── .gitignore │ ├── commands/ │ │ ├── commands.tsx │ │ ├── createEnvironment.tsx │ │ ├── deleteWebsocketConnections.ts │ │ ├── moveToWorkspace.tsx │ │ ├── openFolderSettings.tsx │ │ ├── openSettings.tsx │ │ ├── openWorkspaceFromSyncDir.tsx │ │ ├── openWorkspaceSettings.tsx │ │ └── switchWorkspace.tsx │ ├── components/ │ │ ├── BinaryFileEditor.tsx │ │ ├── CargoFeature.tsx │ │ ├── CloneGitRepositoryDialog.tsx │ │ ├── ColorIndicator.tsx │ │ ├── CommandPaletteDialog.tsx │ │ ├── ConfirmLargeRequestBody.tsx │ │ ├── ConfirmLargeResponse.tsx │ │ ├── ConfirmLargeResponseRequest.tsx │ │ ├── CookieDialog.tsx │ │ ├── CookieDropdown.tsx │ │ ├── CopyButton.tsx │ │ ├── CopyIconButton.tsx │ │ ├── CreateDropdown.tsx │ │ ├── CreateEnvironmentDialog.tsx │ │ ├── CreateWorkspaceDialog.tsx │ │ ├── Dialogs.tsx │ │ ├── DnsOverridesEditor.tsx │ │ ├── DropMarker.tsx │ │ ├── DynamicForm.tsx │ │ ├── EmptyStateText.tsx │ │ ├── EncryptionHelp.tsx │ │ ├── EnvironmentActionsDropdown.tsx │ │ ├── EnvironmentColorIndicator.tsx │ │ ├── EnvironmentColorPicker.tsx │ │ ├── EnvironmentEditDialog.tsx │ │ ├── EnvironmentEditor.tsx │ │ ├── EnvironmentSharableTooltip.tsx │ │ ├── ErrorBoundary.tsx │ │ ├── ExportDataDialog.tsx │ │ ├── FolderLayout.tsx │ │ ├── FolderSettingsDialog.tsx │ │ ├── FormMultipartEditor.tsx │ │ ├── FormUrlencodedEditor.tsx │ │ ├── GlobalHooks.tsx │ │ ├── GrpcConnectionLayout.tsx │ │ ├── GrpcEditor.tsx │ │ ├── GrpcProtoSelectionDialog.tsx │ │ ├── GrpcRequestPane.tsx │ │ ├── GrpcResponsePane.tsx │ │ ├── HeaderSize.tsx │ │ ├── HeadersEditor.tsx │ │ ├── HttpAuthenticationEditor.tsx │ │ ├── HttpRequestLayout.tsx │ │ ├── HttpRequestPane.tsx │ │ ├── HttpResponsePane.tsx │ │ ├── HttpResponseTimeline.tsx │ │ ├── ImportCurlButton.tsx │ │ ├── ImportDataDialog.tsx │ │ ├── IsDev.tsx │ │ ├── JsonBodyEditor.tsx │ │ ├── KeyboardShortcutsDialog.tsx │ │ ├── LicenseBadge.tsx │ │ ├── LocalImage.tsx │ │ ├── Markdown.tsx │ │ ├── MarkdownEditor.tsx │ │ ├── MoveToWorkspaceDialog.tsx │ │ ├── Overlay.tsx │ │ ├── Portal.tsx │ │ ├── Prose.css │ │ ├── Prose.tsx │ │ ├── RecentGrpcConnectionsDropdown.tsx │ │ ├── RecentHttpResponsesDropdown.tsx │ │ ├── RecentRequestsDropdown.tsx │ │ ├── RecentWebsocketConnectionsDropdown.tsx │ │ ├── RedirectToLatestWorkspace.tsx │ │ ├── RequestBodyViewer.tsx │ │ ├── RequestMethodDropdown.tsx │ │ ├── ResizeHandle.tsx │ │ ├── ResponseCookies.tsx │ │ ├── ResponseHeaders.tsx │ │ ├── ResponseInfo.tsx │ │ ├── RouteError.tsx │ │ ├── SelectFile.tsx │ │ ├── Settings/ │ │ │ ├── Settings.tsx │ │ │ ├── SettingsCertificates.tsx │ │ │ ├── SettingsGeneral.tsx │ │ │ ├── SettingsHotkeys.tsx │ │ │ ├── SettingsInterface.tsx │ │ │ ├── SettingsLicense.tsx │ │ │ ├── SettingsPlugins.tsx │ │ │ ├── SettingsProxy.tsx │ │ │ └── SettingsTheme.tsx │ │ ├── SettingsDropdown.tsx │ │ ├── Sidebar.tsx │ │ ├── SidebarActions.tsx │ │ ├── SwitchWorkspaceDialog.tsx │ │ ├── SyncToFilesystemSetting.tsx │ │ ├── TemplateFunctionDialog.tsx │ │ ├── Toasts.tsx │ │ ├── UrlBar.tsx │ │ ├── UrlParameterEditor.tsx │ │ ├── WebsocketRequestLayout.tsx │ │ ├── WebsocketRequestPane.tsx │ │ ├── WebsocketResponsePane.tsx │ │ ├── WindowControls.tsx │ │ ├── Workspace.tsx │ │ ├── WorkspaceActionsDropdown.tsx │ │ ├── WorkspaceEncryptionSetting.tsx │ │ ├── WorkspaceHeader.tsx │ │ ├── WorkspaceSettingsDialog.tsx │ │ ├── core/ │ │ │ ├── Alert.tsx │ │ │ ├── AutoScroller.tsx │ │ │ ├── Banner.tsx │ │ │ ├── BulkPairEditor.tsx │ │ │ ├── Button.tsx │ │ │ ├── ButtonInfiniteLoading.tsx │ │ │ ├── Checkbox.tsx │ │ │ ├── ColorPicker.tsx │ │ │ ├── Confirm.tsx │ │ │ ├── CountBadge.tsx │ │ │ ├── DetailsBanner.tsx │ │ │ ├── Dialog.tsx │ │ │ ├── DismissibleBanner.tsx │ │ │ ├── Dropdown.tsx │ │ │ ├── Editor/ │ │ │ │ ├── BetterMatchDecorator.ts │ │ │ │ ├── DiffViewer.css │ │ │ │ ├── DiffViewer.tsx │ │ │ │ ├── Editor.css │ │ │ │ ├── Editor.tsx │ │ │ │ ├── LazyEditor.tsx │ │ │ │ ├── extensions.ts │ │ │ │ ├── filter/ │ │ │ │ │ ├── extension.ts │ │ │ │ │ ├── filter.grammar │ │ │ │ │ ├── filter.ts │ │ │ │ │ ├── highlight.ts │ │ │ │ │ └── query.ts │ │ │ │ ├── genericCompletion.ts │ │ │ │ ├── hyperlink/ │ │ │ │ │ └── extension.ts │ │ │ │ ├── json-lint.ts │ │ │ │ ├── pairs/ │ │ │ │ │ ├── extension.ts │ │ │ │ │ ├── highlight.ts │ │ │ │ │ ├── pairs.grammar │ │ │ │ │ ├── pairs.terms.ts │ │ │ │ │ └── pairs.ts │ │ │ │ ├── searchMatchCount.ts │ │ │ │ ├── singleLine.ts │ │ │ │ ├── text/ │ │ │ │ │ ├── extension.ts │ │ │ │ │ ├── text.grammar │ │ │ │ │ ├── text.terms.ts │ │ │ │ │ └── text.ts │ │ │ │ ├── timeline/ │ │ │ │ │ ├── extension.ts │ │ │ │ │ ├── highlight.ts │ │ │ │ │ ├── timeline.grammar │ │ │ │ │ ├── timeline.terms.ts │ │ │ │ │ └── timeline.ts │ │ │ │ ├── twig/ │ │ │ │ │ ├── completion.ts │ │ │ │ │ ├── extension.ts │ │ │ │ │ ├── highlight.ts │ │ │ │ │ ├── pathParameters.ts │ │ │ │ │ ├── templateTags.ts │ │ │ │ │ ├── twig.grammar │ │ │ │ │ ├── twig.terms.ts │ │ │ │ │ ├── twig.test.ts │ │ │ │ │ ├── twig.ts │ │ │ │ │ └── util.ts │ │ │ │ └── url/ │ │ │ │ ├── completion.ts │ │ │ │ ├── extension.ts │ │ │ │ ├── highlight.ts │ │ │ │ ├── url.grammar │ │ │ │ ├── url.terms.ts │ │ │ │ └── url.ts │ │ │ ├── EventViewer.tsx │ │ │ ├── EventViewerRow.tsx │ │ │ ├── FormattedError.tsx │ │ │ ├── Heading.tsx │ │ │ ├── Hotkey.tsx │ │ │ ├── HotkeyLabel.tsx │ │ │ ├── HotkeyList.tsx │ │ │ ├── HttpMethodTag.tsx │ │ │ ├── HttpResponseDurationTag.tsx │ │ │ ├── HttpStatusTag.tsx │ │ │ ├── Icon.tsx │ │ │ ├── IconButton.tsx │ │ │ ├── IconTooltip.tsx │ │ │ ├── InlineCode.tsx │ │ │ ├── Input.tsx │ │ │ ├── JsonAttributeTree.tsx │ │ │ ├── KeyValueRow.tsx │ │ │ ├── Label.tsx │ │ │ ├── Link.tsx │ │ │ ├── LoadingIcon.tsx │ │ │ ├── PairEditor.tsx │ │ │ ├── PairEditor.util.tsx │ │ │ ├── PairOrBulkEditor.tsx │ │ │ ├── PillButton.tsx │ │ │ ├── PlainInput.tsx │ │ │ ├── Prompt.tsx │ │ │ ├── RadioCards.tsx │ │ │ ├── RadioDropdown.tsx │ │ │ ├── SegmentedControl.tsx │ │ │ ├── Select.tsx │ │ │ ├── Separator.tsx │ │ │ ├── SizeTag.tsx │ │ │ ├── SplitLayout.tsx │ │ │ ├── Stacks.tsx │ │ │ ├── Table.tsx │ │ │ ├── Tabs/ │ │ │ │ └── Tabs.tsx │ │ │ ├── Toast.tsx │ │ │ ├── Tooltip.tsx │ │ │ ├── WebsocketStatusTag.tsx │ │ │ └── tree/ │ │ │ ├── Tree.tsx │ │ │ ├── TreeDragOverlay.tsx │ │ │ ├── TreeDropMarker.tsx │ │ │ ├── TreeIndentGuide.tsx │ │ │ ├── TreeItem.tsx │ │ │ ├── TreeItemList.tsx │ │ │ ├── atoms.ts │ │ │ ├── common.ts │ │ │ └── useSelectableItems.ts │ │ ├── git/ │ │ │ ├── BranchSelectionDialog.tsx │ │ │ ├── GitCommitDialog.tsx │ │ │ ├── GitDropdown.tsx │ │ │ ├── GitRemotesDialog.tsx │ │ │ ├── HistoryDialog.tsx │ │ │ ├── callbacks.tsx │ │ │ ├── credentials.tsx │ │ │ ├── diverged.tsx │ │ │ ├── git-util.ts │ │ │ ├── showAddRemoteDialog.tsx │ │ │ └── uncommitted.tsx │ │ ├── graphql/ │ │ │ ├── GraphQLDocsExplorer.tsx │ │ │ ├── GraphQLEditor.tsx │ │ │ └── graphqlAtoms.ts │ │ └── responseViewers/ │ │ ├── AudioViewer.tsx │ │ ├── BinaryViewer.tsx │ │ ├── CsvViewer.tsx │ │ ├── EventStreamViewer.tsx │ │ ├── HTMLOrTextViewer.tsx │ │ ├── ImageViewer.tsx │ │ ├── JsonViewer.tsx │ │ ├── MultipartViewer.tsx │ │ ├── PdfViewer.css │ │ ├── PdfViewer.tsx │ │ ├── SvgViewer.tsx │ │ ├── TextViewer.tsx │ │ ├── VideoViewer.tsx │ │ └── WebPageViewer.tsx │ ├── font-size.ts │ ├── font.ts │ ├── hooks/ │ │ ├── useActiveCookieJar.ts │ │ ├── useActiveEnvironment.ts │ │ ├── useActiveEnvironmentVariables.ts │ │ ├── useActiveFolder.ts │ │ ├── useActiveFolderId.ts │ │ ├── useActiveRequest.ts │ │ ├── useActiveRequestId.ts │ │ ├── useActiveWorkspace.ts │ │ ├── useActiveWorkspaceChangedToast.tsx │ │ ├── useAllRequests.ts │ │ ├── useAuthTab.tsx │ │ ├── useCancelHttpResponse.ts │ │ ├── useCheckForUpdates.tsx │ │ ├── useClickOutside.ts │ │ ├── useContainerQuery.ts │ │ ├── useCopyHttpResponse.ts │ │ ├── useCreateCookieJar.ts │ │ ├── useCreateDropdownItems.tsx │ │ ├── useCreateWorkspace.tsx │ │ ├── useDebouncedState.ts │ │ ├── useDebouncedValue.ts │ │ ├── useDeleteGrpcConnections.ts │ │ ├── useDeleteHttpResponses.ts │ │ ├── useDeleteSendHistory.tsx │ │ ├── useEnvironmentValueVisibility.ts │ │ ├── useEnvironmentVariables.ts │ │ ├── useEnvironmentsBreakdown.ts │ │ ├── useEventViewerKeyboard.ts │ │ ├── useExportData.tsx │ │ ├── useFastMutation.ts │ │ ├── useFloatingSidebarHidden.ts │ │ ├── useFolderActions.ts │ │ ├── useFormatText.ts │ │ ├── useGrpc.ts │ │ ├── useGrpcProtoFiles.ts │ │ ├── useGrpcRequestActions.ts │ │ ├── useHeadersTab.tsx │ │ ├── useHotKey.ts │ │ ├── useHttpAuthentication.ts │ │ ├── useHttpAuthenticationConfig.ts │ │ ├── useHttpRequestActions.ts │ │ ├── useHttpRequestBody.ts │ │ ├── useHttpResponseEvents.ts │ │ ├── useImportCurl.ts │ │ ├── useInheritedAuthentication.ts │ │ ├── useInheritedHeaders.ts │ │ ├── useInstallPlugin.ts │ │ ├── useIntrospectGraphQL.ts │ │ ├── useIsEncryptionEnabled.ts │ │ ├── useIsFullscreen.ts │ │ ├── useKeyValue.ts │ │ ├── useKeyboardEvent.ts │ │ ├── useLatestGrpcConnection.ts │ │ ├── useLatestHttpResponse.ts │ │ ├── useListenToTauriEvent.ts │ │ ├── useModelAncestors.ts │ │ ├── useParentFolders.ts │ │ ├── usePinnedGrpcConnection.ts │ │ ├── usePinnedHttpResponse.ts │ │ ├── usePinnedWebsocketConnection.ts │ │ ├── usePluginInfo.ts │ │ ├── usePlugins.ts │ │ ├── usePortal.ts │ │ ├── usePreferredAppearance.ts │ │ ├── useRandomKey.ts │ │ ├── useRecentCookieJars.ts │ │ ├── useRecentEnvironments.ts │ │ ├── useRecentRequests.ts │ │ ├── useRecentWorkspaces.ts │ │ ├── useRenderTemplate.ts │ │ ├── useRequestEditor.tsx │ │ ├── useRequestUpdateKey.ts │ │ ├── useResolvedAppearance.ts │ │ ├── useResolvedTheme.ts │ │ ├── useResponseBodyEventSource.ts │ │ ├── useResponseBodyText.ts │ │ ├── useResponseViewMode.ts │ │ ├── useSaveResponse.tsx │ │ ├── useScrollIntoView.ts │ │ ├── useSendAnyHttpRequest.ts │ │ ├── useSendManyRequests.ts │ │ ├── useShouldFloatSidebar.ts │ │ ├── useSidebarHidden.ts │ │ ├── useSidebarItemCollapsed.ts │ │ ├── useSidebarWidth.ts │ │ ├── useStateWithDeps.ts │ │ ├── useStoplightsVisible.ts │ │ ├── useSyncFontSizeSetting.ts │ │ ├── useSyncWorkspaceChildModels.ts │ │ ├── useSyncWorkspaceRequestTitle.ts │ │ ├── useSyncZoomSetting.ts │ │ ├── useTemplateFunctionConfig.ts │ │ ├── useTemplateFunctions.tsx │ │ ├── useTemplateTokensToString.ts │ │ ├── useTimedBoolean.ts │ │ ├── useTimelineViewMode.ts │ │ ├── useToggle.ts │ │ ├── useToggleCommandPalette.tsx │ │ ├── useWebsocketRequestActions.ts │ │ ├── useWindowFocus.ts │ │ ├── useWorkspaceActions.ts │ │ └── useZoom.ts │ ├── index.html │ ├── init/ │ │ └── sync.ts │ ├── lib/ │ │ ├── alert.ts │ │ ├── appInfo.ts │ │ ├── atoms/ │ │ │ └── atomWithKVStorage.ts │ │ ├── atoms.ts │ │ ├── capitalize.ts │ │ ├── clamp.ts │ │ ├── color.ts │ │ ├── confirm.ts │ │ ├── constants.ts │ │ ├── contentType.ts │ │ ├── copy.ts │ │ ├── createRequestAndNavigate.tsx │ │ ├── data/ │ │ │ ├── charsets.ts │ │ │ ├── connections.ts │ │ │ ├── encodings.ts │ │ │ ├── headerNames.ts │ │ │ └── mimetypes.ts │ │ ├── defaultHeaders.ts │ │ ├── deleteModelWithConfirm.tsx │ │ ├── dialog.ts │ │ ├── diffYaml.ts │ │ ├── dnd.ts │ │ ├── duplicateRequestOrFolderAndNavigate.tsx │ │ ├── editEnvironment.tsx │ │ ├── encryption.ts │ │ ├── fireAndForget.ts │ │ ├── formatters.ts │ │ ├── generateId.ts │ │ ├── getNodeText.ts │ │ ├── importData.tsx │ │ ├── initGlobalListeners.tsx │ │ ├── jotai.ts │ │ ├── jsonComments.ts │ │ ├── keyValueStore.ts │ │ ├── markdown.ts │ │ ├── minPromiseMillis.ts │ │ ├── model_util.test.ts │ │ ├── model_util.ts │ │ ├── pluralize.ts │ │ ├── prepareImportQuerystring.ts │ │ ├── prompt-form.tsx │ │ ├── prompt.ts │ │ ├── queryClient.ts │ │ ├── renameModelWithPrompt.tsx │ │ ├── resolvedModelName.ts │ │ ├── responseBody.ts │ │ ├── reveal.ts │ │ ├── router.ts │ │ ├── scopes.ts │ │ ├── sendEphemeralRequest.ts │ │ ├── setWorkspaceSearchParams.ts │ │ ├── settings.ts │ │ ├── setupOrConfigureEncryption.tsx │ │ ├── showColorPicker.tsx │ │ ├── sleep.ts │ │ ├── tauri.ts │ │ ├── theme/ │ │ │ ├── appearance.ts │ │ │ ├── themes.ts │ │ │ ├── window.ts │ │ │ └── yaakColor.ts │ │ ├── toast.tsx │ │ └── truncate.ts │ ├── main.css │ ├── main.tsx │ ├── modules.d.ts │ ├── package.json │ ├── postcss.config.cjs │ ├── routeTree.gen.ts │ ├── routes/ │ │ ├── __root.tsx │ │ ├── index.tsx │ │ └── workspaces/ │ │ ├── $workspaceId/ │ │ │ ├── index.tsx │ │ │ ├── requests/ │ │ │ │ └── $requestId.tsx │ │ │ └── settings.tsx │ │ └── index.tsx │ ├── tailwind.config.cjs │ ├── theme.ts │ ├── tsconfig.json │ ├── tsconfig.node.json │ ├── tsr.config.json │ ├── vite-env.d.ts │ └── vite.config.ts ├── tsconfig.json └── vite.config.ts ================================================ FILE CONTENTS ================================================ ================================================ FILE: .claude/commands/release/generate-release-notes.md ================================================ --- description: Generate formatted release notes for Yaak releases allowed-tools: Bash(git tag:*) --- Generate formatted release notes for Yaak releases by analyzing git history and pull request descriptions. ## What to do 1. Identifies the version tag and previous version 2. Retrieves all commits between versions - If the version is a beta version, it retrieves commits between the beta version and previous beta version - If the version is a stable version, it retrieves commits between the stable version and the previous stable version 3. Fetches PR descriptions for linked issues to find: - Feedback URLs (feedback.yaak.app) - Additional context and descriptions - Installation links for plugins 4. Formats the release notes using the standard Yaak format: - Changelog badge at the top - Bulleted list of changes with PR links - Feedback links where available - Full changelog comparison link at the bottom ## Output Format The skill generates markdown-formatted release notes following this structure: ```markdown [![Changelog](https://img.shields.io/badge/Changelog-VERSION-blue)](https://yaak.app/changelog/VERSION) - Feature/fix description in by @username [#123](https://github.com/mountain-loop/yaak/pull/123) - [Linked feedback item](https://feedback.yaak.app/p/item) by @username in [#456](https://github.com/mountain-loop/yaak/pull/456) - A simple item that doesn't have a feedback or PR link **Full Changelog**: https://github.com/mountain-loop/yaak/compare/vPREV...vCURRENT ``` **IMPORTANT**: Always add a blank lines around the markdown code fence and output the markdown code block last **IMPORTANT**: PRs by `@gschier` should not mention the @username **IMPORTANT**: These are app release notes. Exclude CLI-only changes (commits prefixed with `cli:` or only touching `crates-cli/`) since the CLI has its own release process. ## After Generating Release Notes After outputting the release notes, ask the user if they would like to create a draft GitHub release with these notes. If they confirm, create the release using: ```bash gh release create --draft --prerelease --title "Release " --notes '' ``` **IMPORTANT**: The release title format is "Release XXXX" where XXXX is the version WITHOUT the `v` prefix. For example, tag `v2026.2.1-beta.1` gets title "Release 2026.2.1-beta.1". ================================================ FILE: .claude/rules.md ================================================ # Project Rules ## General Development - **NEVER** commit or push without explicit confirmation ## Build and Lint - **ALWAYS** run `npm run lint` after modifying TypeScript or JavaScript files - Run `npm run bootstrap` after changing plugin runtime or MCP server code ## Plugin System ### Backend Constraints - Always use `UpdateSource::Plugin` when calling database methods from plugin events - Never send timestamps (`createdAt`, `updatedAt`) from TypeScript - Rust backend controls these - Backend uses `NaiveDateTime` (no timezone) so avoid sending ISO timestamp strings ### MCP Server - MCP server has **no active window context** - cannot call `window.workspaceId()` - Get workspace ID from `workspaceCtx.yaak.workspace.list()` instead ## Rust Type Generation - Run `cargo test --package yaak-plugins` (and for other crates) to regenerate TypeScript bindings after modifying Rust event types ================================================ FILE: .claude-context.md ================================================ # Claude Context: Detaching Tauri from Yaak ## Goal Make Yaak runnable as a standalone CLI without Tauri as a dependency. The core Rust crates in `crates/` should be usable independently, while Tauri-specific code lives in `crates-tauri/`. ## Project Structure ``` crates/ # Core crates - should NOT depend on Tauri crates-tauri/ # Tauri-specific crates (yaak-app, yaak-tauri-utils, etc.) crates-cli/ # CLI crate (yaak-cli) ``` ## Completed Work ### 1. Folder Restructure - Moved Tauri-dependent app code to `crates-tauri/yaak-app/` - Created `crates-tauri/yaak-tauri-utils/` for shared Tauri utilities (window traits, api_client, error handling) - Created `crates-cli/yaak-cli/` for the standalone CLI ### 2. Decoupled Crates (no longer depend on Tauri) - **yaak-models**: Uses `init_standalone()` pattern for CLI database access - **yaak-http**: Removed Tauri plugin, HttpConnectionManager initialized in yaak-app setup - **yaak-common**: Only contains Tauri-free utilities (serde, platform) - **yaak-crypto**: Removed Tauri plugin, EncryptionManager initialized in yaak-app setup, commands moved to yaak-app - **yaak-grpc**: Replaced AppHandle with GrpcConfig struct, uses tokio::process::Command instead of Tauri sidecar ### 3. CLI Implementation - Basic CLI at `crates-cli/yaak-cli/src/main.rs` - Commands: workspaces, requests, send (by ID), get (ad-hoc URL), create - Uses same database as Tauri app via `yaak_models::init_standalone()` ## Remaining Work ### Crates Still Depending on Tauri (in `crates/`) 1. **yaak-git** (3 files) - Moderate complexity 2. **yaak-plugins** (13 files) - **Hardest** - deeply integrated with Tauri for plugin-to-window communication 3. **yaak-sync** (4 files) - Moderate complexity 4. **yaak-ws** (5 files) - Moderate complexity ### Pattern for Decoupling 1. Remove Tauri plugin `init()` function from the crate 2. Move commands to `yaak-app/src/commands.rs` or keep inline in `lib.rs` 3. Move extension traits (e.g., `SomethingManagerExt`) to yaak-app or yaak-tauri-utils 4. Initialize managers in yaak-app's `.setup()` block 5. Remove `tauri` from Cargo.toml dependencies 6. Update `crates-tauri/yaak-app/capabilities/default.json` to remove the plugin permission 7. Replace `tauri::async_runtime::block_on` with `tokio::runtime::Handle::current().block_on()` ## Key Files - `crates-tauri/yaak-app/src/lib.rs` - Main Tauri app, setup block initializes managers - `crates-tauri/yaak-app/src/commands.rs` - Migrated Tauri commands - `crates-tauri/yaak-app/src/models_ext.rs` - Database plugin and extension traits - `crates-tauri/yaak-tauri-utils/src/window.rs` - WorkspaceWindowTrait for window state - `crates/yaak-models/src/lib.rs` - Contains `init_standalone()` for CLI usage ## Git Branch Working on `detach-tauri` branch. ## Recent Commits ``` c40cff40 Remove Tauri dependencies from yaak-crypto and yaak-grpc df495f1d Move Tauri utilities from yaak-common to yaak-tauri-utils 481e0273 Remove Tauri dependencies from yaak-http and yaak-common 10568ac3 Add HTTP request sending to yaak-cli bcb7d600 Add yaak-cli stub with basic database access e718a5f1 Refactor models_ext to use init_standalone from yaak-models ``` ## Testing - Run `cargo check -p ` to verify a crate builds without Tauri - Run `npm run app-dev` to test the Tauri app still works - Run `cargo run -p yaak-cli -- --help` to test the CLI ================================================ FILE: .codex/skills/release-generate-release-notes/SKILL.md ================================================ --- name: release-generate-release-notes description: Generate Yaak release notes from git history and PR metadata, including feedback links and full changelog compare links. Use when asked to run or replace the old Claude generate-release-notes command. --- # Generate Release Notes Generate formatted markdown release notes for a Yaak tag. ## Workflow 1. Determine target tag. 2. Determine previous comparable tag: - Beta tag: compare against previous beta (if the root version is the same) or stable tag. - Stable tag: compare against previous stable tag. 3. Collect commits in range: - `git log --oneline ..` 4. For linked PRs, fetch metadata: - `gh pr view --json number,title,body,author,url` 5. Extract useful details: - Feedback URLs (`feedback.yaak.app`) - Plugin install links or other notable context 6. Format notes using Yaak style: - Changelog badge at top - Bulleted items with PR links where available - Feedback links where available - Full changelog compare link at bottom ## Formatting Rules - Wrap final notes in a markdown code fence. - Keep a blank line before and after the code fence. - Output the markdown code block last. - Do not append `by @gschier` for PRs authored by `@gschier`. - These are app release notes. Exclude CLI-only changes (commits prefixed with `cli:` or only touching `crates-cli/`) since the CLI has its own release process. ## Release Creation Prompt After producing notes, ask whether to create a draft GitHub release. If confirmed and release does not yet exist, run: `gh release create --draft --prerelease --title "Release " --notes ''` If a draft release for the tag already exists, update it instead: `gh release edit --title "Release " --notes-file ` Use title format `Release `, e.g. `v2026.2.1-beta.1` -> `Release 2026.2.1-beta.1`. ================================================ FILE: .gitattributes ================================================ crates-tauri/yaak-app/vendored/**/* linguist-generated=true crates-tauri/yaak-app/gen/schemas/**/* linguist-generated=true **/bindings/* linguist-generated=true crates/yaak-templates/pkg/* linguist-generated=true # Ensure consistent line endings for test files that check exact content crates/yaak-http/tests/test.txt text eol=lf ================================================ FILE: .github/FUNDING.yml ================================================ # These are supported funding model platforms github: gschier ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.md ================================================ --- 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. ================================================ FILE: .github/ISSUE_TEMPLATE/config.yml ================================================ 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." ================================================ FILE: .github/pull_request_template.md ================================================ ## Summary ## Submission - [ ] This PR is a bug fix or small-scope improvement. - [ ] If this PR is not a bug fix or small-scope improvement, I linked an approved feedback item below. - [ ] I have read and followed [`CONTRIBUTING.md`](CONTRIBUTING.md). - [ ] I tested this change locally. - [ ] I added or updated tests when reasonable. Approved feedback item (required if not a bug fix or small-scope improvement): ## Related ================================================ FILE: .github/workflows/ci.yml ================================================ on: pull_request: push: branches: - main name: Lint and Test permissions: contents: read jobs: test: name: Lint/Test runs-on: macos-latest steps: - uses: actions/checkout@v4 - uses: voidzero-dev/setup-vp@v1 with: node-version: "24" cache: true - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 with: shared-key: ci cache-on-failure: true - run: vp install - run: npm run bootstrap - run: npm run lint - name: Run JS Tests run: vp test - name: Run Rust Tests run: cargo test --all ================================================ FILE: .github/workflows/claude.yml ================================================ name: Claude Code on: issue_comment: types: [created] pull_request_review_comment: types: [created] issues: types: [opened, assigned] pull_request_review: types: [submitted] jobs: claude: if: | (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) runs-on: ubuntu-latest permissions: contents: read pull-requests: read issues: read id-token: write actions: read # Required for Claude to read CI results on PRs steps: - name: Checkout repository uses: actions/checkout@v4 with: fetch-depth: 1 - name: Run Claude Code id: claude uses: anthropics/claude-code-action@v1 with: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} # This is an optional setting that allows Claude to read CI results on PRs additional_permissions: | actions: read # Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it. # prompt: 'Update the pull request description to include a summary of changes.' # Optional: Add claude_args to customize behavior and configuration # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md # or https://code.claude.com/docs/en/cli-reference for available options # claude_args: '--allowed-tools Bash(gh pr:*)' ================================================ FILE: .github/workflows/flathub.yml ================================================ name: Update Flathub on: release: types: [published] permissions: contents: read jobs: update-flathub: name: Update Flathub manifest runs-on: ubuntu-latest # Only run for stable releases (skip betas/pre-releases) if: ${{ !github.event.release.prerelease }} steps: - name: Checkout app repo uses: actions/checkout@v4 - name: Checkout Flathub repo uses: actions/checkout@v4 with: repository: flathub/app.yaak.Yaak token: ${{ secrets.FLATHUB_TOKEN }} path: flathub-repo - name: Set up Python uses: actions/setup-python@v5 with: python-version: "3.12" - name: Set up Node.js uses: actions/setup-node@v4 with: node-version: "22" - name: Install source generators run: | pip install flatpak-node-generator tomlkit aiohttp git clone --depth 1 https://github.com/flatpak/flatpak-builder-tools flatpak/flatpak-builder-tools - name: Run update-manifest.sh run: bash flatpak/update-manifest.sh "${{ github.event.release.tag_name }}" flathub-repo - name: Commit and push to Flathub working-directory: flathub-repo run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" git add -A git diff --cached --quiet && echo "No changes to commit" && exit 0 git commit -m "Update to ${{ github.event.release.tag_name }}" git push ================================================ FILE: .github/workflows/release-api-npm.yml ================================================ name: Release API to NPM on: push: tags: [yaak-api-*] workflow_dispatch: inputs: version: description: API version to publish (for example 0.9.0 or v0.9.0) required: true type: string permissions: contents: read jobs: publish-npm: name: Publish @yaakapp/api runs-on: ubuntu-latest permissions: contents: read id-token: write steps: - name: Checkout uses: actions/checkout@v4 - name: Setup Node uses: actions/setup-node@v4 with: node-version: lts/* registry-url: https://registry.npmjs.org - name: Install dependencies run: npm ci - name: Set @yaakapp/api version shell: bash env: WORKFLOW_VERSION: ${{ inputs.version }} run: | set -euo pipefail if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then VERSION="$WORKFLOW_VERSION" else VERSION="${GITHUB_REF_NAME#yaak-api-}" fi VERSION="${VERSION#v}" echo "Preparing @yaakapp/api version: $VERSION" cd packages/plugin-runtime-types npm version "$VERSION" --no-git-tag-version --allow-same-version - name: Build @yaakapp/api working-directory: packages/plugin-runtime-types run: npm run build - name: Publish @yaakapp/api working-directory: packages/plugin-runtime-types run: npm publish --provenance --access public ================================================ FILE: .github/workflows/release-app.yml ================================================ name: Release App 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" os: "macos" targets: "aarch64-apple-darwin" - platform: "macos-latest" # for Intel-based Macs. args: "--target x86_64-apple-darwin" yaak_arch: "x64" os: "macos" targets: "x86_64-apple-darwin" - platform: "ubuntu-22.04" args: "" yaak_arch: "x64" os: "ubuntu" targets: "" - platform: "ubuntu-22.04-arm" args: "" yaak_arch: "arm64" os: "ubuntu" targets: "" - platform: "windows-latest" args: "" yaak_arch: "x64" os: "windows" targets: "" # Windows ARM64 - platform: "windows-latest" args: "--target aarch64-pc-windows-msvc" yaak_arch: "arm64" os: "windows" targets: "aarch64-pc-windows-msvc" runs-on: ${{ matrix.platform }} timeout-minutes: 40 steps: - name: Checkout yaakapp/app uses: actions/checkout@v4 - name: Setup Vite+ uses: voidzero-dev/setup-vp@v1 with: node-version: "24" cache: true - name: install Rust stable uses: dtolnay/rust-toolchain@stable with: targets: ${{ matrix.targets }} - uses: Swatinem/rust-cache@v2 with: shared-key: ci cache-on-failure: true - name: install dependencies (Linux only) if: matrix.os == 'ubuntu' run: | sudo apt-get update sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf xdg-utils - name: Install Protoc for plugin-runtime uses: arduino/setup-protoc@v3 with: repo-token: ${{ secrets.GITHUB_TOKEN }} - name: Install trusted-signing-cli (Windows only) if: matrix.os == 'windows' shell: pwsh run: | $ErrorActionPreference = 'Stop' $dir = "$env:USERPROFILE\trusted-signing" New-Item -ItemType Directory -Force -Path $dir | Out-Null $url = "https://github.com/Levminer/trusted-signing-cli/releases/download/0.8.0/trusted-signing-cli.exe" $exe = Join-Path $dir "trusted-signing-cli.exe" Invoke-WebRequest -Uri $url -OutFile $exe echo $dir >> $env:GITHUB_PATH & $exe --version - run: vp install - run: npm run bootstrap env: YAAK_TARGET_ARCH: ${{ matrix.yaak_arch }} - run: npm run lint - name: Run JS Tests run: vp test - name: Run Rust Tests run: cargo test --all --exclude yaak-cli - name: Set version run: npm run replace-version env: YAAK_VERSION: ${{ github.ref_name }} - name: Sign vendored binaries (macOS only) if: matrix.os == 'macos' env: APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} run: | # Create keychain KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH security set-keychain-settings -lut 21600 $KEYCHAIN_PATH security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH # Import certificate echo "$APPLE_CERTIFICATE" | base64 --decode > certificate.p12 security import certificate.p12 -P "$APPLE_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH security list-keychain -d user -s $KEYCHAIN_PATH # Sign vendored binaries with hardened runtime and their specific entitlements codesign --force --options runtime --entitlements crates-tauri/yaak-app/macos/entitlements.yaakprotoc.plist --sign "$APPLE_SIGNING_IDENTITY" crates-tauri/yaak-app/vendored/protoc/yaakprotoc || true codesign --force --options runtime --entitlements crates-tauri/yaak-app/macos/entitlements.yaaknode.plist --sign "$APPLE_SIGNING_IDENTITY" crates-tauri/yaak-app/vendored/node/yaaknode || true - 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.os == 'macos' && secrets.APPLE_CERTIFICATE }} APPLE_CERTIFICATE_PASSWORD: ${{ matrix.os == 'macos' && secrets.APPLE_CERTIFICATE_PASSWORD }} APPLE_ID: ${{ matrix.os == 'macos' && secrets.APPLE_ID }} APPLE_PASSWORD: ${{ matrix.os == 'macos' && secrets.APPLE_PASSWORD }} APPLE_SIGNING_IDENTITY: ${{ matrix.os == 'macos' && secrets.APPLE_SIGNING_IDENTITY }} APPLE_TEAM_ID: ${{ matrix.os == 'macos' && secrets.APPLE_TEAM_ID }} # Windows signing stuff AZURE_CLIENT_ID: ${{ matrix.os == 'windows' && secrets.AZURE_CLIENT_ID }} AZURE_CLIENT_SECRET: ${{ matrix.os == 'windows' && secrets.AZURE_CLIENT_SECRET }} AZURE_TENANT_ID: ${{ matrix.os == 'windows' && secrets.AZURE_TENANT_ID }} with: tagName: "v__VERSION__" releaseName: "Release __VERSION__" releaseBody: "[Changelog __VERSION__](https://yaak.app/blog/__VERSION__)" releaseDraft: true prerelease: true args: "${{ matrix.args }} --config ./crates-tauri/yaak-app/tauri.release.conf.json" # Build a per-machine NSIS installer for enterprise deployment (PDQ, SCCM, Intune) - name: Build and upload machine-wide installer (Windows only) if: matrix.os == 'windows' shell: pwsh env: YAAK_TARGET_ARCH: ${{ matrix.yaak_arch }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }} run: | Get-ChildItem -Recurse -Path target -File -Filter "*.exe.sig" | Remove-Item -Force npx tauri bundle ${{ matrix.args }} --bundles nsis --config ./crates-tauri/yaak-app/tauri.release.conf.json --config '{"bundle":{"createUpdaterArtifacts":true,"windows":{"nsis":{"installMode":"perMachine"}}}}' $setup = Get-ChildItem -Recurse -Path target -Filter "*setup*.exe" | Select-Object -First 1 $setupSig = "$($setup.FullName).sig" $dest = $setup.FullName -replace '-setup\.exe$', '-setup-machine.exe' $destSig = "$dest.sig" Copy-Item $setup.FullName $dest Copy-Item $setupSig $destSig gh release upload "${{ github.ref_name }}" "$dest" --clobber gh release upload "${{ github.ref_name }}" "$destSig" --clobber ================================================ FILE: .github/workflows/release-cli-npm.yml ================================================ name: Release CLI to NPM on: push: tags: [yaak-cli-*] workflow_dispatch: inputs: version: description: CLI version to publish (for example 0.4.0 or v0.4.0) required: true type: string permissions: contents: read jobs: prepare-vendored-assets: name: Prepare vendored plugin assets runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 - name: Setup Node uses: actions/setup-node@v4 with: node-version: lts/* - name: Install Rust stable uses: dtolnay/rust-toolchain@stable - name: Install dependencies run: npm ci - name: Build plugin assets env: SKIP_WASM_BUILD: "1" run: | npm run build npm run vendor:vendor-plugins - name: Upload vendored assets uses: actions/upload-artifact@v4 with: name: vendored-assets path: | crates-tauri/yaak-app/vendored/plugin-runtime/index.cjs crates-tauri/yaak-app/vendored/plugins if-no-files-found: error build-binaries: name: Build ${{ matrix.pkg }} needs: prepare-vendored-assets runs-on: ${{ matrix.runner }} strategy: fail-fast: false matrix: include: - pkg: cli-darwin-arm64 runner: macos-latest target: aarch64-apple-darwin binary: yaak - pkg: cli-darwin-x64 runner: macos-latest target: x86_64-apple-darwin binary: yaak - pkg: cli-linux-arm64 runner: ubuntu-22.04-arm target: aarch64-unknown-linux-gnu binary: yaak - pkg: cli-linux-x64 runner: ubuntu-22.04 target: x86_64-unknown-linux-gnu binary: yaak - pkg: cli-win32-arm64 runner: windows-latest target: aarch64-pc-windows-msvc binary: yaak.exe - pkg: cli-win32-x64 runner: windows-latest target: x86_64-pc-windows-msvc binary: yaak.exe steps: - name: Checkout uses: actions/checkout@v4 - name: Install Rust stable uses: dtolnay/rust-toolchain@stable with: targets: ${{ matrix.target }} - name: Restore Rust cache uses: Swatinem/rust-cache@v2 with: shared-key: release-cli-npm cache-on-failure: true - name: Install Linux build dependencies if: startsWith(matrix.runner, 'ubuntu') run: | sudo apt-get update sudo apt-get install -y pkg-config libdbus-1-dev - name: Download vendored assets uses: actions/download-artifact@v4 with: name: vendored-assets path: crates-tauri/yaak-app/vendored - name: Set CLI build version shell: bash env: WORKFLOW_VERSION: ${{ inputs.version }} run: | set -euo pipefail if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then VERSION="$WORKFLOW_VERSION" else VERSION="${GITHUB_REF_NAME#yaak-cli-}" fi VERSION="${VERSION#v}" echo "Building yaak version: $VERSION" echo "YAAK_CLI_VERSION=$VERSION" >> "$GITHUB_ENV" - name: Build yaak run: cargo build --locked --release -p yaak-cli --bin yaak --target ${{ matrix.target }} - name: Stage binary artifact shell: bash run: | set -euo pipefail mkdir -p "npm/dist/${{ matrix.pkg }}" cp "target/${{ matrix.target }}/release/${{ matrix.binary }}" "npm/dist/${{ matrix.pkg }}/${{ matrix.binary }}" - name: Upload binary artifact uses: actions/upload-artifact@v4 with: name: ${{ matrix.pkg }} path: npm/dist/${{ matrix.pkg }}/${{ matrix.binary }} if-no-files-found: error publish-npm: name: Publish @yaakapp/cli packages needs: build-binaries runs-on: ubuntu-latest permissions: contents: read id-token: write steps: - name: Checkout uses: actions/checkout@v4 - name: Setup Node uses: actions/setup-node@v4 with: node-version: lts/* registry-url: https://registry.npmjs.org - name: Download binary artifacts uses: actions/download-artifact@v4 with: pattern: cli-* path: npm/dist merge-multiple: false - name: Prepare npm packages shell: bash env: WORKFLOW_VERSION: ${{ inputs.version }} run: | set -euo pipefail if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then VERSION="$WORKFLOW_VERSION" else VERSION="${GITHUB_REF_NAME#yaak-cli-}" fi VERSION="${VERSION#v}" if [[ "$VERSION" == *-* ]]; then PRERELEASE="${VERSION#*-}" NPM_TAG="${PRERELEASE%%.*}" else NPM_TAG="latest" fi echo "Preparing CLI npm packages for version: $VERSION" echo "Publishing with npm dist-tag: $NPM_TAG" echo "NPM_TAG=$NPM_TAG" >> "$GITHUB_ENV" YAAK_CLI_VERSION="$VERSION" node npm/prepare-publish.js - name: Publish @yaakapp/cli-darwin-arm64 run: npm publish --provenance --access public --tag "$NPM_TAG" working-directory: npm/cli-darwin-arm64 - name: Publish @yaakapp/cli-darwin-x64 run: npm publish --provenance --access public --tag "$NPM_TAG" working-directory: npm/cli-darwin-x64 - name: Publish @yaakapp/cli-linux-arm64 run: npm publish --provenance --access public --tag "$NPM_TAG" working-directory: npm/cli-linux-arm64 - name: Publish @yaakapp/cli-linux-x64 run: npm publish --provenance --access public --tag "$NPM_TAG" working-directory: npm/cli-linux-x64 - name: Publish @yaakapp/cli-win32-arm64 run: npm publish --provenance --access public --tag "$NPM_TAG" working-directory: npm/cli-win32-arm64 - name: Publish @yaakapp/cli-win32-x64 run: npm publish --provenance --access public --tag "$NPM_TAG" working-directory: npm/cli-win32-x64 - name: Publish @yaakapp/cli run: npm publish --provenance --access public --tag "$NPM_TAG" working-directory: npm/cli ================================================ FILE: .github/workflows/sponsors.yml ================================================ name: Generate Sponsors README on: workflow_dispatch: schedule: - cron: 30 15 * * 0-6 permissions: contents: write jobs: deploy: runs-on: ubuntu-latest steps: - name: Checkout 🛎️ uses: actions/checkout@v2 - name: Generate Sponsors uses: JamesIves/github-sponsors-readme-action@v1 with: token: ${{ secrets.SPONSORS_PAT }} file: "README.md" maximum: 1999 template: 'User avatar: {{{ login }}}  ' active-only: false include-private: true marker: "sponsors-base" - name: Generate Sponsors uses: JamesIves/github-sponsors-readme-action@v1 with: token: ${{ secrets.SPONSORS_PAT }} file: "README.md" minimum: 2000 template: 'User avatar: {{{ login }}}  ' active-only: false include-private: true marker: "sponsors-premium" # ⚠️ Note: You can use any deployment step here to automatically push the README # changes back to your branch. - name: Commit Changes uses: JamesIves/github-pages-deploy-action@v4 with: branch: main force: false folder: "." ================================================ FILE: .gitignore ================================================ # Logs logs *.log npm-debug.log* yarn-debug.log* yarn-error.log* pnpm-debug.log* lerna-debug.log* node_modules dist dist-ssr *.local # Editor directories and files .vscode/* !.vscode/extensions.json !.vscode/settings.json !.vscode/launch.json .idea .DS_Store *.suo *.ntvs* *.njsproj *.sln *.sw? .eslintcache out *.sqlite *.sqlite-* .cargo .tmp tmp .zed codebook.toml target # Per-worktree Tauri config (generated by post-checkout hook) crates-tauri/yaak-app/tauri.worktree.conf.json # Tauri auto-generated permission files **/permissions/autogenerated **/permissions/schemas # Flatpak build artifacts flatpak-repo/ .flatpak-builder/ flatpak/flatpak-builder-tools/ flatpak/cargo-sources.json flatpak/node-sources.json # Local Codex desktop env state .codex/environments/environment.toml # Claude Code local settings .claude/settings.local.json ================================================ FILE: .node-version ================================================ 24.14.0 ================================================ FILE: .npmrc ================================================ # vite-plugin-wasm has not yet declared Vite 8 in its peerDependencies legacy-peer-deps=true ================================================ FILE: .nvmrc ================================================ 20 ================================================ FILE: .oxfmtignore ================================================ **/bindings/** crates/yaak-templates/pkg/** ================================================ FILE: .vite-hooks/post-checkout ================================================ node scripts/git-hooks/post-checkout.mjs "$@" ================================================ FILE: .vite-hooks/pre-commit ================================================ vp lint ================================================ FILE: .vscode/extensions.json ================================================ { "recommendations": [ "rust-lang.rust-analyzer", "bradlc.vscode-tailwindcss", "VoidZero.vite-plus-extension-pack" ] } ================================================ FILE: .vscode/launch.json ================================================ { "version": "0.2.0", "configurations": [ { "type": "node", "request": "launch", "name": "Dev App", "runtimeExecutable": "npm", "runtimeArgs": ["run", "start"] }, { "type": "node", "request": "launch", "name": "Build App", "runtimeExecutable": "npm", "runtimeArgs": ["run", "start"] }, { "type": "node", "request": "launch", "name": "Bootstrap", "runtimeExecutable": "npm", "runtimeArgs": ["run", "bootstrap"] } ] } ================================================ FILE: .vscode/settings.json ================================================ { "editor.defaultFormatter": "oxc.oxc-vscode", "editor.formatOnSave": true, "editor.formatOnSaveMode": "file", "editor.codeActionsOnSave": { "source.fixAll.oxc": "explicit" } } ================================================ FILE: AGENTS.md ================================================ - Tag safety: app releases use `v*` tags and CLI releases use `yaak-cli-*` tags; always confirm which one is requested before retagging. - Do not commit, push, or tag without explicit approval ================================================ FILE: CONTRIBUTING.md ================================================ # Contributing to Yaak Yaak accepts community pull requests for: - Bug fixes - Small-scope improvements directly tied to existing behavior Pull requests that introduce broad new features, major redesigns, or large refactors are out of scope unless explicitly approved first. ## Approval for Non-Bugfix Changes If your PR is not a bug fix or small-scope improvement, include a link to the approved [feedback item](https://yaak.app/feedback) where contribution approval was explicitly stated. ## Development Setup For local setup and development workflows, see [`DEVELOPMENT.md`](DEVELOPMENT.md). ================================================ FILE: Cargo.toml ================================================ [workspace] resolver = "2" members = [ "crates/yaak", # Shared crates (no Tauri dependency) "crates/yaak-core", "crates/yaak-common", "crates/yaak-crypto", "crates/yaak-git", "crates/yaak-grpc", "crates/yaak-http", "crates/yaak-models", "crates/yaak-plugins", "crates/yaak-sse", "crates/yaak-sync", "crates/yaak-templates", "crates/yaak-tls", "crates/yaak-ws", "crates/yaak-api", # CLI crates "crates-cli/yaak-cli", # Tauri-specific crates "crates-tauri/yaak-app", "crates-tauri/yaak-fonts", "crates-tauri/yaak-license", "crates-tauri/yaak-mac-window", "crates-tauri/yaak-tauri-utils", ] [workspace.dependencies] chrono = "0.4.42" hex = "0.4.3" keyring = "3.6.3" log = "0.4.29" reqwest = "0.12.20" rustls = { version = "0.23.34", default-features = false } rustls-platform-verifier = "0.6.2" schemars = { version = "0.8.22", features = ["chrono"] } serde = "1.0.228" serde_json = "1.0.145" sha2 = "0.10.9" tauri = "2.9.5" tauri-plugin = "2.5.2" tauri-plugin-dialog = "2.4.2" tauri-plugin-shell = "2.3.3" thiserror = "2.0.17" tokio = "1.48.0" ts-rs = "11.1.0" # Internal crates - shared yaak-core = { path = "crates/yaak-core" } yaak = { path = "crates/yaak" } yaak-common = { path = "crates/yaak-common" } yaak-crypto = { path = "crates/yaak-crypto" } yaak-git = { path = "crates/yaak-git" } yaak-grpc = { path = "crates/yaak-grpc" } yaak-http = { path = "crates/yaak-http" } yaak-models = { path = "crates/yaak-models" } yaak-plugins = { path = "crates/yaak-plugins" } yaak-sse = { path = "crates/yaak-sse" } yaak-sync = { path = "crates/yaak-sync" } yaak-templates = { path = "crates/yaak-templates" } yaak-tls = { path = "crates/yaak-tls" } yaak-ws = { path = "crates/yaak-ws" } yaak-api = { path = "crates/yaak-api" } # Internal crates - Tauri-specific yaak-fonts = { path = "crates-tauri/yaak-fonts" } yaak-license = { path = "crates-tauri/yaak-license" } yaak-mac-window = { path = "crates-tauri/yaak-mac-window" } yaak-tauri-utils = { path = "crates-tauri/yaak-tauri-utils" } [profile.release] strip = false ================================================ FILE: DEVELOPMENT.md ================================================ # 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) (v24+) - [Rust](https://www.rust-lang.org/tools/install) - [Vite+](https://vite.dev/guide/vite-plus) (`vp` CLI) Check the installations with the following commands: ```shell node -v npm -v vp --version 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 npm run migration ``` Rerun the app to apply the migrations. _Note: For safety, development builds use a separate database location from production builds._ ## Lezer Grammar Generation ```sh # Example lezer-generator components/core/Editor//.grammar > components/core/Editor//.ts ``` ## Linting and Formatting This repo uses [Vite+](https://vite.dev/guide/vite-plus) for linting (oxlint) and formatting (oxfmt). - Lint the entire repo: ```sh npm run lint ``` - Format code: ```sh npm run format ``` Notes: - A pre-commit hook runs `vp lint` automatically on commit. - Some workspace packages also run `tsc --noEmit` for type-checking. - VS Code users should install the recommended extensions for format-on-save support. ================================================ FILE: LICENSE ================================================ 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. ================================================ FILE: README.md ================================================

💫 Yaak ➟ Desktop API Client 💫

A fast, privacy-first API client for REST, GraphQL, SSE, WebSocket, and gRPC – built with Tauri, Rust, and React.

Development is funded by community-purchased licenses. You can also become a sponsor to have your logo appear below. 💖


User avatar: MVST-Solutions  User avatar: dharsanb  User avatar: railwayapp  User avatar: caseyamcl  User avatar: bytebase  User avatar:   

User avatar: seanwash  User avatar: jerath  User avatar: itsa-sh  User avatar: dmmulroy  User avatar: timcole  User avatar: VLZH  User avatar: terasaka2k  User avatar: andriyor  User avatar: majudhu  User avatar: axelrindle  User avatar: jirizverina  User avatar: chip-well  User avatar: GRAYAH  User avatar: flashblaze  User avatar: Frostist  

![Yaak API Client](https://yaak.app/static/screenshot.png) ## Features Yaak is an offline-first API client designed to stay out of your way while giving you everything you need when you need it. Built with [Tauri](https://tauri.app), Rust, and React, it’s fast, lightweight, and private. No telemetry, no VC funding, and no cloud lock-in. ### 🌐 Work with any API - Import collections from Postman, Insomnia, OpenAPI, Swagger, or Curl. - Send requests via REST, GraphQL, gRPC, WebSocket, or Server-Sent Events. - Filter and inspect responses with JSONPath or XPath. ### 🔐 Stay secure - Use OAuth 2.0, JWT, Basic Auth, or custom plugins for authentication. - Secure sensitive values with encrypted secrets. - Store secrets in your OS keychain. ### ☁️ Organize & collaborate - Group requests into workspaces and nested folders. - Use environment variables to switch between dev, staging, and prod. - Mirror workspaces to your filesystem for versioning in Git or syncing with Dropbox. ### 🧩 Extend & customize - Insert dynamic values like UUIDs or timestamps with template tags. - Pick from built-in themes or build your own. - Create plugins to extend authentication, template tags, or the UI. ## Contribution Policy > [!IMPORTANT] > Community PRs are currently limited to bug fixes and small-scope improvements. > If your PR is out of scope, link an approved feedback item from [yaak.app/feedback](https://yaak.app/feedback). > See [`CONTRIBUTING.md`](CONTRIBUTING.md) for policy details and [`DEVELOPMENT.md`](DEVELOPMENT.md) for local setup. ## Useful Resources - [Feedback and Bug Reports](https://feedback.yaak.app) - [Documentation](https://yaak.app/docs) - [Yaak vs Postman](https://yaak.app/alternatives/postman) - [Yaak vs Bruno](https://yaak.app/alternatives/bruno) - [Yaak vs Insomnia](https://yaak.app/alternatives/insomnia) ================================================ FILE: crates/yaak/Cargo.toml ================================================ [package] name = "yaak" version = "0.1.0" edition = "2024" publish = false [dependencies] async-trait = "0.1" log = { workspace = true } md5 = "0.8.0" serde_json = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true, features = ["sync", "rt"] } yaak-http = { workspace = true } yaak-crypto = { workspace = true } yaak-models = { workspace = true } yaak-plugins = { workspace = true } yaak-templates = { workspace = true } yaak-tls = { workspace = true } [dev-dependencies] tempfile = "3" ================================================ FILE: crates/yaak/src/error.rs ================================================ use thiserror::Error; #[derive(Debug, Error)] pub enum Error { #[error(transparent)] Send(#[from] crate::send::SendHttpRequestError), } pub type Result = std::result::Result; ================================================ FILE: crates/yaak/src/lib.rs ================================================ pub mod error; pub mod plugin_events; pub mod render; pub mod send; pub use error::Error; pub type Result = error::Result; ================================================ FILE: crates/yaak/src/plugin_events.rs ================================================ use yaak_models::models::AnyModel; use yaak_models::query_manager::QueryManager; use yaak_models::util::UpdateSource; use yaak_plugins::events::{ CloseWindowRequest, CopyTextRequest, DeleteKeyValueRequest, DeleteKeyValueResponse, DeleteModelRequest, DeleteModelResponse, ErrorResponse, FindHttpResponsesRequest, FindHttpResponsesResponse, GetCookieValueRequest, GetHttpRequestByIdRequest, GetHttpRequestByIdResponse, GetKeyValueRequest, GetKeyValueResponse, InternalEventPayload, ListCookieNamesRequest, ListFoldersRequest, ListFoldersResponse, ListHttpRequestsRequest, ListHttpRequestsResponse, ListOpenWorkspacesRequest, OpenExternalUrlRequest, OpenWindowRequest, PromptFormRequest, PromptTextRequest, ReloadResponse, RenderGrpcRequestRequest, RenderHttpRequestRequest, SendHttpRequestRequest, SetKeyValueRequest, ShowToastRequest, TemplateRenderRequest, UpsertModelRequest, UpsertModelResponse, WindowInfoRequest, }; pub struct SharedPluginEventContext<'a> { pub plugin_name: &'a str, pub workspace_id: Option<&'a str>, } #[derive(Debug)] pub enum GroupedPluginEvent<'a> { Handled(Option), ToHandle(HostRequest<'a>), } #[derive(Debug)] pub enum GroupedPluginRequest<'a> { Shared(SharedRequest<'a>), Host(HostRequest<'a>), Ignore, } #[derive(Debug)] pub enum SharedRequest<'a> { GetKeyValue(&'a GetKeyValueRequest), SetKeyValue(&'a SetKeyValueRequest), DeleteKeyValue(&'a DeleteKeyValueRequest), GetHttpRequestById(&'a GetHttpRequestByIdRequest), ListFolders(&'a ListFoldersRequest), ListHttpRequests(&'a ListHttpRequestsRequest), FindHttpResponses(&'a FindHttpResponsesRequest), UpsertModel(&'a UpsertModelRequest), DeleteModel(&'a DeleteModelRequest), } #[derive(Debug)] pub enum HostRequest<'a> { ShowToast(&'a ShowToastRequest), CopyText(&'a CopyTextRequest), PromptText(&'a PromptTextRequest), PromptForm(&'a PromptFormRequest), RenderGrpcRequest(&'a RenderGrpcRequestRequest), RenderHttpRequest(&'a RenderHttpRequestRequest), TemplateRender(&'a TemplateRenderRequest), SendHttpRequest(&'a SendHttpRequestRequest), OpenWindow(&'a OpenWindowRequest), CloseWindow(&'a CloseWindowRequest), OpenExternalUrl(&'a OpenExternalUrlRequest), ListOpenWorkspaces(&'a ListOpenWorkspacesRequest), ListCookieNames(&'a ListCookieNamesRequest), GetCookieValue(&'a GetCookieValueRequest), WindowInfo(&'a WindowInfoRequest), ErrorResponse(&'a ErrorResponse), ReloadResponse(&'a ReloadResponse), OtherRequest(&'a InternalEventPayload), } impl HostRequest<'_> { pub fn type_name(&self) -> String { match self { HostRequest::ShowToast(_) => "show_toast_request".to_string(), HostRequest::CopyText(_) => "copy_text_request".to_string(), HostRequest::PromptText(_) => "prompt_text_request".to_string(), HostRequest::PromptForm(_) => "prompt_form_request".to_string(), HostRequest::RenderGrpcRequest(_) => "render_grpc_request_request".to_string(), HostRequest::RenderHttpRequest(_) => "render_http_request_request".to_string(), HostRequest::TemplateRender(_) => "template_render_request".to_string(), HostRequest::SendHttpRequest(_) => "send_http_request_request".to_string(), HostRequest::OpenWindow(_) => "open_window_request".to_string(), HostRequest::CloseWindow(_) => "close_window_request".to_string(), HostRequest::OpenExternalUrl(_) => "open_external_url_request".to_string(), HostRequest::ListOpenWorkspaces(_) => "list_open_workspaces_request".to_string(), HostRequest::ListCookieNames(_) => "list_cookie_names_request".to_string(), HostRequest::GetCookieValue(_) => "get_cookie_value_request".to_string(), HostRequest::WindowInfo(_) => "window_info_request".to_string(), HostRequest::ErrorResponse(_) => "error_response".to_string(), HostRequest::ReloadResponse(_) => "reload_response".to_string(), HostRequest::OtherRequest(payload) => payload.type_name(), } } } impl<'a> From<&'a InternalEventPayload> for GroupedPluginRequest<'a> { fn from(payload: &'a InternalEventPayload) -> Self { match payload { InternalEventPayload::GetKeyValueRequest(req) => { GroupedPluginRequest::Shared(SharedRequest::GetKeyValue(req)) } InternalEventPayload::SetKeyValueRequest(req) => { GroupedPluginRequest::Shared(SharedRequest::SetKeyValue(req)) } InternalEventPayload::DeleteKeyValueRequest(req) => { GroupedPluginRequest::Shared(SharedRequest::DeleteKeyValue(req)) } InternalEventPayload::GetHttpRequestByIdRequest(req) => { GroupedPluginRequest::Shared(SharedRequest::GetHttpRequestById(req)) } InternalEventPayload::ErrorResponse(resp) => { GroupedPluginRequest::Host(HostRequest::ErrorResponse(resp)) } InternalEventPayload::ReloadResponse(req) => { GroupedPluginRequest::Host(HostRequest::ReloadResponse(req)) } InternalEventPayload::ListOpenWorkspacesRequest(req) => { GroupedPluginRequest::Host(HostRequest::ListOpenWorkspaces(req)) } InternalEventPayload::ListFoldersRequest(req) => { GroupedPluginRequest::Shared(SharedRequest::ListFolders(req)) } InternalEventPayload::ListHttpRequestsRequest(req) => { GroupedPluginRequest::Shared(SharedRequest::ListHttpRequests(req)) } InternalEventPayload::ShowToastRequest(req) => { GroupedPluginRequest::Host(HostRequest::ShowToast(req)) } InternalEventPayload::CopyTextRequest(req) => { GroupedPluginRequest::Host(HostRequest::CopyText(req)) } InternalEventPayload::PromptTextRequest(req) => { GroupedPluginRequest::Host(HostRequest::PromptText(req)) } InternalEventPayload::PromptFormRequest(req) => { GroupedPluginRequest::Host(HostRequest::PromptForm(req)) } InternalEventPayload::FindHttpResponsesRequest(req) => { GroupedPluginRequest::Shared(SharedRequest::FindHttpResponses(req)) } InternalEventPayload::UpsertModelRequest(req) => { GroupedPluginRequest::Shared(SharedRequest::UpsertModel(req)) } InternalEventPayload::DeleteModelRequest(req) => { GroupedPluginRequest::Shared(SharedRequest::DeleteModel(req)) } InternalEventPayload::RenderGrpcRequestRequest(req) => { GroupedPluginRequest::Host(HostRequest::RenderGrpcRequest(req)) } InternalEventPayload::RenderHttpRequestRequest(req) => { GroupedPluginRequest::Host(HostRequest::RenderHttpRequest(req)) } InternalEventPayload::TemplateRenderRequest(req) => { GroupedPluginRequest::Host(HostRequest::TemplateRender(req)) } InternalEventPayload::SendHttpRequestRequest(req) => { GroupedPluginRequest::Host(HostRequest::SendHttpRequest(req)) } InternalEventPayload::OpenWindowRequest(req) => { GroupedPluginRequest::Host(HostRequest::OpenWindow(req)) } InternalEventPayload::CloseWindowRequest(req) => { GroupedPluginRequest::Host(HostRequest::CloseWindow(req)) } InternalEventPayload::OpenExternalUrlRequest(req) => { GroupedPluginRequest::Host(HostRequest::OpenExternalUrl(req)) } InternalEventPayload::ListCookieNamesRequest(req) => { GroupedPluginRequest::Host(HostRequest::ListCookieNames(req)) } InternalEventPayload::GetCookieValueRequest(req) => { GroupedPluginRequest::Host(HostRequest::GetCookieValue(req)) } InternalEventPayload::WindowInfoRequest(req) => { GroupedPluginRequest::Host(HostRequest::WindowInfo(req)) } payload if payload.type_name().ends_with("_request") => { GroupedPluginRequest::Host(HostRequest::OtherRequest(payload)) } _ => GroupedPluginRequest::Ignore, } } } pub fn handle_shared_plugin_event<'a>( query_manager: &QueryManager, payload: &'a InternalEventPayload, context: SharedPluginEventContext<'_>, ) -> GroupedPluginEvent<'a> { match GroupedPluginRequest::from(payload) { GroupedPluginRequest::Shared(req) => { GroupedPluginEvent::Handled(Some(build_shared_reply(query_manager, req, context))) } GroupedPluginRequest::Host(req) => GroupedPluginEvent::ToHandle(req), GroupedPluginRequest::Ignore => GroupedPluginEvent::Handled(None), } } fn build_shared_reply( query_manager: &QueryManager, request: SharedRequest<'_>, context: SharedPluginEventContext<'_>, ) -> InternalEventPayload { match request { SharedRequest::GetKeyValue(req) => { let value = query_manager .connect() .get_plugin_key_value(context.plugin_name, &req.key) .map(|v| v.value); InternalEventPayload::GetKeyValueResponse(GetKeyValueResponse { value }) } SharedRequest::SetKeyValue(req) => { query_manager.connect().set_plugin_key_value(context.plugin_name, &req.key, &req.value); InternalEventPayload::SetKeyValueResponse(yaak_plugins::events::SetKeyValueResponse {}) } SharedRequest::DeleteKeyValue(req) => { match query_manager.connect().delete_plugin_key_value(context.plugin_name, &req.key) { Ok(deleted) => { InternalEventPayload::DeleteKeyValueResponse(DeleteKeyValueResponse { deleted }) } Err(err) => InternalEventPayload::ErrorResponse(ErrorResponse { error: format!("Failed to delete plugin key '{}' : {err}", req.key), }), } } SharedRequest::GetHttpRequestById(req) => { let http_request = query_manager.connect().get_http_request(&req.id).ok(); InternalEventPayload::GetHttpRequestByIdResponse(GetHttpRequestByIdResponse { http_request, }) } SharedRequest::ListFolders(_) => { let Some(workspace_id) = context.workspace_id else { return InternalEventPayload::ErrorResponse(ErrorResponse { error: "workspace_id is required for list_folders_request".to_string(), }); }; let folders = match query_manager.connect().list_folders(workspace_id) { Ok(folders) => folders, Err(err) => { return InternalEventPayload::ErrorResponse(ErrorResponse { error: format!("Failed to list folders: {err}"), }); } }; InternalEventPayload::ListFoldersResponse(ListFoldersResponse { folders }) } SharedRequest::ListHttpRequests(req) => { let http_requests = if let Some(folder_id) = req.folder_id.as_deref() { match query_manager.connect().list_http_requests_for_folder_recursive(folder_id) { Ok(http_requests) => http_requests, Err(err) => { return InternalEventPayload::ErrorResponse(ErrorResponse { error: format!("Failed to list HTTP requests for folder: {err}"), }); } } } else { let Some(workspace_id) = context.workspace_id else { return InternalEventPayload::ErrorResponse(ErrorResponse { error: "workspace_id is required for list_http_requests_request without folder_id" .to_string(), }); }; match query_manager.connect().list_http_requests(workspace_id) { Ok(http_requests) => http_requests, Err(err) => { return InternalEventPayload::ErrorResponse(ErrorResponse { error: format!("Failed to list HTTP requests: {err}"), }); } } }; InternalEventPayload::ListHttpRequestsResponse(ListHttpRequestsResponse { http_requests, }) } SharedRequest::FindHttpResponses(req) => { let http_responses = query_manager .connect() .list_http_responses_for_request(&req.request_id, req.limit.map(|l| l as u64)) .unwrap_or_default(); InternalEventPayload::FindHttpResponsesResponse(FindHttpResponsesResponse { http_responses, }) } SharedRequest::UpsertModel(req) => { use AnyModel::*; let model = match &req.model { HttpRequest(m) => { match query_manager.connect().upsert_http_request(m, &UpdateSource::Plugin) { Ok(model) => HttpRequest(model), Err(err) => { return InternalEventPayload::ErrorResponse(ErrorResponse { error: format!("Failed to upsert HTTP request: {err}"), }); } } } GrpcRequest(m) => { match query_manager.connect().upsert_grpc_request(m, &UpdateSource::Plugin) { Ok(model) => GrpcRequest(model), Err(err) => { return InternalEventPayload::ErrorResponse(ErrorResponse { error: format!("Failed to upsert gRPC request: {err}"), }); } } } WebsocketRequest(m) => { match query_manager.connect().upsert_websocket_request(m, &UpdateSource::Plugin) { Ok(model) => WebsocketRequest(model), Err(err) => { return InternalEventPayload::ErrorResponse(ErrorResponse { error: format!("Failed to upsert WebSocket request: {err}"), }); } } } Folder(m) => { match query_manager.connect().upsert_folder(m, &UpdateSource::Plugin) { Ok(model) => Folder(model), Err(err) => { return InternalEventPayload::ErrorResponse(ErrorResponse { error: format!("Failed to upsert folder: {err}"), }); } } } Environment(m) => { match query_manager.connect().upsert_environment(m, &UpdateSource::Plugin) { Ok(model) => Environment(model), Err(err) => { return InternalEventPayload::ErrorResponse(ErrorResponse { error: format!("Failed to upsert environment: {err}"), }); } } } Workspace(m) => { match query_manager.connect().upsert_workspace(m, &UpdateSource::Plugin) { Ok(model) => Workspace(model), Err(err) => { return InternalEventPayload::ErrorResponse(ErrorResponse { error: format!("Failed to upsert workspace: {err}"), }); } } } _ => { return InternalEventPayload::ErrorResponse(ErrorResponse { error: "Upsert not supported for this model type".to_string(), }); } }; InternalEventPayload::UpsertModelResponse(UpsertModelResponse { model }) } SharedRequest::DeleteModel(req) => { let model = match req.model.as_str() { "http_request" => { match query_manager .connect() .delete_http_request_by_id(&req.id, &UpdateSource::Plugin) { Ok(model) => AnyModel::HttpRequest(model), Err(err) => { return InternalEventPayload::ErrorResponse(ErrorResponse { error: format!("Failed to delete HTTP request: {err}"), }); } } } "grpc_request" => { match query_manager .connect() .delete_grpc_request_by_id(&req.id, &UpdateSource::Plugin) { Ok(model) => AnyModel::GrpcRequest(model), Err(err) => { return InternalEventPayload::ErrorResponse(ErrorResponse { error: format!("Failed to delete gRPC request: {err}"), }); } } } "websocket_request" => { match query_manager .connect() .delete_websocket_request_by_id(&req.id, &UpdateSource::Plugin) { Ok(model) => AnyModel::WebsocketRequest(model), Err(err) => { return InternalEventPayload::ErrorResponse(ErrorResponse { error: format!("Failed to delete WebSocket request: {err}"), }); } } } "folder" => match query_manager .connect() .delete_folder_by_id(&req.id, &UpdateSource::Plugin) { Ok(model) => AnyModel::Folder(model), Err(err) => { return InternalEventPayload::ErrorResponse(ErrorResponse { error: format!("Failed to delete folder: {err}"), }); } }, "environment" => { match query_manager .connect() .delete_environment_by_id(&req.id, &UpdateSource::Plugin) { Ok(model) => AnyModel::Environment(model), Err(err) => { return InternalEventPayload::ErrorResponse(ErrorResponse { error: format!("Failed to delete environment: {err}"), }); } } } _ => { return InternalEventPayload::ErrorResponse(ErrorResponse { error: "Delete not supported for this model type".to_string(), }); } }; InternalEventPayload::DeleteModelResponse(DeleteModelResponse { model }) } } } #[cfg(test)] mod tests { use super::*; use tempfile::TempDir; use yaak_models::models::{AnyModel, Folder, HttpRequest, Workspace}; use yaak_models::util::UpdateSource; fn seed_query_manager() -> (QueryManager, TempDir) { let temp_dir = TempDir::new().expect("Failed to create temp dir"); let db_path = temp_dir.path().join("db.sqlite"); let blob_path = temp_dir.path().join("blobs.sqlite"); let (query_manager, _blob_manager, _rx) = yaak_models::init_standalone(&db_path, &blob_path).expect("Failed to initialize DB"); query_manager .connect() .upsert_workspace( &Workspace { id: "wk_test".to_string(), name: "Workspace".to_string(), ..Default::default() }, &UpdateSource::Sync, ) .expect("Failed to seed workspace"); query_manager .connect() .upsert_folder( &Folder { id: "fl_test".to_string(), workspace_id: "wk_test".to_string(), name: "Folder".to_string(), ..Default::default() }, &UpdateSource::Sync, ) .expect("Failed to seed folder"); query_manager .connect() .upsert_http_request( &HttpRequest { id: "rq_test".to_string(), workspace_id: "wk_test".to_string(), folder_id: Some("fl_test".to_string()), name: "Request".to_string(), method: "GET".to_string(), url: "https://example.com".to_string(), ..Default::default() }, &UpdateSource::Sync, ) .expect("Failed to seed request"); (query_manager, temp_dir) } #[test] fn list_requests_requires_workspace_when_folder_missing() { let (query_manager, _temp_dir) = seed_query_manager(); let payload = InternalEventPayload::ListHttpRequestsRequest( yaak_plugins::events::ListHttpRequestsRequest { folder_id: None }, ); let result = handle_shared_plugin_event( &query_manager, &payload, SharedPluginEventContext { plugin_name: "@yaak/test", workspace_id: None }, ); assert!(matches!( result, GroupedPluginEvent::Handled(Some(InternalEventPayload::ErrorResponse(_))) )); } #[test] fn list_requests_by_workspace_and_folder() { let (query_manager, _temp_dir) = seed_query_manager(); let by_workspace_payload = InternalEventPayload::ListHttpRequestsRequest( yaak_plugins::events::ListHttpRequestsRequest { folder_id: None }, ); let by_workspace = handle_shared_plugin_event( &query_manager, &by_workspace_payload, SharedPluginEventContext { plugin_name: "@yaak/test", workspace_id: Some("wk_test") }, ); match by_workspace { GroupedPluginEvent::Handled(Some(InternalEventPayload::ListHttpRequestsResponse( resp, ))) => { assert_eq!(resp.http_requests.len(), 1); } other => panic!("unexpected workspace response: {other:?}"), } let by_folder_payload = InternalEventPayload::ListHttpRequestsRequest( yaak_plugins::events::ListHttpRequestsRequest { folder_id: Some("fl_test".to_string()), }, ); let by_folder = handle_shared_plugin_event( &query_manager, &by_folder_payload, SharedPluginEventContext { plugin_name: "@yaak/test", workspace_id: None }, ); match by_folder { GroupedPluginEvent::Handled(Some(InternalEventPayload::ListHttpRequestsResponse( resp, ))) => { assert_eq!(resp.http_requests.len(), 1); } other => panic!("unexpected folder response: {other:?}"), } } #[test] fn find_http_responses_is_shared_handled() { let (query_manager, _temp_dir) = seed_query_manager(); let payload = InternalEventPayload::FindHttpResponsesRequest(FindHttpResponsesRequest { request_id: "rq_test".to_string(), limit: Some(1), }); let result = handle_shared_plugin_event( &query_manager, &payload, SharedPluginEventContext { plugin_name: "@yaak/test", workspace_id: Some("wk_test") }, ); match result { GroupedPluginEvent::Handled(Some(InternalEventPayload::FindHttpResponsesResponse( resp, ))) => { assert!(resp.http_responses.is_empty()); } other => panic!("unexpected find responses result: {other:?}"), } } #[test] fn upsert_and_delete_model_are_shared_handled() { let (query_manager, _temp_dir) = seed_query_manager(); let existing = query_manager .connect() .get_http_request("rq_test") .expect("Failed to load seeded request"); let upsert_payload = InternalEventPayload::UpsertModelRequest(UpsertModelRequest { model: AnyModel::HttpRequest(HttpRequest { name: "Request Updated".to_string(), ..existing }), }); let upsert_result = handle_shared_plugin_event( &query_manager, &upsert_payload, SharedPluginEventContext { plugin_name: "@yaak/test", workspace_id: Some("wk_test") }, ); match upsert_result { GroupedPluginEvent::Handled(Some(InternalEventPayload::UpsertModelResponse(resp))) => { match resp.model { AnyModel::HttpRequest(r) => assert_eq!(r.name, "Request Updated"), other => panic!("unexpected upsert model type: {other:?}"), } } other => panic!("unexpected upsert result: {other:?}"), } let delete_payload = InternalEventPayload::DeleteModelRequest(DeleteModelRequest { model: "http_request".to_string(), id: "rq_test".to_string(), }); let delete_result = handle_shared_plugin_event( &query_manager, &delete_payload, SharedPluginEventContext { plugin_name: "@yaak/test", workspace_id: Some("wk_test") }, ); match delete_result { GroupedPluginEvent::Handled(Some(InternalEventPayload::DeleteModelResponse(resp))) => { match resp.model { AnyModel::HttpRequest(r) => assert_eq!(r.id, "rq_test"), other => panic!("unexpected delete model type: {other:?}"), } } other => panic!("unexpected delete result: {other:?}"), } } #[test] fn host_request_classification_works() { let (query_manager, _temp_dir) = seed_query_manager(); let payload = InternalEventPayload::WindowInfoRequest(WindowInfoRequest { label: "main".to_string(), }); let result = handle_shared_plugin_event( &query_manager, &payload, SharedPluginEventContext { plugin_name: "@yaak/test", workspace_id: None }, ); match result { GroupedPluginEvent::ToHandle(HostRequest::WindowInfo(req)) => { assert_eq!(req.label, "main") } other => panic!("unexpected host classification: {other:?}"), } } } ================================================ FILE: crates/yaak/src/render.rs ================================================ use log::info; use serde_json::Value; use std::collections::BTreeMap; use yaak_http::path_placeholders::apply_path_placeholders; use yaak_models::models::{Environment, GrpcRequest, HttpRequest, HttpRequestHeader, HttpUrlParameter}; use yaak_models::render::make_vars_hashmap; use yaak_templates::{RenderOptions, TemplateCallback, parse_and_render, render_json_value_raw}; pub async fn render_http_request( request: &HttpRequest, environment_chain: Vec, callback: &T, options: &RenderOptions, ) -> yaak_templates::error::Result { let vars = &make_vars_hashmap(environment_chain); let mut url_parameters = Vec::new(); for parameter in request.url_parameters.clone() { if !parameter.enabled { continue; } url_parameters.push(HttpUrlParameter { enabled: parameter.enabled, name: parse_and_render(parameter.name.as_str(), vars, callback, options).await?, value: parse_and_render(parameter.value.as_str(), vars, callback, options).await?, id: parameter.id, }) } let mut headers = Vec::new(); for header in request.headers.clone() { if !header.enabled { continue; } headers.push(HttpRequestHeader { enabled: header.enabled, name: parse_and_render(header.name.as_str(), vars, callback, options).await?, value: parse_and_render(header.value.as_str(), vars, callback, options).await?, id: header.id, }) } let mut body = BTreeMap::new(); for (key, value) in request.body.clone() { let value = if key == "form" { strip_disabled_form_entries(value) } else { value }; body.insert(key, render_json_value_raw(value, vars, callback, options).await?); } let authentication = { let mut disabled = false; let mut auth = BTreeMap::new(); match request.authentication.get("disabled") { Some(Value::Bool(true)) => { disabled = true; } Some(Value::String(template)) => { disabled = parse_and_render(template.as_str(), vars, callback, options) .await .unwrap_or_default() .is_empty(); info!( "Rendering authentication.disabled as a template: {disabled} from \"{template}\"" ); } _ => {} } if disabled { auth.insert("disabled".to_string(), Value::Bool(true)); } else { for (key, value) in request.authentication.clone() { if key == "disabled" { auth.insert(key, Value::Bool(false)); } else { auth.insert(key, render_json_value_raw(value, vars, callback, options).await?); } } } auth }; let url = parse_and_render(request.url.clone().as_str(), vars, callback, options).await?; let (url, url_parameters) = apply_path_placeholders(&url, &url_parameters); Ok(HttpRequest { url, url_parameters, headers, body, authentication, ..request.to_owned() }) } pub async fn render_grpc_request( r: &GrpcRequest, environment_chain: Vec, cb: &T, opt: &RenderOptions, ) -> yaak_templates::error::Result { let vars = &make_vars_hashmap(environment_chain); let mut metadata = Vec::new(); for p in r.metadata.clone() { if !p.enabled { continue; } metadata.push(HttpRequestHeader { enabled: p.enabled, name: parse_and_render(p.name.as_str(), vars, cb, opt).await?, value: parse_and_render(p.value.as_str(), vars, cb, opt).await?, id: p.id, }) } let authentication = { let mut disabled = false; let mut auth = BTreeMap::new(); match r.authentication.get("disabled") { Some(Value::Bool(true)) => { disabled = true; } Some(Value::String(tmpl)) => { disabled = parse_and_render(tmpl.as_str(), vars, cb, opt) .await .unwrap_or_default() .is_empty(); info!( "Rendering authentication.disabled as a template: {disabled} from \"{tmpl}\"" ); } _ => {} } if disabled { auth.insert("disabled".to_string(), Value::Bool(true)); } else { for (k, v) in r.authentication.clone() { if k == "disabled" { auth.insert(k, Value::Bool(false)); } else { auth.insert(k, render_json_value_raw(v, vars, cb, opt).await?); } } } auth }; let url = parse_and_render(r.url.as_str(), vars, cb, opt).await?; Ok(GrpcRequest { url, metadata, authentication, ..r.to_owned() }) } fn strip_disabled_form_entries(v: Value) -> Value { match v { Value::Array(items) => Value::Array( items .into_iter() .filter(|item| item.get("enabled").and_then(|e| e.as_bool()).unwrap_or(true)) .collect(), ), v => v, } } #[cfg(test)] mod tests { use super::*; use serde_json::json; #[test] fn test_strip_disabled_form_entries() { let input = json!([ {"enabled": true, "name": "foo", "value": "bar"}, {"enabled": false, "name": "disabled", "value": "gone"}, {"enabled": true, "name": "baz", "value": "qux"}, ]); let result = strip_disabled_form_entries(input); assert_eq!( result, json!([ {"enabled": true, "name": "foo", "value": "bar"}, {"enabled": true, "name": "baz", "value": "qux"}, ]) ); } #[test] fn test_strip_disabled_form_entries_all_disabled() { let input = json!([ {"enabled": false, "name": "a", "value": "b"}, {"enabled": false, "name": "c", "value": "d"}, ]); let result = strip_disabled_form_entries(input); assert_eq!(result, json!([])); } #[test] fn test_strip_disabled_form_entries_missing_enabled_defaults_to_kept() { let input = json!([ {"name": "no_enabled_field", "value": "kept"}, {"enabled": false, "name": "disabled", "value": "gone"}, ]); let result = strip_disabled_form_entries(input); assert_eq!( result, json!([ {"name": "no_enabled_field", "value": "kept"}, ]) ); } #[test] fn test_strip_disabled_form_entries_non_array_passthrough() { let input = json!("just a string"); let result = strip_disabled_form_entries(input.clone()); assert_eq!(result, input); } } ================================================ FILE: crates/yaak/src/send.rs ================================================ use crate::render::render_http_request; use async_trait::async_trait; use log::warn; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::sync::atomic::{AtomicI32, Ordering}; use std::time::Instant; use thiserror::Error; use tokio::fs::File; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::sync::mpsc; use tokio::sync::watch; use yaak_crypto::manager::EncryptionManager; use yaak_http::client::{ HttpConnectionOptions, HttpConnectionProxySetting, HttpConnectionProxySettingAuth, }; use yaak_http::cookies::CookieStore; use yaak_http::manager::HttpConnectionManager; use yaak_http::sender::{HttpResponseEvent as SenderHttpResponseEvent, ReqwestSender}; use yaak_http::tee_reader::TeeReader; use yaak_http::transaction::HttpTransaction; use yaak_http::types::{ SendableBody, SendableHttpRequest, SendableHttpRequestOptions, append_query_params, }; use yaak_models::blob_manager::{BlobManager, BodyChunk}; use yaak_models::models::{ ClientCertificate, CookieJar, DnsOverride, Environment, HttpRequest, HttpResponse, HttpResponseEvent, HttpResponseHeader, HttpResponseState, ProxySetting, ProxySettingAuth, }; use yaak_models::query_manager::QueryManager; use yaak_models::util::{UpdateSource, generate_prefixed_id}; use yaak_plugins::events::{ CallHttpAuthenticationRequest, HttpHeader, PluginContext, RenderPurpose, }; use yaak_plugins::manager::PluginManager; use yaak_plugins::template_callback::PluginTemplateCallback; use yaak_templates::{RenderOptions, TemplateCallback}; use yaak_tls::find_client_certificate; const HTTP_EVENT_CHANNEL_CAPACITY: usize = 100; const REQUEST_BODY_CHUNK_SIZE: usize = 1024 * 1024; const RESPONSE_PROGRESS_UPDATE_INTERVAL_MS: u128 = 100; #[derive(Debug, Error)] pub enum SendHttpRequestError { #[error("Failed to load request: {0}")] LoadRequest(#[source] yaak_models::error::Error), #[error("Failed to load workspace: {0}")] LoadWorkspace(#[source] yaak_models::error::Error), #[error("Failed to resolve environments: {0}")] ResolveEnvironments(#[source] yaak_models::error::Error), #[error("Failed to resolve inherited request settings: {0}")] ResolveRequestInheritance(#[source] yaak_models::error::Error), #[error("Failed to load cookie jar: {0}")] LoadCookieJar(#[source] yaak_models::error::Error), #[error("Failed to persist cookie jar: {0}")] PersistCookieJar(#[source] yaak_models::error::Error), #[error("Failed to render request templates: {0}")] RenderRequest(#[source] yaak_templates::error::Error), #[error("Failed to prepare request before send: {0}")] PrepareSendableRequest(String), #[error("Failed to persist response metadata: {0}")] PersistResponse(#[source] yaak_models::error::Error), #[error("Failed to create HTTP client: {0}")] CreateHttpClient(#[source] yaak_http::error::Error), #[error("Failed to build sendable request: {0}")] BuildSendableRequest(#[source] yaak_http::error::Error), #[error("Failed to send request: {0}")] SendRequest(#[source] yaak_http::error::Error), #[error("Failed to read response body: {0}")] ReadResponseBody(#[source] yaak_http::error::Error), #[error("Failed to create response directory {path:?}: {source}")] CreateResponseDirectory { path: PathBuf, #[source] source: std::io::Error, }, #[error("Failed to write response body to {path:?}: {source}")] WriteResponseBody { path: PathBuf, #[source] source: std::io::Error, }, } pub type Result = std::result::Result; #[async_trait] pub trait PrepareSendableRequest: Send + Sync { async fn prepare_sendable_request( &self, rendered_request: &HttpRequest, auth_context_id: &str, sendable_request: &mut SendableHttpRequest, ) -> std::result::Result<(), String>; } #[async_trait] pub trait SendRequestExecutor: Send + Sync { async fn send( &self, sendable_request: SendableHttpRequest, event_tx: mpsc::Sender, cookie_store: Option, ) -> yaak_http::error::Result; } struct DefaultSendRequestExecutor; #[async_trait] impl SendRequestExecutor for DefaultSendRequestExecutor { async fn send( &self, sendable_request: SendableHttpRequest, event_tx: mpsc::Sender, cookie_store: Option, ) -> yaak_http::error::Result { let sender = ReqwestSender::new()?; let transaction = match cookie_store { Some(store) => HttpTransaction::with_cookie_store(sender, store), None => HttpTransaction::new(sender), }; let (_cancel_tx, cancel_rx) = watch::channel(false); transaction.execute_with_cancellation(sendable_request, cancel_rx, event_tx).await } } struct PluginPrepareSendableRequest { plugin_manager: Arc, plugin_context: PluginContext, cancelled_rx: Option>, } #[async_trait] impl PrepareSendableRequest for PluginPrepareSendableRequest { async fn prepare_sendable_request( &self, rendered_request: &HttpRequest, auth_context_id: &str, sendable_request: &mut SendableHttpRequest, ) -> std::result::Result<(), String> { if let Some(cancelled_rx) = &self.cancelled_rx { let mut cancelled_rx = cancelled_rx.clone(); tokio::select! { result = apply_plugin_authentication( sendable_request, rendered_request, auth_context_id, &self.plugin_manager, &self.plugin_context, ) => result, _ = cancelled_rx.changed() => Err("Request canceled".to_string()), } } else { apply_plugin_authentication( sendable_request, rendered_request, auth_context_id, &self.plugin_manager, &self.plugin_context, ) .await } } } struct ConnectionManagerSendRequestExecutor<'a> { connection_manager: &'a HttpConnectionManager, plugin_context_id: String, query_manager: QueryManager, workspace_id: String, cancelled_rx: Option>, } #[async_trait] impl SendRequestExecutor for ConnectionManagerSendRequestExecutor<'_> { async fn send( &self, sendable_request: SendableHttpRequest, event_tx: mpsc::Sender, cookie_store: Option, ) -> yaak_http::error::Result { let runtime_config = resolve_http_send_runtime_config(&self.query_manager, &self.workspace_id) .map_err(|e| yaak_http::error::Error::RequestError(e.to_string()))?; let client_certificate = find_client_certificate(&sendable_request.url, &runtime_config.client_certificates); let cached_client = self .connection_manager .get_client(&HttpConnectionOptions { id: self.plugin_context_id.clone(), validate_certificates: runtime_config.validate_certificates, proxy: runtime_config.proxy, client_certificate, dns_overrides: runtime_config.dns_overrides, }) .await?; cached_client.resolver.set_event_sender(Some(event_tx.clone())).await; let sender = ReqwestSender::with_client(cached_client.client); let transaction = match cookie_store { Some(cs) => HttpTransaction::with_cookie_store(sender, cs), None => HttpTransaction::new(sender), }; let result = if let Some(cancelled_rx) = self.cancelled_rx.clone() { transaction.execute_with_cancellation(sendable_request, cancelled_rx, event_tx).await } else { let (_cancel_tx, cancel_rx) = watch::channel(false); transaction.execute_with_cancellation(sendable_request, cancel_rx, event_tx).await }; cached_client.resolver.set_event_sender(None).await; result } } pub struct SendHttpRequestByIdParams<'a, T: TemplateCallback> { pub query_manager: &'a QueryManager, pub blob_manager: &'a BlobManager, pub request_id: &'a str, pub environment_id: Option<&'a str>, pub template_callback: &'a T, pub update_source: UpdateSource, pub cookie_jar_id: Option, pub response_dir: &'a Path, pub emit_events_to: Option>, pub emit_response_body_chunks_to: Option>>, pub cancelled_rx: Option>, pub prepare_sendable_request: Option<&'a dyn PrepareSendableRequest>, pub executor: Option<&'a dyn SendRequestExecutor>, } pub struct SendHttpRequestParams<'a, T: TemplateCallback> { pub query_manager: &'a QueryManager, pub blob_manager: &'a BlobManager, pub request: HttpRequest, pub environment_id: Option<&'a str>, pub template_callback: &'a T, pub send_options: Option, pub update_source: UpdateSource, pub cookie_jar_id: Option, pub response_dir: &'a Path, pub emit_events_to: Option>, pub emit_response_body_chunks_to: Option>>, pub cancelled_rx: Option>, pub auth_context_id: Option, pub existing_response: Option, pub prepare_sendable_request: Option<&'a dyn PrepareSendableRequest>, pub executor: Option<&'a dyn SendRequestExecutor>, } pub struct SendHttpRequestWithPluginsParams<'a> { pub query_manager: &'a QueryManager, pub blob_manager: &'a BlobManager, pub request: HttpRequest, pub environment_id: Option<&'a str>, pub update_source: UpdateSource, pub cookie_jar_id: Option, pub response_dir: &'a Path, pub emit_events_to: Option>, pub emit_response_body_chunks_to: Option>>, pub existing_response: Option, pub plugin_manager: Arc, pub encryption_manager: Arc, pub plugin_context: &'a PluginContext, pub cancelled_rx: Option>, pub connection_manager: Option<&'a HttpConnectionManager>, } pub struct SendHttpRequestByIdWithPluginsParams<'a> { pub query_manager: &'a QueryManager, pub blob_manager: &'a BlobManager, pub request_id: &'a str, pub environment_id: Option<&'a str>, pub update_source: UpdateSource, pub cookie_jar_id: Option, pub response_dir: &'a Path, pub emit_events_to: Option>, pub emit_response_body_chunks_to: Option>>, pub plugin_manager: Arc, pub encryption_manager: Arc, pub plugin_context: &'a PluginContext, pub cancelled_rx: Option>, pub connection_manager: Option<&'a HttpConnectionManager>, } pub struct SendHttpRequestResult { pub rendered_request: HttpRequest, pub response: HttpResponse, pub response_body: Vec, } pub struct HttpSendRuntimeConfig { pub send_options: SendableHttpRequestOptions, pub validate_certificates: bool, pub proxy: HttpConnectionProxySetting, pub dns_overrides: Vec, pub client_certificates: Vec, } pub fn resolve_http_send_runtime_config( query_manager: &QueryManager, workspace_id: &str, ) -> Result { let db = query_manager.connect(); let workspace = db.get_workspace(workspace_id).map_err(SendHttpRequestError::LoadWorkspace)?; let settings = db.get_settings(); Ok(HttpSendRuntimeConfig { send_options: SendableHttpRequestOptions { follow_redirects: workspace.setting_follow_redirects, timeout: if workspace.setting_request_timeout > 0 { Some(std::time::Duration::from_millis( workspace.setting_request_timeout.unsigned_abs() as u64, )) } else { None }, }, validate_certificates: workspace.setting_validate_certificates, proxy: proxy_setting_from_settings(settings.proxy), dns_overrides: workspace.setting_dns_overrides, client_certificates: settings.client_certificates, }) } pub async fn send_http_request_by_id_with_plugins( params: SendHttpRequestByIdWithPluginsParams<'_>, ) -> Result { let request = params .query_manager .connect() .get_http_request(params.request_id) .map_err(SendHttpRequestError::LoadRequest)?; send_http_request_with_plugins(SendHttpRequestWithPluginsParams { query_manager: params.query_manager, blob_manager: params.blob_manager, request, environment_id: params.environment_id, update_source: params.update_source, cookie_jar_id: params.cookie_jar_id, response_dir: params.response_dir, emit_events_to: params.emit_events_to, emit_response_body_chunks_to: params.emit_response_body_chunks_to, existing_response: None, plugin_manager: params.plugin_manager, encryption_manager: params.encryption_manager, plugin_context: params.plugin_context, cancelled_rx: params.cancelled_rx, connection_manager: params.connection_manager, }) .await } pub async fn send_http_request_with_plugins( params: SendHttpRequestWithPluginsParams<'_>, ) -> Result { let template_callback = PluginTemplateCallback::new( params.plugin_manager.clone(), params.encryption_manager.clone(), params.plugin_context, RenderPurpose::Send, ); let auth_hook = PluginPrepareSendableRequest { plugin_manager: params.plugin_manager, plugin_context: params.plugin_context.clone(), cancelled_rx: params.cancelled_rx.clone(), }; let executor = params.connection_manager.map(|connection_manager| ConnectionManagerSendRequestExecutor { connection_manager, plugin_context_id: params.plugin_context.id.clone(), query_manager: params.query_manager.clone(), workspace_id: params.request.workspace_id.clone(), cancelled_rx: params.cancelled_rx.clone(), }); send_http_request(SendHttpRequestParams { query_manager: params.query_manager, blob_manager: params.blob_manager, request: params.request, environment_id: params.environment_id, template_callback: &template_callback, send_options: None, update_source: params.update_source, cookie_jar_id: params.cookie_jar_id, response_dir: params.response_dir, emit_events_to: params.emit_events_to, emit_response_body_chunks_to: params.emit_response_body_chunks_to, cancelled_rx: params.cancelled_rx, auth_context_id: None, existing_response: params.existing_response, prepare_sendable_request: Some(&auth_hook), executor: executor.as_ref().map(|e| e as &dyn SendRequestExecutor), }) .await } pub async fn send_http_request_by_id( params: SendHttpRequestByIdParams<'_, T>, ) -> Result { let request = params .query_manager .connect() .get_http_request(params.request_id) .map_err(SendHttpRequestError::LoadRequest)?; let (request, auth_context_id) = resolve_inherited_request(params.query_manager, &request)?; send_http_request(SendHttpRequestParams { query_manager: params.query_manager, blob_manager: params.blob_manager, request, environment_id: params.environment_id, template_callback: params.template_callback, send_options: None, update_source: params.update_source, cookie_jar_id: params.cookie_jar_id, response_dir: params.response_dir, emit_events_to: params.emit_events_to, emit_response_body_chunks_to: params.emit_response_body_chunks_to, cancelled_rx: params.cancelled_rx, existing_response: None, prepare_sendable_request: params.prepare_sendable_request, executor: params.executor, auth_context_id: Some(auth_context_id), }) .await } pub async fn send_http_request( params: SendHttpRequestParams<'_, T>, ) -> Result { let environment_chain = resolve_environment_chain(params.query_manager, ¶ms.request, params.environment_id)?; let (resolved_request, auth_context_id) = if let Some(auth_context_id) = params.auth_context_id.clone() { (params.request.clone(), auth_context_id) } else { resolve_inherited_request(params.query_manager, ¶ms.request)? }; let runtime_config = resolve_http_send_runtime_config(params.query_manager, ¶ms.request.workspace_id)?; let send_options = params.send_options.unwrap_or(runtime_config.send_options); let mut cookie_jar = load_cookie_jar(params.query_manager, params.cookie_jar_id.as_deref())?; let cookie_store = cookie_jar.as_ref().map(|jar| CookieStore::from_cookies(jar.cookies.clone())); let rendered_request = render_http_request( &resolved_request, environment_chain, params.template_callback, &RenderOptions::throw(), ) .await .map_err(SendHttpRequestError::RenderRequest)?; let mut sendable_request = SendableHttpRequest::from_http_request(&rendered_request, send_options) .await .map_err(SendHttpRequestError::BuildSendableRequest)?; if let Some(hook) = params.prepare_sendable_request { hook.prepare_sendable_request(&rendered_request, &auth_context_id, &mut sendable_request) .await .map_err(SendHttpRequestError::PrepareSendableRequest)?; } let request_content_length = sendable_body_length(sendable_request.body.as_ref()); let mut response = params.existing_response.unwrap_or_default(); response.request_id = params.request.id.clone(); response.workspace_id = params.request.workspace_id.clone(); response.request_content_length = request_content_length; response.request_headers = sendable_request .headers .iter() .map(|(name, value)| HttpResponseHeader { name: name.clone(), value: value.clone() }) .collect(); response.url = sendable_request.url.clone(); response.state = HttpResponseState::Initialized; response.error = None; response.content_length = None; response.content_length_compressed = None; response.body_path = None; response.status = 0; response.status_reason = None; response.headers = Vec::new(); response.remote_addr = None; response.version = None; response.elapsed = 0; response.elapsed_headers = 0; response.elapsed_dns = 0; let persist_response = !response.request_id.is_empty(); if persist_response { response = params .query_manager .connect() .upsert_http_response(&response, ¶ms.update_source, params.blob_manager) .map_err(SendHttpRequestError::PersistResponse)?; } else if response.id.is_empty() { response.id = generate_prefixed_id("rs"); } let request_body_id = format!("{}.request", response.id); let mut request_body_capture_task = None; let mut request_body_capture_error = None; if persist_response { match sendable_request.body.as_mut() { Some(SendableBody::Bytes(bytes)) => { if let Err(err) = persist_request_body_bytes( params.blob_manager, &request_body_id, bytes.as_ref(), ) { request_body_capture_error = Some(err); } } Some(SendableBody::Stream { data, .. }) => { let (tx, rx) = tokio::sync::mpsc::unbounded_channel::>(); let inner = std::mem::replace(data, Box::pin(tokio::io::empty())); let tee_reader = TeeReader::new(inner, tx); *data = Box::pin(tee_reader); let blob_manager = params.blob_manager.clone(); let body_id = request_body_id.clone(); request_body_capture_task = Some(tokio::spawn(async move { persist_request_body_stream(blob_manager, body_id, rx).await })); } None => {} } } let (event_tx, mut event_rx) = mpsc::channel::(HTTP_EVENT_CHANNEL_CAPACITY); let event_query_manager = params.query_manager.clone(); let event_response_id = response.id.clone(); let event_workspace_id = params.request.workspace_id.clone(); let event_update_source = params.update_source.clone(); let emit_events_to = params.emit_events_to.clone(); let dns_elapsed = Arc::new(AtomicI32::new(0)); let event_dns_elapsed = dns_elapsed.clone(); let event_handle = tokio::spawn(async move { while let Some(event) = event_rx.recv().await { if let SenderHttpResponseEvent::DnsResolved { duration, .. } = &event { event_dns_elapsed.store(u64_to_i32(*duration), Ordering::Relaxed); } if persist_response { let db_event = HttpResponseEvent::new( &event_response_id, &event_workspace_id, event.clone().into(), ); if let Err(err) = event_query_manager .connect() .upsert_http_response_event(&db_event, &event_update_source) { warn!("Failed to persist HTTP response event: {}", err); } } if let Some(tx) = emit_events_to.as_ref() { let _ = tx.try_send(event); } } }); let default_executor = DefaultSendRequestExecutor; let executor = params.executor.unwrap_or(&default_executor); let started_at = Instant::now(); let request_started_url = sendable_request.url.clone(); let mut http_response = match executor .send(sendable_request, event_tx, cookie_store.clone()) .await { Ok(response) => response, Err(err) => { persist_cookie_jar(params.query_manager, cookie_jar.as_mut(), cookie_store.as_ref())?; if persist_response { let _ = persist_response_error( params.query_manager, params.blob_manager, ¶ms.update_source, &response, started_at, err.to_string(), request_started_url, ); } if let Err(join_err) = event_handle.await { warn!("Failed to join response event task: {}", join_err); } if let Some(task) = request_body_capture_task.take() { let _ = task.await; } return Err(SendHttpRequestError::SendRequest(err)); } }; let headers_elapsed = duration_to_i32(started_at.elapsed()); std::fs::create_dir_all(params.response_dir).map_err(|source| { SendHttpRequestError::CreateResponseDirectory { path: params.response_dir.to_path_buf(), source, } })?; let body_path = params.response_dir.join(&response.id); let connected_response = HttpResponse { state: HttpResponseState::Connected, elapsed_headers: headers_elapsed, status: i32::from(http_response.status), status_reason: http_response.status_reason.clone(), url: http_response.url.clone(), remote_addr: http_response.remote_addr.clone(), version: http_response.version.clone(), elapsed_dns: dns_elapsed.load(Ordering::Relaxed), body_path: Some(body_path.to_string_lossy().to_string()), content_length: http_response.content_length.map(u64_to_i32), headers: http_response .headers .iter() .map(|(name, value)| HttpResponseHeader { name: name.clone(), value: value.clone() }) .collect(), request_headers: http_response .request_headers .iter() .map(|(name, value)| HttpResponseHeader { name: name.clone(), value: value.clone() }) .collect(), ..response }; if persist_response { response = params .query_manager .connect() .upsert_http_response(&connected_response, ¶ms.update_source, params.blob_manager) .map_err(SendHttpRequestError::PersistResponse)?; } else { response = connected_response; } let mut file = File::options().create(true).truncate(true).write(true).open(&body_path).await.map_err( |source| SendHttpRequestError::WriteResponseBody { path: body_path.clone(), source }, )?; let mut body_stream = http_response.into_body_stream().map_err(SendHttpRequestError::ReadResponseBody)?; let mut response_body = Vec::new(); let mut body_read_error = None; let mut written_bytes: usize = 0; let mut last_progress_update = started_at; let mut cancelled_rx = params.cancelled_rx.clone(); loop { let read_result = if let Some(cancelled_rx) = cancelled_rx.as_mut() { if *cancelled_rx.borrow() { break; } tokio::select! { biased; _ = cancelled_rx.changed() => { None } result = body_stream.read_buf(&mut response_body) => { Some(result) } } } else { Some(body_stream.read_buf(&mut response_body).await) }; let Some(read_result) = read_result else { break; }; match read_result { Ok(0) => break, Ok(n) => { written_bytes += n; let start_idx = response_body.len() - n; let chunk = &response_body[start_idx..]; file.write_all(chunk).await.map_err(|source| { SendHttpRequestError::WriteResponseBody { path: body_path.clone(), source } })?; file.flush().await.map_err(|source| SendHttpRequestError::WriteResponseBody { path: body_path.clone(), source, })?; if let Some(tx) = params.emit_response_body_chunks_to.as_ref() { let _ = tx.send(chunk.to_vec()); } let now = Instant::now(); let should_update = now.duration_since(last_progress_update).as_millis() >= RESPONSE_PROGRESS_UPDATE_INTERVAL_MS; if should_update { let elapsed = duration_to_i32(started_at.elapsed()); let progress_response = HttpResponse { elapsed, content_length: Some(usize_to_i32(written_bytes)), elapsed_dns: dns_elapsed.load(Ordering::Relaxed), ..response.clone() }; if persist_response { response = params .query_manager .connect() .upsert_http_response( &progress_response, ¶ms.update_source, params.blob_manager, ) .map_err(SendHttpRequestError::PersistResponse)?; } else { response = progress_response; } last_progress_update = now; } } Err(err) => { body_read_error = Some(SendHttpRequestError::ReadResponseBody( yaak_http::error::Error::BodyReadError(err.to_string()), )); break; } } } file.flush().await.map_err(|source| SendHttpRequestError::WriteResponseBody { path: body_path.clone(), source, })?; drop(body_stream); if let Some(task) = request_body_capture_task.take() { match task.await { Ok(Ok(total)) => { response.request_content_length = Some(usize_to_i32(total)); } Ok(Err(err)) => request_body_capture_error = Some(err), Err(err) => request_body_capture_error = Some(err.to_string()), } } if let Some(err) = request_body_capture_error.take() { response.error = Some(append_error_message( response.error.take(), format!("Request succeeded but failed to store request body: {err}"), )); } if let Err(join_err) = event_handle.await { warn!("Failed to join response event task: {}", join_err); } if let Some(err) = body_read_error { if persist_response { let _ = persist_response_error( params.query_manager, params.blob_manager, ¶ms.update_source, &response, started_at, err.to_string(), request_started_url, ); } persist_cookie_jar(params.query_manager, cookie_jar.as_mut(), cookie_store.as_ref())?; return Err(err); } let compressed_length = http_response.content_length.unwrap_or(written_bytes as u64); let final_response = HttpResponse { body_path: Some(body_path.to_string_lossy().to_string()), content_length: Some(usize_to_i32(written_bytes)), content_length_compressed: Some(u64_to_i32(compressed_length)), elapsed: duration_to_i32(started_at.elapsed()), elapsed_headers: headers_elapsed, elapsed_dns: dns_elapsed.load(Ordering::Relaxed), state: HttpResponseState::Closed, ..response }; if persist_response { response = params .query_manager .connect() .upsert_http_response(&final_response, ¶ms.update_source, params.blob_manager) .map_err(SendHttpRequestError::PersistResponse)?; } else { response = final_response; } persist_cookie_jar(params.query_manager, cookie_jar.as_mut(), cookie_store.as_ref())?; Ok(SendHttpRequestResult { rendered_request, response, response_body }) } fn persist_request_body_bytes( blob_manager: &BlobManager, body_id: &str, bytes: &[u8], ) -> std::result::Result<(), String> { if bytes.is_empty() { return Ok(()); } let blob_ctx = blob_manager.connect(); let mut offset = 0; let mut chunk_index: i32 = 0; while offset < bytes.len() { let end = std::cmp::min(offset + REQUEST_BODY_CHUNK_SIZE, bytes.len()); let chunk = BodyChunk::new(body_id, chunk_index, bytes[offset..end].to_vec()); blob_ctx.insert_chunk(&chunk).map_err(|e| e.to_string())?; chunk_index += 1; offset = end; } Ok(()) } async fn persist_request_body_stream( blob_manager: BlobManager, body_id: String, mut rx: tokio::sync::mpsc::UnboundedReceiver>, ) -> std::result::Result { let mut chunk_index: i32 = 0; let mut total_bytes = 0usize; while let Some(data) = rx.recv().await { total_bytes += data.len(); if data.is_empty() { continue; } let chunk = BodyChunk::new(&body_id, chunk_index, data); blob_manager.connect().insert_chunk(&chunk).map_err(|e| e.to_string())?; chunk_index += 1; } Ok(total_bytes) } fn append_error_message(existing_error: Option, message: String) -> String { match existing_error { Some(existing) => format!("{existing}; {message}"), None => message, } } fn resolve_environment_chain( query_manager: &QueryManager, request: &HttpRequest, environment_id: Option<&str>, ) -> Result> { let db = query_manager.connect(); db.resolve_environments(&request.workspace_id, request.folder_id.as_deref(), environment_id) .map_err(SendHttpRequestError::ResolveEnvironments) } fn resolve_inherited_request( query_manager: &QueryManager, request: &HttpRequest, ) -> Result<(HttpRequest, String)> { let db = query_manager.connect(); let (authentication_type, authentication, auth_context_id) = db .resolve_auth_for_http_request(request) .map_err(SendHttpRequestError::ResolveRequestInheritance)?; let resolved_headers = db .resolve_headers_for_http_request(request) .map_err(SendHttpRequestError::ResolveRequestInheritance)?; let mut request = request.clone(); request.authentication_type = authentication_type; request.authentication = authentication; request.headers = resolved_headers; Ok((request, auth_context_id)) } fn load_cookie_jar( query_manager: &QueryManager, cookie_jar_id: Option<&str>, ) -> Result> { let Some(cookie_jar_id) = cookie_jar_id else { return Ok(None); }; query_manager .connect() .get_cookie_jar(cookie_jar_id) .map(Some) .map_err(SendHttpRequestError::LoadCookieJar) } fn persist_cookie_jar( query_manager: &QueryManager, cookie_jar: Option<&mut CookieJar>, cookie_store: Option<&CookieStore>, ) -> Result<()> { match (cookie_jar, cookie_store) { (Some(cookie_jar), Some(cookie_store)) => { cookie_jar.cookies = cookie_store.get_all_cookies(); query_manager .connect() .upsert_cookie_jar(cookie_jar, &UpdateSource::Background) .map_err(SendHttpRequestError::PersistCookieJar)?; Ok(()) } _ => Ok(()), } } fn proxy_setting_from_settings(proxy: Option) -> HttpConnectionProxySetting { match proxy { None => HttpConnectionProxySetting::System, Some(ProxySetting::Disabled) => HttpConnectionProxySetting::Disabled, Some(ProxySetting::Enabled { http, https, auth, bypass, disabled }) => { if disabled { HttpConnectionProxySetting::System } else { HttpConnectionProxySetting::Enabled { http, https, bypass, auth: auth.map(|ProxySettingAuth { user, password }| { HttpConnectionProxySettingAuth { user, password } }), } } } } } pub async fn apply_plugin_authentication( sendable_request: &mut SendableHttpRequest, request: &HttpRequest, auth_context_id: &str, plugin_manager: &PluginManager, plugin_context: &PluginContext, ) -> std::result::Result<(), String> { match &request.authentication_type { None => {} Some(authentication_type) if authentication_type == "none" => {} Some(authentication_type) => { let req = CallHttpAuthenticationRequest { context_id: format!("{:x}", md5::compute(auth_context_id)), values: serde_json::from_value( serde_json::to_value(&request.authentication) .map_err(|e| format!("Failed to serialize auth values: {e}"))?, ) .map_err(|e| format!("Failed to parse auth values: {e}"))?, url: sendable_request.url.clone(), method: sendable_request.method.clone(), headers: sendable_request .headers .iter() .map(|(name, value)| HttpHeader { name: name.to_string(), value: value.to_string(), }) .collect(), }; let plugin_result = plugin_manager .call_http_authentication(plugin_context, authentication_type, req) .await .map_err(|e| format!("Failed to apply authentication plugin: {e}"))?; for header in plugin_result.set_headers.unwrap_or_default() { sendable_request.insert_header((header.name, header.value)); } if let Some(params) = plugin_result.set_query_parameters { let params = params.into_iter().map(|p| (p.name, p.value)).collect::>(); sendable_request.url = append_query_params(&sendable_request.url, params); } } } Ok(()) } fn persist_response_error( query_manager: &QueryManager, blob_manager: &BlobManager, update_source: &UpdateSource, response: &HttpResponse, started_at: Instant, error: String, fallback_url: String, ) -> Result { let elapsed = duration_to_i32(started_at.elapsed()); query_manager .connect() .upsert_http_response( &HttpResponse { state: HttpResponseState::Closed, elapsed, elapsed_headers: if response.elapsed_headers == 0 { elapsed } else { response.elapsed_headers }, error: Some(error), url: if response.url.is_empty() { fallback_url } else { response.url.clone() }, ..response.clone() }, update_source, blob_manager, ) .map_err(SendHttpRequestError::PersistResponse) } fn sendable_body_length(body: Option<&SendableBody>) -> Option { match body { Some(SendableBody::Bytes(bytes)) => Some(usize_to_i32(bytes.len())), Some(SendableBody::Stream { content_length: Some(length), .. }) => { Some(u64_to_i32(*length)) } _ => None, } } fn duration_to_i32(duration: std::time::Duration) -> i32 { u128_to_i32(duration.as_millis()) } fn usize_to_i32(value: usize) -> i32 { if value > i32::MAX as usize { i32::MAX } else { value as i32 } } fn u64_to_i32(value: u64) -> i32 { if value > i32::MAX as u64 { i32::MAX } else { value as i32 } } fn u128_to_i32(value: u128) -> i32 { if value > i32::MAX as u128 { i32::MAX } else { value as i32 } } ================================================ FILE: crates/yaak-api/Cargo.toml ================================================ [package] name = "yaak-api" version = "0.1.0" edition = "2024" publish = false [dependencies] log = { workspace = true } reqwest = { workspace = true, features = ["gzip"] } sysproxy = "0.3" thiserror = { workspace = true } yaak-common = { workspace = true } ================================================ FILE: crates/yaak-api/src/error.rs ================================================ use thiserror::Error; #[derive(Error, Debug)] pub enum Error { #[error(transparent)] ReqwestError(#[from] reqwest::Error), } pub type Result = std::result::Result; ================================================ FILE: crates/yaak-api/src/lib.rs ================================================ mod error; pub use error::{Error, Result}; use log::{debug, warn}; use reqwest::Client; use reqwest::header::{HeaderMap, HeaderValue}; use std::time::Duration; use yaak_common::platform::{get_ua_arch, get_ua_platform}; #[derive(Debug, Clone, Copy, Eq, PartialEq)] pub enum ApiClientKind { App, Cli, } /// Build a reqwest Client configured for Yaak's own API calls. /// /// Includes a custom User-Agent, JSON accept header, 20s timeout, gzip, /// and automatic OS-level proxy detection via sysproxy. pub fn yaak_api_client(kind: ApiClientKind, version: &str) -> Result { let platform = get_ua_platform(); let arch = get_ua_arch(); let product = match kind { ApiClientKind::App => "Yaak", ApiClientKind::Cli => "YaakCli", }; let ua = format!("{product}/{version} ({platform}; {arch})"); let mut default_headers = HeaderMap::new(); default_headers.insert("Accept", HeaderValue::from_str("application/json").unwrap()); let mut builder = reqwest::ClientBuilder::new() .timeout(Duration::from_secs(20)) .default_headers(default_headers) .gzip(true) .user_agent(ua); if let Some(sys) = get_enabled_system_proxy() { let proxy_url = format!("http://{}:{}", sys.host, sys.port); match reqwest::Proxy::all(&proxy_url) { Ok(p) => { let p = if !sys.bypass.is_empty() { p.no_proxy(reqwest::NoProxy::from_string(&sys.bypass)) } else { p }; builder = builder.proxy(p); } Err(e) => { warn!("Failed to configure system proxy: {e}"); } } } Ok(builder.build()?) } /// Returns the system proxy URL if one is enabled, e.g. `http://host:port`. pub fn get_system_proxy_url() -> Option { let sys = get_enabled_system_proxy()?; Some(format!("http://{}:{}", sys.host, sys.port)) } fn get_enabled_system_proxy() -> Option { match sysproxy::Sysproxy::get_system_proxy() { Ok(sys) if sys.enable => { debug!("Detected system proxy: http://{}:{}", sys.host, sys.port); Some(sys) } Ok(_) => { debug!("System proxy detected but not enabled"); None } Err(e) => { debug!("Could not detect system proxy: {e}"); None } } } ================================================ FILE: crates/yaak-common/Cargo.toml ================================================ [package] name = "yaak-common" version = "0.1.0" edition = "2024" publish = false [dependencies] serde_json = { workspace = true } tokio = { workspace = true, features = ["process"] } ================================================ FILE: crates/yaak-common/src/command.rs ================================================ use std::ffi::{OsStr, OsString}; use std::io::{self, ErrorKind}; use std::process::Stdio; #[cfg(target_os = "windows")] const CREATE_NO_WINDOW: u32 = 0x0800_0000; /// Creates a new `tokio::process::Command` that won't spawn a console window on Windows. pub fn new_xplatform_command>(program: S) -> tokio::process::Command { #[allow(unused_mut)] let mut cmd = tokio::process::Command::new(program); #[cfg(target_os = "windows")] { use std::os::windows::process::CommandExt; cmd.creation_flags(CREATE_NO_WINDOW); } cmd } /// Creates a command only if the binary exists and can be invoked with the given probe argument. pub async fn new_checked_command>( program: S, probe_arg: &str, ) -> io::Result { let program: OsString = program.as_ref().to_os_string(); let mut probe = new_xplatform_command(&program); probe.arg(probe_arg).stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()); let status = probe.status().await?; if !status.success() { return Err(io::Error::new( ErrorKind::NotFound, format!( "'{}' is not available on PATH or failed to execute", program.to_string_lossy() ), )); } Ok(new_xplatform_command(&program)) } ================================================ FILE: crates/yaak-common/src/lib.rs ================================================ pub mod command; pub mod platform; pub mod serde; ================================================ FILE: crates/yaak-common/src/platform.rs ================================================ use crate::platform::OperatingSystem::{Linux, MacOS, Unknown, Windows}; pub enum OperatingSystem { Windows, MacOS, Linux, Unknown, } pub fn get_os() -> OperatingSystem { if cfg!(target_os = "windows") { Windows } else if cfg!(target_os = "macos") { MacOS } else if cfg!(target_os = "linux") { Linux } else { Unknown } } pub fn get_os_str() -> &'static str { match get_os() { Windows => "windows", MacOS => "macos", Linux => "linux", Unknown => "unknown", } } pub fn get_ua_platform() -> &'static str { if cfg!(target_os = "windows") { "Win" } else if cfg!(target_os = "macos") { "Mac" } else if cfg!(target_os = "linux") { "Linux" } else { "Unknown" } } pub fn get_ua_arch() -> &'static str { if cfg!(target_arch = "x86_64") { "x86_64" } else if cfg!(target_arch = "x86") { "i386" } else if cfg!(target_arch = "arm") { "ARM" } else if cfg!(target_arch = "aarch64") { "ARM64" } else { "Unknown" } } ================================================ FILE: crates/yaak-common/src/serde.rs ================================================ use serde_json::Value; use std::collections::BTreeMap; pub fn get_bool(v: &Value, key: &str, fallback: bool) -> bool { match v.get(key) { None => fallback, Some(v) => v.as_bool().unwrap_or(fallback), } } pub fn get_str<'a>(v: &'a Value, key: &str) -> &'a str { match v.get(key) { None => "", Some(v) => v.as_str().unwrap_or_default(), } } pub fn get_str_map<'a>(v: &'a BTreeMap, key: &str) -> &'a str { match v.get(key) { None => "", Some(v) => v.as_str().unwrap_or_default(), } } pub fn get_bool_map(v: &BTreeMap, key: &str, fallback: bool) -> bool { match v.get(key) { None => fallback, Some(v) => v.as_bool().unwrap_or(fallback), } } ================================================ FILE: crates/yaak-core/Cargo.toml ================================================ [package] name = "yaak-core" version = "0.0.0" edition = "2024" authors = ["Gregory Schier"] publish = false [dependencies] thiserror = { workspace = true } ================================================ FILE: crates/yaak-core/src/context.rs ================================================ use std::path::PathBuf; /// Context for a workspace operation. /// /// In Tauri, this is extracted from the WebviewWindow URL. /// In CLI, this is constructed from command arguments or config. #[derive(Debug, Clone, Default)] pub struct WorkspaceContext { pub workspace_id: Option, pub environment_id: Option, pub cookie_jar_id: Option, pub request_id: Option, } impl WorkspaceContext { pub fn new() -> Self { Self::default() } pub fn with_workspace(mut self, workspace_id: impl Into) -> Self { self.workspace_id = Some(workspace_id.into()); self } pub fn with_environment(mut self, environment_id: impl Into) -> Self { self.environment_id = Some(environment_id.into()); self } pub fn with_cookie_jar(mut self, cookie_jar_id: impl Into) -> Self { self.cookie_jar_id = Some(cookie_jar_id.into()); self } pub fn with_request(mut self, request_id: impl Into) -> Self { self.request_id = Some(request_id.into()); self } } /// Application context trait for accessing app-level resources. /// /// This abstracts over Tauri's `AppHandle` for path resolution and app identity. /// Implemented by Tauri's AppHandle and by CLI's own context struct. pub trait AppContext: Send + Sync + Clone { /// Returns the path to the application data directory. /// This is where the database and other persistent data are stored. fn app_data_dir(&self) -> PathBuf; /// Returns the application identifier (e.g., "app.yaak.desktop"). /// Used for keyring access and other platform-specific features. fn app_identifier(&self) -> &str; /// Returns true if running in development mode. fn is_dev(&self) -> bool; } ================================================ FILE: crates/yaak-core/src/error.rs ================================================ use thiserror::Error; pub type Result = std::result::Result; #[derive(Error, Debug)] pub enum Error { #[error("Missing required context: {0}")] MissingContext(String), #[error("Configuration error: {0}")] Config(String), #[error("IO error: {0}")] Io(#[from] std::io::Error), } ================================================ FILE: crates/yaak-core/src/lib.rs ================================================ //! Core abstractions for Yaak that work without Tauri. //! //! This crate provides foundational types and traits that allow Yaak's //! business logic to run in both Tauri (desktop app) and CLI contexts. mod context; mod error; pub use context::{AppContext, WorkspaceContext}; pub use error::{Error, Result}; ================================================ FILE: crates/yaak-crypto/Cargo.toml ================================================ [package] name = "yaak-crypto" version = "0.1.0" edition = "2021" publish = false [dependencies] base32 = "0.5.1" # For encoding human-readable key base64 = "0.22.1" # For encoding in the database chacha20poly1305 = "0.10.1" keyring = { workspace = true, features = ["apple-native", "windows-native", "sync-secret-service"] } log = { workspace = true } serde = { workspace = true, features = ["derive"] } thiserror = { workspace = true } yaak-models = { workspace = true } ================================================ FILE: crates/yaak-crypto/index.ts ================================================ import { invoke } from "@tauri-apps/api/core"; export function enableEncryption(workspaceId: string) { return invoke("cmd_enable_encryption", { workspaceId }); } export function revealWorkspaceKey(workspaceId: string) { return invoke("cmd_reveal_workspace_key", { workspaceId }); } export function setWorkspaceKey(args: { workspaceId: string; key: string }) { return invoke("cmd_set_workspace_key", args); } export function disableEncryption(workspaceId: string) { return invoke("cmd_disable_encryption", { workspaceId }); } ================================================ FILE: crates/yaak-crypto/package.json ================================================ { "name": "@yaakapp-internal/crypto", "version": "1.0.0", "private": true, "main": "index.ts" } ================================================ FILE: crates/yaak-crypto/src/encryption.rs ================================================ use crate::error::Error::{DecryptionError, EncryptionError, InvalidEncryptedData}; use crate::error::Result; use chacha20poly1305::aead::generic_array::typenum::Unsigned; use chacha20poly1305::aead::{Aead, AeadCore, Key, KeyInit, OsRng}; use chacha20poly1305::XChaCha20Poly1305; const ENCRYPTION_TAG: &str = "yA4k3nC"; const ENCRYPTION_VERSION: u8 = 1; pub(crate) fn encrypt_data(data: &[u8], key: &Key) -> Result> { let nonce = XChaCha20Poly1305::generate_nonce(&mut OsRng); let cipher = XChaCha20Poly1305::new(&key); let ciphered_data = cipher.encrypt(&nonce, data).map_err(|_| EncryptionError)?; let mut data: Vec = Vec::new(); data.extend_from_slice(ENCRYPTION_TAG.as_bytes()); // Tag data.push(ENCRYPTION_VERSION); // Version data.extend_from_slice(&nonce.as_slice()); // Nonce data.extend_from_slice(&ciphered_data); // Ciphertext Ok(data) } pub(crate) fn decrypt_data(cipher_data: &[u8], key: &Key) -> Result> { // Yaak Tag + ID + Version + Nonce + ... ciphertext ... let (tag, rest) = cipher_data.split_at_checked(ENCRYPTION_TAG.len()).ok_or(InvalidEncryptedData)?; if tag != ENCRYPTION_TAG.as_bytes() { return Err(InvalidEncryptedData); } let (version, rest) = rest.split_at_checked(1).ok_or(InvalidEncryptedData)?; if version[0] != ENCRYPTION_VERSION { return Err(InvalidEncryptedData); } let nonce_bytes = ::NonceSize::to_usize(); let (nonce, ciphered_data) = rest.split_at_checked(nonce_bytes).ok_or(InvalidEncryptedData)?; let cipher = XChaCha20Poly1305::new(&key); cipher.decrypt(nonce.into(), ciphered_data).map_err(|_e| DecryptionError) } #[cfg(test)] mod test { use crate::encryption::{decrypt_data, encrypt_data}; use crate::error::Error::InvalidEncryptedData; use crate::error::Result; use chacha20poly1305::aead::OsRng; use chacha20poly1305::{KeyInit, XChaCha20Poly1305}; #[test] fn test_encrypt_decrypt() -> Result<()> { let key = XChaCha20Poly1305::generate_key(OsRng); let encrypted = encrypt_data("hello world".as_bytes(), &key)?; let decrypted = decrypt_data(encrypted.as_slice(), &key)?; assert_eq!(String::from_utf8(decrypted).unwrap(), "hello world"); Ok(()) } #[test] fn test_decrypt_empty() -> Result<()> { let key = XChaCha20Poly1305::generate_key(OsRng); let encrypted = encrypt_data(&[], &key)?; assert_eq!(encrypted.len(), 48); let decrypted = decrypt_data(encrypted.as_slice(), &key)?; assert_eq!(String::from_utf8(decrypted).unwrap(), ""); Ok(()) } #[test] fn test_decrypt_bad_version() -> Result<()> { let key = XChaCha20Poly1305::generate_key(OsRng); let mut encrypted = encrypt_data("hello world".as_bytes(), &key)?; encrypted[7] = 0; let decrypted = decrypt_data(encrypted.as_slice(), &key); assert!(matches!(decrypted, Err(InvalidEncryptedData))); Ok(()) } #[test] fn test_decrypt_bad_tag() -> Result<()> { let key = XChaCha20Poly1305::generate_key(OsRng); let mut encrypted = encrypt_data("hello world".as_bytes(), &key)?; encrypted[0] = 2; let decrypted = decrypt_data(encrypted.as_slice(), &key); assert!(matches!(decrypted, Err(InvalidEncryptedData))); Ok(()) } #[test] fn test_decrypt_unencrypted_data() -> Result<()> { let key = XChaCha20Poly1305::generate_key(OsRng); let decrypted = decrypt_data("123".as_bytes(), &key); assert!(matches!(decrypted, Err(InvalidEncryptedData))); Ok(()) } } ================================================ FILE: crates/yaak-crypto/src/error.rs ================================================ use serde::{Serialize, Serializer}; use std::io; use thiserror::Error; #[derive(Error, Debug)] pub enum Error { #[error(transparent)] DbError(#[from] yaak_models::error::Error), #[error("Keyring error: {0}")] KeyringError(#[from] keyring::Error), #[error("Missing workspace encryption key")] MissingWorkspaceKey, #[error("Incorrect workspace key")] IncorrectWorkspaceKey, #[error("Failed to decrypt workspace key: {0}")] WorkspaceKeyDecryptionError(String), #[error("Crypto IO error: {0}")] IoError(#[from] io::Error), #[error("Failed to encrypt data")] EncryptionError, #[error("Failed to decrypt data")] DecryptionError, #[error("Invalid encrypted data")] InvalidEncryptedData, #[error("Invalid key provided")] InvalidHumanKey, #[error("Encryption error: {0}")] GenericError(String), } impl Serialize for Error { fn serialize(&self, serializer: S) -> std::result::Result where S: Serializer, { serializer.serialize_str(self.to_string().as_ref()) } } pub type Result = std::result::Result; ================================================ FILE: crates/yaak-crypto/src/lib.rs ================================================ extern crate core; pub mod encryption; pub mod error; pub mod manager; mod master_key; mod workspace_key; ================================================ FILE: crates/yaak-crypto/src/manager.rs ================================================ use crate::error::Error::{ GenericError, IncorrectWorkspaceKey, MissingWorkspaceKey, WorkspaceKeyDecryptionError, }; use crate::error::{Error, Result}; use crate::master_key::MasterKey; use crate::workspace_key::WorkspaceKey; use base64::prelude::BASE64_STANDARD; use base64::Engine; use log::{info, warn}; use std::collections::HashMap; use std::sync::{Arc, Mutex}; use yaak_models::models::{EncryptedKey, Workspace, WorkspaceMeta}; use yaak_models::query_manager::QueryManager; use yaak_models::util::{generate_id_of_length, UpdateSource}; const KEY_USER: &str = "encryption-key"; #[derive(Debug, Clone)] pub struct EncryptionManager { cached_master_key: Arc>>, cached_workspace_keys: Arc>>, query_manager: QueryManager, app_id: String, } impl EncryptionManager { pub fn new(query_manager: QueryManager, app_id: impl Into) -> Self { Self { cached_master_key: Default::default(), cached_workspace_keys: Default::default(), query_manager, app_id: app_id.into(), } } pub fn encrypt(&self, workspace_id: &str, data: &[u8]) -> Result> { let workspace_secret = self.get_workspace_key(workspace_id)?; workspace_secret.encrypt(data) } pub fn decrypt(&self, workspace_id: &str, data: &[u8]) -> Result> { let workspace_secret = self.get_workspace_key(workspace_id)?; workspace_secret.decrypt(data) } pub fn reveal_workspace_key(&self, workspace_id: &str) -> Result { let key = self.get_workspace_key(workspace_id)?; key.to_human() } pub fn set_human_key(&self, workspace_id: &str, human_key: &str) -> Result { let wkey = WorkspaceKey::from_human(human_key)?; let workspace = self.query_manager.connect().get_workspace(workspace_id)?; let encryption_key_challenge = match workspace.encryption_key_challenge { None => return self.set_workspace_key(workspace_id, &wkey), Some(c) => c, }; let encryption_key_challenge = match BASE64_STANDARD.decode(encryption_key_challenge) { Ok(c) => c, Err(_) => return Err(GenericError("Failed to decode workspace challenge".to_string())), }; if let Err(_) = wkey.decrypt(encryption_key_challenge.as_slice()) { return Err(IncorrectWorkspaceKey); }; self.set_workspace_key(workspace_id, &wkey) } pub(crate) fn set_workspace_key( &self, workspace_id: &str, wkey: &WorkspaceKey, ) -> Result { info!("Created workspace key for {workspace_id}"); let encrypted_key = BASE64_STANDARD.encode(self.get_master_key()?.encrypt(wkey.raw_key())?); let encrypted_key = EncryptedKey { encrypted_key }; let encryption_key_challenge = wkey.encrypt(generate_id_of_length(50).as_bytes())?; let encryption_key_challenge = Some(BASE64_STANDARD.encode(encryption_key_challenge)); let workspace_meta = self.query_manager.with_tx::(|tx| { let workspace = tx.get_workspace(workspace_id)?; let workspace_meta = tx.get_or_create_workspace_meta(workspace_id)?; tx.upsert_workspace( &Workspace { encryption_key_challenge, ..workspace }, &UpdateSource::Background, )?; Ok(tx.upsert_workspace_meta( &WorkspaceMeta { encryption_key: Some(encrypted_key.clone()), ..workspace_meta }, &UpdateSource::Background, )?) })?; let mut cache = self.cached_workspace_keys.lock().unwrap(); cache.insert(workspace_id.to_string(), wkey.clone()); Ok(workspace_meta) } pub fn ensure_workspace_key(&self, workspace_id: &str) -> Result { let workspace_meta = self.query_manager.connect().get_or_create_workspace_meta(workspace_id)?; // Already exists if let Some(_) = workspace_meta.encryption_key { warn!("Tried to create workspace key when one already exists for {workspace_id}"); return Ok(workspace_meta); } let wkey = WorkspaceKey::create()?; self.set_workspace_key(workspace_id, &wkey) } pub fn disable_encryption(&self, workspace_id: &str) -> Result<()> { info!("Disabling encryption for {workspace_id}"); self.query_manager.with_tx::<(), Error>(|tx| { let workspace = tx.get_workspace(workspace_id)?; let workspace_meta = tx.get_or_create_workspace_meta(workspace_id)?; // Clear encryption challenge on workspace tx.upsert_workspace( &Workspace { encryption_key_challenge: None, ..workspace }, &UpdateSource::Background, )?; // Clear encryption key on workspace meta tx.upsert_workspace_meta( &WorkspaceMeta { encryption_key: None, ..workspace_meta }, &UpdateSource::Background, )?; Ok(()) })?; // Remove from cache let mut cache = self.cached_workspace_keys.lock().unwrap(); cache.remove(workspace_id); Ok(()) } fn get_workspace_key(&self, workspace_id: &str) -> Result { { let cache = self.cached_workspace_keys.lock().unwrap(); if let Some(k) = cache.get(workspace_id) { return Ok(k.clone()); } }; let db = self.query_manager.connect(); let workspace_meta = db.get_or_create_workspace_meta(workspace_id)?; let key = match workspace_meta.encryption_key { None => return Err(MissingWorkspaceKey), Some(k) => k, }; let mkey = self.get_master_key()?; let decoded_key = BASE64_STANDARD .decode(key.encrypted_key) .map_err(|e| WorkspaceKeyDecryptionError(e.to_string()))?; let raw_key = mkey .decrypt(decoded_key.as_slice()) .map_err(|e| WorkspaceKeyDecryptionError(e.to_string()))?; let wkey = WorkspaceKey::from_raw_key(raw_key.as_slice()); Ok(wkey) } fn get_master_key(&self) -> Result { // NOTE: This locks the key for the entire function which seems wrong, but this prevents // concurrent access from prompting the user for a keychain password multiple times. let mut master_secret = self.cached_master_key.lock().unwrap(); if let Some(k) = master_secret.as_ref() { return Ok(k.to_owned()); } let mkey = MasterKey::get_or_create(&self.app_id, KEY_USER)?; *master_secret = Some(mkey.clone()); Ok(mkey) } } ================================================ FILE: crates/yaak-crypto/src/master_key.rs ================================================ use crate::encryption::{decrypt_data, encrypt_data}; use crate::error::Error::GenericError; use crate::error::Result; use base32::Alphabet; use chacha20poly1305::aead::{Key, KeyInit, OsRng}; use chacha20poly1305::XChaCha20Poly1305; use keyring::{Entry, Error}; use log::info; const HUMAN_PREFIX: &str = "YKM_"; #[derive(Debug, Clone)] pub(crate) struct MasterKey { key: Key, } impl MasterKey { pub(crate) fn get_or_create(app_id: &str, user: &str) -> Result { let id = format!("{app_id}.EncryptionKey"); let entry = Entry::new(&id, user)?; let key = match entry.get_password() { Ok(encoded) => { let without_prefix = encoded.strip_prefix(HUMAN_PREFIX).unwrap_or(&encoded); let key_bytes = base32::decode(Alphabet::Crockford {}, &without_prefix) .ok_or(GenericError("Failed to decode master key".to_string()))?; Key::::clone_from_slice(key_bytes.as_slice()) } Err(Error::NoEntry) => { info!("Creating new master key"); let key = XChaCha20Poly1305::generate_key(OsRng); let encoded = base32::encode(Alphabet::Crockford {}, key.as_slice()); let with_prefix = format!("{HUMAN_PREFIX}{encoded}"); entry.set_password(&with_prefix)?; key } Err(e) => return Err(GenericError(e.to_string())), }; Ok(Self { key }) } pub(crate) fn encrypt(&self, data: &[u8]) -> Result> { encrypt_data(data, &self.key) } pub(crate) fn decrypt(&self, data: &[u8]) -> Result> { decrypt_data(data, &self.key) } #[cfg(test)] pub(crate) fn test_key() -> Self { let key: Key = Key::::clone_from_slice( "00000000000000000000000000000000".as_bytes(), ); Self { key } } } #[cfg(test)] mod tests { use crate::error::Result; use crate::master_key::MasterKey; #[test] fn test_master_key() -> Result<()> { // Test out the master key let mkey = MasterKey::test_key(); let encrypted = mkey.encrypt("hello".as_bytes())?; let decrypted = mkey.decrypt(encrypted.as_slice()).unwrap(); assert_eq!(decrypted, "hello".as_bytes().to_vec()); let mkey = MasterKey::test_key(); let decrypted = mkey.decrypt(encrypted.as_slice()).unwrap(); assert_eq!(decrypted, "hello".as_bytes().to_vec()); Ok(()) } } ================================================ FILE: crates/yaak-crypto/src/workspace_key.rs ================================================ use crate::encryption::{decrypt_data, encrypt_data}; use crate::error::Error::InvalidHumanKey; use crate::error::Result; use base32::Alphabet; use chacha20poly1305::aead::{Key, KeyInit, OsRng}; use chacha20poly1305::{KeySizeUser, XChaCha20Poly1305}; #[derive(Debug, Clone)] pub struct WorkspaceKey { key: Key, } const HUMAN_PREFIX: &str = "YK"; impl WorkspaceKey { pub(crate) fn to_human(&self) -> Result { let encoded = base32::encode(Alphabet::Crockford {}, self.key.as_slice()); let with_prefix = format!("{HUMAN_PREFIX}{encoded}"); let with_separators = with_prefix .chars() .collect::>() .chunks(6) .map(|chunk| chunk.iter().collect::()) .collect::>() .join("-"); Ok(with_separators) } #[allow(dead_code)] pub(crate) fn from_human(human_key: &str) -> Result { let without_prefix = human_key.strip_prefix(HUMAN_PREFIX).unwrap_or(human_key); let without_separators = without_prefix.replace("-", ""); let key = base32::decode(Alphabet::Crockford {}, &without_separators).ok_or(InvalidHumanKey)?; if key.len() != XChaCha20Poly1305::key_size() { return Err(InvalidHumanKey); } Ok(Self::from_raw_key(key.as_slice())) } pub(crate) fn from_raw_key(key: &[u8]) -> Self { Self { key: Key::::clone_from_slice(key) } } pub(crate) fn raw_key(&self) -> &[u8] { self.key.as_slice() } pub(crate) fn create() -> Result { let key = XChaCha20Poly1305::generate_key(OsRng); Ok(Self::from_raw_key(key.as_slice())) } pub(crate) fn encrypt(&self, data: &[u8]) -> Result> { encrypt_data(data, &self.key) } pub(crate) fn decrypt(&self, data: &[u8]) -> Result> { decrypt_data(data, &self.key) } #[cfg(test)] pub(crate) fn test_key() -> Self { Self::from_raw_key("f1a2d4b3c8e799af1456be3478a4c3f2".as_bytes()) } } #[cfg(test)] mod tests { use crate::error::Error::InvalidHumanKey; use crate::error::Result; use crate::workspace_key::WorkspaceKey; #[test] fn test_persisted_key() -> Result<()> { let key = WorkspaceKey::test_key(); let encrypted = key.encrypt("hello".as_bytes())?; assert_eq!(key.decrypt(encrypted.as_slice())?, "hello".as_bytes()); Ok(()) } #[test] fn test_human_format() -> Result<()> { let key = WorkspaceKey::test_key(); let encrypted = key.encrypt("hello".as_bytes())?; assert_eq!(key.decrypt(encrypted.as_slice())?, "hello".as_bytes()); let human = key.to_human()?; assert_eq!(human, "YKCRRP-2CK46H-H36RSR-CMVKJE-B1CRRK-8D9PC9-JK6D1Q-71GK8R-SKCRS0"); assert_eq!( WorkspaceKey::from_human(&human)?.decrypt(encrypted.as_slice())?, "hello".as_bytes() ); Ok(()) } #[test] fn test_from_human_invalid() -> Result<()> { assert!(matches!( WorkspaceKey::from_human( "YKCRRP-2CK46H-H36RSR-CMVKJE-B1CRRK-8D9PC9-JK6D1Q-71GK8R-SKCRS0-H3X38D", ), Err(InvalidHumanKey) )); assert!(matches!(WorkspaceKey::from_human("bad-key",), Err(InvalidHumanKey))); assert!(matches!(WorkspaceKey::from_human("",), Err(InvalidHumanKey))); Ok(()) } } ================================================ FILE: crates/yaak-git/Cargo.toml ================================================ [package] name = "yaak-git" version = "0.1.0" edition = "2024" publish = false [dependencies] chrono = { workspace = true, features = ["serde"] } git2 = { version = "0.20.4", features = ["vendored-libgit2", "vendored-openssl"] } log = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } serde_yaml = "0.9.34" thiserror = { workspace = true } tokio = { workspace = true, features = ["io-util"] } ts-rs = { workspace = true, features = ["chrono-impl", "serde-json-impl"] } url = "2" yaak-common = { workspace = true } yaak-models = { workspace = true } yaak-sync = { workspace = true } ================================================ FILE: crates/yaak-git/bindings/gen_git.ts ================================================ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { SyncModel } from "./gen_models"; export type BranchDeleteResult = { "type": "success", message: string, } | { "type": "not_fully_merged" }; export type CloneResult = { "type": "success" } | { "type": "cancelled" } | { "type": "needs_credentials", url: string, error: string | null, }; export type GitAuthor = { name: string | null, email: string | null, }; export type GitCommit = { author: GitAuthor, when: string, message: string | null, }; export type GitRemote = { name: string, url: string | null, }; export type GitStatus = "untracked" | "conflict" | "current" | "modified" | "removed" | "renamed" | "type_change"; export type GitStatusEntry = { relaPath: string, status: GitStatus, staged: boolean, prev: SyncModel | null, next: SyncModel | null, }; export type GitStatusSummary = { path: string, headRef: string | null, headRefShorthand: string | null, entries: Array, origins: Array, localBranches: Array, remoteBranches: Array, ahead: number, behind: number, }; export type PullResult = { "type": "success", message: string, } | { "type": "up_to_date" } | { "type": "needs_credentials", url: string, error: string | null, } | { "type": "diverged", remote: string, branch: string, } | { "type": "uncommitted_changes" }; export type PushResult = { "type": "success", message: string, } | { "type": "up_to_date" } | { "type": "needs_credentials", url: string, error: string | null, }; ================================================ FILE: crates/yaak-git/bindings/gen_models.ts ================================================ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. export type DnsOverride = { hostname: string, ipv4: Array, ipv6: Array, enabled?: boolean, }; export type Environment = { model: "environment", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, parentModel: string, parentId: string | null, variables: Array, color: string | null, sortPriority: number, }; 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, authenticationType: string | null, description: string, headers: Array, 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, description: string, message: string, metadata: Array, 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, authenticationType: string | null, body: Record, bodyType: string | null, description: string, headers: Array, method: string, name: string, sortPriority: number, url: string, urlParameters: Array, }; export type HttpRequestHeader = { enabled?: boolean, name: string, value: string, id?: string, }; export type HttpUrlParameter = { enabled?: boolean, name: string, value: string, id?: string, }; export type SyncModel = { "type": "workspace" } & Workspace | { "type": "environment" } & Environment | { "type": "folder" } & Folder | { "type": "http_request" } & HttpRequest | { "type": "grpc_request" } & GrpcRequest | { "type": "websocket_request" } & WebsocketRequest; export type WebsocketRequest = { model: "websocket_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record, authenticationType: string | null, description: string, headers: Array, message: string, name: string, sortPriority: number, url: string, urlParameters: Array, }; export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, authentication: Record, authenticationType: string | null, description: string, headers: Array, name: string, encryptionKeyChallenge: string | null, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, settingDnsOverrides: Array, }; ================================================ FILE: crates/yaak-git/index.ts ================================================ import { useQuery } from "@tanstack/react-query"; import { invoke } from "@tauri-apps/api/core"; import { createFastMutation } from "@yaakapp/app/hooks/useFastMutation"; import { queryClient } from "@yaakapp/app/lib/queryClient"; import { useMemo } from "react"; import { BranchDeleteResult, CloneResult, GitCommit, GitRemote, GitStatusSummary, PullResult, PushResult, } from "./bindings/gen_git"; import { showToast } from "@yaakapp/app/lib/toast"; export * from "./bindings/gen_git"; export * from "./bindings/gen_models"; export interface GitCredentials { username: string; password: string; } export type DivergedStrategy = "force_reset" | "merge" | "cancel"; export type UncommittedChangesStrategy = "reset" | "cancel"; export interface GitCallbacks { addRemote: () => Promise; promptCredentials: ( result: Extract, ) => Promise; promptDiverged: (result: Extract) => Promise; promptUncommittedChanges: () => Promise; forceSync: () => Promise; } const onSuccess = () => queryClient.invalidateQueries({ queryKey: ["git"] }); export function useGit(dir: string, callbacks: GitCallbacks, refreshKey?: string) { const mutations = useMemo(() => gitMutations(dir, callbacks), [dir, callbacks]); const fetchAll = useQuery({ queryKey: ["git", "fetch_all", dir, refreshKey], queryFn: () => invoke("cmd_git_fetch_all", { dir }), refetchInterval: 10 * 60_000, }); return [ { remotes: useQuery({ queryKey: ["git", "remotes", dir, refreshKey], queryFn: () => getRemotes(dir), placeholderData: (prev) => prev, }), log: useQuery({ queryKey: ["git", "log", dir, refreshKey], queryFn: () => invoke("cmd_git_log", { dir }), placeholderData: (prev) => prev, }), status: useQuery({ refetchOnMount: true, queryKey: ["git", "status", dir, refreshKey, fetchAll.dataUpdatedAt], queryFn: () => invoke("cmd_git_status", { dir }), placeholderData: (prev) => prev, }), }, mutations, ] as const; } export const gitMutations = (dir: string, callbacks: GitCallbacks) => { const push = async () => { const remotes = await getRemotes(dir); if (remotes.length === 0) { const remote = await callbacks.addRemote(); if (remote == null) throw new Error("No remote found"); } const result = await invoke("cmd_git_push", { dir }); if (result.type !== "needs_credentials") return result; // Needs credentials, prompt for them const creds = await callbacks.promptCredentials(result); if (creds == null) throw new Error("Canceled"); await invoke("cmd_git_add_credential", { remoteUrl: result.url, username: creds.username, password: creds.password, }); // Push again return invoke("cmd_git_push", { dir }); }; const handleError = (err: unknown) => { showToast({ id: err instanceof Error ? err.message : String(err), message: err instanceof Error ? err.message : String(err), color: "danger", timeout: 5000, }); }; return { init: createFastMutation({ mutationKey: ["git", "init"], mutationFn: () => invoke("cmd_git_initialize", { dir }), onSuccess, }), add: createFastMutation({ mutationKey: ["git", "add", dir], mutationFn: (args) => invoke("cmd_git_add", { dir, ...args }), onSuccess, }), addRemote: createFastMutation({ mutationKey: ["git", "add-remote"], mutationFn: (args) => invoke("cmd_git_add_remote", { dir, ...args }), onSuccess, }), rmRemote: createFastMutation({ mutationKey: ["git", "rm-remote", dir], mutationFn: (args) => invoke("cmd_git_rm_remote", { dir, ...args }), onSuccess, }), createBranch: createFastMutation({ mutationKey: ["git", "branch", dir], mutationFn: (args) => invoke("cmd_git_branch", { dir, ...args }), onSuccess, }), mergeBranch: createFastMutation({ mutationKey: ["git", "merge", dir], mutationFn: (args) => invoke("cmd_git_merge_branch", { dir, ...args }), onSuccess, }), deleteBranch: createFastMutation< BranchDeleteResult, string, { branch: string; force?: boolean } >({ mutationKey: ["git", "delete-branch", dir], mutationFn: (args) => invoke("cmd_git_delete_branch", { dir, ...args }), onSuccess, }), deleteRemoteBranch: createFastMutation({ mutationKey: ["git", "delete-remote-branch", dir], mutationFn: (args) => invoke("cmd_git_delete_remote_branch", { dir, ...args }), onSuccess, }), renameBranch: createFastMutation({ mutationKey: ["git", "rename-branch", dir], mutationFn: (args) => invoke("cmd_git_rename_branch", { dir, ...args }), onSuccess, }), checkout: createFastMutation({ mutationKey: ["git", "checkout", dir], mutationFn: (args) => invoke("cmd_git_checkout", { dir, ...args }), onSuccess, }), commit: createFastMutation({ mutationKey: ["git", "commit", dir], mutationFn: (args) => invoke("cmd_git_commit", { dir, ...args }), onSuccess, }), commitAndPush: createFastMutation({ mutationKey: ["git", "commit_push", dir], mutationFn: async (args) => { await invoke("cmd_git_commit", { dir, ...args }); return push(); }, onSuccess, }), push: createFastMutation({ mutationKey: ["git", "push", dir], mutationFn: push, onSuccess, }), pull: createFastMutation({ mutationKey: ["git", "pull", dir], async mutationFn() { const result = await invoke("cmd_git_pull", { dir }); if (result.type === "needs_credentials") { const creds = await callbacks.promptCredentials(result); if (creds == null) throw new Error("Canceled"); await invoke("cmd_git_add_credential", { remoteUrl: result.url, username: creds.username, password: creds.password, }); // Pull again after credentials return invoke("cmd_git_pull", { dir }); } if (result.type === "uncommitted_changes") { void callbacks .promptUncommittedChanges() .then(async (strategy) => { if (strategy === "cancel") return; await invoke("cmd_git_reset_changes", { dir }); return invoke("cmd_git_pull", { dir }); }) .then(async () => { await onSuccess(); await callbacks.forceSync(); }, handleError); } if (result.type === "diverged") { void callbacks .promptDiverged(result) .then((strategy) => { if (strategy === "cancel") return; if (strategy === "force_reset") { return invoke("cmd_git_pull_force_reset", { dir, remote: result.remote, branch: result.branch, }); } return invoke("cmd_git_pull_merge", { dir, remote: result.remote, branch: result.branch, }); }) .then(async () => { await onSuccess(); await callbacks.forceSync(); }, handleError); } return result; }, onSuccess, }), unstage: createFastMutation({ mutationKey: ["git", "unstage", dir], mutationFn: (args) => invoke("cmd_git_unstage", { dir, ...args }), onSuccess, }), resetChanges: createFastMutation({ mutationKey: ["git", "reset-changes", dir], mutationFn: () => invoke("cmd_git_reset_changes", { dir }), onSuccess, }), } as const; }; async function getRemotes(dir: string) { return invoke("cmd_git_remotes", { dir }); } /** * Clone a git repository, prompting for credentials if needed. */ export async function gitClone( url: string, dir: string, promptCredentials: (args: { url: string; error: string | null; }) => Promise, ): Promise { const result = await invoke("cmd_git_clone", { url, dir }); if (result.type !== "needs_credentials") return result; // Prompt for credentials const creds = await promptCredentials({ url: result.url, error: result.error }); if (creds == null) return { type: "cancelled" }; // Store credentials and retry await invoke("cmd_git_add_credential", { remoteUrl: result.url, username: creds.username, password: creds.password, }); return invoke("cmd_git_clone", { url, dir }); } ================================================ FILE: crates/yaak-git/package.json ================================================ { "name": "@yaakapp-internal/git", "version": "1.0.0", "private": true, "main": "index.ts" } ================================================ FILE: crates/yaak-git/src/add.rs ================================================ use crate::error::Result; use crate::repository::open_repo; use git2::IndexAddOption; use log::info; use std::path::Path; pub fn git_add(dir: &Path, rela_path: &Path) -> Result<()> { let repo = open_repo(dir)?; let mut index = repo.index()?; info!("Staging file {rela_path:?} to {dir:?}"); index.add_all(&[rela_path], IndexAddOption::DEFAULT, None)?; index.write()?; Ok(()) } ================================================ FILE: crates/yaak-git/src/binary.rs ================================================ use crate::error::Error::GitNotFound; use crate::error::Result; use std::path::Path; use tokio::process::Command; use yaak_common::command::new_checked_command; /// Create a git command that runs in the specified directory pub(crate) async fn new_binary_command(dir: &Path) -> Result { let mut cmd = new_binary_command_global().await?; cmd.arg("-C").arg(dir); Ok(cmd) } /// Create a git command without a specific directory (for global operations) pub(crate) async fn new_binary_command_global() -> Result { new_checked_command("git", "--version").await.map_err(|_| GitNotFound) } ================================================ FILE: crates/yaak-git/src/branch.rs ================================================ use serde::{Deserialize, Serialize}; use ts_rs::TS; use crate::binary::new_binary_command; use crate::error::Error::GenericError; use crate::error::Result; use std::path::Path; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] #[serde(rename_all = "snake_case", tag = "type")] #[ts(export, export_to = "gen_git.ts")] pub enum BranchDeleteResult { Success { message: String }, NotFullyMerged, } pub async fn git_checkout_branch(dir: &Path, branch_name: &str, force: bool) -> Result { let branch_name = branch_name.trim_start_matches("origin/"); let mut args = vec!["checkout"]; if force { args.push("--force"); } args.push(branch_name); let out = new_binary_command(dir) .await? .args(&args) .output() .await .map_err(|e| GenericError(format!("failed to run git checkout: {e}")))?; let stdout = String::from_utf8_lossy(&out.stdout); let stderr = String::from_utf8_lossy(&out.stderr); let combined = format!("{}{}", stdout, stderr); if !out.status.success() { return Err(GenericError(format!("Failed to checkout: {}", combined.trim()))); } Ok(branch_name.to_string()) } pub async fn git_create_branch(dir: &Path, name: &str, base: Option<&str>) -> Result<()> { let mut cmd = new_binary_command(dir).await?; cmd.arg("branch").arg(name); if let Some(base_branch) = base { cmd.arg(base_branch); } let out = cmd.output().await.map_err(|e| GenericError(format!("failed to run git branch: {e}")))?; let stdout = String::from_utf8_lossy(&out.stdout); let stderr = String::from_utf8_lossy(&out.stderr); let combined = format!("{}{}", stdout, stderr); if !out.status.success() { return Err(GenericError(format!("Failed to create branch: {}", combined.trim()))); } Ok(()) } pub async fn git_delete_branch(dir: &Path, name: &str, force: bool) -> Result { let mut cmd = new_binary_command(dir).await?; let out = if force { cmd.args(["branch", "-D", name]) } else { cmd.args(["branch", "-d", name]) } .output() .await .map_err(|e| GenericError(format!("failed to run git branch -d: {e}")))?; let stdout = String::from_utf8_lossy(&out.stdout); let stderr = String::from_utf8_lossy(&out.stderr); let combined = format!("{}{}", stdout, stderr); if !out.status.success() && stderr.to_lowercase().contains("not fully merged") { return Ok(BranchDeleteResult::NotFullyMerged); } if !out.status.success() { return Err(GenericError(format!("Failed to delete branch: {}", combined.trim()))); } Ok(BranchDeleteResult::Success { message: combined }) } pub async fn git_merge_branch(dir: &Path, name: &str) -> Result<()> { let out = new_binary_command(dir) .await? .args(["merge", name]) .output() .await .map_err(|e| GenericError(format!("failed to run git merge: {e}")))?; let stdout = String::from_utf8_lossy(&out.stdout); let stderr = String::from_utf8_lossy(&out.stderr); let combined = format!("{}{}", stdout, stderr); if !out.status.success() { // Check for merge conflicts if combined.to_lowercase().contains("conflict") { return Err(GenericError( "Merge conflicts detected. Please resolve them manually.".to_string(), )); } return Err(GenericError(format!("Failed to merge: {}", combined.trim()))); } Ok(()) } pub async fn git_delete_remote_branch(dir: &Path, name: &str) -> Result<()> { // Remote branch names come in as "origin/branch-name", extract the branch name let branch_name = name.trim_start_matches("origin/"); let out = new_binary_command(dir) .await? .args(["push", "origin", "--delete", branch_name]) .output() .await .map_err(|e| GenericError(format!("failed to run git push --delete: {e}")))?; let stdout = String::from_utf8_lossy(&out.stdout); let stderr = String::from_utf8_lossy(&out.stderr); let combined = format!("{}{}", stdout, stderr); if !out.status.success() { return Err(GenericError(format!("Failed to delete remote branch: {}", combined.trim()))); } Ok(()) } pub async fn git_rename_branch(dir: &Path, old_name: &str, new_name: &str) -> Result<()> { let out = new_binary_command(dir) .await? .args(["branch", "-m", old_name, new_name]) .output() .await .map_err(|e| GenericError(format!("failed to run git branch -m: {e}")))?; let stdout = String::from_utf8_lossy(&out.stdout); let stderr = String::from_utf8_lossy(&out.stderr); let combined = format!("{}{}", stdout, stderr); if !out.status.success() { return Err(GenericError(format!("Failed to rename branch: {}", combined.trim()))); } Ok(()) } ================================================ FILE: crates/yaak-git/src/clone.rs ================================================ use crate::binary::new_binary_command; use crate::error::Error::GenericError; use crate::error::Result; use log::info; use serde::{Deserialize, Serialize}; use std::fs; use std::path::Path; use ts_rs::TS; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] #[serde(rename_all = "snake_case", tag = "type")] #[ts(export, export_to = "gen_git.ts")] pub enum CloneResult { Success, Cancelled, NeedsCredentials { url: String, error: Option }, } pub async fn git_clone(url: &str, dir: &Path) -> Result { let parent = dir.parent().ok_or_else(|| GenericError("Invalid clone directory".to_string()))?; fs::create_dir_all(parent) .map_err(|e| GenericError(format!("Failed to create directory: {e}")))?; let mut cmd = new_binary_command(parent).await?; cmd.args(["clone", url]).arg(dir).env("GIT_TERMINAL_PROMPT", "0"); let out = cmd.output().await.map_err(|e| GenericError(format!("failed to run git clone: {e}")))?; let stdout = String::from_utf8_lossy(&out.stdout); let stderr = String::from_utf8_lossy(&out.stderr); let combined = format!("{}{}", stdout, stderr); let combined_lower = combined.to_lowercase(); info!("Cloned status={}: {combined}", out.status); if !out.status.success() { // Check for credentials error if combined_lower.contains("could not read") { return Ok(CloneResult::NeedsCredentials { url: url.to_string(), error: None }); } if combined_lower.contains("unable to access") || combined_lower.contains("authentication failed") { return Ok(CloneResult::NeedsCredentials { url: url.to_string(), error: Some(combined.to_string()), }); } return Err(GenericError(format!("Failed to clone: {}", combined.trim()))); } Ok(CloneResult::Success) } ================================================ FILE: crates/yaak-git/src/commit.rs ================================================ use crate::binary::new_binary_command; use crate::error::Error::GenericError; use log::info; use std::path::Path; pub async fn git_commit(dir: &Path, message: &str) -> crate::error::Result<()> { let out = new_binary_command(dir).await?.args(["commit", "--message", message]).output().await?; let stdout = String::from_utf8_lossy(&out.stdout); let stderr = String::from_utf8_lossy(&out.stderr); let combined = stdout + stderr; if !out.status.success() { return Err(GenericError(format!("Failed to commit: {}", combined))); } info!("Committed to {dir:?}"); Ok(()) } ================================================ FILE: crates/yaak-git/src/credential.rs ================================================ use crate::binary::new_binary_command_global; use crate::error::Error::GenericError; use crate::error::Result; use std::process::Stdio; use tokio::io::AsyncWriteExt; use url::Url; pub async fn git_add_credential(remote_url: &str, username: &str, password: &str) -> Result<()> { let url = Url::parse(remote_url) .map_err(|e| GenericError(format!("Failed to parse remote url {remote_url}: {e:?}")))?; let protocol = url.scheme(); let host = url.host_str().unwrap(); let path = Some(url.path()); let mut child = new_binary_command_global() .await? .args(["credential", "approve"]) .stdin(Stdio::piped()) .stdout(Stdio::null()) .spawn()?; { let stdin = child.stdin.as_mut().unwrap(); stdin.write_all(format!("protocol={}\n", protocol).as_bytes()).await?; stdin.write_all(format!("host={}\n", host).as_bytes()).await?; if let Some(path) = path { if !path.is_empty() { stdin .write_all(format!("path={}\n", path.trim_start_matches('/')).as_bytes()) .await?; } } stdin.write_all(format!("username={}\n", username).as_bytes()).await?; stdin.write_all(format!("password={}\n", password).as_bytes()).await?; stdin.write_all(b"\n").await?; // blank line terminator } let status = child.wait().await?; if !status.success() { return Err(GenericError("Failed to approve git credential".to_string())); } Ok(()) } ================================================ FILE: crates/yaak-git/src/error.rs ================================================ use serde::{Serialize, Serializer}; use std::io; use std::path::PathBuf; use std::string::FromUtf8Error; use thiserror::Error; #[derive(Error, Debug)] pub enum Error { #[error("Git repo not found {0}")] GitRepoNotFound(PathBuf), #[error("Git error: {0}")] GitUnknown(#[from] git2::Error), #[error("Yaml error: {0}")] YamlParseError(#[from] serde_yaml::Error), #[error(transparent)] ModelError(#[from] yaak_models::error::Error), #[error("Sync error: {0}")] SyncError(#[from] yaak_sync::error::Error), #[error("I/o error: {0}")] IoError(#[from] io::Error), #[error("JSON error: {0}")] JsonParseError(#[from] serde_json::Error), #[error("UTF8 error: {0}")] Utf8ConversionError(#[from] FromUtf8Error), #[error("Git error: {0}")] GenericError(String), #[error("'git' not found. Please ensure it's installed and available in $PATH")] GitNotFound, #[error("Credentials required: {0}")] CredentialsRequiredError(String), #[error("No default remote found")] NoDefaultRemoteFound, #[error("No remotes found for repo")] NoRemotesFound, #[error("Merge failed due to conflicts")] MergeConflicts, #[error("No active branch")] NoActiveBranch, } impl Serialize for Error { fn serialize(&self, serializer: S) -> std::result::Result where S: Serializer, { serializer.serialize_str(self.to_string().as_ref()) } } pub type Result = std::result::Result; ================================================ FILE: crates/yaak-git/src/fetch.rs ================================================ use crate::binary::new_binary_command; use crate::error::Error::GenericError; use crate::error::Result; use std::path::Path; pub async fn git_fetch_all(dir: &Path) -> Result<()> { let out = new_binary_command(dir) .await? .args(["fetch", "--all", "--prune", "--tags"]) .output() .await .map_err(|e| GenericError(format!("failed to run git pull: {e}")))?; let stdout = String::from_utf8_lossy(&out.stdout); let stderr = String::from_utf8_lossy(&out.stderr); let combined = stdout + stderr; if !out.status.success() { return Err(GenericError(format!("Failed to fetch: {}", combined))); } Ok(()) } ================================================ FILE: crates/yaak-git/src/init.rs ================================================ use crate::error::Result; use crate::repository::open_repo; use log::info; use std::path::Path; pub fn git_init(dir: &Path) -> Result<()> { git2::Repository::init(dir)?; let repo = open_repo(dir)?; // Default to main instead of master, to align with // the official Git and GitHub behavior repo.set_head("refs/heads/main")?; info!("Initialized {dir:?}"); Ok(()) } ================================================ FILE: crates/yaak-git/src/lib.rs ================================================ mod add; mod binary; mod branch; mod clone; mod commit; mod credential; pub mod error; mod fetch; mod init; mod log; mod pull; mod push; mod remotes; mod repository; mod reset; mod status; mod unstage; mod util; // Re-export all git functions for external use pub use add::git_add; pub use branch::{ BranchDeleteResult, git_checkout_branch, git_create_branch, git_delete_branch, git_delete_remote_branch, git_merge_branch, git_rename_branch, }; pub use clone::{CloneResult, git_clone}; pub use commit::git_commit; pub use credential::git_add_credential; pub use fetch::git_fetch_all; pub use init::git_init; pub use log::{GitCommit, git_log}; pub use pull::{PullResult, git_pull, git_pull_force_reset, git_pull_merge}; pub use push::{PushResult, git_push}; pub use remotes::{GitRemote, git_add_remote, git_remotes, git_rm_remote}; pub use reset::git_reset_changes; pub use status::{GitStatusSummary, git_status}; pub use unstage::git_unstage; ================================================ FILE: crates/yaak-git/src/log.rs ================================================ use crate::repository::open_repo; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use std::path::Path; use ts_rs::TS; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] #[serde(rename_all = "camelCase")] #[ts(export, export_to = "gen_git.ts")] pub struct GitCommit { pub author: GitAuthor, pub when: DateTime, pub message: Option, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] #[serde(rename_all = "camelCase")] #[ts(export, export_to = "gen_git.ts")] pub struct GitAuthor { pub name: Option, pub email: Option, } pub fn git_log(dir: &Path) -> crate::error::Result> { let repo = open_repo(dir)?; // Return empty if empty repo or no head (new repo) if repo.is_empty()? || repo.head().is_err() { return Ok(vec![]); } let mut revwalk = repo.revwalk()?; revwalk.push_head()?; revwalk.set_sorting(git2::Sort::TIME)?; // Run git log macro_rules! filter_try { ($e:expr) => { match $e { Ok(t) => t, Err(_) => return None, } }; } let log: Vec = revwalk .filter_map(|oid| { let oid = filter_try!(oid); let commit = filter_try!(repo.find_commit(oid)); let author = commit.author(); Some(GitCommit { author: GitAuthor { name: author.name().map(|s| s.to_string()), email: author.email().map(|s| s.to_string()), }, when: convert_git_time_to_date(author.when()), message: commit.message().map(|m| m.to_string()), }) }) .collect(); Ok(log) } #[cfg(test)] fn convert_git_time_to_date(_git_time: git2::Time) -> DateTime { DateTime::from_timestamp(0, 0).unwrap() } #[cfg(not(test))] fn convert_git_time_to_date(git_time: git2::Time) -> DateTime { let timestamp = git_time.seconds(); DateTime::from_timestamp(timestamp, 0).unwrap() } ================================================ FILE: crates/yaak-git/src/pull.rs ================================================ use crate::binary::new_binary_command; use crate::error::Error::GenericError; use crate::error::Result; use crate::repository::open_repo; use crate::util::{get_current_branch_name, get_default_remote_in_repo}; use log::info; use serde::{Deserialize, Serialize}; use std::path::Path; use ts_rs::TS; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] #[serde(rename_all = "snake_case", tag = "type")] #[ts(export, export_to = "gen_git.ts")] pub enum PullResult { Success { message: String }, UpToDate, NeedsCredentials { url: String, error: Option }, Diverged { remote: String, branch: String }, UncommittedChanges, } fn has_uncommitted_changes(dir: &Path) -> Result { let repo = open_repo(dir)?; let mut opts = git2::StatusOptions::new(); opts.include_ignored(false).include_untracked(false); let statuses = repo.statuses(Some(&mut opts))?; Ok(statuses.iter().any(|e| e.status() != git2::Status::CURRENT)) } pub async fn git_pull(dir: &Path) -> Result { if has_uncommitted_changes(dir)? { return Ok(PullResult::UncommittedChanges); } // Extract all git2 data before any await points (git2 types are not Send) let (branch_name, remote_name, remote_url) = { let repo = open_repo(dir)?; let branch_name = get_current_branch_name(&repo)?; let remote = get_default_remote_in_repo(&repo)?; let remote_name = remote.name().ok_or(GenericError("Failed to get remote name".to_string()))?.to_string(); let remote_url = remote.url().ok_or(GenericError("Failed to get remote url".to_string()))?.to_string(); (branch_name, remote_name, remote_url) }; // Step 1: fetch the specific branch // NOTE: We use fetch + merge instead of `git pull` to avoid conflicts with // global git config (e.g. pull.ff=only) and the background fetch --all. let fetch_out = new_binary_command(dir) .await? .args(["fetch", &remote_name, &branch_name]) .env("GIT_TERMINAL_PROMPT", "0") .output() .await .map_err(|e| GenericError(format!("failed to run git fetch: {e}")))?; let fetch_stdout = String::from_utf8_lossy(&fetch_out.stdout); let fetch_stderr = String::from_utf8_lossy(&fetch_out.stderr); let fetch_combined = format!("{fetch_stdout}{fetch_stderr}"); info!("Fetched status={} {fetch_combined}", fetch_out.status); if fetch_combined.to_lowercase().contains("could not read") { return Ok(PullResult::NeedsCredentials { url: remote_url.to_string(), error: None }); } if fetch_combined.to_lowercase().contains("unable to access") { return Ok(PullResult::NeedsCredentials { url: remote_url.to_string(), error: Some(fetch_combined.to_string()), }); } if !fetch_out.status.success() { return Err(GenericError(format!("Failed to fetch: {fetch_combined}"))); } // Step 2: merge the fetched branch let ref_name = format!("{}/{}", remote_name, branch_name); let merge_out = new_binary_command(dir) .await? .args(["merge", "--ff-only", &ref_name]) .output() .await .map_err(|e| GenericError(format!("failed to run git merge: {e}")))?; let merge_stdout = String::from_utf8_lossy(&merge_out.stdout); let merge_stderr = String::from_utf8_lossy(&merge_out.stderr); let merge_combined = format!("{merge_stdout}{merge_stderr}"); info!("Merged status={} {merge_combined}", merge_out.status); if !merge_out.status.success() { let merge_lower = merge_combined.to_lowercase(); if merge_lower.contains("cannot fast-forward") || merge_lower.contains("not possible to fast-forward") || merge_lower.contains("diverged") { return Ok(PullResult::Diverged { remote: remote_name, branch: branch_name }); } return Err(GenericError(format!("Failed to merge: {merge_combined}"))); } if merge_combined.to_lowercase().contains("up to date") { return Ok(PullResult::UpToDate); } Ok(PullResult::Success { message: format!("Pulled from {}/{}", remote_name, branch_name) }) } pub async fn git_pull_force_reset(dir: &Path, remote: &str, branch: &str) -> Result { // Step 1: fetch the remote let fetch_out = new_binary_command(dir) .await? .args(["fetch", remote]) .env("GIT_TERMINAL_PROMPT", "0") .output() .await .map_err(|e| GenericError(format!("failed to run git fetch: {e}")))?; if !fetch_out.status.success() { let stderr = String::from_utf8_lossy(&fetch_out.stderr); return Err(GenericError(format!("Failed to fetch: {stderr}"))); } // Step 2: reset --hard to remote/branch let ref_name = format!("{}/{}", remote, branch); let reset_out = new_binary_command(dir) .await? .args(["reset", "--hard", &ref_name]) .output() .await .map_err(|e| GenericError(format!("failed to run git reset: {e}")))?; if !reset_out.status.success() { let stderr = String::from_utf8_lossy(&reset_out.stderr); return Err(GenericError(format!("Failed to reset: {}", stderr.trim()))); } Ok(PullResult::Success { message: format!("Reset to {}/{}", remote, branch) }) } pub async fn git_pull_merge(dir: &Path, remote: &str, branch: &str) -> Result { let out = new_binary_command(dir) .await? .args(["pull", "--no-rebase", remote, branch]) .env("GIT_TERMINAL_PROMPT", "0") .output() .await .map_err(|e| GenericError(format!("failed to run git pull --no-rebase: {e}")))?; let stdout = String::from_utf8_lossy(&out.stdout); let stderr = String::from_utf8_lossy(&out.stderr); let combined = format!("{}{}", stdout, stderr); info!("Pull merge status={} {combined}", out.status); if !out.status.success() { if combined.to_lowercase().contains("conflict") { return Err(GenericError( "Merge conflicts detected. Please resolve them manually.".to_string(), )); } return Err(GenericError(format!("Failed to merge pull: {}", combined.trim()))); } Ok(PullResult::Success { message: format!("Merged from {}/{}", remote, branch) }) } // pub(crate) fn git_pull_old(dir: &Path) -> Result { // let repo = open_repo(dir)?; // // let branch = get_current_branch(&repo)?.ok_or(NoActiveBranch)?; // let branch_ref = branch.get(); // let branch_ref = bytes_to_string(branch_ref.name_bytes())?; // // let remote_name = repo.branch_upstream_remote(&branch_ref)?; // let remote_name = bytes_to_string(&remote_name)?; // debug!("Pulling from {remote_name}"); // // let mut remote = repo.find_remote(&remote_name)?; // // let mut options = FetchOptions::new(); // let callbacks = default_callbacks(); // options.remote_callbacks(callbacks); // // let mut proxy = ProxyOptions::new(); // proxy.auto(); // options.proxy_options(proxy); // // remote.fetch(&[&branch_ref], Some(&mut options), None)?; // // let stats = remote.stats(); // // let fetch_head = repo.find_reference("FETCH_HEAD")?; // let fetch_commit = repo.reference_to_annotated_commit(&fetch_head)?; // do_merge(&repo, &branch, &fetch_commit)?; // // Ok(PullResult::Success { // message: "Hello".to_string(), // // received_bytes: stats.received_bytes(), // // received_objects: stats.received_objects(), // }) // } ================================================ FILE: crates/yaak-git/src/push.rs ================================================ use crate::binary::new_binary_command; use crate::error::Error::GenericError; use crate::error::Result; use crate::repository::open_repo; use crate::util::{get_current_branch_name, get_default_remote_for_push_in_repo}; use log::info; use serde::{Deserialize, Serialize}; use std::path::Path; use ts_rs::TS; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] #[serde(rename_all = "snake_case", tag = "type")] #[ts(export, export_to = "gen_git.ts")] pub enum PushResult { Success { message: String }, UpToDate, NeedsCredentials { url: String, error: Option }, } pub async fn git_push(dir: &Path) -> Result { // Extract all git2 data before any await points (git2 types are not Send) let (branch_name, remote_name, remote_url) = { let repo = open_repo(dir)?; let branch_name = get_current_branch_name(&repo)?; let remote = get_default_remote_for_push_in_repo(&repo)?; let remote_name = remote.name().ok_or(GenericError("Failed to get remote name".to_string()))?.to_string(); let remote_url = remote.url().ok_or(GenericError("Failed to get remote url".to_string()))?.to_string(); (branch_name, remote_name, remote_url) }; let out = new_binary_command(dir) .await? .args(["push", &remote_name, &branch_name]) .env("GIT_TERMINAL_PROMPT", "0") .output() .await .map_err(|e| GenericError(format!("failed to run git push: {e}")))?; let stdout = String::from_utf8_lossy(&out.stdout); let stderr = String::from_utf8_lossy(&out.stderr); let combined = stdout + stderr; let combined_lower = combined.to_lowercase(); info!("Pushed to repo status={} {combined}", out.status); // Helper to check if this is a credentials error let is_credentials_error = || { combined_lower.contains("could not read") || combined_lower.contains("unable to access") || combined_lower.contains("authentication failed") }; // Check for explicit rejection indicators first (e.g., protected branch rejections) // These can occur even if some git servers don't properly set exit codes if combined_lower.contains("rejected") || combined_lower.contains("failed to push") { if is_credentials_error() { return Ok(PushResult::NeedsCredentials { url: remote_url.to_string(), error: Some(combined.to_string()), }); } return Err(GenericError(format!("Failed to push: {combined}"))); } // Check exit status for any other failures if !out.status.success() { if combined_lower.contains("could not read") { return Ok(PushResult::NeedsCredentials { url: remote_url.to_string(), error: None }); } if combined_lower.contains("unable to access") || combined_lower.contains("authentication failed") { return Ok(PushResult::NeedsCredentials { url: remote_url.to_string(), error: Some(combined.to_string()), }); } return Err(GenericError(format!("Failed to push: {combined}"))); } // Success cases (exit code 0 and no rejection indicators) if combined_lower.contains("up-to-date") { return Ok(PushResult::UpToDate); } Ok(PushResult::Success { message: format!("Pushed to {}/{}", remote_name, branch_name) }) } ================================================ FILE: crates/yaak-git/src/remotes.rs ================================================ use crate::error::Result; use crate::repository::open_repo; use log::warn; use serde::{Deserialize, Serialize}; use std::path::Path; use ts_rs::TS; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] #[ts(export, export_to = "gen_git.ts")] pub struct GitRemote { name: String, url: Option, } pub fn git_remotes(dir: &Path) -> Result> { let repo = open_repo(dir)?; let mut remotes = Vec::new(); for remote in repo.remotes()?.into_iter() { let name = match remote { None => continue, Some(name) => name, }; let r = match repo.find_remote(name) { Ok(r) => r, Err(e) => { warn!("Failed to get remote {name}: {e:?}"); continue; } }; remotes.push(GitRemote { name: name.to_string(), url: r.url().map(|u| u.to_string()) }); } Ok(remotes) } pub fn git_add_remote(dir: &Path, name: &str, url: &str) -> Result { let repo = open_repo(dir)?; repo.remote(name, url)?; Ok(GitRemote { name: name.to_string(), url: Some(url.to_string()) }) } pub fn git_rm_remote(dir: &Path, name: &str) -> Result<()> { let repo = open_repo(dir)?; repo.remote_delete(name)?; Ok(()) } ================================================ FILE: crates/yaak-git/src/repository.rs ================================================ use crate::error::Error::{GitRepoNotFound, GitUnknown}; use std::path::Path; pub(crate) fn open_repo(dir: &Path) -> crate::error::Result { match git2::Repository::discover(dir) { Ok(r) => Ok(r), Err(e) if e.code() == git2::ErrorCode::NotFound => Err(GitRepoNotFound(dir.to_path_buf())), Err(e) => Err(GitUnknown(e)), } } ================================================ FILE: crates/yaak-git/src/reset.rs ================================================ use crate::binary::new_binary_command; use crate::error::Error::GenericError; use crate::error::Result; use std::path::Path; pub async fn git_reset_changes(dir: &Path) -> Result<()> { let out = new_binary_command(dir) .await? .args(["reset", "--hard", "HEAD"]) .output() .await .map_err(|e| GenericError(format!("failed to run git reset: {e}")))?; if !out.status.success() { let stderr = String::from_utf8_lossy(&out.stderr); return Err(GenericError(format!("Failed to reset: {}", stderr.trim()))); } Ok(()) } ================================================ FILE: crates/yaak-git/src/status.rs ================================================ use crate::repository::open_repo; use crate::util::{local_branch_names, remote_branch_names}; use log::warn; use serde::{Deserialize, Serialize}; use std::fs; use std::path::Path; use ts_rs::TS; use yaak_sync::models::SyncModel; #[derive(Debug, Clone, Serialize, Deserialize, TS, PartialEq)] #[serde(rename_all = "camelCase")] #[ts(export, export_to = "gen_git.ts")] pub struct GitStatusSummary { pub path: String, pub head_ref: Option, pub head_ref_shorthand: Option, pub entries: Vec, pub origins: Vec, pub local_branches: Vec, pub remote_branches: Vec, pub ahead: u32, pub behind: u32, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] #[serde(rename_all = "camelCase")] #[ts(export, export_to = "gen_git.ts")] pub struct GitStatusEntry { pub rela_path: String, pub status: GitStatus, pub staged: bool, pub prev: Option, pub next: Option, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] #[serde(rename_all = "snake_case")] #[ts(export, export_to = "gen_git.ts")] pub enum GitStatus { Untracked, Conflict, Current, Modified, Removed, Renamed, TypeChange, } pub fn git_status(dir: &Path) -> crate::error::Result { let repo = open_repo(dir)?; let (head_tree, head_ref, head_ref_shorthand) = match repo.head() { Ok(head) => { let tree = head.peel_to_tree().ok(); let head_ref_shorthand = head.shorthand().map(|s| s.to_string()); let head_ref = head.name().map(|s| s.to_string()); (tree, head_ref, head_ref_shorthand) } Err(_) => { // For "unborn" repos, reading from HEAD is the only way to get the branch name // See https://github.com/starship/starship/pull/1336 let head_path = repo.path().join("HEAD"); let head_ref = fs::read_to_string(&head_path) .ok() .unwrap_or_default() .lines() .next() .map(|s| s.trim_start_matches("ref:").trim().to_string()); let head_ref_shorthand = head_ref.clone().map(|r| r.split('/').last().unwrap_or("unknown").to_string()); (None, head_ref, head_ref_shorthand) } }; let mut opts = git2::StatusOptions::new(); opts.include_ignored(false) .include_untracked(true) // Include untracked .recurse_untracked_dirs(true) // Show all untracked .include_unmodified(true); // Include unchanged // TODO: Support renames let mut entries: Vec = Vec::new(); for entry in repo.statuses(Some(&mut opts))?.into_iter() { let rela_path = entry.path().unwrap().to_string(); let status = entry.status(); let index_status = match status { // Note: order matters here, since we're checking a bitmap! s if s.contains(git2::Status::CONFLICTED) => GitStatus::Conflict, s if s.contains(git2::Status::INDEX_NEW) => GitStatus::Untracked, s if s.contains(git2::Status::INDEX_MODIFIED) => GitStatus::Modified, s if s.contains(git2::Status::INDEX_DELETED) => GitStatus::Removed, s if s.contains(git2::Status::INDEX_RENAMED) => GitStatus::Renamed, s if s.contains(git2::Status::INDEX_TYPECHANGE) => GitStatus::TypeChange, s if s.contains(git2::Status::CURRENT) => GitStatus::Current, s => { warn!("Unknown index status {s:?}"); continue; } }; let worktree_status = match status { // Note: order matters here, since we're checking a bitmap! s if s.contains(git2::Status::CONFLICTED) => GitStatus::Conflict, s if s.contains(git2::Status::WT_NEW) => GitStatus::Untracked, s if s.contains(git2::Status::WT_MODIFIED) => GitStatus::Modified, s if s.contains(git2::Status::WT_DELETED) => GitStatus::Removed, s if s.contains(git2::Status::WT_RENAMED) => GitStatus::Renamed, s if s.contains(git2::Status::WT_TYPECHANGE) => GitStatus::TypeChange, s if s.contains(git2::Status::CURRENT) => GitStatus::Current, s => { warn!("Unknown worktree status {s:?}"); continue; } }; let status = if index_status == GitStatus::Current { worktree_status.clone() } else { index_status.clone() }; let staged = if index_status == GitStatus::Current && worktree_status == GitStatus::Current { // No change, so can't be added false } else if index_status != GitStatus::Current { true } else { false }; // Get previous content from Git, if it's in there let prev = match head_tree.clone() { None => None, Some(t) => match t.get_path(&Path::new(&rela_path)) { Ok(entry) => { let obj = entry.to_object(&repo)?; let content = obj.as_blob().unwrap().content(); let name = Path::new(entry.name().unwrap_or_default()); SyncModel::from_bytes(content.into(), name)?.map(|m| m.0) } Err(_) => None, }, }; let next = { let full_path = repo.workdir().unwrap().join(rela_path.clone()); SyncModel::from_file(full_path.as_path())?.map(|m| m.0) }; entries.push(GitStatusEntry { status, staged, rela_path, prev: prev.clone(), next: next.clone(), }) } let origins = repo.remotes()?.into_iter().filter_map(|o| Some(o?.to_string())).collect(); let local_branches = local_branch_names(&repo)?; let remote_branches = remote_branch_names(&repo)?; // Compute ahead/behind relative to remote tracking branch let (ahead, behind) = (|| -> Option<(usize, usize)> { let head = repo.head().ok()?; let local_oid = head.target()?; let branch_name = head.shorthand()?; let upstream_ref = repo.find_branch(&format!("origin/{branch_name}"), git2::BranchType::Remote).ok()?; let upstream_oid = upstream_ref.get().target()?; repo.graph_ahead_behind(local_oid, upstream_oid).ok() })() .unwrap_or((0, 0)); Ok(GitStatusSummary { entries, origins, path: dir.to_string_lossy().to_string(), head_ref, head_ref_shorthand, local_branches, remote_branches, ahead: ahead as u32, behind: behind as u32, }) } ================================================ FILE: crates/yaak-git/src/unstage.rs ================================================ use crate::repository::open_repo; use log::info; use std::path::Path; pub fn git_unstage(dir: &Path, rela_path: &Path) -> crate::error::Result<()> { let repo = open_repo(dir)?; let head = match repo.head() { Ok(h) => h, Err(e) if e.code() == git2::ErrorCode::UnbornBranch => { info!("Unstaging file in empty branch {rela_path:?} to {dir:?}"); // Repo has no commits, so "unstage" means remove from index let mut index = repo.index()?; index.remove_path(rela_path)?; index.write()?; return Ok(()); } Err(e) => return Err(e.into()), }; // If repo has commits, update the index entry back to HEAD info!("Unstaging file {rela_path:?} to {dir:?}"); let commit = head.peel_to_commit()?; repo.reset_default(Some(commit.as_object()), &[rela_path])?; Ok(()) } ================================================ FILE: crates/yaak-git/src/util.rs ================================================ use crate::error::Error::{GenericError, NoDefaultRemoteFound}; use crate::error::Result; use git2::{Branch, BranchType, Remote, Repository}; const DEFAULT_REMOTE_NAME: &str = "origin"; pub(crate) fn get_current_branch(repo: &Repository) -> Result>> { for b in repo.branches(None)? { let branch = b?.0; if branch.is_head() { return Ok(Some(branch)); } } Ok(None) } pub(crate) fn get_current_branch_name(repo: &Repository) -> Result { Ok(get_current_branch(&repo)? .ok_or(GenericError("Failed to get current branch".to_string()))? .name()? .ok_or(GenericError("Failed to get current branch name".to_string()))? .to_string()) } pub(crate) fn local_branch_names(repo: &Repository) -> Result> { let mut branches = Vec::new(); for branch in repo.branches(Some(BranchType::Local))? { let (branch, _) = branch?; let name = branch.name_bytes()?; let name = bytes_to_string(name)?; branches.push(name); } Ok(branches) } pub(crate) fn remote_branch_names(repo: &Repository) -> Result> { let mut branches = Vec::new(); for branch in repo.branches(Some(BranchType::Remote))? { let (branch, _) = branch?; let name = branch.name_bytes()?; let name = bytes_to_string(name)?; if name.ends_with("/HEAD") { continue; } branches.push(name); } Ok(branches) } pub(crate) fn bytes_to_string(bytes: &[u8]) -> Result { Ok(String::from_utf8(bytes.to_vec())?) } pub(crate) fn get_default_remote_for_push_in_repo(repo: &'_ Repository) -> Result> { let name = get_default_remote_name_for_push_in_repo(repo)?; let remote = repo.find_remote(&name)?; Ok(remote) } pub(crate) fn get_default_remote_name_for_push_in_repo(repo: &Repository) -> Result { let config = repo.config()?; let branch = get_current_branch(repo)?; if let Some(branch) = branch { let remote_name = bytes_to_string(branch.name_bytes()?)?; let entry_name = format!("branch.{}.pushRemote", &remote_name); if let Ok(entry) = config.get_entry(&entry_name) { return bytes_to_string(entry.value_bytes()); } if let Ok(entry) = config.get_entry("remote.pushDefault") { return bytes_to_string(entry.value_bytes()); } let entry_name = format!("branch.{}.remote", &remote_name); if let Ok(entry) = config.get_entry(&entry_name) { return bytes_to_string(entry.value_bytes()); } } get_default_remote_name_in_repo(repo) } pub(crate) fn get_default_remote_in_repo(repo: &'_ Repository) -> Result> { let name = get_default_remote_name_in_repo(repo)?; let remote = repo.find_remote(&name)?; Ok(remote) } pub(crate) fn get_default_remote_name_in_repo(repo: &Repository) -> Result { let remotes = repo.remotes()?; if remotes.is_empty() { return Err(NoDefaultRemoteFound); } // if `origin` exists return that let found_origin = remotes.iter().any(|r| r.is_some_and(|r| r == DEFAULT_REMOTE_NAME)); if found_origin { return Ok(DEFAULT_REMOTE_NAME.into()); } // if only one remote exists, pick that if remotes.len() == 1 { let first_remote = remotes .iter() .next() .flatten() .map(String::from) .ok_or_else(|| GenericError("no remote found".into()))?; return Ok(first_remote); } // inconclusive Err(NoDefaultRemoteFound) } ================================================ FILE: crates/yaak-grpc/Cargo.toml ================================================ [package] name = "yaak-grpc" version = "0.1.0" edition = "2024" publish = false [dependencies] anyhow = "1.0.97" async-recursion = "1.1.1" dunce = "1.0.4" hyper-rustls = { version = "0.27.7", default-features = false, features = ["http2"] } hyper-util = { version = "0.1.13", default-features = false, features = ["client-legacy"] } log = { workspace = true } md5 = "0.7.0" prost = "0.13.4" prost-reflect = { version = "0.14.4", default-features = false, features = ["serde", "derive"] } prost-types = "0.13.4" serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } tokio = { workspace = true, features = ["macros", "rt-multi-thread", "fs", "process"] } tokio-stream = "0.1.14" tonic = { version = "0.12.3", default-features = false, features = ["transport"] } tonic-reflection = "0.12.3" uuid = { version = "1.7.0", features = ["v4"] } yaak-common = { workspace = true } yaak-tls = { workspace = true } thiserror = "2.0.17" ================================================ FILE: crates/yaak-grpc/src/any.rs ================================================ use log::error; pub(crate) fn collect_any_types(json: &str, out: &mut Vec) { let value = match serde_json::from_str(json).map_err(|e| e.to_string()) { Ok(v) => v, Err(e) => { error!("Failed to parse gRPC message JSON: {e:?}"); return; } }; collect_any_types_value(&value, out); } fn collect_any_types_value(json: &serde_json::Value, out: &mut Vec) { match json { serde_json::Value::Object(map) => { if let Some(t) = map.get("@type").and_then(|v| v.as_str()) { if let Some(full_name) = t.rsplit_once('/').map(|(_, n)| n) { out.push(full_name.to_string()); } } for v in map.values() { collect_any_types_value(v, out); } } serde_json::Value::Array(arr) => { for v in arr { collect_any_types_value(v, out); } } _ => {} } } // Write tests for this #[cfg(test)] mod tests { #[test] fn test_collect_any_types() { let json = r#"{ "mounts": [ { "mountSource": { "@type": "type.googleapis.com/mount_source.MountSourceRBDVolume", "volumeID": "volumes/rbd" } } ], "foo": { "@type": "type.googleapis.com/foo.bar", "foo": "fooo" } }"#; let mut out = Vec::new(); super::collect_any_types(json, &mut out); out.sort(); assert_eq!(out, vec!["foo.bar", "mount_source.MountSourceRBDVolume"]); } } ================================================ FILE: crates/yaak-grpc/src/client.rs ================================================ use crate::error::Error::GenericError; use crate::error::Result; use crate::manager::decorate_req; use crate::transport::get_transport; use async_recursion::async_recursion; use hyper_rustls::HttpsConnector; use hyper_util::client::legacy::Client; use hyper_util::client::legacy::connect::HttpConnector; use log::debug; use std::collections::BTreeMap; use tokio_stream::StreamExt; use tonic::Request; use tonic::body::BoxBody; use tonic::transport::Uri; use tonic_reflection::pb::v1::server_reflection_request::MessageRequest; use tonic_reflection::pb::v1::server_reflection_response::MessageResponse; use tonic_reflection::pb::v1::{ ErrorResponse, ExtensionNumberResponse, ListServiceResponse, ServerReflectionRequest, ServiceResponse, }; use tonic_reflection::pb::v1::{ExtensionRequest, FileDescriptorResponse}; use tonic_reflection::pb::{v1, v1alpha}; use yaak_tls::ClientCertificateConfig; pub struct AutoReflectionClient, BoxBody>> { use_v1alpha: bool, client_v1: v1::server_reflection_client::ServerReflectionClient, client_v1alpha: v1alpha::server_reflection_client::ServerReflectionClient, } impl AutoReflectionClient { pub fn new( uri: &Uri, validate_certificates: bool, client_cert: Option, ) -> Result { let client_v1 = v1::server_reflection_client::ServerReflectionClient::with_origin( get_transport(validate_certificates, client_cert.clone())?, uri.clone(), ); let client_v1alpha = v1alpha::server_reflection_client::ServerReflectionClient::with_origin( get_transport(validate_certificates, client_cert.clone())?, uri.clone(), ); Ok(AutoReflectionClient { use_v1alpha: false, client_v1, client_v1alpha }) } #[async_recursion] pub async fn send_reflection_request( &mut self, message: MessageRequest, metadata: &BTreeMap, ) -> Result { let reflection_request = ServerReflectionRequest { host: "".into(), // Doesn't matter message_request: Some(message.clone()), }; if self.use_v1alpha { let mut request = Request::new(tokio_stream::once(to_v1alpha_request(reflection_request))); decorate_req(metadata, &mut request)?; self.client_v1alpha .server_reflection_info(request) .await .map_err(|e| match e.code() { tonic::Code::Unavailable => { GenericError("Failed to connect to endpoint".to_string()) } tonic::Code::Unauthenticated => { GenericError("Authentication failed".to_string()) } tonic::Code::DeadlineExceeded => GenericError("Deadline exceeded".to_string()), _ => GenericError(e.to_string()), })? .into_inner() .next() .await .ok_or(GenericError("Missing reflection message".to_string()))?? .message_response .ok_or(GenericError("No reflection response".to_string())) .map(|resp| to_v1_msg_response(resp)) } else { let mut request = Request::new(tokio_stream::once(reflection_request)); decorate_req(metadata, &mut request)?; let resp = self.client_v1.server_reflection_info(request).await; match resp { Ok(r) => Ok(r), Err(e) => match e.code().clone() { tonic::Code::Unimplemented => { // If v1 fails, change to v1alpha and try again debug!("gRPC schema reflection falling back to v1alpha"); self.use_v1alpha = true; return self.send_reflection_request(message, metadata).await; } _ => Err(e), }, } .map_err(|e| match e.code() { tonic::Code::Unavailable => { GenericError("Failed to connect to endpoint".to_string()) } tonic::Code::Unauthenticated => GenericError("Authentication failed".to_string()), tonic::Code::DeadlineExceeded => GenericError("Deadline exceeded".to_string()), _ => GenericError(e.to_string()), })? .into_inner() .next() .await .ok_or(GenericError("Missing reflection message".to_string()))?? .message_response .ok_or(GenericError("No reflection response".to_string())) } } } fn to_v1_msg_response( response: v1alpha::server_reflection_response::MessageResponse, ) -> MessageResponse { match response { v1alpha::server_reflection_response::MessageResponse::FileDescriptorResponse(v) => { MessageResponse::FileDescriptorResponse(FileDescriptorResponse { file_descriptor_proto: v.file_descriptor_proto, }) } v1alpha::server_reflection_response::MessageResponse::AllExtensionNumbersResponse(v) => { MessageResponse::AllExtensionNumbersResponse(ExtensionNumberResponse { extension_number: v.extension_number, base_type_name: v.base_type_name, }) } v1alpha::server_reflection_response::MessageResponse::ListServicesResponse(v) => { MessageResponse::ListServicesResponse(ListServiceResponse { service: v .service .iter() .map(|s| ServiceResponse { name: s.name.clone() }) .collect(), }) } v1alpha::server_reflection_response::MessageResponse::ErrorResponse(v) => { MessageResponse::ErrorResponse(ErrorResponse { error_code: v.error_code, error_message: v.error_message, }) } } } fn to_v1alpha_request(request: ServerReflectionRequest) -> v1alpha::ServerReflectionRequest { v1alpha::ServerReflectionRequest { host: request.host, message_request: request.message_request.map(|m| to_v1alpha_msg_request(m)), } } fn to_v1alpha_msg_request( message: MessageRequest, ) -> v1alpha::server_reflection_request::MessageRequest { match message { MessageRequest::FileByFilename(v) => { v1alpha::server_reflection_request::MessageRequest::FileByFilename(v) } MessageRequest::FileContainingSymbol(v) => { v1alpha::server_reflection_request::MessageRequest::FileContainingSymbol(v) } MessageRequest::FileContainingExtension(ExtensionRequest { extension_number, containing_type, }) => v1alpha::server_reflection_request::MessageRequest::FileContainingExtension( v1alpha::ExtensionRequest { extension_number, containing_type }, ), MessageRequest::AllExtensionNumbersOfType(v) => { v1alpha::server_reflection_request::MessageRequest::AllExtensionNumbersOfType(v) } MessageRequest::ListServices(v) => { v1alpha::server_reflection_request::MessageRequest::ListServices(v) } } } ================================================ FILE: crates/yaak-grpc/src/codec.rs ================================================ use prost_reflect::prost::Message; use prost_reflect::{DynamicMessage, MethodDescriptor}; use tonic::Status; use tonic::codec::{Codec, DecodeBuf, Decoder, EncodeBuf, Encoder}; #[derive(Clone)] pub struct DynamicCodec(MethodDescriptor); impl DynamicCodec { #[allow(dead_code)] pub fn new(md: MethodDescriptor) -> Self { Self(md) } } impl Codec for DynamicCodec { type Encode = DynamicMessage; type Decode = DynamicMessage; type Encoder = Self; type Decoder = Self; fn encoder(&mut self) -> Self::Encoder { self.clone() } fn decoder(&mut self) -> Self::Decoder { self.clone() } } impl Encoder for DynamicCodec { type Item = DynamicMessage; type Error = Status; fn encode(&mut self, item: Self::Item, dst: &mut EncodeBuf<'_>) -> Result<(), Self::Error> { item.encode(dst).expect("buffer is too small to decode this message"); Ok(()) } } impl Decoder for DynamicCodec { type Item = DynamicMessage; type Error = Status; fn decode(&mut self, src: &mut DecodeBuf<'_>) -> Result, Self::Error> { let mut msg = DynamicMessage::new(self.0.output()); msg.merge(src).map_err(|err| Status::internal(err.to_string()))?; Ok(Some(msg)) } } ================================================ FILE: crates/yaak-grpc/src/error.rs ================================================ use crate::manager::GrpcStreamError; use prost::DecodeError; use serde::{Serialize, Serializer}; use serde_json::Error as SerdeJsonError; use std::io; use thiserror::Error; use tonic::Status; #[derive(Error, Debug)] pub enum Error { #[error(transparent)] TlsError(#[from] yaak_tls::error::Error), #[error(transparent)] TonicError(#[from] Status), #[error("Prost reflect error: {0:?}")] ProstReflectError(#[from] prost_reflect::DescriptorError), #[error(transparent)] DeserializerError(#[from] SerdeJsonError), #[error(transparent)] GrpcStreamError(#[from] GrpcStreamError), #[error(transparent)] GrpcDecodeError(#[from] DecodeError), #[error(transparent)] GrpcInvalidMetadataKeyError(#[from] tonic::metadata::errors::InvalidMetadataKey), #[error(transparent)] GrpcInvalidMetadataValueError(#[from] tonic::metadata::errors::InvalidMetadataValue), #[error(transparent)] IOError(#[from] io::Error), #[error("GRPC error: {0}")] GenericError(String), } impl Serialize for Error { fn serialize(&self, serializer: S) -> std::result::Result where S: Serializer, { serializer.serialize_str(self.to_string().as_ref()) } } pub type Result = std::result::Result; ================================================ FILE: crates/yaak-grpc/src/json_schema.rs ================================================ use prost_reflect::{DescriptorPool, FieldDescriptor, MessageDescriptor}; use std::collections::{HashMap, HashSet, VecDeque}; pub fn message_to_json_schema(_: &DescriptorPool, root_msg: MessageDescriptor) -> JsonSchemaEntry { JsonSchemaGenerator::generate_json_schema(root_msg) } struct JsonSchemaGenerator { msg_mapping: HashMap, } impl JsonSchemaGenerator { pub fn new() -> Self { JsonSchemaGenerator { msg_mapping: HashMap::new() } } pub fn generate_json_schema(msg: MessageDescriptor) -> JsonSchemaEntry { let generator = JsonSchemaGenerator::new(); generator.scan_root(msg) } fn add_message(&mut self, msg: &MessageDescriptor) { let name = msg.full_name().to_string(); if self.msg_mapping.contains_key(&name) { return; } self.msg_mapping.insert(name.clone(), JsonSchemaEntry::object()); } pub fn scan_root(mut self, root_msg: MessageDescriptor) -> JsonSchemaEntry { self.init_structure(root_msg.clone()); self.fill_properties(root_msg.clone()); let mut root = self.msg_mapping.remove(root_msg.full_name()).unwrap(); if self.msg_mapping.len() > 0 { root.defs = Some(self.msg_mapping); } root } fn fill_properties(&mut self, root_msg: MessageDescriptor) { let root_name = root_msg.full_name().to_string(); let mut visited = HashSet::new(); let mut msg_queue = VecDeque::new(); msg_queue.push_back(root_msg); while !msg_queue.is_empty() { let msg = msg_queue.pop_front().unwrap(); let msg_name = msg.full_name(); if visited.contains(msg_name) { continue; } visited.insert(msg_name.to_string()); let entry = self.msg_mapping.get_mut(msg_name).unwrap(); for field in msg.fields() { let field_name = field.name().to_string(); if matches!(field.cardinality(), prost_reflect::Cardinality::Required) { entry.add_required(field_name.clone()); } if let Some(oneof) = field.containing_oneof() { for oneof_field in oneof.fields() { if let Some(fm) = is_message_field(&oneof_field) { msg_queue.push_back(fm); } entry.add_property( oneof_field.name().to_string(), field_to_type_or_ref(&root_name, oneof_field), ); } continue; } let (field_type, nest_msg) = { if let Some(fm) = is_message_field(&field) { if field.is_list() { // repeated message type ( JsonSchemaEntry::array(field_to_type_or_ref(&root_name, field)), Some(fm), ) } else if field.is_map() { let value_field = fm.get_field_by_name("value").unwrap(); if let Some(fm) = is_message_field(&value_field) { ( JsonSchemaEntry::map(field_to_type_or_ref( &root_name, value_field, )), Some(fm), ) } else { ( JsonSchemaEntry::map(field_to_type_or_ref( &root_name, value_field, )), None, ) } } else { (field_to_type_or_ref(&root_name, field), Some(fm)) } } else { if field.is_list() { // repeated scalar type (JsonSchemaEntry::array(field_to_type_or_ref(&root_name, field)), None) } else { (field_to_type_or_ref(&root_name, field), None) } } }; if let Some(fm) = nest_msg { msg_queue.push_back(fm); } entry.add_property(field_name, field_type); } } } fn init_structure(&mut self, root_msg: MessageDescriptor) { let mut visited = HashSet::new(); let mut msg_queue = VecDeque::new(); msg_queue.push_back(root_msg.clone()); // level traversal, to make sure all message type is defined before used while !msg_queue.is_empty() { let msg = msg_queue.pop_front().unwrap(); let name = msg.full_name(); if visited.contains(name) { continue; } visited.insert(name.to_string()); self.add_message(&msg); for child in msg.child_messages() { if child.is_map_entry() { // for field with map type, there will be a child message type *Entry generated // just skip it continue; } self.add_message(&child); msg_queue.push_back(child); } for field in msg.fields() { if let Some(oneof) = field.containing_oneof() { for oneof_field in oneof.fields() { if let Some(fm) = is_message_field(&oneof_field) { self.add_message(&fm); msg_queue.push_back(fm); } } continue; } if field.is_map() { // key is always scalar type, so no need to process // value can be any type, so need to unpack value type let map_field_msg = is_message_field(&field).unwrap(); let map_value_field = map_field_msg.get_field_by_name("value").unwrap(); if let Some(value_fm) = is_message_field(&map_value_field) { self.add_message(&value_fm); msg_queue.push_back(value_fm); } continue; } if let Some(fm) = is_message_field(&field) { self.add_message(&fm); msg_queue.push_back(fm); } } } } } fn field_to_type_or_ref(root_name: &str, field: FieldDescriptor) -> JsonSchemaEntry { match field.kind() { prost_reflect::Kind::Bool => JsonSchemaEntry::boolean(), prost_reflect::Kind::Double => JsonSchemaEntry::number("double"), prost_reflect::Kind::Float => JsonSchemaEntry::number("float"), prost_reflect::Kind::Int32 => JsonSchemaEntry::number("int32"), prost_reflect::Kind::Int64 => JsonSchemaEntry::string_with_format("int64"), prost_reflect::Kind::Uint32 => JsonSchemaEntry::number("int64"), prost_reflect::Kind::Uint64 => JsonSchemaEntry::string_with_format("uint64"), prost_reflect::Kind::Sint32 => JsonSchemaEntry::number("sint32"), prost_reflect::Kind::Sint64 => JsonSchemaEntry::string_with_format("sint64"), prost_reflect::Kind::Fixed32 => JsonSchemaEntry::number("int64"), prost_reflect::Kind::Fixed64 => JsonSchemaEntry::string_with_format("fixed64"), prost_reflect::Kind::Sfixed32 => JsonSchemaEntry::number("sfixed32"), prost_reflect::Kind::Sfixed64 => JsonSchemaEntry::string_with_format("sfixed64"), prost_reflect::Kind::String => JsonSchemaEntry::string(), prost_reflect::Kind::Bytes => JsonSchemaEntry::string_with_format("byte"), prost_reflect::Kind::Enum(enums) => { let values = enums.values().map(|v| v.name().to_string()).collect::>(); JsonSchemaEntry::enums(values) } prost_reflect::Kind::Message(fm) => { let field_type_full_name = fm.full_name(); match field_type_full_name { // [Protocol Buffers Well-Known Types]: https://protobuf.dev/reference/protobuf/google.protobuf/ "google.protobuf.FieldMask" => JsonSchemaEntry::string(), "google.protobuf.Timestamp" => JsonSchemaEntry::string_with_format("date-time"), "google.protobuf.Duration" => JsonSchemaEntry::string(), "google.protobuf.StringValue" => JsonSchemaEntry::string(), "google.protobuf.BytesValue" => JsonSchemaEntry::string_with_format("byte"), "google.protobuf.Int32Value" => JsonSchemaEntry::number("int32"), "google.protobuf.UInt32Value" => JsonSchemaEntry::string_with_format("int64"), "google.protobuf.Int64Value" => JsonSchemaEntry::string_with_format("int64"), "google.protobuf.UInt64Value" => JsonSchemaEntry::string_with_format("uint64"), "google.protobuf.FloatValue" => JsonSchemaEntry::number("float"), "google.protobuf.DoubleValue" => JsonSchemaEntry::number("double"), "google.protobuf.BoolValue" => JsonSchemaEntry::boolean(), "google.protobuf.Empty" => JsonSchemaEntry::default(), "google.protobuf.Struct" => JsonSchemaEntry::object(), "google.protobuf.ListValue" => JsonSchemaEntry::array(JsonSchemaEntry::default()), "google.protobuf.NullValue" => JsonSchemaEntry::null(), name @ _ if name == root_name => JsonSchemaEntry::root_reference(), _ => JsonSchemaEntry::reference(fm.full_name()), } } } } fn is_message_field(field: &FieldDescriptor) -> Option { match field.kind() { prost_reflect::Kind::Message(m) => Some(m), _ => None, } } #[derive(Default, serde::Serialize)] #[serde(default, rename_all = "camelCase")] pub struct JsonSchemaEntry { #[serde(skip_serializing_if = "Option::is_none")] title: Option, #[serde(rename = "type", skip_serializing_if = "Option::is_none")] type_: Option, #[serde(skip_serializing_if = "Option::is_none")] format: Option, #[serde(skip_serializing_if = "Option::is_none")] description: Option, #[serde(skip_serializing_if = "Option::is_none")] properties: Option>, #[serde(rename = "enum", skip_serializing_if = "Option::is_none")] enum_: Option>, // for map type #[serde(skip_serializing_if = "Option::is_none")] additional_properties: Option>, // Set all properties to required #[serde(skip_serializing_if = "Option::is_none")] required: Option>, #[serde(skip_serializing_if = "Option::is_none")] items: Option>, #[serde(skip_serializing_if = "Option::is_none", rename = "$defs")] defs: Option>, #[serde(skip_serializing_if = "Option::is_none", rename = "$ref")] ref_: Option, } impl JsonSchemaEntry { pub fn add_property(&mut self, name: String, entry: JsonSchemaEntry) { if self.properties.is_none() { self.properties = Some(HashMap::new()); } self.properties.as_mut().unwrap().insert(name, entry); } pub fn add_required(&mut self, name: String) { if self.required.is_none() { self.required = Some(Vec::new()); } self.required.as_mut().unwrap().push(name); } } impl JsonSchemaEntry { pub fn object() -> Self { JsonSchemaEntry { type_: Some(JsonType::Object), ..Default::default() } } pub fn boolean() -> Self { JsonSchemaEntry { type_: Some(JsonType::Boolean), ..Default::default() } } pub fn number>(format: S) -> Self { JsonSchemaEntry { type_: Some(JsonType::Number), format: Some(format.into()), ..Default::default() } } pub fn string() -> Self { JsonSchemaEntry { type_: Some(JsonType::String), ..Default::default() } } pub fn string_with_format>(format: S) -> Self { JsonSchemaEntry { type_: Some(JsonType::String), format: Some(format.into()), ..Default::default() } } pub fn reference>(ref_: S) -> Self { JsonSchemaEntry { ref_: Some(format!("#/$defs/{}", ref_.as_ref())), ..Default::default() } } pub fn root_reference() -> Self { JsonSchemaEntry { ref_: Some("#".to_string()), ..Default::default() } } pub fn array(item: JsonSchemaEntry) -> Self { JsonSchemaEntry { type_: Some(JsonType::Array), items: Some(Box::new(item)), ..Default::default() } } pub fn enums(enums: Vec) -> Self { JsonSchemaEntry { type_: Some(JsonType::String), enum_: Some(enums), ..Default::default() } } pub fn map(value_type: JsonSchemaEntry) -> Self { JsonSchemaEntry { type_: Some(JsonType::Object), additional_properties: Some(Box::new(value_type)), ..Default::default() } } pub fn null() -> Self { JsonSchemaEntry { type_: Some(JsonType::Null), ..Default::default() } } } enum JsonType { String, Number, Object, Array, Boolean, Null, _UNKNOWN, } impl Default for JsonType { fn default() -> Self { JsonType::_UNKNOWN } } impl serde::Serialize for JsonType { fn serialize(&self, serializer: S) -> std::result::Result where S: serde::Serializer, { match self { JsonType::String => serializer.serialize_str("string"), JsonType::Number => serializer.serialize_str("number"), JsonType::Object => serializer.serialize_str("object"), JsonType::Array => serializer.serialize_str("array"), JsonType::Boolean => serializer.serialize_str("boolean"), JsonType::Null => serializer.serialize_str("null"), JsonType::_UNKNOWN => serializer.serialize_str("unknown"), } } } ================================================ FILE: crates/yaak-grpc/src/lib.rs ================================================ use prost_reflect::{DynamicMessage, MethodDescriptor, SerializeOptions}; use serde::{Deserialize, Serialize}; use serde_json::Deserializer; mod any; mod client; mod codec; pub mod error; mod json_schema; pub mod manager; mod reflection; mod transport; pub use tonic::Code; pub use tonic::metadata::*; pub fn serialize_options() -> SerializeOptions { SerializeOptions::new().skip_default_fields(false) } #[derive(Serialize, Deserialize, Debug, Default)] #[serde(default, rename_all = "camelCase")] pub struct ServiceDefinition { pub name: String, pub methods: Vec, } #[derive(Serialize, Deserialize, Debug, Default)] #[serde(default, rename_all = "camelCase")] pub struct MethodDefinition { pub name: String, pub schema: String, pub client_streaming: bool, pub server_streaming: bool, } static SERIALIZE_OPTIONS: &'static SerializeOptions = &SerializeOptions::new().skip_default_fields(false).stringify_64_bit_integers(false); pub fn serialize_message(msg: &DynamicMessage) -> Result { let mut buf = Vec::new(); let mut se = serde_json::Serializer::pretty(&mut buf); msg.serialize_with_options(&mut se, SERIALIZE_OPTIONS).map_err(|e| e.to_string())?; let s = String::from_utf8(buf).expect("serde_json to emit valid utf8"); Ok(s) } pub fn deserialize_message(msg: &str, method: MethodDescriptor) -> Result { let mut deserializer = Deserializer::from_str(&msg); let req_message = DynamicMessage::deserialize(method.input(), &mut deserializer) .map_err(|e| e.to_string())?; deserializer.end().map_err(|e| e.to_string())?; Ok(req_message) } ================================================ FILE: crates/yaak-grpc/src/manager.rs ================================================ use crate::codec::DynamicCodec; use crate::error::Error::GenericError; use crate::error::Result; use crate::reflection::{ fill_pool_from_files, fill_pool_from_reflection, method_desc_to_path, reflect_types_for_message, }; use crate::transport::get_transport; use crate::{MethodDefinition, ServiceDefinition, json_schema}; use hyper_rustls::HttpsConnector; use hyper_util::client::legacy::Client; use hyper_util::client::legacy::connect::HttpConnector; use log::{info, warn}; pub use prost_reflect::DynamicMessage; use prost_reflect::{DescriptorPool, MethodDescriptor, ServiceDescriptor}; use serde_json::Deserializer; use std::collections::BTreeMap; use std::error::Error; use std::fmt; use std::fmt::Display; use std::path::PathBuf; use std::str::FromStr; use std::sync::Arc; use tokio::sync::RwLock; use tokio_stream::StreamExt; use tokio_stream::wrappers::ReceiverStream; use tonic::body::BoxBody; use tonic::metadata::{MetadataKey, MetadataValue}; use tonic::transport::Uri; use tonic::{IntoRequest, IntoStreamingRequest, Request, Response, Status, Streaming}; use yaak_tls::ClientCertificateConfig; #[derive(Clone)] pub struct GrpcConnection { pool: Arc>, conn: Client, BoxBody>, pub uri: Uri, use_reflection: bool, } #[derive(Default, Debug)] pub struct GrpcStreamError { pub message: String, pub status: Option, } impl Error for GrpcStreamError {} impl Display for GrpcStreamError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match &self.status { Some(status) => write!(f, "[{}] {}", status, self.message), None => write!(f, "{}", self.message), } } } impl From for GrpcStreamError { fn from(value: String) -> Self { GrpcStreamError { message: value.to_string(), status: None } } } impl From for GrpcStreamError { fn from(s: Status) -> Self { GrpcStreamError { message: s.message().to_string(), status: Some(s) } } } impl GrpcConnection { pub async fn method(&self, service: &str, method: &str) -> Result { let service = self.service(service).await?; let method = service .methods() .find(|m| m.name() == method) .ok_or(GenericError("Failed to find method".to_string()))?; Ok(method) } async fn service(&self, service: &str) -> Result { let pool = self.pool.read().await; let service = pool .get_service_by_name(service) .ok_or(GenericError("Failed to find service".to_string()))?; Ok(service) } pub async fn unary( &self, service: &str, method: &str, message: &str, metadata: &BTreeMap, client_cert: Option, ) -> Result> { if self.use_reflection { reflect_types_for_message(self.pool.clone(), &self.uri, message, metadata, client_cert) .await?; } let method = &self.method(&service, &method).await?; let input_message = method.input(); let mut deserializer = Deserializer::from_str(message); let req_message = DynamicMessage::deserialize(input_message, &mut deserializer)?; deserializer.end()?; let mut client = tonic::client::Grpc::with_origin(self.conn.clone(), self.uri.clone()); let mut req = req_message.into_request(); decorate_req(metadata, &mut req)?; let path = method_desc_to_path(method); let codec = DynamicCodec::new(method.clone()); client.ready().await.map_err(|e| GenericError(format!("Failed to connect: {}", e)))?; Ok(client.unary(req, path, codec).await?) } pub async fn streaming( &self, service: &str, method: &str, stream: ReceiverStream, metadata: &BTreeMap, client_cert: Option, on_message: F, ) -> Result>> where F: Fn(std::result::Result) + Send + Sync + Clone + 'static, { let method = &self.method(&service, &method).await?; let mapped_stream = { let input_message = method.input(); let pool = self.pool.clone(); let uri = self.uri.clone(); let md = metadata.clone(); let use_reflection = self.use_reflection.clone(); let client_cert = client_cert.clone(); stream .then(move |json| { let pool = pool.clone(); let uri = uri.clone(); let input_message = input_message.clone(); let md = md.clone(); let use_reflection = use_reflection.clone(); let client_cert = client_cert.clone(); let on_message = on_message.clone(); let json_clone = json.clone(); async move { if use_reflection { if let Err(e) = reflect_types_for_message(pool, &uri, &json, &md, client_cert).await { warn!("Failed to resolve Any types: {e}"); } } let mut de = Deserializer::from_str(&json); match DynamicMessage::deserialize(input_message, &mut de) { Ok(m) => { on_message(Ok(json_clone)); Some(m) } Err(e) => { warn!("Failed to deserialize message: {e}"); on_message(Err(e.to_string())); None } } } }) .filter_map(|x| x) }; let mut client = tonic::client::Grpc::with_origin(self.conn.clone(), self.uri.clone()); let path = method_desc_to_path(method); let codec = DynamicCodec::new(method.clone()); let mut req = mapped_stream.into_streaming_request(); decorate_req(metadata, &mut req)?; client.ready().await.map_err(|e| GenericError(format!("Failed to connect: {}", e)))?; Ok(client.streaming(req, path, codec).await?) } pub async fn client_streaming( &self, service: &str, method: &str, stream: ReceiverStream, metadata: &BTreeMap, client_cert: Option, on_message: F, ) -> Result> where F: Fn(std::result::Result) + Send + Sync + Clone + 'static, { let method = &self.method(&service, &method).await?; let mapped_stream = { let input_message = method.input(); let pool = self.pool.clone(); let uri = self.uri.clone(); let md = metadata.clone(); let use_reflection = self.use_reflection.clone(); let client_cert = client_cert.clone(); stream .then(move |json| { let pool = pool.clone(); let uri = uri.clone(); let input_message = input_message.clone(); let md = md.clone(); let use_reflection = use_reflection.clone(); let client_cert = client_cert.clone(); let on_message = on_message.clone(); let json_clone = json.clone(); async move { if use_reflection { if let Err(e) = reflect_types_for_message(pool, &uri, &json, &md, client_cert).await { warn!("Failed to resolve Any types: {e}"); } } let mut de = Deserializer::from_str(&json); match DynamicMessage::deserialize(input_message, &mut de) { Ok(m) => { on_message(Ok(json_clone)); Some(m) } Err(e) => { warn!("Failed to deserialize message: {e}"); on_message(Err(e.to_string())); None } } } }) .filter_map(|x| x) }; let mut client = tonic::client::Grpc::with_origin(self.conn.clone(), self.uri.clone()); let path = method_desc_to_path(method); let codec = DynamicCodec::new(method.clone()); let mut req = mapped_stream.into_streaming_request(); decorate_req(metadata, &mut req)?; client.ready().await.map_err(|e| GenericError(format!("Failed to connect: {}", e)))?; Ok(client .client_streaming(req, path, codec) .await .map_err(|e| GrpcStreamError { message: e.message().to_string(), status: Some(e) })?) } pub async fn server_streaming( &self, service: &str, method: &str, message: &str, metadata: &BTreeMap, ) -> Result>> { let method = &self.method(&service, &method).await?; let input_message = method.input(); let mut deserializer = Deserializer::from_str(message); let req_message = DynamicMessage::deserialize(input_message, &mut deserializer)?; deserializer.end()?; let mut client = tonic::client::Grpc::with_origin(self.conn.clone(), self.uri.clone()); let mut req = req_message.into_request(); decorate_req(metadata, &mut req)?; let path = method_desc_to_path(method); let codec = DynamicCodec::new(method.clone()); client.ready().await.map_err(|e| GenericError(format!("Failed to connect: {}", e)))?; Ok(client.server_streaming(req, path, codec).await?) } } /// Configuration for GrpcHandle to compile proto files #[derive(Clone)] pub struct GrpcConfig { /// Path to the protoc include directory (vendored/protoc/include) pub protoc_include_dir: PathBuf, /// Path to the yaakprotoc sidecar binary pub protoc_bin_path: PathBuf, } pub struct GrpcHandle { config: GrpcConfig, pools: BTreeMap, } impl GrpcHandle { pub fn new(config: GrpcConfig) -> Self { let pools = BTreeMap::new(); Self { pools, config } } } impl GrpcHandle { /// Remove cached descriptor pool for the given key, if present. pub fn invalidate_pool(&mut self, id: &str, uri: &str, proto_files: &Vec) { let key = make_pool_key(id, uri, proto_files); self.pools.remove(&key); } pub async fn reflect( &mut self, id: &str, uri: &str, proto_files: &Vec, metadata: &BTreeMap, validate_certificates: bool, client_cert: Option, ) -> Result { let server_reflection = proto_files.is_empty(); let key = make_pool_key(id, uri, proto_files); // If we already have a pool for this key, reuse it and avoid re-reflection if self.pools.contains_key(&key) { return Ok(server_reflection); } let pool = if server_reflection { let full_uri = uri_from_str(uri)?; fill_pool_from_reflection(&full_uri, metadata, validate_certificates, client_cert).await } else { fill_pool_from_files(&self.config, proto_files).await }?; self.pools.insert(key, pool.clone()); Ok(server_reflection) } pub async fn services( &mut self, id: &str, uri: &str, proto_files: &Vec, metadata: &BTreeMap, validate_certificates: bool, client_cert: Option, ) -> Result> { // Ensure we have a pool; reflect only if missing if self.get_pool(id, uri, proto_files).is_none() { info!("Reflecting gRPC services for {} at {}", id, uri); self.reflect(id, uri, proto_files, metadata, validate_certificates, client_cert) .await?; } let pool = self .get_pool(id, uri, proto_files) .ok_or(GenericError("Failed to get pool".to_string()))?; Ok(self.services_from_pool(&pool)) } fn services_from_pool(&self, pool: &DescriptorPool) -> Vec { pool.services() .map(|s| { let mut def = ServiceDefinition { name: s.full_name().to_string(), methods: vec![] }; for method in s.methods() { let input_message = method.input(); def.methods.push(MethodDefinition { name: method.name().to_string(), server_streaming: method.is_server_streaming(), client_streaming: method.is_client_streaming(), schema: serde_json::to_string_pretty(&json_schema::message_to_json_schema( &pool, input_message, )) .expect("Failed to serialize JSON schema"), }) } def }) .collect::>() } pub async fn connect( &mut self, id: &str, uri: &str, proto_files: &Vec, metadata: &BTreeMap, validate_certificates: bool, client_cert: Option, ) -> Result { let use_reflection = proto_files.is_empty(); if self.get_pool(id, uri, proto_files).is_none() { self.reflect( id, uri, proto_files, metadata, validate_certificates, client_cert.clone(), ) .await?; } let pool = self .get_pool(id, uri, proto_files) .ok_or(GenericError("Failed to get pool".to_string()))? .clone(); let uri = uri_from_str(uri)?; let conn = get_transport(validate_certificates, client_cert.clone())?; Ok(GrpcConnection { pool: Arc::new(RwLock::new(pool)), use_reflection, conn, uri }) } fn get_pool(&self, id: &str, uri: &str, proto_files: &Vec) -> Option<&DescriptorPool> { self.pools.get(make_pool_key(id, uri, proto_files).as_str()) } } pub(crate) fn decorate_req( metadata: &BTreeMap, req: &mut Request, ) -> Result<()> { for (k, v) in metadata { req.metadata_mut() .insert(MetadataKey::from_str(k.as_str())?, MetadataValue::from_str(v.as_str())?); } Ok(()) } fn uri_from_str(uri_str: &str) -> Result { match Uri::from_str(uri_str) { Ok(uri) => Ok(uri), Err(err) => { // Uri::from_str basically only returns "invalid format" so we add more context here Err(GenericError(format!("Failed to parse URL, {}", err.to_string()))) } } } fn make_pool_key(id: &str, uri: &str, proto_files: &Vec) -> String { let pool_key = format!( "{}::{}::{}", id, uri, proto_files .iter() .map(|p| p.to_string_lossy().to_string()) .collect::>() .join(":") ); format!("{:x}", md5::compute(pool_key)) } ================================================ FILE: crates/yaak-grpc/src/reflection.rs ================================================ use crate::any::collect_any_types; use crate::client::AutoReflectionClient; use crate::error::Error::GenericError; use crate::error::Result; use crate::manager::GrpcConfig; use anyhow::anyhow; use async_recursion::async_recursion; use log::{debug, info, warn}; use prost::Message; use prost_reflect::{DescriptorPool, MethodDescriptor}; use prost_types::{FileDescriptorProto, FileDescriptorSet}; use std::collections::{BTreeMap, HashSet}; use std::env::temp_dir; use std::ops::Deref; use std::path::{Path, PathBuf}; use std::str::FromStr; use std::sync::Arc; use tokio::fs; use tokio::sync::RwLock; use tonic::codegen::http::uri::PathAndQuery; use tonic::transport::Uri; use tonic_reflection::pb::v1::server_reflection_request::MessageRequest; use tonic_reflection::pb::v1::server_reflection_response::MessageResponse; use yaak_common::command::new_xplatform_command; use yaak_tls::ClientCertificateConfig; pub async fn fill_pool_from_files( config: &GrpcConfig, paths: &Vec, ) -> Result { let mut pool = DescriptorPool::new(); let random_file_name = format!("{}.desc", uuid::Uuid::new_v4()); let desc_path = temp_dir().join(random_file_name); // HACK: Remove UNC prefix for Windows paths let global_import_dir = dunce::simplified(config.protoc_include_dir.as_path()).to_string_lossy().to_string(); let desc_path = dunce::simplified(desc_path.as_path()); let mut args = vec![ "--include_imports".to_string(), "--include_source_info".to_string(), "-I".to_string(), global_import_dir, "-o".to_string(), desc_path.to_string_lossy().to_string(), ]; let mut include_dirs = HashSet::new(); let mut include_protos = HashSet::new(); for p in paths { if !p.exists() { continue; } // Dirs are added as includes if p.is_dir() { include_dirs.insert(p.to_string_lossy().to_string()); continue; } let parent = p.as_path().parent(); if let Some(parent_path) = parent { match find_parent_proto_dir(parent_path) { None => { // Add parent/grandparent as fallback include_dirs.insert(parent_path.to_string_lossy().to_string()); if let Some(grandparent_path) = parent_path.parent() { include_dirs.insert(grandparent_path.to_string_lossy().to_string()); } } Some(p) => { include_dirs.insert(p.to_string_lossy().to_string()); } }; } else { debug!("ignoring {:?} since it does not exist.", parent) } include_protos.insert(p.to_string_lossy().to_string()); } for d in include_dirs.clone() { args.push("-I".to_string()); args.push(d); } for p in include_protos.clone() { args.push(p); } info!("Invoking protoc with {}", args.join(" ")); let mut cmd = new_xplatform_command(&config.protoc_bin_path); cmd.args(&args); let out = cmd.output().await.map_err(|e| GenericError(format!("Failed to run protoc: {}", e)))?; if !out.status.success() { return Err(GenericError(format!( "protoc failed with status {}: {}", out.status.code().unwrap_or(-1), String::from_utf8_lossy(out.stderr.as_slice()) ))); } let bytes = fs::read(desc_path).await?; let fdp = FileDescriptorSet::decode(bytes.deref())?; pool.add_file_descriptor_set(fdp)?; fs::remove_file(desc_path).await?; Ok(pool) } pub async fn fill_pool_from_reflection( uri: &Uri, metadata: &BTreeMap, validate_certificates: bool, client_cert: Option, ) -> Result { let mut pool = DescriptorPool::new(); let mut client = AutoReflectionClient::new(uri, validate_certificates, client_cert)?; for service in list_services(&mut client, metadata).await? { if service == "grpc.reflection.v1alpha.ServerReflection" { continue; } if service == "grpc.reflection.v1.ServerReflection" { continue; } debug!("Fetching descriptors for {}", service); file_descriptor_set_from_service_name(&service, &mut pool, &mut client, metadata).await; } Ok(pool) } async fn list_services( client: &mut AutoReflectionClient, metadata: &BTreeMap, ) -> Result> { let response = client.send_reflection_request(MessageRequest::ListServices("".into()), metadata).await?; let list_services_response = match response { MessageResponse::ListServicesResponse(resp) => resp, _ => panic!("Expected a ListServicesResponse variant"), }; Ok(list_services_response.service.iter().map(|s| s.name.clone()).collect::>()) } async fn file_descriptor_set_from_service_name( service_name: &str, pool: &mut DescriptorPool, client: &mut AutoReflectionClient, metadata: &BTreeMap, ) { let response = match client .send_reflection_request( MessageRequest::FileContainingSymbol(service_name.into()), metadata, ) .await { Ok(resp) => resp, Err(e) => { warn!("Error fetching file descriptor for service {}: {:?}", service_name, e); return; } }; let file_descriptor_response = match response { MessageResponse::FileDescriptorResponse(resp) => resp, _ => panic!("Expected a FileDescriptorResponse variant"), }; add_file_descriptors_to_pool( file_descriptor_response.file_descriptor_proto, pool, client, metadata, ) .await; } pub(crate) async fn reflect_types_for_message( pool: Arc>, uri: &Uri, json: &str, metadata: &BTreeMap, client_cert: Option, ) -> Result<()> { // 1. Collect all Any types in the JSON let mut extra_types = Vec::new(); collect_any_types(json, &mut extra_types); if extra_types.is_empty() { return Ok(()); // nothing to do } let mut client = AutoReflectionClient::new(uri, false, client_cert)?; for extra_type in extra_types { { let guard = pool.read().await; if guard.get_message_by_name(&extra_type).is_some() { continue; } } info!("Adding file descriptor for {:?} from reflection", extra_type); let req = MessageRequest::FileContainingSymbol(extra_type.clone().into()); let resp = match client.send_reflection_request(req, metadata).await { Ok(r) => r, Err(e) => { return Err(GenericError(format!( "Error sending reflection request for @type \"{extra_type}\": {e:?}", ))); } }; let files = match resp { MessageResponse::FileDescriptorResponse(resp) => resp.file_descriptor_proto, _ => panic!("Expected a FileDescriptorResponse variant"), }; { let mut guard = pool.write().await; add_file_descriptors_to_pool(files, &mut *guard, &mut client, metadata).await; } } Ok(()) } #[async_recursion] pub(crate) async fn add_file_descriptors_to_pool( fds: Vec>, pool: &mut DescriptorPool, client: &mut AutoReflectionClient, metadata: &BTreeMap, ) { let mut topo_sort = topology::SimpleTopoSort::new(); let mut fd_mapping = std::collections::HashMap::with_capacity(fds.len()); for fd in fds { let fdp = FileDescriptorProto::decode(fd.deref()).unwrap(); topo_sort.insert(fdp.name().to_string(), fdp.dependency.clone()); fd_mapping.insert(fdp.name().to_string(), fdp); } for node in topo_sort { match node { Ok(node) => { if let Some(fdp) = fd_mapping.remove(&node) { pool.add_file_descriptor_proto(fdp).expect("add file descriptor proto"); } else { file_descriptor_set_by_filename(node.as_str(), pool, client, metadata).await; } } Err(_) => panic!("proto file got cycle!"), } } } async fn file_descriptor_set_by_filename( filename: &str, pool: &mut DescriptorPool, client: &mut AutoReflectionClient, metadata: &BTreeMap, ) { // We already fetched this file if let Some(_) = pool.get_file_by_name(filename) { return; } let msg = MessageRequest::FileByFilename(filename.into()); let response = client.send_reflection_request(msg, metadata).await; let file_descriptor_response = match response { Ok(MessageResponse::FileDescriptorResponse(resp)) => resp, Ok(_) => { panic!("Expected a FileDescriptorResponse variant") } Err(e) => { warn!("Error fetching file descriptor for {}: {:?}", filename, e); return; } }; add_file_descriptors_to_pool( file_descriptor_response.file_descriptor_proto, pool, client, metadata, ) .await; } pub fn method_desc_to_path(md: &MethodDescriptor) -> PathAndQuery { let full_name = md.full_name(); let (namespace, method_name) = full_name .rsplit_once('.') .ok_or_else(|| anyhow!("invalid method path")) .expect("invalid method path"); PathAndQuery::from_str(&format!("/{}/{}", namespace, method_name)).expect("invalid method path") } mod topology { use std::collections::{HashMap, HashSet}; pub struct SimpleTopoSort { out_graph: HashMap>, in_graph: HashMap>, } impl SimpleTopoSort where T: Eq + std::hash::Hash + Clone, { pub fn new() -> Self { SimpleTopoSort { out_graph: HashMap::new(), in_graph: HashMap::new() } } pub fn insert>(&mut self, node: T, deps: I) { self.out_graph.entry(node.clone()).or_insert(HashSet::new()); for dep in deps { self.out_graph.entry(node.clone()).or_insert(HashSet::new()).insert(dep.clone()); self.in_graph.entry(dep.clone()).or_insert(HashSet::new()).insert(node.clone()); } } } impl IntoIterator for SimpleTopoSort where T: Eq + std::hash::Hash + Clone, { type Item = as Iterator>::Item; type IntoIter = SimpleTopoSortIter; fn into_iter(self) -> Self::IntoIter { SimpleTopoSortIter::new(self) } } pub struct SimpleTopoSortIter { data: SimpleTopoSort, zero_indegree: Vec, } impl SimpleTopoSortIter where T: Eq + std::hash::Hash + Clone, { pub fn new(data: SimpleTopoSort) -> Self { let mut zero_indegree = Vec::new(); for (node, _) in data.in_graph.iter() { if !data.out_graph.contains_key(node) { zero_indegree.push(node.clone()); } } for (node, deps) in data.out_graph.iter() { if deps.is_empty() { zero_indegree.push(node.clone()); } } SimpleTopoSortIter { data, zero_indegree } } } impl Iterator for SimpleTopoSortIter where T: Eq + std::hash::Hash + Clone, { type Item = Result; fn next(&mut self) -> Option { if self.zero_indegree.is_empty() { if self.data.out_graph.is_empty() { return None; } return Some(Err("Cycle detected")); } let node = self.zero_indegree.pop().unwrap(); if let Some(parents) = self.data.in_graph.get(&node) { for parent in parents.iter() { let deps = self.data.out_graph.get_mut(parent).unwrap(); deps.remove(&node); if deps.is_empty() { self.zero_indegree.push(parent.clone()); } } } self.data.out_graph.remove(&node); Some(Ok(node)) } } #[test] fn test_sort() { { let mut topo_sort = SimpleTopoSort::new(); topo_sort.insert("a", []); for node in topo_sort { match node { Ok(n) => assert_eq!(n, "a"), Err(e) => panic!("err {}", e), } } } { let mut topo_sort = SimpleTopoSort::new(); topo_sort.insert("a", ["b"]); topo_sort.insert("b", []); let mut iter = topo_sort.into_iter(); match iter.next() { Some(Ok(n)) => assert_eq!(n, "b"), _ => panic!("err"), } match iter.next() { Some(Ok(n)) => assert_eq!(n, "a"), _ => panic!("err"), } assert_eq!(iter.next(), None); } } } fn find_parent_proto_dir(start_path: impl AsRef) -> Option { let mut dir = start_path.as_ref().canonicalize().ok()?; loop { if let Some(name) = dir.file_name().and_then(|n| n.to_str()) { if name == "proto" { return Some(dir); } } let parent = dir.parent()?; if parent == dir { return None; // Reached root } dir = parent.to_path_buf(); } } ================================================ FILE: crates/yaak-grpc/src/transport.rs ================================================ use crate::error::Result; use hyper_rustls::{HttpsConnector, HttpsConnectorBuilder}; use hyper_util::client::legacy::Client; use hyper_util::client::legacy::connect::HttpConnector; use hyper_util::rt::TokioExecutor; use log::info; use tonic::body::BoxBody; use yaak_tls::{ClientCertificateConfig, get_tls_config}; // I think ALPN breaks this because we're specifying http2_only const WITH_ALPN: bool = false; pub(crate) fn get_transport( validate_certificates: bool, client_cert: Option, ) -> Result, BoxBody>> { let tls_config = get_tls_config(validate_certificates, WITH_ALPN, client_cert.clone())?; let mut http = HttpConnector::new(); http.enforce_http(false); let connector = HttpsConnectorBuilder::new() .with_tls_config(tls_config) .https_or_http() .enable_http2() .build(); let client = Client::builder(TokioExecutor::new()) .pool_max_idle_per_host(0) .http2_only(true) .build(connector); info!( "Created gRPC client validate_certs={} client_cert={}", validate_certificates, client_cert.is_some() ); Ok(client) } ================================================ FILE: crates/yaak-http/Cargo.toml ================================================ [package] name = "yaak-http" version = "0.1.0" edition = "2024" publish = false [dependencies] async-compression = { version = "0.4", features = ["tokio", "gzip", "deflate", "brotli", "zstd"] } async-trait = "0.1" brotli = "7" bytes = "1.11.1" cookie = "0.18.1" flate2 = "1" futures-util = "0.3" http-body = "1" url = "2" zstd = "0.13" hyper-util = { version = "0.1.17", default-features = false, features = ["client-legacy"] } log = { workspace = true } mime_guess = "2.0.5" regex = "1.11.1" reqwest = { workspace = true, features = [ "rustls-tls-manual-roots-no-provider", "socks", "http2", "stream", ] } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true, features = ["macros", "rt", "fs", "io-util"] } tokio-util = { version = "0.7", features = ["codec", "io", "io-util"] } tower-service = "0.3.3" urlencoding = "2.1.3" yaak-common = { workspace = true } yaak-models = { workspace = true } yaak-templates = { workspace = true } yaak-tls = { workspace = true } ================================================ FILE: crates/yaak-http/src/chained_reader.rs ================================================ use std::io; use std::pin::Pin; use std::task::{Context, Poll}; use tokio::io::{AsyncRead, ReadBuf}; /// A stream that chains multiple AsyncRead sources together pub(crate) struct ChainedReader { readers: Vec, current_index: usize, current_reader: Option>, } #[derive(Clone)] pub(crate) enum ReaderType { Bytes(Vec), FilePath(String), } impl ChainedReader { pub(crate) fn new(readers: Vec) -> Self { Self { readers, current_index: 0, current_reader: None } } } impl AsyncRead for ChainedReader { fn poll_read( mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>, ) -> Poll> { loop { // Try to read from current reader if we have one if let Some(ref mut reader) = self.current_reader { let before_len = buf.filled().len(); return match Pin::new(reader).poll_read(cx, buf) { Poll::Ready(Ok(())) => { if buf.filled().len() == before_len && buf.remaining() > 0 { // Current reader is exhausted, move to next self.current_reader = None; continue; } Poll::Ready(Ok(())) } Poll::Ready(Err(e)) => Poll::Ready(Err(e)), Poll::Pending => Poll::Pending, }; } // We need to get the next reader if self.current_index >= self.readers.len() { // No more readers return Poll::Ready(Ok(())); } // Get the next reader let reader_type = self.readers[self.current_index].clone(); self.current_index += 1; match reader_type { ReaderType::Bytes(bytes) => { self.current_reader = Some(Box::new(io::Cursor::new(bytes))); } ReaderType::FilePath(path) => { // We need to handle file opening synchronously in poll_read // This is a limitation - we'll use blocking file open match std::fs::File::open(&path) { Ok(file) => { // Convert std File to tokio File let tokio_file = tokio::fs::File::from_std(file); self.current_reader = Some(Box::new(tokio_file)); } Err(e) => return Poll::Ready(Err(e)), } } } } } } ================================================ FILE: crates/yaak-http/src/client.rs ================================================ use crate::dns::LocalhostResolver; use crate::error::Result; use log::{debug, info, warn}; use reqwest::{Client, Proxy, redirect}; use std::sync::Arc; use yaak_models::models::DnsOverride; use yaak_tls::{ClientCertificateConfig, get_tls_config}; #[derive(Clone)] pub struct HttpConnectionProxySettingAuth { pub user: String, pub password: String, } #[derive(Clone)] pub enum HttpConnectionProxySetting { Disabled, System, Enabled { http: String, https: String, auth: Option, bypass: String, }, } #[derive(Clone)] pub struct HttpConnectionOptions { pub id: String, pub validate_certificates: bool, pub proxy: HttpConnectionProxySetting, pub client_certificate: Option, pub dns_overrides: Vec, } impl HttpConnectionOptions { /// Build a reqwest Client and return it along with the DNS resolver. /// The resolver is returned separately so it can be configured per-request /// to emit DNS timing events to the appropriate channel. pub(crate) fn build_client(&self) -> Result<(Client, Arc)> { let mut client = Client::builder() .connection_verbose(true) .redirect(redirect::Policy::none()) // Decompression is handled by HttpTransaction, not reqwest .no_gzip() .no_brotli() .no_deflate() .referer(false) .tls_info(true) // Disable connection pooling to ensure DNS resolution happens on each request // This is needed so we can emit DNS timing events for each request .pool_max_idle_per_host(0); // Configure TLS with optional client certificate let config = get_tls_config(self.validate_certificates, true, self.client_certificate.clone())?; client = client.use_preconfigured_tls(config); // Configure DNS resolver - keep a reference to configure per-request let resolver = LocalhostResolver::new(self.dns_overrides.clone()); client = client.dns_resolver(resolver.clone()); // Configure proxy match self.proxy.clone() { HttpConnectionProxySetting::System => { /* Default */ } HttpConnectionProxySetting::Disabled => { client = client.no_proxy(); } HttpConnectionProxySetting::Enabled { http, https, auth, bypass } => { for p in build_enabled_proxy(http, https, auth, bypass) { client = client.proxy(p) } } } info!( "Building new HTTP client validate_certificates={} client_cert={}", self.validate_certificates, self.client_certificate.is_some() ); Ok((client.build()?, resolver)) } } fn build_enabled_proxy( http: String, https: String, auth: Option, bypass: String, ) -> Vec { debug!("Using proxy http={http} https={https} bypass={bypass}"); let mut proxies = Vec::new(); if !http.is_empty() { match Proxy::http(http) { Ok(mut proxy) => { if let Some(HttpConnectionProxySettingAuth { user, password }) = auth.clone() { debug!("Using http proxy auth"); proxy = proxy.basic_auth(user.as_str(), password.as_str()); } proxies.push(proxy.no_proxy(reqwest::NoProxy::from_string(&bypass))); } Err(e) => { warn!("Failed to apply http proxy {e:?}"); } }; } if !https.is_empty() { match Proxy::https(https) { Ok(mut proxy) => { if let Some(HttpConnectionProxySettingAuth { user, password }) = auth { debug!("Using https proxy auth"); proxy = proxy.basic_auth(user.as_str(), password.as_str()); } proxies.push(proxy.no_proxy(reqwest::NoProxy::from_string(&bypass))); } Err(e) => { warn!("Failed to apply https proxy {e:?}"); } }; } proxies } ================================================ FILE: crates/yaak-http/src/cookies.rs ================================================ //! Custom cookie handling for HTTP requests //! //! This module provides cookie storage and matching functionality that was previously //! delegated to reqwest. It implements RFC 6265 cookie domain and path matching. use log::debug; use std::sync::{Arc, Mutex}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use url::Url; use yaak_models::models::{Cookie, CookieDomain, CookieExpires}; /// A thread-safe cookie store that can be shared across requests #[derive(Debug, Clone)] pub struct CookieStore { cookies: Arc>>, } impl Default for CookieStore { fn default() -> Self { Self::new() } } impl CookieStore { /// Create a new empty cookie store pub fn new() -> Self { Self { cookies: Arc::new(Mutex::new(Vec::new())) } } /// Create a cookie store from existing cookies pub fn from_cookies(cookies: Vec) -> Self { Self { cookies: Arc::new(Mutex::new(cookies)) } } /// Get all cookies (for persistence) pub fn get_all_cookies(&self) -> Vec { self.cookies.lock().unwrap().clone() } /// Get the Cookie header value for the given URL pub fn get_cookie_header(&self, url: &Url) -> Option { let cookies = self.cookies.lock().unwrap(); let now = SystemTime::now(); let matching_cookies: Vec<_> = cookies .iter() .filter(|cookie| self.cookie_matches(cookie, url, &now)) .filter_map(|cookie| { // Parse the raw cookie to get name=value parse_cookie_name_value(&cookie.raw_cookie) }) .collect(); if matching_cookies.is_empty() { None } else { Some( matching_cookies .into_iter() .map(|(name, value)| format!("{}={}", name, value)) .collect::>() .join("; "), ) } } /// Parse Set-Cookie headers and add cookies to the store pub fn store_cookies_from_response(&self, url: &Url, set_cookie_headers: &[String]) { let mut cookies = self.cookies.lock().unwrap(); for header_value in set_cookie_headers { if let Some(cookie) = parse_set_cookie(header_value, url) { // Remove any existing cookie with the same name and domain cookies.retain(|existing| !cookies_match(existing, &cookie)); debug!( "Storing cookie: {} for domain {:?}", parse_cookie_name_value(&cookie.raw_cookie) .map(|(n, _)| n) .unwrap_or_else(|| "unknown".to_string()), cookie.domain ); cookies.push(cookie); } } } /// Check if a cookie matches the given URL fn cookie_matches(&self, cookie: &Cookie, url: &Url, now: &SystemTime) -> bool { // Check expiration if let CookieExpires::AtUtc(expiry_str) = &cookie.expires { if let Ok(expiry) = parse_cookie_date(expiry_str) { if expiry < *now { return false; } } } // Check domain let url_host = match url.host_str() { Some(h) => h.to_lowercase(), None => return false, }; let domain_matches = match &cookie.domain { CookieDomain::HostOnly(domain) => url_host == domain.to_lowercase(), CookieDomain::Suffix(domain) => { let domain_lower = domain.to_lowercase(); url_host == domain_lower || url_host.ends_with(&format!(".{}", domain_lower)) } // NotPresent and Empty should never occur in practice since we always set domain // when parsing Set-Cookie headers. Treat as non-matching to be safe. CookieDomain::NotPresent | CookieDomain::Empty => false, }; if !domain_matches { return false; } // Check path let (cookie_path, _) = &cookie.path; let url_path = url.path(); path_matches(url_path, cookie_path) } } /// Parse name=value from a cookie string (raw_cookie format) fn parse_cookie_name_value(raw_cookie: &str) -> Option<(String, String)> { // The raw_cookie typically looks like "name=value" or "name=value; attr1; attr2=..." let first_part = raw_cookie.split(';').next()?; let mut parts = first_part.splitn(2, '='); let name = parts.next()?.trim().to_string(); let value = parts.next().unwrap_or("").trim().to_string(); if name.is_empty() { None } else { Some((name, value)) } } /// Parse a Set-Cookie header into a Cookie fn parse_set_cookie(header_value: &str, request_url: &Url) -> Option { let parsed = cookie::Cookie::parse(header_value).ok()?; let raw_cookie = format!("{}={}", parsed.name(), parsed.value()); // Determine domain let domain = if let Some(domain_attr) = parsed.domain() { // Domain attribute present - this is a suffix match let domain = domain_attr.trim_start_matches('.').to_lowercase(); // Reject single-component domains (TLDs) except localhost if is_single_component_domain(&domain) && !is_localhost(&domain) { debug!("Rejecting cookie with single-component domain: {}", domain); return None; } CookieDomain::Suffix(domain) } else { // No domain attribute - host-only cookie CookieDomain::HostOnly(request_url.host_str().unwrap_or("").to_lowercase()) }; // Determine expiration let expires = if let Some(max_age) = parsed.max_age() { let duration = Duration::from_secs(max_age.whole_seconds().max(0) as u64); let expiry = SystemTime::now() + duration; let expiry_secs = expiry.duration_since(UNIX_EPOCH).unwrap_or_default().as_secs(); CookieExpires::AtUtc(format!("{}", expiry_secs)) } else if let Some(expires_time) = parsed.expires() { match expires_time { cookie::Expiration::DateTime(dt) => { let timestamp = dt.unix_timestamp(); CookieExpires::AtUtc(format!("{}", timestamp)) } cookie::Expiration::Session => CookieExpires::SessionEnd, } } else { CookieExpires::SessionEnd }; // Determine path let path = if let Some(path_attr) = parsed.path() { (path_attr.to_string(), true) } else { // Default path is the directory of the request URI let default_path = default_cookie_path(request_url.path()); (default_path, false) }; Some(Cookie { raw_cookie, domain, expires, path }) } /// Get the default cookie path from a request path (RFC 6265 Section 5.1.4) fn default_cookie_path(request_path: &str) -> String { if request_path.is_empty() || !request_path.starts_with('/') { return "/".to_string(); } // Find the last slash if let Some(last_slash) = request_path.rfind('/') { if last_slash == 0 { "/".to_string() } else { request_path[..last_slash].to_string() } } else { "/".to_string() } } /// Check if a request path matches a cookie path (RFC 6265 Section 5.1.4) fn path_matches(request_path: &str, cookie_path: &str) -> bool { if request_path == cookie_path { return true; } if request_path.starts_with(cookie_path) { // Cookie path must end with / or the char after cookie_path in request_path must be / if cookie_path.ends_with('/') { return true; } if request_path.chars().nth(cookie_path.len()) == Some('/') { return true; } } false } /// Check if two cookies match (same name and domain) fn cookies_match(a: &Cookie, b: &Cookie) -> bool { let name_a = parse_cookie_name_value(&a.raw_cookie).map(|(n, _)| n); let name_b = parse_cookie_name_value(&b.raw_cookie).map(|(n, _)| n); if name_a != name_b { return false; } // Check domain match match (&a.domain, &b.domain) { (CookieDomain::HostOnly(d1), CookieDomain::HostOnly(d2)) => { d1.to_lowercase() == d2.to_lowercase() } (CookieDomain::Suffix(d1), CookieDomain::Suffix(d2)) => { d1.to_lowercase() == d2.to_lowercase() } _ => false, } } /// Parse a cookie date string (Unix timestamp in our format) fn parse_cookie_date(date_str: &str) -> Result { let timestamp: i64 = date_str.parse().map_err(|_| ())?; let duration = Duration::from_secs(timestamp.max(0) as u64); Ok(UNIX_EPOCH + duration) } /// Check if a domain is a single-component domain (TLD) /// e.g., "com", "org", "net" - domains without any dots fn is_single_component_domain(domain: &str) -> bool { // Empty or only dots let trimmed = domain.trim_matches('.'); if trimmed.is_empty() { return true; } // IPv6 addresses use colons, not dots - don't consider them single-component if domain.contains(':') { return false; } !trimmed.contains('.') } /// Check if a domain is localhost or a localhost variant fn is_localhost(domain: &str) -> bool { let lower = domain.to_lowercase(); lower == "localhost" || lower.ends_with(".localhost") || lower == "127.0.0.1" || lower == "::1" || lower == "[::1]" } #[cfg(test)] mod tests { use super::*; #[test] fn test_parse_cookie_name_value() { assert_eq!( parse_cookie_name_value("session=abc123"), Some(("session".to_string(), "abc123".to_string())) ); assert_eq!( parse_cookie_name_value("name=value; Path=/; HttpOnly"), Some(("name".to_string(), "value".to_string())) ); assert_eq!(parse_cookie_name_value("empty="), Some(("empty".to_string(), "".to_string()))); assert_eq!(parse_cookie_name_value(""), None); } #[test] fn test_path_matches() { assert!(path_matches("/", "/")); assert!(path_matches("/foo", "/")); assert!(path_matches("/foo/bar", "/foo")); assert!(path_matches("/foo/bar", "/foo/")); assert!(!path_matches("/foobar", "/foo")); assert!(!path_matches("/foo", "/foo/bar")); } #[test] fn test_default_cookie_path() { assert_eq!(default_cookie_path("/"), "/"); assert_eq!(default_cookie_path("/foo"), "/"); assert_eq!(default_cookie_path("/foo/bar"), "/foo"); assert_eq!(default_cookie_path("/foo/bar/baz"), "/foo/bar"); assert_eq!(default_cookie_path(""), "/"); } #[test] fn test_cookie_store_basic() { let store = CookieStore::new(); let url = Url::parse("https://example.com/path").unwrap(); // Initially empty assert!(store.get_cookie_header(&url).is_none()); // Add a cookie store.store_cookies_from_response(&url, &["session=abc123".to_string()]); // Should now have the cookie let header = store.get_cookie_header(&url); assert_eq!(header, Some("session=abc123".to_string())); } #[test] fn test_cookie_domain_matching() { let store = CookieStore::new(); let url = Url::parse("https://example.com/").unwrap(); // Cookie with domain attribute (suffix match) store.store_cookies_from_response( &url, &["domain_cookie=value; Domain=example.com".to_string()], ); // Should match example.com assert!(store.get_cookie_header(&url).is_some()); // Should match subdomain let subdomain_url = Url::parse("https://sub.example.com/").unwrap(); assert!(store.get_cookie_header(&subdomain_url).is_some()); // Should not match different domain let other_url = Url::parse("https://other.com/").unwrap(); assert!(store.get_cookie_header(&other_url).is_none()); } #[test] fn test_cookie_path_matching() { let store = CookieStore::new(); let url = Url::parse("https://example.com/api/v1").unwrap(); // Cookie with path store.store_cookies_from_response(&url, &["api_cookie=value; Path=/api".to_string()]); // Should match /api/v1 assert!(store.get_cookie_header(&url).is_some()); // Should match /api let api_url = Url::parse("https://example.com/api").unwrap(); assert!(store.get_cookie_header(&api_url).is_some()); // Should not match /other let other_url = Url::parse("https://example.com/other").unwrap(); assert!(store.get_cookie_header(&other_url).is_none()); } #[test] fn test_cookie_replacement() { let store = CookieStore::new(); let url = Url::parse("https://example.com/").unwrap(); // Add a cookie store.store_cookies_from_response(&url, &["session=old".to_string()]); assert_eq!(store.get_cookie_header(&url), Some("session=old".to_string())); // Replace with new value store.store_cookies_from_response(&url, &["session=new".to_string()]); assert_eq!(store.get_cookie_header(&url), Some("session=new".to_string())); // Should only have one cookie assert_eq!(store.get_all_cookies().len(), 1); } #[test] fn test_is_single_component_domain() { // Single-component domains (TLDs) assert!(is_single_component_domain("com")); assert!(is_single_component_domain("org")); assert!(is_single_component_domain("net")); assert!(is_single_component_domain("localhost")); // Still single-component, but allowed separately // Multi-component domains assert!(!is_single_component_domain("example.com")); assert!(!is_single_component_domain("sub.example.com")); assert!(!is_single_component_domain("co.uk")); // Edge cases assert!(is_single_component_domain("")); // Empty is treated as single-component assert!(is_single_component_domain(".")); // Only dots assert!(is_single_component_domain("..")); // Only dots // IPv6 addresses (have colons, not dots) assert!(!is_single_component_domain("::1")); // IPv6 localhost assert!(!is_single_component_domain("[::1]")); // Bracketed IPv6 assert!(!is_single_component_domain("2001:db8::1")); // IPv6 address } #[test] fn test_is_localhost() { // Localhost variants assert!(is_localhost("localhost")); assert!(is_localhost("LOCALHOST")); // Case-insensitive assert!(is_localhost("sub.localhost")); assert!(is_localhost("app.sub.localhost")); // IP localhost assert!(is_localhost("127.0.0.1")); assert!(is_localhost("::1")); assert!(is_localhost("[::1]")); // Not localhost assert!(!is_localhost("example.com")); assert!(!is_localhost("localhost.com")); // .com domain, not localhost assert!(!is_localhost("notlocalhost")); } #[test] fn test_reject_tld_cookies() { let store = CookieStore::new(); let url = Url::parse("https://example.com/").unwrap(); // Try to set a cookie with Domain=com (TLD) store.store_cookies_from_response(&url, &["bad=cookie; Domain=com".to_string()]); // Should be rejected - no cookies stored assert_eq!(store.get_all_cookies().len(), 0); assert!(store.get_cookie_header(&url).is_none()); } #[test] fn test_allow_localhost_cookies() { let store = CookieStore::new(); let url = Url::parse("http://localhost:3000/").unwrap(); // Cookie with Domain=localhost should be allowed store.store_cookies_from_response(&url, &["session=abc; Domain=localhost".to_string()]); // Should be accepted assert_eq!(store.get_all_cookies().len(), 1); assert!(store.get_cookie_header(&url).is_some()); } #[test] fn test_allow_127_0_0_1_cookies() { let store = CookieStore::new(); let url = Url::parse("http://127.0.0.1:8080/").unwrap(); // Cookie without Domain attribute (host-only) should work store.store_cookies_from_response(&url, &["session=xyz".to_string()]); // Should be accepted assert_eq!(store.get_all_cookies().len(), 1); assert!(store.get_cookie_header(&url).is_some()); } #[test] fn test_allow_normal_domain_cookies() { let store = CookieStore::new(); let url = Url::parse("https://example.com/").unwrap(); // Cookie with valid domain should be allowed store.store_cookies_from_response(&url, &["session=abc; Domain=example.com".to_string()]); // Should be accepted assert_eq!(store.get_all_cookies().len(), 1); assert!(store.get_cookie_header(&url).is_some()); } } ================================================ FILE: crates/yaak-http/src/decompress.rs ================================================ use crate::error::{Error, Result}; use async_compression::tokio::bufread::{ BrotliDecoder, DeflateDecoder as AsyncDeflateDecoder, GzipDecoder, ZstdDecoder as AsyncZstdDecoder, }; use flate2::read::{DeflateDecoder, GzDecoder}; use std::io::Read; use tokio::io::{AsyncBufRead, AsyncRead}; /// Supported compression encodings #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ContentEncoding { Gzip, Deflate, Brotli, Zstd, Identity, } impl ContentEncoding { /// Parse a Content-Encoding header value into an encoding type. /// Returns Identity for unknown or missing encodings. pub fn from_header(value: Option<&str>) -> Self { match value.map(|s| s.trim().to_lowercase()).as_deref() { Some("gzip") | Some("x-gzip") => ContentEncoding::Gzip, Some("deflate") => ContentEncoding::Deflate, Some("br") => ContentEncoding::Brotli, Some("zstd") => ContentEncoding::Zstd, _ => ContentEncoding::Identity, } } } /// Result of decompression, containing both the decompressed data and size info #[derive(Debug)] pub struct DecompressResult { pub data: Vec, pub compressed_size: u64, pub decompressed_size: u64, } /// Decompress data based on the Content-Encoding. /// Returns the original data unchanged if encoding is Identity or unknown. pub fn decompress(data: Vec, encoding: ContentEncoding) -> Result { let compressed_size = data.len() as u64; let decompressed = match encoding { ContentEncoding::Identity => data, ContentEncoding::Gzip => decompress_gzip(&data)?, ContentEncoding::Deflate => decompress_deflate(&data)?, ContentEncoding::Brotli => decompress_brotli(&data)?, ContentEncoding::Zstd => decompress_zstd(&data)?, }; let decompressed_size = decompressed.len() as u64; Ok(DecompressResult { data: decompressed, compressed_size, decompressed_size }) } fn decompress_gzip(data: &[u8]) -> Result> { let mut decoder = GzDecoder::new(data); let mut decompressed = Vec::new(); decoder .read_to_end(&mut decompressed) .map_err(|e| Error::DecompressionError(format!("gzip decompression failed: {}", e)))?; Ok(decompressed) } fn decompress_deflate(data: &[u8]) -> Result> { let mut decoder = DeflateDecoder::new(data); let mut decompressed = Vec::new(); decoder .read_to_end(&mut decompressed) .map_err(|e| Error::DecompressionError(format!("deflate decompression failed: {}", e)))?; Ok(decompressed) } fn decompress_brotli(data: &[u8]) -> Result> { let mut decompressed = Vec::new(); brotli::BrotliDecompress(&mut std::io::Cursor::new(data), &mut decompressed) .map_err(|e| Error::DecompressionError(format!("brotli decompression failed: {}", e)))?; Ok(decompressed) } fn decompress_zstd(data: &[u8]) -> Result> { zstd::stream::decode_all(std::io::Cursor::new(data)) .map_err(|e| Error::DecompressionError(format!("zstd decompression failed: {}", e))) } /// Create a streaming decompressor that wraps an async reader. /// Returns an AsyncRead that decompresses data on-the-fly. pub fn streaming_decoder( reader: R, encoding: ContentEncoding, ) -> Box { match encoding { ContentEncoding::Identity => Box::new(reader), ContentEncoding::Gzip => Box::new(GzipDecoder::new(reader)), ContentEncoding::Deflate => Box::new(AsyncDeflateDecoder::new(reader)), ContentEncoding::Brotli => Box::new(BrotliDecoder::new(reader)), ContentEncoding::Zstd => Box::new(AsyncZstdDecoder::new(reader)), } } #[cfg(test)] mod tests { use super::*; use flate2::Compression; use flate2::write::GzEncoder; use std::io::Write; #[test] fn test_content_encoding_from_header() { assert_eq!(ContentEncoding::from_header(Some("gzip")), ContentEncoding::Gzip); assert_eq!(ContentEncoding::from_header(Some("x-gzip")), ContentEncoding::Gzip); assert_eq!(ContentEncoding::from_header(Some("GZIP")), ContentEncoding::Gzip); assert_eq!(ContentEncoding::from_header(Some("deflate")), ContentEncoding::Deflate); assert_eq!(ContentEncoding::from_header(Some("br")), ContentEncoding::Brotli); assert_eq!(ContentEncoding::from_header(Some("zstd")), ContentEncoding::Zstd); assert_eq!(ContentEncoding::from_header(Some("identity")), ContentEncoding::Identity); assert_eq!(ContentEncoding::from_header(Some("unknown")), ContentEncoding::Identity); assert_eq!(ContentEncoding::from_header(None), ContentEncoding::Identity); } #[test] fn test_decompress_identity() { let data = b"hello world".to_vec(); let result = decompress(data.clone(), ContentEncoding::Identity).unwrap(); assert_eq!(result.data, data); assert_eq!(result.compressed_size, 11); assert_eq!(result.decompressed_size, 11); } #[test] fn test_decompress_gzip() { // Compress some data with gzip let original = b"hello world, this is a test of gzip compression"; let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); encoder.write_all(original).unwrap(); let compressed = encoder.finish().unwrap(); let result = decompress(compressed.clone(), ContentEncoding::Gzip).unwrap(); assert_eq!(result.data, original); assert_eq!(result.compressed_size, compressed.len() as u64); assert_eq!(result.decompressed_size, original.len() as u64); } #[test] fn test_decompress_deflate() { // Compress some data with deflate let original = b"hello world, this is a test of deflate compression"; let mut encoder = flate2::write::DeflateEncoder::new(Vec::new(), Compression::default()); encoder.write_all(original).unwrap(); let compressed = encoder.finish().unwrap(); let result = decompress(compressed.clone(), ContentEncoding::Deflate).unwrap(); assert_eq!(result.data, original); assert_eq!(result.compressed_size, compressed.len() as u64); assert_eq!(result.decompressed_size, original.len() as u64); } #[test] fn test_decompress_brotli() { // Compress some data with brotli let original = b"hello world, this is a test of brotli compression"; let mut compressed = Vec::new(); let mut writer = brotli::CompressorWriter::new(&mut compressed, 4096, 4, 22); writer.write_all(original).unwrap(); drop(writer); let result = decompress(compressed.clone(), ContentEncoding::Brotli).unwrap(); assert_eq!(result.data, original); assert_eq!(result.compressed_size, compressed.len() as u64); assert_eq!(result.decompressed_size, original.len() as u64); } #[test] fn test_decompress_zstd() { // Compress some data with zstd let original = b"hello world, this is a test of zstd compression"; let compressed = zstd::stream::encode_all(std::io::Cursor::new(original), 3).unwrap(); let result = decompress(compressed.clone(), ContentEncoding::Zstd).unwrap(); assert_eq!(result.data, original); assert_eq!(result.compressed_size, compressed.len() as u64); assert_eq!(result.decompressed_size, original.len() as u64); } } ================================================ FILE: crates/yaak-http/src/dns.rs ================================================ use crate::sender::HttpResponseEvent; use hyper_util::client::legacy::connect::dns::{ GaiResolver as HyperGaiResolver, Name as HyperName, }; use log::info; use reqwest::dns::{Addrs, Name, Resolve, Resolving}; use std::collections::HashMap; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; use std::str::FromStr; use std::sync::Arc; use std::time::Instant; use tokio::sync::{RwLock, mpsc}; use tower_service::Service; use yaak_models::models::DnsOverride; /// Stores resolved addresses for a hostname override #[derive(Clone)] pub struct ResolvedOverride { pub ipv4: Vec, pub ipv6: Vec, } #[derive(Clone)] pub struct LocalhostResolver { fallback: HyperGaiResolver, event_tx: Arc>>>, overrides: Arc>, } impl LocalhostResolver { pub fn new(dns_overrides: Vec) -> Arc { let resolver = HyperGaiResolver::new(); // Pre-parse DNS overrides into a lookup map let mut overrides = HashMap::new(); for o in dns_overrides { if !o.enabled { continue; } let hostname = o.hostname.to_lowercase(); let ipv4: Vec = o.ipv4.iter().filter_map(|s| s.parse::().ok()).collect(); let ipv6: Vec = o.ipv6.iter().filter_map(|s| s.parse::().ok()).collect(); // Only add if at least one address is valid if !ipv4.is_empty() || !ipv6.is_empty() { overrides.insert(hostname, ResolvedOverride { ipv4, ipv6 }); } } Arc::new(Self { fallback: resolver, event_tx: Arc::new(RwLock::new(None)), overrides: Arc::new(overrides), }) } /// Set the event sender for the current request. /// This should be called before each request to direct DNS events /// to the appropriate channel. pub async fn set_event_sender(&self, tx: Option>) { let mut guard = self.event_tx.write().await; *guard = tx; } } impl Resolve for LocalhostResolver { fn resolve(&self, name: Name) -> Resolving { let host = name.as_str().to_lowercase(); let event_tx = self.event_tx.clone(); let overrides = self.overrides.clone(); info!("DNS resolve called for: {}", host); // Check for DNS override first if let Some(resolved) = overrides.get(&host) { log::debug!("DNS override found for: {}", host); let hostname = host.clone(); let mut addrs: Vec = Vec::new(); // Add IPv4 addresses for ip in &resolved.ipv4 { addrs.push(SocketAddr::new(IpAddr::V4(*ip), 0)); } // Add IPv6 addresses for ip in &resolved.ipv6 { addrs.push(SocketAddr::new(IpAddr::V6(*ip), 0)); } let addresses: Vec = addrs.iter().map(|a| a.ip().to_string()).collect(); return Box::pin(async move { // Emit DNS event for override let guard = event_tx.read().await; if let Some(tx) = guard.as_ref() { let _ = tx .send(HttpResponseEvent::DnsResolved { hostname, addresses, duration: 0, overridden: true, }) .await; } Ok::>(Box::new(addrs.into_iter())) }); } // Check for .localhost suffix let is_localhost = host.ends_with(".localhost"); if is_localhost { let hostname = host.clone(); // Port 0 is fine; reqwest replaces it with the URL's explicit // port or the scheme's default (80/443, etc.). let addrs: Vec = vec![ SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0), SocketAddr::new(IpAddr::V6(Ipv6Addr::LOCALHOST), 0), ]; let addresses: Vec = addrs.iter().map(|a| a.ip().to_string()).collect(); return Box::pin(async move { // Emit DNS event for localhost resolution let guard = event_tx.read().await; if let Some(tx) = guard.as_ref() { let _ = tx .send(HttpResponseEvent::DnsResolved { hostname, addresses, duration: 0, overridden: false, }) .await; } Ok::>(Box::new(addrs.into_iter())) }); } // Fall back to system DNS let mut fallback = self.fallback.clone(); let name_str = name.as_str().to_string(); let hostname = host.clone(); Box::pin(async move { let start = Instant::now(); let result = match HyperName::from_str(&name_str) { Ok(n) => fallback.call(n).await, Err(e) => return Err(Box::new(e) as Box), }; let duration = start.elapsed().as_millis() as u64; match result { Ok(addrs) => { // Collect addresses for event emission let addr_vec: Vec = addrs.collect(); let addresses: Vec = addr_vec.iter().map(|a| a.ip().to_string()).collect(); // Emit DNS event let guard = event_tx.read().await; if let Some(tx) = guard.as_ref() { let _ = tx .send(HttpResponseEvent::DnsResolved { hostname, addresses, duration, overridden: false, }) .await; } Ok(Box::new(addr_vec.into_iter()) as Addrs) } Err(err) => Err(Box::new(err) as Box), } }) } } ================================================ FILE: crates/yaak-http/src/error.rs ================================================ use serde::{Serialize, Serializer}; use thiserror::Error; #[derive(Error, Debug)] pub enum Error { #[error("Client error: {0:?}")] Client(#[from] reqwest::Error), #[error(transparent)] TlsError(#[from] yaak_tls::error::Error), #[error("Request failed with {0:?}")] RequestError(String), #[error("Request canceled")] RequestCanceledError, #[error("Timeout of {0:?} reached")] RequestTimeout(std::time::Duration), #[error("Decompression error: {0}")] DecompressionError(String), #[error("Failed to read response body: {0}")] BodyReadError(String), } impl Serialize for Error { fn serialize(&self, serializer: S) -> std::result::Result where S: Serializer, { serializer.serialize_str(self.to_string().as_ref()) } } pub type Result = std::result::Result; ================================================ FILE: crates/yaak-http/src/lib.rs ================================================ mod chained_reader; pub mod client; pub mod cookies; pub mod decompress; pub mod dns; pub mod error; pub mod manager; pub mod path_placeholders; mod proto; pub mod sender; pub mod tee_reader; pub mod transaction; pub mod types; ================================================ FILE: crates/yaak-http/src/manager.rs ================================================ use crate::client::HttpConnectionOptions; use crate::dns::LocalhostResolver; use crate::error::Result; use reqwest::Client; use std::collections::BTreeMap; use std::sync::Arc; use std::time::{Duration, Instant}; use tokio::sync::RwLock; /// A cached HTTP client along with its DNS resolver. /// The resolver is needed to set the event sender per-request. pub struct CachedClient { pub client: Client, pub resolver: Arc, } pub struct HttpConnectionManager { connections: Arc>>, ttl: Duration, } impl HttpConnectionManager { pub fn new() -> Self { Self { connections: Arc::new(RwLock::new(BTreeMap::new())), ttl: Duration::from_secs(10 * 60), } } pub async fn get_client(&self, opt: &HttpConnectionOptions) -> Result { let mut connections = self.connections.write().await; let id = opt.id.clone(); // Clean old connections connections.retain(|_, (_, last_used)| last_used.elapsed() <= self.ttl); if let Some((cached, last_used)) = connections.get_mut(&id) { *last_used = Instant::now(); return Ok(CachedClient { client: cached.client.clone(), resolver: cached.resolver.clone(), }); } let (client, resolver) = opt.build_client()?; let cached = CachedClient { client: client.clone(), resolver: resolver.clone() }; connections.insert(id.into(), (cached, Instant::now())); Ok(CachedClient { client, resolver }) } } ================================================ FILE: crates/yaak-http/src/path_placeholders.rs ================================================ use yaak_models::models::HttpUrlParameter; pub fn apply_path_placeholders( url: &str, parameters: &Vec, ) -> (String, Vec) { let mut new_parameters = Vec::new(); let mut url = url.to_string(); for p in parameters { if !p.enabled || p.name.is_empty() { continue; } // Replace path parameters with values from URL parameters let old_url_string = url.clone(); url = replace_path_placeholder(&p, url.as_str()); // Remove as param if it modified the URL if old_url_string == *url { new_parameters.push(p.to_owned()); } } (url, new_parameters) } fn replace_path_placeholder(p: &HttpUrlParameter, url: &str) -> String { if !p.enabled { return url.to_string(); } if !p.name.starts_with(":") { return url.to_string(); } let re = regex::Regex::new(format!("(/){}([/?#]|$)", p.name).as_str()).unwrap(); let result = re .replace_all(url, |cap: ®ex::Captures| { format!( "{}{}{}", cap[1].to_string(), urlencoding::encode(p.value.as_str()), cap[2].to_string() ) }) .into_owned(); result } #[cfg(test)] mod placeholder_tests { use crate::path_placeholders::{apply_path_placeholders, replace_path_placeholder}; use yaak_models::models::{HttpRequest, HttpUrlParameter}; #[test] fn placeholder_middle() { let p = HttpUrlParameter { name: ":foo".into(), value: "xxx".into(), enabled: true, id: None }; assert_eq!( replace_path_placeholder(&p, "https://example.com/:foo/bar"), "https://example.com/xxx/bar", ); } #[test] fn placeholder_end() { let p = HttpUrlParameter { name: ":foo".into(), value: "xxx".into(), enabled: true, id: None }; assert_eq!( replace_path_placeholder(&p, "https://example.com/:foo"), "https://example.com/xxx", ); } #[test] fn placeholder_query() { let p = HttpUrlParameter { name: ":foo".into(), value: "xxx".into(), enabled: true, id: None }; assert_eq!( replace_path_placeholder(&p, "https://example.com/:foo?:foo"), "https://example.com/xxx?:foo", ); } #[test] fn placeholder_missing() { let p = HttpUrlParameter { enabled: true, name: "".to_string(), value: "".to_string(), id: None, }; assert_eq!( replace_path_placeholder(&p, "https://example.com/:missing"), "https://example.com/:missing", ); } #[test] fn placeholder_disabled() { let p = HttpUrlParameter { enabled: false, name: ":foo".to_string(), value: "xxx".to_string(), id: None, }; assert_eq!( replace_path_placeholder(&p, "https://example.com/:foo"), "https://example.com/:foo", ); } #[test] fn placeholder_prefix() { let p = HttpUrlParameter { name: ":foo".into(), value: "xxx".into(), enabled: true, id: None }; assert_eq!( replace_path_placeholder(&p, "https://example.com/:foooo"), "https://example.com/:foooo", ); } #[test] fn placeholder_encode() { let p = HttpUrlParameter { name: ":foo".into(), value: "Hello World".into(), enabled: true, id: None, }; assert_eq!( replace_path_placeholder(&p, "https://example.com/:foo"), "https://example.com/Hello%20World", ); } #[test] fn apply_placeholder() { let req = HttpRequest { url: "example.com/:a/bar".to_string(), url_parameters: vec![ HttpUrlParameter { name: "b".to_string(), value: "bbb".to_string(), enabled: true, id: None, }, HttpUrlParameter { name: ":a".to_string(), value: "aaa".to_string(), enabled: true, id: None, }, ], ..Default::default() }; let (url, url_parameters) = apply_path_placeholders(&req.url, &req.url_parameters); // Pattern match back to access it assert_eq!(url, "example.com/aaa/bar"); assert_eq!(url_parameters.len(), 1); assert_eq!(url_parameters[0].name, "b"); assert_eq!(url_parameters[0].value, "bbb"); } } ================================================ FILE: crates/yaak-http/src/proto.rs ================================================ use reqwest::Url; use std::str::FromStr; pub(crate) fn ensure_proto(url_str: &str) -> String { if url_str.is_empty() { return "".to_string(); } if url_str.starts_with("http://") || url_str.starts_with("https://") { return url_str.to_string(); } // Url::from_str will fail without a proto, so add one let parseable_url = format!("http://{}", url_str); if let Ok(u) = Url::from_str(parseable_url.as_str()) { match u.host() { Some(host) => { let h = host.to_string(); // These TLDs force HTTPS if h.ends_with(".app") || h.ends_with(".dev") || h.ends_with(".page") { return format!("https://{url_str}"); } } None => {} } } format!("http://{url_str}") } ================================================ FILE: crates/yaak-http/src/sender.rs ================================================ use crate::decompress::{ContentEncoding, streaming_decoder}; use crate::error::{Error, Result}; use crate::types::{SendableBody, SendableHttpRequest}; use async_trait::async_trait; use bytes::Bytes; use futures_util::StreamExt; use http_body::{Body as HttpBody, Frame, SizeHint}; use reqwest::{Client, Method, Version}; use std::fmt::Display; use std::pin::Pin; use std::task::{Context, Poll}; use std::time::Duration; use tokio::io::{AsyncRead, AsyncReadExt, BufReader, ReadBuf}; use tokio::sync::mpsc; use tokio_util::io::StreamReader; #[derive(Debug, Clone)] pub enum RedirectBehavior { /// 307/308: Method and body are preserved Preserve, /// 303 or 301/302 with POST: Method changed to GET, body dropped DropBody, } #[derive(Debug, Clone)] pub enum HttpResponseEvent { Setting(String, String), Info(String), Redirect { url: String, status: u16, behavior: RedirectBehavior, dropped_body: bool, dropped_headers: Vec, }, SendUrl { method: String, scheme: String, username: String, password: String, host: String, port: u16, path: String, query: String, fragment: String, }, ReceiveUrl { version: Version, status: String, }, HeaderUp(String, String), HeaderDown(String, String), ChunkSent { bytes: usize, }, ChunkReceived { bytes: usize, }, DnsResolved { hostname: String, addresses: Vec, duration: u64, overridden: bool, }, } impl Display for HttpResponseEvent { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { HttpResponseEvent::Setting(name, value) => write!(f, "* Setting {}={}", name, value), HttpResponseEvent::Info(s) => write!(f, "* {}", s), HttpResponseEvent::Redirect { url, status, behavior, dropped_body, dropped_headers, } => { let behavior_str = match behavior { RedirectBehavior::Preserve => "preserve", RedirectBehavior::DropBody => "drop body", }; let body_str = if *dropped_body { ", body dropped" } else { "" }; let headers_str = if dropped_headers.is_empty() { String::new() } else { format!(", headers dropped: {}", dropped_headers.join(", ")) }; write!( f, "* Redirect {} -> {} ({}{}{})", status, url, behavior_str, body_str, headers_str ) } HttpResponseEvent::SendUrl { method, scheme, username, password, host, port, path, query, fragment, } => { let auth_str = if username.is_empty() && password.is_empty() { String::new() } else { format!("{}:{}@", username, password) }; let query_str = if query.is_empty() { String::new() } else { format!("?{}", query) }; let fragment_str = if fragment.is_empty() { String::new() } else { format!("#{}", fragment) }; write!( f, "> {} {}://{}{}:{}{}{}{}", method, scheme, auth_str, host, port, path, query_str, fragment_str ) } HttpResponseEvent::ReceiveUrl { version, status } => { write!(f, "< {} {}", version_to_str(version), status) } HttpResponseEvent::HeaderUp(name, value) => write!(f, "> {}: {}", name, value), HttpResponseEvent::HeaderDown(name, value) => write!(f, "< {}: {}", name, value), HttpResponseEvent::ChunkSent { bytes } => write!(f, "> [{} bytes sent]", bytes), HttpResponseEvent::ChunkReceived { bytes } => write!(f, "< [{} bytes received]", bytes), HttpResponseEvent::DnsResolved { hostname, addresses, duration, overridden } => { if *overridden { write!(f, "* DNS override {} -> {}", hostname, addresses.join(", ")) } else { write!( f, "* DNS resolved {} to {} ({}ms)", hostname, addresses.join(", "), duration ) } } } } } impl From for yaak_models::models::HttpResponseEventData { fn from(event: HttpResponseEvent) -> Self { use yaak_models::models::HttpResponseEventData as D; match event { HttpResponseEvent::Setting(name, value) => D::Setting { name, value }, HttpResponseEvent::Info(message) => D::Info { message }, HttpResponseEvent::Redirect { url, status, behavior, dropped_body, dropped_headers, } => D::Redirect { url, status, behavior: match behavior { RedirectBehavior::Preserve => "preserve".to_string(), RedirectBehavior::DropBody => "drop_body".to_string(), }, dropped_body, dropped_headers, }, HttpResponseEvent::SendUrl { method, scheme, username, password, host, port, path, query, fragment, } => { D::SendUrl { method, scheme, username, password, host, port, path, query, fragment } } HttpResponseEvent::ReceiveUrl { version, status } => { D::ReceiveUrl { version: format!("{:?}", version), status } } HttpResponseEvent::HeaderUp(name, value) => D::HeaderUp { name, value }, HttpResponseEvent::HeaderDown(name, value) => D::HeaderDown { name, value }, HttpResponseEvent::ChunkSent { bytes } => D::ChunkSent { bytes }, HttpResponseEvent::ChunkReceived { bytes } => D::ChunkReceived { bytes }, HttpResponseEvent::DnsResolved { hostname, addresses, duration, overridden } => { D::DnsResolved { hostname, addresses, duration, overridden } } } } } /// Statistics about the body after consumption #[derive(Debug, Default, Clone)] pub struct BodyStats { /// Size of the body as received over the wire (before decompression) pub size_compressed: u64, /// Size of the body after decompression pub size_decompressed: u64, } /// An AsyncRead wrapper that sends chunk events as data is read pub struct TrackingRead { inner: R, event_tx: mpsc::Sender, ended: bool, } impl TrackingRead { pub fn new(inner: R, event_tx: mpsc::Sender) -> Self { Self { inner, event_tx, ended: false } } } impl AsyncRead for TrackingRead { fn poll_read( mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>, ) -> Poll> { let before = buf.filled().len(); let result = Pin::new(&mut self.inner).poll_read(cx, buf); if let Poll::Ready(Ok(())) = &result { let bytes_read = buf.filled().len() - before; if bytes_read > 0 { // Ignore send errors - receiver may have been dropped or channel is full let _ = self.event_tx.try_send(HttpResponseEvent::ChunkReceived { bytes: bytes_read }); } else if !self.ended { self.ended = true; } } result } } /// Type alias for the body stream type BodyStream = Pin>; /// HTTP response with deferred body consumption. /// Headers are available immediately after send(), body can be consumed in different ways. /// Note: Debug is manually implemented since BodyStream doesn't implement Debug. pub struct HttpResponse { /// HTTP status code pub status: u16, /// HTTP status reason phrase (e.g., "OK", "Not Found") pub status_reason: Option, /// Response headers (Vec to support multiple headers with same name, e.g., Set-Cookie) pub headers: Vec<(String, String)>, /// Request headers (Vec to support multiple headers with same name) pub request_headers: Vec<(String, String)>, /// Content-Length from headers (may differ from actual body size) pub content_length: Option, /// Final URL (after redirects) pub url: String, /// Remote address of the server pub remote_addr: Option, /// HTTP version (e.g., "HTTP/1.1", "HTTP/2") pub version: Option, /// The body stream (consumed when calling bytes(), text(), write_to_file(), or drain()) body_stream: Option, /// Content-Encoding for decompression encoding: ContentEncoding, } impl std::fmt::Debug for HttpResponse { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("HttpResponse") .field("status", &self.status) .field("status_reason", &self.status_reason) .field("headers", &self.headers) .field("content_length", &self.content_length) .field("url", &self.url) .field("remote_addr", &self.remote_addr) .field("version", &self.version) .field("body_stream", &"") .field("encoding", &self.encoding) .finish() } } impl HttpResponse { /// Create a new HttpResponse with an unconsumed body stream #[allow(clippy::too_many_arguments)] pub fn new( status: u16, status_reason: Option, headers: Vec<(String, String)>, request_headers: Vec<(String, String)>, content_length: Option, url: String, remote_addr: Option, version: Option, body_stream: BodyStream, encoding: ContentEncoding, ) -> Self { Self { status, status_reason, headers, request_headers, content_length, url, remote_addr, version, body_stream: Some(body_stream), encoding, } } /// Consume the body and return it as bytes (loads entire body into memory). /// Also decompresses the body if Content-Encoding is set. pub async fn bytes(mut self) -> Result<(Vec, BodyStats)> { let stream = self.body_stream.take().ok_or_else(|| { Error::RequestError("Response body has already been consumed".to_string()) })?; let buf_reader = BufReader::new(stream); let mut decoder = streaming_decoder(buf_reader, self.encoding); let mut decompressed = Vec::new(); let mut bytes_read = 0u64; // Read through the decoder in chunks to track compressed size let mut buf = [0u8; 8192]; loop { match decoder.read(&mut buf).await { Ok(0) => break, Ok(n) => { decompressed.extend_from_slice(&buf[..n]); bytes_read += n as u64; } Err(e) => { return Err(Error::BodyReadError(e.to_string())); } } } let stats = BodyStats { // For now, we can't easily track compressed size when streaming through decoder // Use content_length as an approximation, or decompressed size if identity encoding size_compressed: self.content_length.unwrap_or(bytes_read), size_decompressed: decompressed.len() as u64, }; Ok((decompressed, stats)) } /// Consume the body and return it as a UTF-8 string. pub async fn text(self) -> Result<(String, BodyStats)> { let (bytes, stats) = self.bytes().await?; let text = String::from_utf8(bytes) .map_err(|e| Error::RequestError(format!("Response is not valid UTF-8: {}", e)))?; Ok((text, stats)) } /// Take the body stream for manual consumption. /// Returns an AsyncRead that decompresses on-the-fly if Content-Encoding is set. /// The caller is responsible for reading and processing the stream. pub fn into_body_stream(&mut self) -> Result> { let stream = self.body_stream.take().ok_or_else(|| { Error::RequestError("Response body has already been consumed".to_string()) })?; let buf_reader = BufReader::new(stream); let decoder = streaming_decoder(buf_reader, self.encoding); Ok(decoder) } /// Discard the body without reading it (useful for redirects). pub async fn drain(mut self) -> Result<()> { let stream = self.body_stream.take().ok_or_else(|| { Error::RequestError("Response body has already been consumed".to_string()) })?; // Just read and discard all bytes let mut reader = stream; let mut buf = [0u8; 8192]; loop { match reader.read(&mut buf).await { Ok(0) => break, Ok(_) => continue, Err(e) => { return Err(Error::RequestError(format!( "Failed to drain response body: {}", e ))); } } } Ok(()) } } /// Trait for sending HTTP requests #[async_trait] pub trait HttpSender: Send + Sync { /// Send an HTTP request and return the response with headers. /// The body is not consumed until you call bytes(), text(), write_to_file(), or drain(). /// Events are sent through the provided channel. async fn send( &self, request: SendableHttpRequest, event_tx: mpsc::Sender, ) -> Result; } /// Reqwest-based implementation of HttpSender pub struct ReqwestSender { client: Client, } impl ReqwestSender { /// Create a new ReqwestSender with a default client pub fn new() -> Result { let client = Client::builder().build().map_err(Error::Client)?; Ok(Self { client }) } /// Create a new ReqwestSender with a custom client pub fn with_client(client: Client) -> Self { Self { client } } } #[async_trait] impl HttpSender for ReqwestSender { async fn send( &self, request: SendableHttpRequest, event_tx: mpsc::Sender, ) -> Result { // Helper to send events (ignores errors if receiver is dropped or channel is full) let send_event = |event: HttpResponseEvent| { let _ = event_tx.try_send(event); }; // Parse the HTTP method let method = Method::from_bytes(request.method.as_bytes()) .map_err(|e| Error::RequestError(format!("Invalid HTTP method: {}", e)))?; // Build the request let mut req_builder = self.client.request(method, &request.url); // Add headers for header in request.headers { if header.0.is_empty() { continue; } req_builder = req_builder.header(&header.0, &header.1); } // Configure timeout if let Some(d) = request.options.timeout && !d.is_zero() { req_builder = req_builder.timeout(d); } // Add body match request.body { None => {} Some(SendableBody::Bytes(bytes)) => { req_builder = req_builder.body(bytes); } Some(SendableBody::Stream { data, content_length }) => { // Convert AsyncRead stream to reqwest Body. If content length is // known, wrap with a SizedBody so hyper can set Content-Length // automatically (for both HTTP/1.1 and HTTP/2). let stream = tokio_util::io::ReaderStream::new(data); let body = if let Some(len) = content_length { reqwest::Body::wrap(SizedBody::new(stream, len)) } else { reqwest::Body::wrap_stream(stream) }; req_builder = req_builder.body(body); } } // Send the request let sendable_req = req_builder.build()?; send_event(HttpResponseEvent::Setting( "timeout".to_string(), if request.options.timeout.unwrap_or_default().is_zero() { "Infinity".to_string() } else { format!("{:?}", request.options.timeout) }, )); send_event(HttpResponseEvent::SendUrl { method: sendable_req.method().to_string(), scheme: sendable_req.url().scheme().to_string(), username: sendable_req.url().username().to_string(), password: sendable_req.url().password().unwrap_or_default().to_string(), host: sendable_req.url().host_str().unwrap_or_default().to_string(), port: sendable_req.url().port_or_known_default().unwrap_or(0), path: sendable_req.url().path().to_string(), query: sendable_req.url().query().unwrap_or_default().to_string(), fragment: sendable_req.url().fragment().unwrap_or_default().to_string(), }); let mut request_headers = Vec::new(); for (name, value) in sendable_req.headers() { let v = value.to_str().unwrap_or_default().to_string(); request_headers.push((name.to_string(), v.clone())); send_event(HttpResponseEvent::HeaderUp(name.to_string(), v)); } send_event(HttpResponseEvent::Info("Sending request to server".to_string())); // Map some errors to our own, so they look nicer let response = self.client.execute(sendable_req).await.map_err(|e| { if reqwest::Error::is_timeout(&e) { Error::RequestTimeout( request.options.timeout.unwrap_or(Duration::from_secs(0)).clone(), ) } else { Error::Client(e) } })?; let status = response.status().as_u16(); let status_reason = response.status().canonical_reason().map(|s| s.to_string()); let url = response.url().to_string(); let remote_addr = response.remote_addr().map(|a| a.to_string()); let version = Some(version_to_str(&response.version())); let content_length = response.content_length(); send_event(HttpResponseEvent::ReceiveUrl { version: response.version(), status: response.status().to_string(), }); // Extract headers (use Vec to preserve duplicates like Set-Cookie) let mut headers = Vec::new(); for (key, value) in response.headers() { if let Ok(v) = value.to_str() { send_event(HttpResponseEvent::HeaderDown(key.to_string(), v.to_string())); headers.push((key.to_string(), v.to_string())); } } // Determine content encoding for decompression // HTTP headers are case-insensitive, so we need to search for any casing let encoding = ContentEncoding::from_header( headers .iter() .find(|(k, _)| k.eq_ignore_ascii_case("content-encoding")) .map(|(_, v)| v.as_str()), ); // Get the byte stream instead of loading into memory let byte_stream = response.bytes_stream(); // Convert the stream to an AsyncRead let stream_reader = StreamReader::new( byte_stream.map(|result| result.map_err(|e| std::io::Error::other(e))), ); // Wrap the stream with tracking to emit chunk received events via the same channel let tracking_reader = TrackingRead::new(stream_reader, event_tx); let body_stream: BodyStream = Box::pin(tracking_reader); Ok(HttpResponse::new( status, status_reason, headers, request_headers, content_length, url, remote_addr, version, body_stream, encoding, )) } } /// A wrapper around a byte stream that reports a known content length via /// `size_hint()`. This lets hyper set the `Content-Length` header /// automatically based on the body size, without us having to add it as an /// explicit header — which can cause duplicate `Content-Length` headers and /// break HTTP/2. struct SizedBody { stream: std::sync::Mutex, remaining: u64, } impl SizedBody { fn new(stream: S, content_length: u64) -> Self { Self { stream: std::sync::Mutex::new(stream), remaining: content_length } } } impl HttpBody for SizedBody where S: futures_util::Stream> + Send + Unpin + 'static, { type Data = Bytes; type Error = std::io::Error; fn poll_frame( self: Pin<&mut Self>, cx: &mut Context<'_>, ) -> Poll, Self::Error>>> { let this = self.get_mut(); let mut stream = this.stream.lock().unwrap(); match stream.poll_next_unpin(cx) { Poll::Ready(Some(Ok(chunk))) => { this.remaining = this.remaining.saturating_sub(chunk.len() as u64); Poll::Ready(Some(Ok(Frame::data(chunk)))) } Poll::Ready(Some(Err(e))) => Poll::Ready(Some(Err(e))), Poll::Ready(None) => Poll::Ready(None), Poll::Pending => Poll::Pending, } } fn size_hint(&self) -> SizeHint { SizeHint::with_exact(self.remaining) } } fn version_to_str(version: &Version) -> String { match *version { Version::HTTP_09 => "HTTP/0.9".to_string(), Version::HTTP_10 => "HTTP/1.0".to_string(), Version::HTTP_11 => "HTTP/1.1".to_string(), Version::HTTP_2 => "HTTP/2".to_string(), Version::HTTP_3 => "HTTP/3".to_string(), _ => "unknown".to_string(), } } ================================================ FILE: crates/yaak-http/src/tee_reader.rs ================================================ use std::io; use std::pin::Pin; use std::task::{Context, Poll}; use tokio::io::{AsyncRead, ReadBuf}; use tokio::sync::mpsc; /// A reader that forwards all read data to a channel while also returning it to the caller. /// This allows capturing request body data as it's being sent. /// Uses an unbounded channel to ensure all data is captured without blocking the request. pub struct TeeReader { inner: R, tx: mpsc::UnboundedSender>, } impl TeeReader { pub fn new(inner: R, tx: mpsc::UnboundedSender>) -> Self { Self { inner, tx } } } impl AsyncRead for TeeReader { fn poll_read( mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>, ) -> Poll> { let before_len = buf.filled().len(); match Pin::new(&mut self.inner).poll_read(cx, buf) { Poll::Ready(Ok(())) => { let after_len = buf.filled().len(); if after_len > before_len { // Data was read, send a copy to the channel let data = buf.filled()[before_len..after_len].to_vec(); // Send to unbounded channel - this never blocks // Ignore error if receiver is closed let _ = self.tx.send(data); } Poll::Ready(Ok(())) } Poll::Ready(Err(e)) => Poll::Ready(Err(e)), Poll::Pending => Poll::Pending, } } } #[cfg(test)] mod tests { use super::*; use std::io::Cursor; use tokio::io::AsyncReadExt; #[tokio::test] async fn test_tee_reader_captures_all_data() { let data = b"Hello, World!"; let cursor = Cursor::new(data.to_vec()); let (tx, mut rx) = mpsc::unbounded_channel(); let mut tee = TeeReader::new(cursor, tx); let mut output = Vec::new(); tee.read_to_end(&mut output).await.unwrap(); // Verify the reader returns the correct data assert_eq!(output, data); // Verify the channel received the data let mut captured = Vec::new(); while let Ok(chunk) = rx.try_recv() { captured.extend(chunk); } assert_eq!(captured, data); } #[tokio::test] async fn test_tee_reader_with_chunked_reads() { let data = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ"; let cursor = Cursor::new(data.to_vec()); let (tx, mut rx) = mpsc::unbounded_channel(); let mut tee = TeeReader::new(cursor, tx); // Read in small chunks let mut buf = [0u8; 5]; let mut output = Vec::new(); loop { let n = tee.read(&mut buf).await.unwrap(); if n == 0 { break; } output.extend_from_slice(&buf[..n]); } // Verify the reader returns the correct data assert_eq!(output, data); // Verify the channel received all chunks let mut captured = Vec::new(); while let Ok(chunk) = rx.try_recv() { captured.extend(chunk); } assert_eq!(captured, data); } #[tokio::test] async fn test_tee_reader_empty_data() { let data: Vec = vec![]; let cursor = Cursor::new(data.clone()); let (tx, mut rx) = mpsc::unbounded_channel(); let mut tee = TeeReader::new(cursor, tx); let mut output = Vec::new(); tee.read_to_end(&mut output).await.unwrap(); // Verify empty output assert!(output.is_empty()); // Verify no data was sent to channel assert!(rx.try_recv().is_err()); } #[tokio::test] async fn test_tee_reader_works_when_receiver_dropped() { let data = b"Hello, World!"; let cursor = Cursor::new(data.to_vec()); let (tx, rx) = mpsc::unbounded_channel(); // Drop the receiver before reading drop(rx); let mut tee = TeeReader::new(cursor, tx); let mut output = Vec::new(); // Should still work even though receiver is dropped tee.read_to_end(&mut output).await.unwrap(); assert_eq!(output, data); } #[tokio::test] async fn test_tee_reader_large_data() { // Test with 1MB of data let data: Vec = (0..1024 * 1024).map(|i| (i % 256) as u8).collect(); let cursor = Cursor::new(data.clone()); let (tx, mut rx) = mpsc::unbounded_channel(); let mut tee = TeeReader::new(cursor, tx); let mut output = Vec::new(); tee.read_to_end(&mut output).await.unwrap(); // Verify the reader returns the correct data assert_eq!(output, data); // Verify the channel received all data let mut captured = Vec::new(); while let Ok(chunk) = rx.try_recv() { captured.extend(chunk); } assert_eq!(captured, data); } } ================================================ FILE: crates/yaak-http/src/transaction.rs ================================================ use crate::cookies::CookieStore; use crate::error::Result; use crate::sender::{HttpResponse, HttpResponseEvent, HttpSender, RedirectBehavior}; use crate::types::{SendableBody, SendableHttpRequest}; use log::debug; use tokio::sync::mpsc; use tokio::sync::watch::Receiver; use url::Url; /// HTTP Transaction that manages the lifecycle of a request, including redirect handling pub struct HttpTransaction { sender: S, max_redirects: usize, cookie_store: Option, } impl HttpTransaction { /// Create a new transaction with default settings pub fn new(sender: S) -> Self { Self { sender, max_redirects: 10, cookie_store: None } } /// Create a new transaction with custom max redirects pub fn with_max_redirects(sender: S, max_redirects: usize) -> Self { Self { sender, max_redirects, cookie_store: None } } /// Create a new transaction with a cookie store pub fn with_cookie_store(sender: S, cookie_store: CookieStore) -> Self { Self { sender, max_redirects: 10, cookie_store: Some(cookie_store) } } /// Create a new transaction with custom max redirects and a cookie store pub fn with_options( sender: S, max_redirects: usize, cookie_store: Option, ) -> Self { Self { sender, max_redirects, cookie_store } } /// Execute the request with cancellation support. /// Returns an HttpResponse with unconsumed body - caller decides how to consume it. /// Events are sent through the provided channel. pub async fn execute_with_cancellation( &self, request: SendableHttpRequest, mut cancelled_rx: Receiver, event_tx: mpsc::Sender, ) -> Result { let mut redirect_count = 0; let mut current_url = request.url; let mut current_method = request.method; let mut current_headers = request.headers; let mut current_body = request.body; // Helper to send events (ignores errors if receiver is dropped or channel is full) let send_event = |event: HttpResponseEvent| { let _ = event_tx.try_send(event); }; loop { // Check for cancellation before each request if *cancelled_rx.borrow() { return Err(crate::error::Error::RequestCanceledError); } // Inject cookies into headers if we have a cookie store let headers_with_cookies = if let Some(cookie_store) = &self.cookie_store { let mut headers = current_headers.clone(); if let Ok(url) = Url::parse(¤t_url) { if let Some(cookie_header) = cookie_store.get_cookie_header(&url) { debug!("Injecting Cookie header: {}", cookie_header); // Check if there's already a Cookie header and merge if so if let Some(existing) = headers.iter_mut().find(|h| h.0.eq_ignore_ascii_case("cookie")) { existing.1 = format!("{}; {}", existing.1, cookie_header); } else { headers.push(("Cookie".to_string(), cookie_header)); } } } headers } else { current_headers.clone() }; // Build request for this iteration let preserved_body = match ¤t_body { Some(SendableBody::Bytes(b)) => Some(SendableBody::Bytes(b.clone())), _ => None, }; let request_had_body = current_body.is_some(); let req = SendableHttpRequest { url: current_url.clone(), method: current_method.clone(), headers: headers_with_cookies, body: current_body, options: request.options.clone(), }; // Send the request send_event(HttpResponseEvent::Setting( "redirects".to_string(), request.options.follow_redirects.to_string(), )); // Execute with cancellation support let response = tokio::select! { result = self.sender.send(req, event_tx.clone()) => result?, _ = cancelled_rx.changed() => { return Err(crate::error::Error::RequestCanceledError); } }; // Parse Set-Cookie headers and store cookies if let Some(cookie_store) = &self.cookie_store { if let Ok(url) = Url::parse(¤t_url) { let set_cookie_headers: Vec = response .headers .iter() .filter(|(k, _)| k.eq_ignore_ascii_case("set-cookie")) .map(|(_, v)| v.clone()) .collect(); if !set_cookie_headers.is_empty() { debug!("Storing {} cookies from response", set_cookie_headers.len()); cookie_store.store_cookies_from_response(&url, &set_cookie_headers); } } } if !Self::is_redirect(response.status) { // Not a redirect - return the response for caller to consume body return Ok(response); } if !request.options.follow_redirects { // Redirects disabled - return the redirect response as-is return Ok(response); } // Check if we've exceeded max redirects if redirect_count >= self.max_redirects { // Drain the response before returning error let _ = response.drain().await; return Err(crate::error::Error::RequestError(format!( "Maximum redirect limit ({}) exceeded", self.max_redirects ))); } // Extract Location header before draining (headers are available immediately) // HTTP headers are case-insensitive, so we need to search for any casing let location = response .headers .iter() .find(|(k, _)| k.eq_ignore_ascii_case("location")) .map(|(_, v)| v.clone()) .ok_or_else(|| { crate::error::Error::RequestError( "Redirect response missing Location header".to_string(), ) })?; // Also get status before draining let status = response.status; send_event(HttpResponseEvent::Info("Ignoring the response body".to_string())); // Drain the redirect response body before following response.drain().await?; // Update the request URL let previous_url = current_url.clone(); current_url = if location.starts_with("http://") || location.starts_with("https://") { // Absolute URL location } else if location.starts_with('/') { // Absolute path - need to extract base URL from current request let base_url = Self::extract_base_url(¤t_url)?; format!("{}{}", base_url, location) } else { // Relative path - need to resolve relative to current path let base_path = Self::extract_base_path(¤t_url)?; format!("{}/{}", base_path, location) }; // Determine redirect behavior based on status code and method let behavior = if status == 303 { // 303 See Other always changes to GET RedirectBehavior::DropBody } else if (status == 301 || status == 302) && current_method == "POST" { // For 301/302, change POST to GET (common browser behavior) RedirectBehavior::DropBody } else { // For 307 and 308, the method and body are preserved // Also for 301/302 with non-POST methods RedirectBehavior::Preserve }; let mut dropped_headers = Self::remove_sensitive_headers(&mut current_headers, &previous_url, ¤t_url); // Handle method changes for certain redirect codes if matches!(behavior, RedirectBehavior::DropBody) { if current_method != "GET" { current_method = "GET".to_string(); } // Remove content-related headers current_headers.retain(|h| { let name_lower = h.0.to_lowercase(); let should_drop = name_lower.starts_with("content-") || name_lower == "transfer-encoding"; if should_drop { Self::push_header_if_missing(&mut dropped_headers, &h.0); } !should_drop }); } // Restore body for Preserve redirects (307/308), drop for others. // Stream bodies can't be replayed (same limitation as reqwest). current_body = if matches!(behavior, RedirectBehavior::Preserve) { if request_had_body && preserved_body.is_none() { // Stream body was consumed and can't be replayed (same as reqwest) return Err(crate::error::Error::RequestError( "Cannot follow redirect: request body was a stream and cannot be resent" .to_string(), )); } preserved_body } else { None }; // Body was dropped if the request had one but we can't resend it let dropped_body = request_had_body && current_body.is_none(); send_event(HttpResponseEvent::Redirect { url: current_url.clone(), status, behavior: behavior.clone(), dropped_body, dropped_headers, }); redirect_count += 1; } } /// Remove sensitive headers when redirecting to a different host. /// This matches reqwest's `remove_sensitive_headers()` behavior and prevents /// credentials from being forwarded to third-party servers (e.g., an /// Authorization header sent from an API redirect to an S3 bucket). fn remove_sensitive_headers( headers: &mut Vec<(String, String)>, previous_url: &str, next_url: &str, ) -> Vec { let mut dropped_headers = Vec::new(); let previous_host = Url::parse(previous_url).ok().and_then(|u| { u.host_str().map(|h| format!("{}:{}", h, u.port_or_known_default().unwrap_or(0))) }); let next_host = Url::parse(next_url).ok().and_then(|u| { u.host_str().map(|h| format!("{}:{}", h, u.port_or_known_default().unwrap_or(0))) }); if previous_host != next_host { headers.retain(|h| { let name_lower = h.0.to_lowercase(); let should_drop = name_lower == "authorization" || name_lower == "cookie" || name_lower == "cookie2" || name_lower == "proxy-authorization" || name_lower == "www-authenticate"; if should_drop { Self::push_header_if_missing(&mut dropped_headers, &h.0); } !should_drop }); } dropped_headers } fn push_header_if_missing(headers: &mut Vec, name: &str) { if !headers.iter().any(|h| h.eq_ignore_ascii_case(name)) { headers.push(name.to_string()); } } /// Check if a status code indicates a redirect fn is_redirect(status: u16) -> bool { matches!(status, 301 | 302 | 303 | 307 | 308) } /// Extract the base URL (scheme + host) from a full URL fn extract_base_url(url: &str) -> Result { // Find the position after "://" let scheme_end = url.find("://").ok_or_else(|| { crate::error::Error::RequestError(format!("Invalid URL format: {}", url)) })?; // Find the first '/' after the scheme let path_start = url[scheme_end + 3..].find('/'); if let Some(idx) = path_start { Ok(url[..scheme_end + 3 + idx].to_string()) } else { // No path, return entire URL Ok(url.to_string()) } } /// Extract the base path (everything except the last segment) from a URL fn extract_base_path(url: &str) -> Result { if let Some(last_slash) = url.rfind('/') { // Don't include the trailing slash if it's part of the host if url[..last_slash].ends_with("://") || url[..last_slash].ends_with(':') { Ok(url.to_string()) } else { Ok(url[..last_slash].to_string()) } } else { Ok(url.to_string()) } } } #[cfg(test)] mod tests { use super::*; use crate::decompress::ContentEncoding; use crate::sender::{HttpResponseEvent, HttpSender}; use async_trait::async_trait; use std::pin::Pin; use std::sync::Arc; use tokio::io::AsyncRead; use tokio::sync::Mutex; /// Captured request metadata for test assertions #[derive(Debug, Clone)] #[allow(dead_code)] struct CapturedRequest { url: String, method: String, headers: Vec<(String, String)>, } /// Mock sender for testing struct MockSender { responses: Arc>>, /// Captured requests for assertions captured_requests: Arc>>, } struct MockResponse { status: u16, headers: Vec<(String, String)>, body: Vec, } impl MockSender { fn new(responses: Vec) -> Self { Self { responses: Arc::new(Mutex::new(responses)), captured_requests: Arc::new(Mutex::new(Vec::new())), } } } #[async_trait] impl HttpSender for MockSender { async fn send( &self, request: SendableHttpRequest, _event_tx: mpsc::Sender, ) -> Result { // Capture the request metadata for later assertions self.captured_requests.lock().await.push(CapturedRequest { url: request.url.clone(), method: request.method.clone(), headers: request.headers.clone(), }); let mut responses = self.responses.lock().await; if responses.is_empty() { Err(crate::error::Error::RequestError("No more mock responses".to_string())) } else { let mock = responses.remove(0); // Create a simple in-memory stream from the body let body_stream: Pin> = Box::pin(std::io::Cursor::new(mock.body)); Ok(HttpResponse::new( mock.status, None, // status_reason mock.headers, Vec::new(), None, // content_length "https://example.com".to_string(), // url None, // remote_addr Some("HTTP/1.1".to_string()), // version body_stream, ContentEncoding::Identity, )) } } } #[tokio::test] async fn test_transaction_no_redirect() { let response = MockResponse { status: 200, headers: Vec::new(), body: b"OK".to_vec() }; let sender = MockSender::new(vec![response]); let transaction = HttpTransaction::new(sender); let request = SendableHttpRequest { url: "https://example.com".to_string(), method: "GET".to_string(), headers: vec![], ..Default::default() }; let (_tx, rx) = tokio::sync::watch::channel(false); let (event_tx, _event_rx) = mpsc::channel(100); let result = transaction.execute_with_cancellation(request, rx, event_tx).await.unwrap(); assert_eq!(result.status, 200); // Consume the body to verify it let (body, _) = result.bytes().await.unwrap(); assert_eq!(body, b"OK"); } #[tokio::test] async fn test_transaction_single_redirect() { let redirect_headers = vec![("Location".to_string(), "https://example.com/new".to_string())]; let responses = vec![ MockResponse { status: 302, headers: redirect_headers, body: vec![] }, MockResponse { status: 200, headers: Vec::new(), body: b"Final".to_vec() }, ]; let sender = MockSender::new(responses); let transaction = HttpTransaction::new(sender); let request = SendableHttpRequest { url: "https://example.com/old".to_string(), method: "GET".to_string(), options: crate::types::SendableHttpRequestOptions { follow_redirects: true, ..Default::default() }, ..Default::default() }; let (_tx, rx) = tokio::sync::watch::channel(false); let (event_tx, _event_rx) = mpsc::channel(100); let result = transaction.execute_with_cancellation(request, rx, event_tx).await.unwrap(); assert_eq!(result.status, 200); let (body, _) = result.bytes().await.unwrap(); assert_eq!(body, b"Final"); } #[tokio::test] async fn test_transaction_max_redirects_exceeded() { let redirect_headers = vec![("Location".to_string(), "https://example.com/loop".to_string())]; // Create more redirects than allowed let responses: Vec = (0..12) .map(|_| MockResponse { status: 302, headers: redirect_headers.clone(), body: vec![] }) .collect(); let sender = MockSender::new(responses); let transaction = HttpTransaction::with_max_redirects(sender, 10); let request = SendableHttpRequest { url: "https://example.com/start".to_string(), method: "GET".to_string(), options: crate::types::SendableHttpRequestOptions { follow_redirects: true, ..Default::default() }, ..Default::default() }; let (_tx, rx) = tokio::sync::watch::channel(false); let (event_tx, _event_rx) = mpsc::channel(100); let result = transaction.execute_with_cancellation(request, rx, event_tx).await; if let Err(crate::error::Error::RequestError(msg)) = result { assert!(msg.contains("Maximum redirect limit")); } else { panic!("Expected RequestError with max redirect message. Got {result:?}"); } } #[test] fn test_is_redirect() { assert!(HttpTransaction::::is_redirect(301)); assert!(HttpTransaction::::is_redirect(302)); assert!(HttpTransaction::::is_redirect(303)); assert!(HttpTransaction::::is_redirect(307)); assert!(HttpTransaction::::is_redirect(308)); assert!(!HttpTransaction::::is_redirect(200)); assert!(!HttpTransaction::::is_redirect(404)); assert!(!HttpTransaction::::is_redirect(500)); } #[test] fn test_extract_base_url() { let result = HttpTransaction::::extract_base_url("https://example.com/path/to/resource"); assert_eq!(result.unwrap(), "https://example.com"); let result = HttpTransaction::::extract_base_url("http://localhost:8080/api"); assert_eq!(result.unwrap(), "http://localhost:8080"); let result = HttpTransaction::::extract_base_url("invalid-url"); assert!(result.is_err()); } #[test] fn test_extract_base_path() { let result = HttpTransaction::::extract_base_path( "https://example.com/path/to/resource", ); assert_eq!(result.unwrap(), "https://example.com/path/to"); let result = HttpTransaction::::extract_base_path("https://example.com/single"); assert_eq!(result.unwrap(), "https://example.com"); let result = HttpTransaction::::extract_base_path("https://example.com/"); assert_eq!(result.unwrap(), "https://example.com"); } #[tokio::test] async fn test_cookie_injection() { // Create a mock sender that verifies the Cookie header was injected struct CookieVerifyingSender { expected_cookie: String, } #[async_trait] impl HttpSender for CookieVerifyingSender { async fn send( &self, request: SendableHttpRequest, _event_tx: mpsc::Sender, ) -> Result { // Verify the Cookie header was injected let cookie_header = request.headers.iter().find(|(k, _)| k.eq_ignore_ascii_case("cookie")); assert!(cookie_header.is_some(), "Cookie header should be present"); assert!( cookie_header.unwrap().1.contains(&self.expected_cookie), "Cookie header should contain expected value" ); let body_stream: Pin> = Box::pin(std::io::Cursor::new(vec![])); Ok(HttpResponse::new( 200, None, Vec::new(), Vec::new(), None, "https://example.com".to_string(), None, Some("HTTP/1.1".to_string()), body_stream, ContentEncoding::Identity, )) } } use yaak_models::models::{Cookie, CookieDomain, CookieExpires}; // Create a cookie store with a test cookie let cookie = Cookie { raw_cookie: "session=abc123".to_string(), domain: CookieDomain::HostOnly("example.com".to_string()), expires: CookieExpires::SessionEnd, path: ("/".to_string(), false), }; let cookie_store = CookieStore::from_cookies(vec![cookie]); let sender = CookieVerifyingSender { expected_cookie: "session=abc123".to_string() }; let transaction = HttpTransaction::with_cookie_store(sender, cookie_store); let request = SendableHttpRequest { url: "https://example.com/api".to_string(), method: "GET".to_string(), headers: vec![], ..Default::default() }; let (_tx, rx) = tokio::sync::watch::channel(false); let (event_tx, _event_rx) = mpsc::channel(100); let result = transaction.execute_with_cancellation(request, rx, event_tx).await; assert!(result.is_ok()); } #[tokio::test] async fn test_set_cookie_parsing() { // Create a cookie store let cookie_store = CookieStore::new(); // Mock sender that returns a Set-Cookie header struct SetCookieSender; #[async_trait] impl HttpSender for SetCookieSender { async fn send( &self, _request: SendableHttpRequest, _event_tx: mpsc::Sender, ) -> Result { let headers = vec![("set-cookie".to_string(), "session=xyz789; Path=/".to_string())]; let body_stream: Pin> = Box::pin(std::io::Cursor::new(vec![])); Ok(HttpResponse::new( 200, None, headers, Vec::new(), None, "https://example.com".to_string(), None, Some("HTTP/1.1".to_string()), body_stream, ContentEncoding::Identity, )) } } let sender = SetCookieSender; let transaction = HttpTransaction::with_cookie_store(sender, cookie_store.clone()); let request = SendableHttpRequest { url: "https://example.com/login".to_string(), method: "POST".to_string(), headers: vec![], ..Default::default() }; let (_tx, rx) = tokio::sync::watch::channel(false); let (event_tx, _event_rx) = mpsc::channel(100); let result = transaction.execute_with_cancellation(request, rx, event_tx).await; assert!(result.is_ok()); // Verify the cookie was stored let cookies = cookie_store.get_all_cookies(); assert_eq!(cookies.len(), 1); assert!(cookies[0].raw_cookie.contains("session=xyz789")); } #[tokio::test] async fn test_multiple_set_cookie_headers() { // Create a cookie store let cookie_store = CookieStore::new(); // Mock sender that returns multiple Set-Cookie headers struct MultiSetCookieSender; #[async_trait] impl HttpSender for MultiSetCookieSender { async fn send( &self, _request: SendableHttpRequest, _event_tx: mpsc::Sender, ) -> Result { // Multiple Set-Cookie headers (this is standard HTTP behavior) let headers = vec![ ("set-cookie".to_string(), "session=abc123; Path=/".to_string()), ("set-cookie".to_string(), "user_id=42; Path=/".to_string()), ( "set-cookie".to_string(), "preferences=dark; Path=/; Max-Age=86400".to_string(), ), ]; let body_stream: Pin> = Box::pin(std::io::Cursor::new(vec![])); Ok(HttpResponse::new( 200, None, headers, Vec::new(), None, "https://example.com".to_string(), None, Some("HTTP/1.1".to_string()), body_stream, ContentEncoding::Identity, )) } } let sender = MultiSetCookieSender; let transaction = HttpTransaction::with_cookie_store(sender, cookie_store.clone()); let request = SendableHttpRequest { url: "https://example.com/login".to_string(), method: "POST".to_string(), headers: vec![], ..Default::default() }; let (_tx, rx) = tokio::sync::watch::channel(false); let (event_tx, _event_rx) = mpsc::channel(100); let result = transaction.execute_with_cancellation(request, rx, event_tx).await; assert!(result.is_ok()); // Verify all three cookies were stored let cookies = cookie_store.get_all_cookies(); assert_eq!(cookies.len(), 3, "All three Set-Cookie headers should be parsed and stored"); let cookie_values: Vec<&str> = cookies.iter().map(|c| c.raw_cookie.as_str()).collect(); assert!( cookie_values.iter().any(|c| c.contains("session=abc123")), "session cookie should be stored" ); assert!( cookie_values.iter().any(|c| c.contains("user_id=42")), "user_id cookie should be stored" ); assert!( cookie_values.iter().any(|c| c.contains("preferences=dark")), "preferences cookie should be stored" ); } #[tokio::test] async fn test_cookies_across_redirects() { use std::sync::atomic::{AtomicUsize, Ordering}; // Create a cookie store let cookie_store = CookieStore::new(); // Track request count let request_count = Arc::new(AtomicUsize::new(0)); let request_count_clone = request_count.clone(); struct RedirectWithCookiesSender { request_count: Arc, } #[async_trait] impl HttpSender for RedirectWithCookiesSender { async fn send( &self, request: SendableHttpRequest, _event_tx: mpsc::Sender, ) -> Result { let count = self.request_count.fetch_add(1, Ordering::SeqCst); let (status, headers) = if count == 0 { // First request: return redirect with Set-Cookie let h = vec![ ("location".to_string(), "https://example.com/final".to_string()), ("set-cookie".to_string(), "redirect_cookie=value1".to_string()), ]; (302, h) } else { // Second request: verify cookie was sent let cookie_header = request.headers.iter().find(|(k, _)| k.eq_ignore_ascii_case("cookie")); assert!(cookie_header.is_some(), "Cookie header should be present on redirect"); assert!( cookie_header.unwrap().1.contains("redirect_cookie=value1"), "Redirect cookie should be included" ); (200, Vec::new()) }; let body_stream: Pin> = Box::pin(std::io::Cursor::new(vec![])); Ok(HttpResponse::new( status, None, headers, Vec::new(), None, "https://example.com".to_string(), None, Some("HTTP/1.1".to_string()), body_stream, ContentEncoding::Identity, )) } } let sender = RedirectWithCookiesSender { request_count: request_count_clone }; let transaction = HttpTransaction::with_cookie_store(sender, cookie_store); let request = SendableHttpRequest { url: "https://example.com/start".to_string(), method: "GET".to_string(), headers: vec![], options: crate::types::SendableHttpRequestOptions { follow_redirects: true, ..Default::default() }, ..Default::default() }; let (_tx, rx) = tokio::sync::watch::channel(false); let (event_tx, _event_rx) = mpsc::channel(100); let result = transaction.execute_with_cancellation(request, rx, event_tx).await; assert!(result.is_ok()); assert_eq!(request_count.load(Ordering::SeqCst), 2); } #[tokio::test] async fn test_cross_origin_redirect_strips_auth_headers() { // Redirect from api.example.com -> s3.amazonaws.com should strip Authorization let responses = vec![ MockResponse { status: 302, headers: vec![( "Location".to_string(), "https://s3.amazonaws.com/bucket/file.pdf".to_string(), )], body: vec![], }, MockResponse { status: 200, headers: Vec::new(), body: b"PDF content".to_vec() }, ]; let sender = MockSender::new(responses); let captured = sender.captured_requests.clone(); let transaction = HttpTransaction::new(sender); let request = SendableHttpRequest { url: "https://api.example.com/download".to_string(), method: "GET".to_string(), headers: vec![ ("Authorization".to_string(), "Basic dXNlcjpwYXNz".to_string()), ("Accept".to_string(), "application/pdf".to_string()), ], options: crate::types::SendableHttpRequestOptions { follow_redirects: true, ..Default::default() }, ..Default::default() }; let (_tx, rx) = tokio::sync::watch::channel(false); let (event_tx, _event_rx) = mpsc::channel(100); let result = transaction.execute_with_cancellation(request, rx, event_tx).await.unwrap(); assert_eq!(result.status, 200); let requests = captured.lock().await; assert_eq!(requests.len(), 2); // First request should have the Authorization header assert!( requests[0].headers.iter().any(|(k, _)| k.eq_ignore_ascii_case("authorization")), "First request should have Authorization header" ); // Second request (to different host) should NOT have the Authorization header assert!( !requests[1].headers.iter().any(|(k, _)| k.eq_ignore_ascii_case("authorization")), "Redirected request to different host should NOT have Authorization header" ); // Non-sensitive headers should still be present assert!( requests[1].headers.iter().any(|(k, _)| k.eq_ignore_ascii_case("accept")), "Non-sensitive headers should be preserved across cross-origin redirects" ); } #[tokio::test] async fn test_same_origin_redirect_preserves_auth_headers() { // Redirect within the same host should keep Authorization let responses = vec![ MockResponse { status: 302, headers: vec![( "Location".to_string(), "https://api.example.com/v2/download".to_string(), )], body: vec![], }, MockResponse { status: 200, headers: Vec::new(), body: b"OK".to_vec() }, ]; let sender = MockSender::new(responses); let captured = sender.captured_requests.clone(); let transaction = HttpTransaction::new(sender); let request = SendableHttpRequest { url: "https://api.example.com/v1/download".to_string(), method: "GET".to_string(), headers: vec![ ("Authorization".to_string(), "Bearer token123".to_string()), ("Accept".to_string(), "application/json".to_string()), ], options: crate::types::SendableHttpRequestOptions { follow_redirects: true, ..Default::default() }, ..Default::default() }; let (_tx, rx) = tokio::sync::watch::channel(false); let (event_tx, _event_rx) = mpsc::channel(100); let result = transaction.execute_with_cancellation(request, rx, event_tx).await.unwrap(); assert_eq!(result.status, 200); let requests = captured.lock().await; assert_eq!(requests.len(), 2); // Both requests should have the Authorization header (same host) assert!( requests[0].headers.iter().any(|(k, _)| k.eq_ignore_ascii_case("authorization")), "First request should have Authorization header" ); assert!( requests[1].headers.iter().any(|(k, _)| k.eq_ignore_ascii_case("authorization")), "Redirected request to same host should preserve Authorization header" ); } } ================================================ FILE: crates/yaak-http/src/types.rs ================================================ use crate::chained_reader::{ChainedReader, ReaderType}; use crate::error::Error::RequestError; use crate::error::Result; use crate::path_placeholders::apply_path_placeholders; use crate::proto::ensure_proto; use bytes::Bytes; use log::warn; use std::collections::BTreeMap; use std::pin::Pin; use std::time::Duration; use tokio::io::AsyncRead; use yaak_common::serde::{get_bool, get_bool_map, get_str, get_str_map}; use yaak_models::models::HttpRequest; use yaak_templates::strip_json_comments::{maybe_strip_json_comments, strip_json_comments}; pub(crate) const MULTIPART_BOUNDARY: &str = "------YaakFormBoundary"; pub enum SendableBody { Bytes(Bytes), Stream { data: Pin>, /// Known content length for the stream, if available. This is used by /// the sender to set the body size hint so that hyper can set /// Content-Length automatically for both HTTP/1.1 and HTTP/2. content_length: Option, }, } enum SendableBodyWithMeta { Bytes(Bytes), Stream { data: Pin>, content_length: Option, }, } impl From for SendableBody { fn from(value: SendableBodyWithMeta) -> Self { match value { SendableBodyWithMeta::Bytes(b) => SendableBody::Bytes(b), SendableBodyWithMeta::Stream { data, content_length } => { SendableBody::Stream { data, content_length: content_length.map(|l| l as u64) } } } } } #[derive(Default)] pub struct SendableHttpRequest { pub url: String, pub method: String, pub headers: Vec<(String, String)>, pub body: Option, pub options: SendableHttpRequestOptions, } #[derive(Default, Clone)] pub struct SendableHttpRequestOptions { pub timeout: Option, pub follow_redirects: bool, } impl SendableHttpRequest { pub async fn from_http_request( r: &HttpRequest, options: SendableHttpRequestOptions, ) -> Result { let initial_headers = build_headers(r); let (body, headers) = build_body(&r.method, &r.body_type, &r.body, initial_headers).await?; Ok(Self { url: build_url(r), method: r.method.to_uppercase(), headers, body: body.into(), options, }) } pub fn insert_header(&mut self, header: (String, String)) { if let Some(existing) = self.headers.iter_mut().find(|h| h.0.to_lowercase() == header.0.to_lowercase()) { existing.1 = header.1; } else { self.headers.push(header); } } } pub fn append_query_params(url: &str, params: Vec<(String, String)>) -> String { let url_string = url.to_string(); if params.is_empty() { return url.to_string(); } // Build query string let query_string = params .iter() .map(|(name, value)| { format!("{}={}", urlencoding::encode(name), urlencoding::encode(value)) }) .collect::>() .join("&"); // Split URL into parts: base URL, query, and fragment let (base_and_query, fragment) = if let Some(hash_pos) = url_string.find('#') { let (before_hash, after_hash) = url_string.split_at(hash_pos); (before_hash.to_string(), Some(after_hash.to_string())) } else { (url_string, None) }; // Now handle query parameters on the base URL (without fragment) let mut result = if base_and_query.contains('?') { // Check if there's already a query string after the '?' let parts: Vec<&str> = base_and_query.splitn(2, '?').collect(); if parts.len() == 2 && !parts[1].trim().is_empty() { // Append with & if there are existing parameters format!("{}&{}", base_and_query, query_string) } else { // Just append the new parameters directly (URL ends with '?') format!("{}{}", base_and_query, query_string) } } else { // No existing query parameters, add with '?' format!("{}?{}", base_and_query, query_string) }; // Re-append the fragment if it exists if let Some(fragment) = fragment { result.push_str(&fragment); } result } fn strip_query_params(url: &str, names: &[&str]) -> String { // Split off fragment let (base_and_query, fragment) = if let Some(hash_pos) = url.find('#') { (&url[..hash_pos], Some(&url[hash_pos..])) } else { (url, None) }; let result = if let Some(q_pos) = base_and_query.find('?') { let base = &base_and_query[..q_pos]; let query = &base_and_query[q_pos + 1..]; let filtered: Vec<&str> = query .split('&') .filter(|pair| { let key = pair.split('=').next().unwrap_or(""); let decoded = urlencoding::decode(key).unwrap_or_default(); !names.contains(&decoded.as_ref()) }) .collect(); if filtered.is_empty() { base.to_string() } else { format!("{}?{}", base, filtered.join("&")) } } else { base_and_query.to_string() }; match fragment { Some(f) => format!("{}{}", result, f), None => result, } } fn build_url(r: &HttpRequest) -> String { let (url_string, params) = apply_path_placeholders(&ensure_proto(&r.url), &r.url_parameters); let mut url = append_query_params( &url_string, params .iter() .filter(|p| p.enabled && !p.name.is_empty()) .map(|p| (p.name.clone(), p.value.clone())) .collect(), ); // GraphQL GET requests encode query/variables as URL query parameters if r.method.to_lowercase() == "get" && r.body_type.as_deref() == Some("graphql") { url = append_graphql_query_params(&url, &r.body); } url } fn append_graphql_query_params(url: &str, body: &BTreeMap) -> String { let query = get_str_map(body, "query").to_string(); let variables = strip_json_comments(&get_str_map(body, "variables")); let mut params = vec![("query".to_string(), query)]; if !variables.trim().is_empty() { params.push(("variables".to_string(), variables)); } // Strip existing query/variables params to avoid duplicates let url = strip_query_params(url, &["query", "variables"]); append_query_params(&url, params) } fn build_headers(r: &HttpRequest) -> Vec<(String, String)> { r.headers .iter() .filter_map(|h| { if h.enabled && !h.name.is_empty() { Some((h.name.clone(), h.value.clone())) } else { None } }) .collect() } async fn build_body( method: &str, body_type: &Option, body: &BTreeMap, headers: Vec<(String, String)>, ) -> Result<(Option, Vec<(String, String)>)> { let body_type = match &body_type { None => return Ok((None, headers)), Some(t) => t, }; let (body, content_type) = match body_type.as_str() { "binary" => (build_binary_body(&body).await?, None), "graphql" => (build_graphql_body(&method, &body), Some("application/json".to_string())), "application/x-www-form-urlencoded" => { (build_form_body(&body), Some("application/x-www-form-urlencoded".to_string())) } "multipart/form-data" => build_multipart_body(&body, &headers).await?, _ if body.contains_key("text") => (build_text_body(&body, body_type), None), t => { warn!("Unsupported body type: {}", t); (None, None) } }; // Add or update the Content-Type header let mut headers = headers; if let Some(ct) = content_type { if let Some(existing) = headers.iter_mut().find(|h| h.0.to_lowercase() == "content-type") { existing.1 = ct; } else { headers.push(("Content-Type".to_string(), ct)); } } // NOTE: Content-Length is NOT set as an explicit header here. Instead, the // body's content length is carried via SendableBody::Stream { content_length } // and used by the sender to set the body size hint. This lets hyper handle // Content-Length automatically for both HTTP/1.1 and HTTP/2, avoiding the // duplicate Content-Length that breaks HTTP/2 servers. Ok((body.map(|b| b.into()), headers)) } fn build_form_body(body: &BTreeMap) -> Option { let form_params = match body.get("form").map(|f| f.as_array()) { Some(Some(f)) => f, _ => return None, }; let mut body = String::new(); for p in form_params { let enabled = get_bool(p, "enabled", true); let name = get_str(p, "name"); if !enabled || name.is_empty() { continue; } let value = get_str(p, "value"); if !body.is_empty() { body.push('&'); } body.push_str(&urlencoding::encode(&name)); body.push('='); body.push_str(&urlencoding::encode(&value)); } if body.is_empty() { None } else { Some(SendableBodyWithMeta::Bytes(Bytes::from(body))) } } async fn build_binary_body( body: &BTreeMap, ) -> Result> { let file_path = match body.get("filePath").map(|f| f.as_str()) { Some(Some(f)) => f, _ => return Ok(None), }; // Open a file for streaming let content_length = tokio::fs::metadata(file_path) .await .map_err(|e| RequestError(format!("Failed to get file metadata: {}", e)))? .len(); let file = tokio::fs::File::open(file_path) .await .map_err(|e| RequestError(format!("Failed to open file: {}", e)))?; Ok(Some(SendableBodyWithMeta::Stream { data: Box::pin(file), content_length: Some(content_length as usize), })) } fn build_text_body(body: &BTreeMap, body_type: &str) -> Option { let text = get_str_map(body, "text"); if text.is_empty() { return None; } let send_comments = get_bool_map(body, "sendJsonComments", false); let text = if !send_comments && body_type == "application/json" { maybe_strip_json_comments(text) } else { text.to_string() }; Some(SendableBodyWithMeta::Bytes(Bytes::from(text))) } fn build_graphql_body( method: &str, body: &BTreeMap, ) -> Option { let query = get_str_map(body, "query"); let variables = strip_json_comments(&get_str_map(body, "variables")); if method.to_lowercase() == "get" { // GraphQL GET requests use query parameters, not a body return None; } let body = if variables.trim().is_empty() { format!(r#"{{"query":{}}}"#, serde_json::to_string(&query).unwrap_or_default()) } else { format!( r#"{{"query":{},"variables":{}}}"#, serde_json::to_string(&query).unwrap_or_default(), variables ) }; Some(SendableBodyWithMeta::Bytes(Bytes::from(body))) } async fn build_multipart_body( body: &BTreeMap, headers: &Vec<(String, String)>, ) -> Result<(Option, Option)> { let boundary = extract_boundary_from_headers(headers); let form_params = match body.get("form").map(|f| f.as_array()) { Some(Some(f)) => f, _ => return Ok((None, None)), }; // Build a list of readers for streaming and calculate total content length let mut readers: Vec = Vec::new(); let mut has_content = false; let mut total_size: usize = 0; for p in form_params { let enabled = get_bool(p, "enabled", true); let name = get_str(p, "name"); if !enabled || name.is_empty() { continue; } has_content = true; // Add boundary delimiter let boundary_bytes = format!("--{}\r\n", boundary).into_bytes(); total_size += boundary_bytes.len(); readers.push(ReaderType::Bytes(boundary_bytes)); let file_path = get_str(p, "file"); let value = get_str(p, "value"); let content_type = get_str(p, "contentType"); if file_path.is_empty() { // Text field let header = if !content_type.is_empty() { format!( "Content-Disposition: form-data; name=\"{}\"\r\nContent-Type: {}\r\n\r\n{}", name, content_type, value ) } else { format!("Content-Disposition: form-data; name=\"{}\"\r\n\r\n{}", name, value) }; let header_bytes = header.into_bytes(); total_size += header_bytes.len(); readers.push(ReaderType::Bytes(header_bytes)); } else { // File field - validate that file exists first if !tokio::fs::try_exists(file_path).await.unwrap_or(false) { return Err(RequestError(format!("File not found: {}", file_path))); } // Get file size for content length calculation let file_metadata = tokio::fs::metadata(file_path) .await .map_err(|e| RequestError(format!("Failed to get file metadata: {}", e)))?; let file_size = file_metadata.len() as usize; let filename = get_str(p, "filename"); let filename = if filename.is_empty() { std::path::Path::new(file_path) .file_name() .and_then(|n| n.to_str()) .unwrap_or("file") } else { filename }; // Add content type let mime_type = if !content_type.is_empty() { content_type.to_string() } else { // Guess mime type from file extension mime_guess::from_path(file_path).first_or_octet_stream().to_string() }; let header = format!( "Content-Disposition: form-data; name=\"{}\"; filename=\"{}\"\r\nContent-Type: {}\r\n\r\n", name, filename, mime_type ); let header_bytes = header.into_bytes(); total_size += header_bytes.len(); total_size += file_size; readers.push(ReaderType::Bytes(header_bytes)); // Add a file path for streaming readers.push(ReaderType::FilePath(file_path.to_string())); } let line_ending = b"\r\n".to_vec(); total_size += line_ending.len(); readers.push(ReaderType::Bytes(line_ending)); } if has_content { // Add the final boundary let final_boundary = format!("--{}--\r\n", boundary).into_bytes(); total_size += final_boundary.len(); readers.push(ReaderType::Bytes(final_boundary)); let content_type = format!("multipart/form-data; boundary={}", boundary); let stream = ChainedReader::new(readers); Ok(( Some(SendableBodyWithMeta::Stream { data: Box::pin(stream), content_length: Some(total_size), }), Some(content_type), )) } else { Ok((None, None)) } } fn extract_boundary_from_headers(headers: &Vec<(String, String)>) -> String { headers .iter() .find(|h| h.0.to_lowercase() == "content-type") .and_then(|h| { // Extract boundary from the Content-Type header (e.g., "multipart/form-data; boundary=xyz") h.1.split(';') .find(|part| part.trim().starts_with("boundary=")) .and_then(|boundary_part| boundary_part.split('=').nth(1)) .map(|b| b.trim().to_string()) }) .unwrap_or_else(|| MULTIPART_BOUNDARY.to_string()) } #[cfg(test)] mod tests { use super::*; use bytes::Bytes; use serde_json::json; use std::collections::BTreeMap; use yaak_models::models::{HttpRequest, HttpUrlParameter}; #[test] fn test_build_url_no_params() { let r = HttpRequest { url: "https://example.com/api".to_string(), url_parameters: vec![], ..Default::default() }; let result = build_url(&r); assert_eq!(result, "https://example.com/api"); } #[test] fn test_build_url_with_params() { let r = HttpRequest { url: "https://example.com/api".to_string(), url_parameters: vec![ HttpUrlParameter { enabled: true, name: "foo".to_string(), value: "bar".to_string(), id: None, }, HttpUrlParameter { enabled: true, name: "baz".to_string(), value: "qux".to_string(), id: None, }, ], ..Default::default() }; let result = build_url(&r); assert_eq!(result, "https://example.com/api?foo=bar&baz=qux"); } #[test] fn test_build_url_with_disabled_params() { let r = HttpRequest { url: "https://example.com/api".to_string(), url_parameters: vec![ HttpUrlParameter { enabled: false, name: "disabled".to_string(), value: "value".to_string(), id: None, }, HttpUrlParameter { enabled: true, name: "enabled".to_string(), value: "value".to_string(), id: None, }, ], ..Default::default() }; let result = build_url(&r); assert_eq!(result, "https://example.com/api?enabled=value"); } #[test] fn test_build_url_with_existing_query() { let r = HttpRequest { url: "https://example.com/api?existing=param".to_string(), url_parameters: vec![HttpUrlParameter { enabled: true, name: "new".to_string(), value: "value".to_string(), id: None, }], ..Default::default() }; let result = build_url(&r); assert_eq!(result, "https://example.com/api?existing=param&new=value"); } #[test] fn test_build_url_with_empty_existing_query() { let r = HttpRequest { url: "https://example.com/api?".to_string(), url_parameters: vec![HttpUrlParameter { enabled: true, name: "new".to_string(), value: "value".to_string(), id: None, }], ..Default::default() }; let result = build_url(&r); assert_eq!(result, "https://example.com/api?new=value"); } #[test] fn test_build_url_with_special_chars() { let r = HttpRequest { url: "https://example.com/api".to_string(), url_parameters: vec![HttpUrlParameter { enabled: true, name: "special chars!@#".to_string(), value: "value with spaces & symbols".to_string(), id: None, }], ..Default::default() }; let result = build_url(&r); assert_eq!( result, "https://example.com/api?special%20chars%21%40%23=value%20with%20spaces%20%26%20symbols" ); } #[test] fn test_build_url_adds_protocol() { let r = HttpRequest { url: "example.com/api".to_string(), url_parameters: vec![HttpUrlParameter { enabled: true, name: "foo".to_string(), value: "bar".to_string(), id: None, }], ..Default::default() }; let result = build_url(&r); // ensure_proto defaults to http:// for regular domains assert_eq!(result, "http://example.com/api?foo=bar"); } #[test] fn test_build_url_adds_https_for_dev_domain() { let r = HttpRequest { url: "example.dev/api".to_string(), url_parameters: vec![HttpUrlParameter { enabled: true, name: "foo".to_string(), value: "bar".to_string(), id: None, }], ..Default::default() }; let result = build_url(&r); // .dev domains force https assert_eq!(result, "https://example.dev/api?foo=bar"); } #[test] fn test_build_url_with_fragment() { let r = HttpRequest { url: "https://example.com/api#section".to_string(), url_parameters: vec![HttpUrlParameter { enabled: true, name: "foo".to_string(), value: "bar".to_string(), id: None, }], ..Default::default() }; let result = build_url(&r); assert_eq!(result, "https://example.com/api?foo=bar#section"); } #[test] fn test_build_url_with_existing_query_and_fragment() { let r = HttpRequest { url: "https://yaak.app?foo=bar#some-hash".to_string(), url_parameters: vec![HttpUrlParameter { enabled: true, name: "baz".to_string(), value: "qux".to_string(), id: None, }], ..Default::default() }; let result = build_url(&r); assert_eq!(result, "https://yaak.app?foo=bar&baz=qux#some-hash"); } #[test] fn test_build_url_with_empty_query_and_fragment() { let r = HttpRequest { url: "https://example.com/api?#section".to_string(), url_parameters: vec![HttpUrlParameter { enabled: true, name: "foo".to_string(), value: "bar".to_string(), id: None, }], ..Default::default() }; let result = build_url(&r); assert_eq!(result, "https://example.com/api?foo=bar#section"); } #[test] fn test_build_url_with_fragment_containing_special_chars() { let r = HttpRequest { url: "https://example.com#section/with/slashes?and=fake&query".to_string(), url_parameters: vec![HttpUrlParameter { enabled: true, name: "real".to_string(), value: "param".to_string(), id: None, }], ..Default::default() }; let result = build_url(&r); assert_eq!(result, "https://example.com?real=param#section/with/slashes?and=fake&query"); } #[test] fn test_build_url_preserves_empty_fragment() { let r = HttpRequest { url: "https://example.com/api#".to_string(), url_parameters: vec![HttpUrlParameter { enabled: true, name: "foo".to_string(), value: "bar".to_string(), id: None, }], ..Default::default() }; let result = build_url(&r); assert_eq!(result, "https://example.com/api?foo=bar#"); } #[test] fn test_build_url_with_multiple_fragments() { // Testing edge case where the URL has multiple # characters (though technically invalid) let r = HttpRequest { url: "https://example.com#section#subsection".to_string(), url_parameters: vec![HttpUrlParameter { enabled: true, name: "foo".to_string(), value: "bar".to_string(), id: None, }], ..Default::default() }; let result = build_url(&r); // Should treat everything after first # as fragment assert_eq!(result, "https://example.com?foo=bar#section#subsection"); } #[tokio::test] async fn test_text_body() { let mut body = BTreeMap::new(); body.insert("text".to_string(), json!("Hello, World!")); let result = build_text_body(&body, "application/json"); match result { Some(SendableBodyWithMeta::Bytes(bytes)) => { assert_eq!(bytes, Bytes::from("Hello, World!")) } _ => panic!("Expected Some(SendableBody::Bytes)"), } } #[tokio::test] async fn test_text_body_empty() { let mut body = BTreeMap::new(); body.insert("text".to_string(), json!("")); let result = build_text_body(&body, "application/json"); assert!(result.is_none()); } #[tokio::test] async fn test_text_body_missing() { let body = BTreeMap::new(); let result = build_text_body(&body, "application/json"); assert!(result.is_none()); } #[tokio::test] async fn test_text_body_strips_json_comments_by_default() { let mut body = BTreeMap::new(); body.insert("text".to_string(), json!("{\n // comment\n \"foo\": \"bar\"\n}")); let result = build_text_body(&body, "application/json"); match result { Some(SendableBodyWithMeta::Bytes(bytes)) => { let text = String::from_utf8_lossy(&bytes); assert!(!text.contains("// comment")); assert!(text.contains("\"foo\": \"bar\"")); } _ => panic!("Expected Some(SendableBody::Bytes)"), } } #[tokio::test] async fn test_text_body_send_json_comments_when_opted_in() { let mut body = BTreeMap::new(); body.insert("text".to_string(), json!("{\n // comment\n \"foo\": \"bar\"\n}")); body.insert("sendJsonComments".to_string(), json!(true)); let result = build_text_body(&body, "application/json"); match result { Some(SendableBodyWithMeta::Bytes(bytes)) => { let text = String::from_utf8_lossy(&bytes); assert!(text.contains("// comment")); } _ => panic!("Expected Some(SendableBody::Bytes)"), } } #[tokio::test] async fn test_text_body_no_strip_for_non_json() { let mut body = BTreeMap::new(); body.insert("text".to_string(), json!("// not json\nsome text")); let result = build_text_body(&body, "text/plain"); match result { Some(SendableBodyWithMeta::Bytes(bytes)) => { let text = String::from_utf8_lossy(&bytes); assert!(text.contains("// not json")); } _ => panic!("Expected Some(SendableBody::Bytes)"), } } #[tokio::test] async fn test_form_urlencoded_body() -> Result<()> { let mut body = BTreeMap::new(); body.insert( "form".to_string(), json!([ { "enabled": true, "name": "basic", "value": "aaa"}, { "enabled": true, "name": "fUnkey Stuff!$*#(", "value": "*)%&#$)@ *$#)@&"}, { "enabled": false, "name": "disabled", "value": "won't show"}, ]), ); let result = build_form_body(&body); match result { Some(SendableBodyWithMeta::Bytes(bytes)) => { let expected = "basic=aaa&fUnkey%20Stuff%21%24%2A%23%28=%2A%29%25%26%23%24%29%40%20%2A%24%23%29%40%26"; assert_eq!(bytes, Bytes::from(expected)); } _ => panic!("Expected Some(SendableBody::Bytes)"), } Ok(()) } #[tokio::test] async fn test_form_urlencoded_body_missing_form() { let body = BTreeMap::new(); let result = build_form_body(&body); assert!(result.is_none()); } #[tokio::test] async fn test_binary_body() -> Result<()> { let mut body = BTreeMap::new(); body.insert("filePath".to_string(), json!("./tests/test.txt")); let result = build_binary_body(&body).await?; assert!(matches!(result, Some(SendableBodyWithMeta::Stream { .. }))); Ok(()) } #[tokio::test] async fn test_binary_body_file_not_found() { let mut body = BTreeMap::new(); body.insert("filePath".to_string(), json!("./nonexistent/file.txt")); let result = build_binary_body(&body).await; assert!(result.is_err()); if let Err(e) = result { assert!(matches!(e, RequestError(_))); } } #[tokio::test] async fn test_graphql_body_with_variables() { let mut body = BTreeMap::new(); body.insert("query".to_string(), json!("{ user(id: $id) { name } }")); body.insert("variables".to_string(), json!(r#"{"id": "123"}"#)); let result = build_graphql_body("POST", &body); match result { Some(SendableBodyWithMeta::Bytes(bytes)) => { let expected = r#"{"query":"{ user(id: $id) { name } }","variables":{"id": "123"}}"#; assert_eq!(bytes, Bytes::from(expected)); } _ => panic!("Expected Some(SendableBody::Bytes)"), } } #[tokio::test] async fn test_graphql_body_without_variables() { let mut body = BTreeMap::new(); body.insert("query".to_string(), json!("{ users { name } }")); body.insert("variables".to_string(), json!("")); let result = build_graphql_body("POST", &body); match result { Some(SendableBodyWithMeta::Bytes(bytes)) => { let expected = r#"{"query":"{ users { name } }"}"#; assert_eq!(bytes, Bytes::from(expected)); } _ => panic!("Expected Some(SendableBody::Bytes)"), } } #[tokio::test] async fn test_graphql_body_get_method() { let mut body = BTreeMap::new(); body.insert("query".to_string(), json!("{ users { name } }")); let result = build_graphql_body("GET", &body); assert!(result.is_none()); } #[tokio::test] async fn test_multipart_body_text_fields() -> Result<()> { let mut body = BTreeMap::new(); body.insert( "form".to_string(), json!([ { "enabled": true, "name": "field1", "value": "value1", "file": "" }, { "enabled": true, "name": "field2", "value": "value2", "file": "" }, { "enabled": false, "name": "disabled", "value": "won't show", "file": "" }, ]), ); let (result, content_type) = build_multipart_body(&body, &vec![]).await?; assert!(content_type.is_some()); match result { Some(SendableBodyWithMeta::Stream { data: mut stream, content_length }) => { // Read the entire stream to verify content let mut buf = Vec::new(); use tokio::io::AsyncReadExt; stream.read_to_end(&mut buf).await.expect("Failed to read stream"); let body_str = String::from_utf8_lossy(&buf); assert_eq!( body_str, "--------YaakFormBoundary\r\nContent-Disposition: form-data; name=\"field1\"\r\n\r\nvalue1\r\n--------YaakFormBoundary\r\nContent-Disposition: form-data; name=\"field2\"\r\n\r\nvalue2\r\n--------YaakFormBoundary--\r\n", ); assert_eq!(content_length, Some(body_str.len())); } _ => panic!("Expected Some(SendableBody::Stream)"), } assert_eq!( content_type.unwrap(), format!("multipart/form-data; boundary={}", MULTIPART_BOUNDARY) ); Ok(()) } #[tokio::test] async fn test_multipart_body_with_file() -> Result<()> { let mut body = BTreeMap::new(); body.insert( "form".to_string(), json!([ { "enabled": true, "name": "file_field", "file": "./tests/test.txt", "filename": "custom.txt", "contentType": "text/plain" }, ]), ); let (result, content_type) = build_multipart_body(&body, &vec![]).await?; assert!(content_type.is_some()); match result { Some(SendableBodyWithMeta::Stream { data: mut stream, content_length }) => { // Read the entire stream to verify content let mut buf = Vec::new(); use tokio::io::AsyncReadExt; stream.read_to_end(&mut buf).await.expect("Failed to read stream"); let body_str = String::from_utf8_lossy(&buf); assert_eq!( body_str, "--------YaakFormBoundary\r\nContent-Disposition: form-data; name=\"file_field\"; filename=\"custom.txt\"\r\nContent-Type: text/plain\r\n\r\nThis is a test file!\n\r\n--------YaakFormBoundary--\r\n" ); assert_eq!(content_length, Some(body_str.len())); } _ => panic!("Expected Some(SendableBody::Stream)"), } assert_eq!( content_type.unwrap(), format!("multipart/form-data; boundary={}", MULTIPART_BOUNDARY) ); Ok(()) } #[tokio::test] async fn test_multipart_body_empty() -> Result<()> { let body = BTreeMap::new(); let (result, content_type) = build_multipart_body(&body, &vec![]).await?; assert!(result.is_none()); assert_eq!(content_type, None); Ok(()) } #[test] fn test_extract_boundary_from_headers_with_custom_boundary() { let headers = vec![( "Content-Type".to_string(), "multipart/form-data; boundary=customBoundary123".to_string(), )]; let boundary = extract_boundary_from_headers(&headers); assert_eq!(boundary, "customBoundary123"); } #[test] fn test_extract_boundary_from_headers_default() { let headers = vec![("Accept".to_string(), "*/*".to_string())]; let boundary = extract_boundary_from_headers(&headers); assert_eq!(boundary, MULTIPART_BOUNDARY); } #[test] fn test_extract_boundary_from_headers_no_boundary_in_content_type() { let headers = vec![("Content-Type".to_string(), "multipart/form-data".to_string())]; let boundary = extract_boundary_from_headers(&headers); assert_eq!(boundary, MULTIPART_BOUNDARY); } #[test] fn test_extract_boundary_case_insensitive() { let headers = vec![( "Content-Type".to_string(), "multipart/form-data; boundary=myBoundary".to_string(), )]; let boundary = extract_boundary_from_headers(&headers); assert_eq!(boundary, "myBoundary"); } #[tokio::test] async fn test_no_content_length_header_added_by_build_body() -> Result<()> { let mut body = BTreeMap::new(); body.insert("text".to_string(), json!("Hello, World!")); let headers = vec![]; let (_, result_headers) = build_body("POST", &Some("text/plain".to_string()), &body, headers).await?; // Content-Length should NOT be set as an explicit header. Instead, the // sender uses the body's size_hint to let hyper set it automatically, // which works correctly for both HTTP/1.1 and HTTP/2. let has_content_length = result_headers.iter().any(|h| h.0.to_lowercase() == "content-length"); assert!(!has_content_length, "Content-Length should not be set as an explicit header"); Ok(()) } #[tokio::test] async fn test_chunked_encoding_header_preserved() -> Result<()> { let mut body = BTreeMap::new(); body.insert("text".to_string(), json!("Hello, World!")); // Headers with Transfer-Encoding: chunked let headers = vec![("Transfer-Encoding".to_string(), "chunked".to_string())]; let (_, result_headers) = build_body("POST", &Some("text/plain".to_string()), &body, headers).await?; // Verify that the Transfer-Encoding header is still present let has_chunked = result_headers.iter().any(|h| { h.0.to_lowercase() == "transfer-encoding" && h.1.to_lowercase().contains("chunked") }); assert!(has_chunked, "Transfer-Encoding: chunked should be preserved"); Ok(()) } } ================================================ FILE: crates/yaak-http/tests/test.txt ================================================ This is a test file! ================================================ FILE: crates/yaak-models/Cargo.toml ================================================ [package] name = "yaak-models" version = "0.1.0" edition = "2024" publish = false [dependencies] chrono = { version = "0.4.38", features = ["serde"] } hex = { workspace = true } include_dir = "0.7" log = { workspace = true } nanoid = "0.4.0" r2d2 = "0.8.10" r2d2_sqlite = { version = "0.25.0" } rusqlite = { version = "0.32.1", features = ["bundled", "chrono"] } sea-query = { version = "0.32.1", features = ["with-chrono", "attr"] } sea-query-rusqlite = { version = "0.7.0", features = ["with-chrono"] } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } schemars = { workspace = true } sha2 = { workspace = true } thiserror = { workspace = true } ts-rs = { workspace = true, features = ["chrono-impl", "serde-json-impl"] } yaak-core = { workspace = true } ================================================ FILE: crates/yaak-models/bindings/gen_models.ts ================================================ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. export type AnyModel = CookieJar | Environment | Folder | GraphQlIntrospection | GrpcConnection | GrpcEvent | GrpcRequest | HttpRequest | HttpResponse | HttpResponseEvent | KeyValue | Plugin | Settings | SyncState | WebsocketConnection | WebsocketEvent | WebsocketRequest | Workspace | WorkspaceMeta; export type ClientCertificate = { host: string, port: number | null, crtFile: string | null, keyFile: string | null, pfxFile: string | null, passphrase: string | null, enabled?: boolean, }; export type Cookie = { raw_cookie: string, domain: CookieDomain, expires: CookieExpires, path: [string, boolean], }; export type CookieDomain = { "HostOnly": string } | { "Suffix": string } | "NotPresent" | "Empty"; export type CookieExpires = { "AtUtc": string } | "SessionEnd"; export type CookieJar = { model: "cookie_jar", id: string, createdAt: string, updatedAt: string, workspaceId: string, cookies: Array, name: string, }; export type DnsOverride = { hostname: string, ipv4: Array, ipv6: Array, enabled?: boolean, }; export type EditorKeymap = "default" | "vim" | "vscode" | "emacs"; export type EncryptedKey = { encryptedKey: string, }; export type Environment = { model: "environment", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, parentModel: string, parentId: string | null, variables: Array, color: string | null, sortPriority: number, }; 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, authenticationType: string | null, description: string, headers: Array, name: string, sortPriority: number, }; export type GraphQlIntrospection = { model: "graphql_introspection", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, content: string | null, }; export type GrpcConnection = { model: "grpc_connection", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, elapsed: number, error: string | null, method: string, service: string, status: number, state: GrpcConnectionState, trailers: { [key in string]?: string }, url: string, }; export type GrpcConnectionState = "initialized" | "connected" | "closed"; export type GrpcEvent = { model: "grpc_event", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, connectionId: string, content: string, error: string | null, eventType: GrpcEventType, metadata: { [key in string]?: string }, status: number | null, }; export type GrpcEventType = "info" | "error" | "client_message" | "server_message" | "connection_start" | "connection_end"; export type GrpcRequest = { model: "grpc_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authenticationType: string | null, authentication: Record, description: string, message: string, metadata: Array, 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, authenticationType: string | null, body: Record, bodyType: string | null, description: string, headers: Array, method: string, name: string, sortPriority: number, url: string, urlParameters: Array, }; 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, contentLengthCompressed: number | null, elapsed: number, elapsedHeaders: number, elapsedDns: number, error: string | null, headers: Array, remoteAddr: string | null, requestContentLength: number | null, requestHeaders: Array, status: number, statusReason: string | null, state: HttpResponseState, url: string, version: string | null, }; export type HttpResponseEvent = { model: "http_response_event", id: string, createdAt: string, updatedAt: string, workspaceId: string, responseId: string, event: HttpResponseEventData, }; /** * Serializable representation of HTTP response events for DB storage. * This mirrors `yaak_http::sender::HttpResponseEvent` but with serde support. * The `From` impl is in yaak-http to avoid circular dependencies. */ export type HttpResponseEventData = { "type": "setting", name: string, value: string, } | { "type": "info", message: string, } | { "type": "redirect", url: string, status: number, behavior: string, dropped_body: boolean, dropped_headers: Array, } | { "type": "send_url", method: string, scheme: string, username: string, password: string, host: string, port: number, path: string, query: string, fragment: string, } | { "type": "receive_url", version: string, status: string, } | { "type": "header_up", name: string, value: string, } | { "type": "header_down", name: string, value: string, } | { "type": "chunk_sent", bytes: number, } | { "type": "chunk_received", bytes: number, } | { "type": "dns_resolved", hostname: string, addresses: Array, duration: bigint, overridden: boolean, }; 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 KeyValue = { model: "key_value", id: string, createdAt: string, updatedAt: string, key: string, namespace: string, value: string, }; export type ModelChangeEvent = { "type": "upsert", created: boolean, } | { "type": "delete" }; export type ModelPayload = { model: AnyModel, updateSource: UpdateSource, change: ModelChangeEvent, }; export type ParentAuthentication = { authentication: Record, authenticationType: string | null, }; export type ParentHeaders = { headers: Array, }; export type Plugin = { model: "plugin", id: string, createdAt: string, updatedAt: string, checkedAt: string | null, directory: string, enabled: boolean, url: string | null, source: PluginSource, }; export type PluginSource = "bundled" | "filesystem" | "registry"; export type PluginKeyValue = { model: "plugin_key_value", createdAt: string, updatedAt: string, pluginName: string, key: string, value: string, }; export type ProxySetting = { "type": "enabled", http: string, https: string, auth: ProxySettingAuth | null, bypass: string, disabled: boolean, } | { "type": "disabled" }; export type ProxySettingAuth = { user: string, password: string, }; export type Settings = { model: "settings", id: string, createdAt: string, updatedAt: string, appearance: string, clientCertificates: Array, coloredMethods: boolean, editorFont: string | null, editorFontSize: number, editorKeymap: EditorKeymap, editorSoftWrap: boolean, hideWindowControls: boolean, useNativeTitlebar: boolean, interfaceFont: string | null, interfaceFontSize: number, interfaceScale: number, openWorkspaceNewWindow: boolean | null, proxy: ProxySetting | null, themeDark: string, themeLight: string, updateChannel: string, hideLicenseBadge: boolean, autoupdate: boolean, autoDownloadUpdates: boolean, checkNotifications: boolean, hotkeys: { [key in string]?: Array }, }; export type SyncState = { model: "sync_state", id: string, workspaceId: string, createdAt: string, updatedAt: string, flushedAt: string, modelId: string, checksum: string, relPath: string, syncDir: string, }; export type UpdateSource = { "type": "background" } | { "type": "import" } | { "type": "plugin" } | { "type": "sync" } | { "type": "window", label: string, }; export type WebsocketConnection = { model: "websocket_connection", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, elapsed: number, error: string | null, headers: Array, state: WebsocketConnectionState, status: number, url: string, }; export type WebsocketConnectionState = "initialized" | "connected" | "closing" | "closed"; export type WebsocketEvent = { model: "websocket_event", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, connectionId: string, isServer: boolean, message: Array, messageType: WebsocketEventType, }; export type WebsocketEventType = "binary" | "close" | "frame" | "open" | "ping" | "pong" | "text"; export type WebsocketMessageType = "text" | "binary"; export type WebsocketRequest = { model: "websocket_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record, authenticationType: string | null, description: string, headers: Array, message: string, name: string, sortPriority: number, url: string, urlParameters: Array, }; export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, authentication: Record, authenticationType: string | null, description: string, headers: Array, name: string, encryptionKeyChallenge: string | null, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, settingDnsOverrides: Array, }; export type WorkspaceMeta = { model: "workspace_meta", id: string, workspaceId: string, createdAt: string, updatedAt: string, encryptionKey: EncryptedKey | null, settingSyncDir: string | null, }; ================================================ FILE: crates/yaak-models/bindings/gen_util.ts ================================================ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { Environment, Folder, GrpcRequest, HttpRequest, WebsocketRequest, Workspace } from "./gen_models"; export type BatchUpsertResult = { workspaces: Array, environments: Array, folders: Array, httpRequests: Array, grpcRequests: Array, websocketRequests: Array, }; ================================================ FILE: crates/yaak-models/blob_migrations/00000000000000_init.sql ================================================ CREATE TABLE body_chunks ( id TEXT PRIMARY KEY, body_id TEXT NOT NULL, chunk_index INTEGER NOT NULL, data BLOB NOT NULL, created_at DATETIME DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')) NOT NULL, UNIQUE (body_id, chunk_index) ); CREATE INDEX idx_body_chunks_body_id ON body_chunks (body_id, chunk_index); ================================================ FILE: crates/yaak-models/build.rs ================================================ fn main() { // Migrations are embedded with include_dir!, so trigger rebuilds when SQL files change. println!("cargo:rerun-if-changed=migrations"); println!("cargo:rerun-if-changed=blob_migrations"); } ================================================ FILE: crates/yaak-models/guest-js/atoms.ts ================================================ import { atom } from "jotai"; import { selectAtom } from "jotai/utils"; import type { AnyModel } from "../bindings/gen_models"; import { ExtractModel } from "./types"; import { newStoreData } from "./util"; export const modelStoreDataAtom = atom(newStoreData()); export const cookieJarsAtom = createOrderedModelAtom("cookie_jar", "name", "asc"); export const environmentsAtom = createOrderedModelAtom("environment", "sortPriority", "asc"); export const foldersAtom = createModelAtom("folder"); export const grpcConnectionsAtom = createOrderedModelAtom("grpc_connection", "createdAt", "desc"); export const grpcEventsAtom = createOrderedModelAtom("grpc_event", "createdAt", "asc"); export const grpcRequestsAtom = createModelAtom("grpc_request"); export const httpRequestsAtom = createModelAtom("http_request"); export const httpResponsesAtom = createOrderedModelAtom("http_response", "createdAt", "desc"); export const httpResponseEventsAtom = createOrderedModelAtom( "http_response_event", "createdAt", "asc", ); export const keyValuesAtom = createModelAtom("key_value"); export const pluginsAtom = createModelAtom("plugin"); export const settingsAtom = createSingularModelAtom("settings"); export const websocketRequestsAtom = createModelAtom("websocket_request"); export const websocketEventsAtom = createOrderedModelAtom("websocket_event", "createdAt", "asc"); export const websocketConnectionsAtom = createOrderedModelAtom( "websocket_connection", "createdAt", "desc", ); export const workspaceMetasAtom = createModelAtom("workspace_meta"); export const workspacesAtom = createOrderedModelAtom("workspace", "name", "asc"); export function createModelAtom(modelType: M) { return selectAtom( modelStoreDataAtom, (data) => Object.values(data[modelType] ?? {}), shallowEqual, ); } export function createSingularModelAtom(modelType: M) { return selectAtom(modelStoreDataAtom, (data) => { const modelData = Object.values(data[modelType] ?? {}); const item = modelData[0]; if (item == null) throw new Error("Failed creating singular model with no data: " + modelType); return item; }); } export function createOrderedModelAtom( modelType: M, field: keyof ExtractModel, order: "asc" | "desc", ) { return selectAtom( modelStoreDataAtom, (data) => { const modelData = data[modelType] ?? {}; return Object.values(modelData).sort( (a: ExtractModel, b: ExtractModel) => { const n = a[field] > b[field] ? 1 : -1; return order === "desc" ? n * -1 : n; }, ); }, shallowEqual, ); } function shallowEqual(a: T[], b: T[]): boolean { if (a.length !== b.length) { return false; } for (let i = 0; i < a.length; i++) { if (a[i] !== b[i]) { return false; } } return true; } ================================================ FILE: crates/yaak-models/guest-js/index.ts ================================================ import { AnyModel } from "../bindings/gen_models"; export * from "../bindings/gen_models"; export * from "../bindings/gen_util"; export * from "./store"; export * from "./atoms"; export function modelTypeLabel(m: AnyModel): string { const capitalize = (str: string) => str.charAt(0).toUpperCase() + str.slice(1); return m.model.split("_").map(capitalize).join(" "); } ================================================ FILE: crates/yaak-models/guest-js/store.ts ================================================ import { invoke } from "@tauri-apps/api/core"; import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; import { resolvedModelName } from "@yaakapp/app/lib/resolvedModelName"; import { AnyModel, ModelPayload } from "../bindings/gen_models"; import { modelStoreDataAtom } from "./atoms"; import { ExtractModel, JotaiStore, ModelStoreData } from "./types"; import { newStoreData } from "./util"; let _store: JotaiStore | null = null; export function initModelStore(store: JotaiStore) { _store = store; getCurrentWebviewWindow() .listen("model_write", ({ payload }) => { if (shouldIgnoreModel(payload)) return; mustStore().set(modelStoreDataAtom, (prev: ModelStoreData) => { if (payload.change.type === "upsert") { return { ...prev, [payload.model.model]: { ...prev[payload.model.model], [payload.model.id]: payload.model, }, }; } else { const modelData = { ...prev[payload.model.model] }; delete modelData[payload.model.id]; return { ...prev, [payload.model.model]: modelData }; } }); }) .catch(console.error); } function mustStore(): JotaiStore { if (_store == null) { throw new Error("Model store was not initialized"); } return _store; } let _activeWorkspaceId: string | null = null; export async function changeModelStoreWorkspace(workspaceId: string | null) { console.log("Syncing models with new workspace", workspaceId); const workspaceModelsStr = await invoke("models_workspace_models", { workspaceId, // NOTE: if no workspace id provided, it will just fetch global models }); const workspaceModels = JSON.parse(workspaceModelsStr) as AnyModel[]; const data = newStoreData(); for (const model of workspaceModels) { data[model.model][model.id] = model; } mustStore().set(modelStoreDataAtom, data); console.log("Synced model store with workspace", workspaceId, data); _activeWorkspaceId = workspaceId; } export function listModels>( modelType: M | ReadonlyArray, ): T[] { let data = mustStore().get(modelStoreDataAtom); const types: ReadonlyArray = Array.isArray(modelType) ? modelType : [modelType]; return types.flatMap((t) => Object.values(data[t]) as T[]); } export function getModel>( modelType: M | ReadonlyArray, id: string, ): T | null { let data = mustStore().get(modelStoreDataAtom); const types: ReadonlyArray = Array.isArray(modelType) ? modelType : [modelType]; for (const t of types) { let v = data[t][id]; if (v?.model === t) return v as T; } return null; } export function getAnyModel(id: string): AnyModel | null { let data = mustStore().get(modelStoreDataAtom); for (const t of Object.keys(data)) { // oxlint-disable-next-line no-explicit-any let v = (data as any)[t]?.[id]; if (v?.model === t) return v; } return null; } export function patchModelById>( model: M, id: string, patch: Partial | ((prev: T) => T), ): Promise { let prev = getModel(model, id); if (prev == null) { throw new Error(`Failed to get model to patch id=${id} model=${model}`); } const newModel = typeof patch === "function" ? patch(prev) : { ...prev, ...patch }; return updateModel(newModel); } export async function patchModel>( base: Pick, patch: Partial, ): Promise { return patchModelById(base.model, base.id, patch); } export async function updateModel>( model: T, ): Promise { return invoke("models_upsert", { model }); } export async function deleteModelById< M extends AnyModel["model"], T extends ExtractModel, >(modelType: M | M[], id: string) { let model = getModel(modelType, id); await deleteModel(model); } export async function deleteModel>( model: T | null, ) { if (model == null) { throw new Error("Failed to delete null model"); } await invoke("models_delete", { model }); } export function duplicateModel>( model: T | null, ) { if (model == null) { throw new Error("Failed to duplicate null model"); } // If the model has a name, try to duplicate it with a name that doesn't conflict let name = "name" in model ? resolvedModelName(model) : undefined; if (name != null) { const existingModels = listModels(model.model); for (let i = 0; i < 100; i++) { const hasConflict = existingModels.some((m) => { if ("folderId" in m && "folderId" in model && model.folderId !== m.folderId) { return false; } else if (resolvedModelName(m) !== name) { return false; } return true; }); if (!hasConflict) { break; } // Name conflict. Try another one const m: RegExpMatchArray | null = name.match(/ Copy( (?\d+))?$/); if (m != null && m.groups?.n == null) { name = name.substring(0, m.index) + " Copy 2"; } else if (m != null && m.groups?.n != null) { name = name.substring(0, m.index) + ` Copy ${parseInt(m.groups.n) + 1}`; } else { name = `${name} Copy`; } } } return invoke("models_duplicate", { model: { ...model, name } }); } export async function createGlobalModel>( patch: Partial & Pick, ): Promise { return invoke("models_upsert", { model: patch }); } export async function createWorkspaceModel>( patch: Partial & Pick, ): Promise { return invoke("models_upsert", { model: patch }); } export function replaceModelsInStore< M extends AnyModel["model"], T extends Extract, >(model: M, models: T[]) { const newModels: Record = {}; for (const model of models) { newModels[model.id] = model; } mustStore().set(modelStoreDataAtom, (prev: ModelStoreData) => { return { ...prev, [model]: newModels, }; }); } export function mergeModelsInStore< M extends AnyModel["model"], T extends Extract, >(model: M, models: T[], filter?: (model: T) => boolean) { mustStore().set(modelStoreDataAtom, (prev: ModelStoreData) => { const existingModels = { ...prev[model] } as Record; // Merge in new models first for (const m of models) { existingModels[m.id] = m; } // Then filter out unwanted models if (filter) { for (const [id, m] of Object.entries(existingModels)) { if (!filter(m)) { delete existingModels[id]; } } } return { ...prev, [model]: existingModels, }; }); } function shouldIgnoreModel({ model, updateSource }: ModelPayload) { // Never ignore updates from non-user sources if (updateSource.type !== "window") { return false; } // Never ignore same-window updates if (updateSource.label === getCurrentWebviewWindow().label) { return false; } // Only sync models that belong to this workspace, if a workspace ID is present if ("workspaceId" in model && model.workspaceId !== _activeWorkspaceId) { return true; } if (model.model === "key_value" && model.namespace === "no_sync") { return true; } return false; } ================================================ FILE: crates/yaak-models/guest-js/types.ts ================================================ import { createStore } from "jotai"; import { AnyModel } from "../bindings/gen_models"; export type ExtractModel = T extends { model: M } ? T : never; export type ModelStoreData = { [M in T["model"]]: Record>; }; export type JotaiStore = ReturnType; ================================================ FILE: crates/yaak-models/guest-js/util.ts ================================================ import { ModelStoreData } from "./types"; export function newStoreData(): ModelStoreData { return { cookie_jar: {}, environment: {}, folder: {}, graphql_introspection: {}, grpc_connection: {}, grpc_event: {}, grpc_request: {}, http_request: {}, http_response: {}, http_response_event: {}, key_value: {}, plugin: {}, settings: {}, sync_state: {}, websocket_connection: {}, websocket_event: {}, websocket_request: {}, workspace: {}, workspace_meta: {}, }; } ================================================ FILE: crates/yaak-models/migrations/20230225181302_init.sql ================================================ CREATE TABLE key_values ( model TEXT DEFAULT 'key_value' NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, deleted_at DATETIME, namespace TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, PRIMARY KEY (namespace, key) ); CREATE TABLE workspaces ( id TEXT NOT NULL PRIMARY KEY, model TEXT DEFAULT 'workspace' NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, deleted_at DATETIME, name TEXT NOT NULL, description TEXT NOT NULL ); CREATE TABLE http_requests ( id TEXT NOT NULL PRIMARY KEY, model TEXT DEFAULT 'http_request' NOT NULL, workspace_id TEXT NOT NULL REFERENCES workspaces ON DELETE CASCADE, created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, deleted_at DATETIME, name TEXT NOT NULL, url TEXT NOT NULL, method TEXT NOT NULL, headers TEXT NOT NULL, body TEXT, body_type TEXT ); CREATE TABLE http_responses ( id TEXT NOT NULL PRIMARY KEY, model TEXT DEFAULT 'http_response' NOT NULL, request_id TEXT NOT NULL REFERENCES http_requests ON DELETE CASCADE, workspace_id TEXT NOT NULL REFERENCES workspaces ON DELETE CASCADE, created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, deleted_at DATETIME, elapsed INTEGER NOT NULL, status INTEGER NOT NULL, status_reason TEXT, url TEXT NOT NULL, body TEXT NOT NULL, headers TEXT NOT NULL, error TEXT ); ================================================ FILE: crates/yaak-models/migrations/20230319042610_sort-priority.sql ================================================ ALTER TABLE main.http_requests ADD COLUMN sort_priority REAL NOT NULL DEFAULT 0; ================================================ FILE: crates/yaak-models/migrations/20230330143214_request-auth.sql ================================================ ALTER TABLE http_requests ADD COLUMN authentication TEXT NOT NULL DEFAULT '{}'; ALTER TABLE http_requests ADD COLUMN authentication_type TEXT; ================================================ FILE: crates/yaak-models/migrations/20230413232435_response-body-blob.sql ================================================ DELETE FROM main.http_responses; ALTER TABLE http_responses DROP COLUMN body; ALTER TABLE http_responses ADD COLUMN body BLOB; ALTER TABLE http_responses ADD COLUMN body_path TEXT; ALTER TABLE http_responses ADD COLUMN content_length INTEGER; ================================================ FILE: crates/yaak-models/migrations/20231022205109_environments.sql ================================================ CREATE TABLE environments ( id TEXT NOT NULL PRIMARY KEY, model TEXT DEFAULT 'workspace' NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, deleted_at DATETIME, workspace_id TEXT NOT NULL REFERENCES workspaces ON DELETE CASCADE, name TEXT NOT NULL, data TEXT NOT NULL DEFAULT '{}' ); ================================================ FILE: crates/yaak-models/migrations/20231028161007_variables.sql ================================================ ALTER TABLE environments DROP COLUMN data; ALTER TABLE environments ADD COLUMN variables DEFAULT '[]' NOT NULL; ================================================ FILE: crates/yaak-models/migrations/20231103004111_workspace-variables.sql ================================================ ALTER TABLE workspaces ADD COLUMN variables DEFAULT '[]' NOT NULL; ================================================ FILE: crates/yaak-models/migrations/20231103142807_folders.sql ================================================ CREATE TABLE folders ( id TEXT NOT NULL PRIMARY KEY, model TEXT DEFAULT 'folder' NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, deleted_at DATETIME, workspace_id TEXT NOT NULL REFERENCES workspaces ON DELETE CASCADE, folder_id TEXT NULL REFERENCES folders ON DELETE CASCADE, name TEXT NOT NULL, sort_priority REAL DEFAULT 0 NOT NULL ); ALTER TABLE http_requests ADD COLUMN folder_id TEXT REFERENCES folders(id) ON DELETE CASCADE; ================================================ FILE: crates/yaak-models/migrations/20231112180500_body_object.sql ================================================ -- Rename old column to backup name ALTER TABLE http_requests RENAME COLUMN body TO body_old; -- Create desired new body column ALTER TABLE http_requests ADD COLUMN body TEXT NOT NULL DEFAULT '{}'; -- Copy data from old to new body, in new JSON format UPDATE http_requests SET body = CASE WHEN body_old IS NULL THEN '{}' ELSE JSON_OBJECT('text', body_old) END WHERE TRUE; -- Drop old column ALTER TABLE http_requests DROP COLUMN body_old; ================================================ FILE: crates/yaak-models/migrations/20231113183810_url_params.sql ================================================ ALTER TABLE http_requests ADD COLUMN url_parameters TEXT NOT NULL DEFAULT '[]'; ================================================ FILE: crates/yaak-models/migrations/20231122055216_remove_body.sql ================================================ ALTER TABLE http_responses DROP COLUMN body; ================================================ FILE: crates/yaak-models/migrations/20240111221224_settings.sql ================================================ CREATE TABLE settings ( id TEXT NOT NULL PRIMARY KEY, model TEXT DEFAULT 'settings' NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, follow_redirects BOOLEAN DEFAULT TRUE NOT NULL, validate_certificates BOOLEAN DEFAULT TRUE NOT NULL, request_timeout INTEGER DEFAULT 0 NOT NULL, theme TEXT DEFAULT 'default' NOT NULL, appearance TEXT DEFAULT 'system' NOT NULL ); ================================================ FILE: crates/yaak-models/migrations/20240115193751_workspace_settings.sql ================================================ -- Add existing request-related settings to workspace ALTER TABLE workspaces ADD COLUMN setting_request_timeout INTEGER DEFAULT '0' NOT NULL; ALTER TABLE workspaces ADD COLUMN setting_validate_certificates BOOLEAN DEFAULT TRUE NOT NULL; ALTER TABLE workspaces ADD COLUMN setting_follow_redirects BOOLEAN DEFAULT TRUE NOT NULL; -- Remove old settings that used to be global ALTER TABLE settings DROP COLUMN request_timeout; ALTER TABLE settings DROP COLUMN follow_redirects; ALTER TABLE settings DROP COLUMN validate_certificates; ================================================ FILE: crates/yaak-models/migrations/20240118181105_channel_setting.sql ================================================ ALTER TABLE settings ADD COLUMN update_channel TEXT DEFAULT 'stable' NOT NULL; ================================================ FILE: crates/yaak-models/migrations/20240127013915_cookies.sql ================================================ CREATE TABLE cookie_jars ( id TEXT NOT NULL PRIMARY KEY, model TEXT DEFAULT 'cookie_jar' NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, name TEXT NOT NULL, cookies TEXT DEFAULT '[]' NOT NULL, workspace_id TEXT NOT NULL ); ================================================ FILE: crates/yaak-models/migrations/20240128230717_more_response_attrs.sql ================================================ ALTER TABLE http_responses ADD COLUMN elapsed_headers INTEGER NOT NULL DEFAULT 0; ALTER TABLE http_responses ADD COLUMN remote_addr TEXT; ALTER TABLE http_responses ADD COLUMN version TEXT; ================================================ FILE: crates/yaak-models/migrations/20240203164833_grpc.sql ================================================ CREATE TABLE grpc_requests ( id TEXT NOT NULL PRIMARY KEY, model TEXT DEFAULT 'grpc_request' NOT NULL, workspace_id TEXT NOT NULL REFERENCES workspaces ON DELETE CASCADE, folder_id TEXT NULL REFERENCES folders ON DELETE CASCADE, created_at DATETIME DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')) NOT NULL, updated_at DATETIME DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')) NOT NULL, name TEXT NOT NULL, sort_priority REAL NOT NULL, url TEXT NOT NULL, service TEXT NULL, method TEXT NULL, message TEXT NOT NULL, authentication TEXT DEFAULT '{}' NOT NULL, authentication_type TEXT NULL, metadata TEXT DEFAULT '[]' NOT NULL ); CREATE TABLE grpc_connections ( id TEXT NOT NULL PRIMARY KEY, model TEXT DEFAULT 'grpc_connection' NOT NULL, workspace_id TEXT NOT NULL REFERENCES workspaces ON DELETE CASCADE, request_id TEXT NOT NULL REFERENCES grpc_requests ON DELETE CASCADE, created_at DATETIME DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')) NOT NULL, updated_at DATETIME DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')) NOT NULL, url TEXT NOT NULL, service TEXT NOT NULL, method TEXT NOT NULL, status INTEGER DEFAULT -1 NOT NULL, error TEXT NULL, elapsed INTEGER DEFAULT 0 NOT NULL, trailers TEXT DEFAULT '{}' NOT NULL ); CREATE TABLE grpc_events ( id TEXT NOT NULL PRIMARY KEY, model TEXT DEFAULT 'grpc_event' NOT NULL, workspace_id TEXT NOT NULL REFERENCES workspaces ON DELETE CASCADE, request_id TEXT NOT NULL REFERENCES grpc_requests ON DELETE CASCADE, connection_id TEXT NOT NULL REFERENCES grpc_connections ON DELETE CASCADE, created_at DATETIME DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')) NOT NULL, updated_at DATETIME DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')) NOT NULL, metadata TEXT DEFAULT '{}' NOT NULL, event_type TEXT NOT NULL, status INTEGER NULL, error TEXT NULL, content TEXT NOT NULL ); ================================================ FILE: crates/yaak-models/migrations/20240522031045_theme-settings.sql ================================================ ALTER TABLE settings ADD COLUMN theme_dark TEXT DEFAULT 'yaak-dark' NOT NULL; ALTER TABLE settings ADD COLUMN theme_light TEXT DEFAULT 'yaak-light' NOT NULL; ================================================ FILE: crates/yaak-models/migrations/20240529143147_more-settings.sql ================================================ ALTER TABLE settings ADD COLUMN interface_font_size INTEGER DEFAULT 15 NOT NULL; ALTER TABLE settings ADD COLUMN interface_scale INTEGER DEFAULT 1 NOT NULL; ALTER TABLE settings ADD COLUMN editor_font_size INTEGER DEFAULT 13 NOT NULL; ALTER TABLE settings ADD COLUMN editor_soft_wrap BOOLEAN DEFAULT 1 NOT NULL; ================================================ FILE: crates/yaak-models/migrations/20240607151115_open-workspace-setting.sql ================================================ ALTER TABLE settings ADD COLUMN open_workspace_new_window BOOLEAN NULL DEFAULT NULL; ================================================ FILE: crates/yaak-models/migrations/20240814013812_fix-env-model.sql ================================================ ALTER TABLE environments DROP COLUMN model; ALTER TABLE environments ADD COLUMN model TEXT DEFAULT 'environment'; ================================================ FILE: crates/yaak-models/migrations/20240826184943_disable-telemetry.sql ================================================ ALTER TABLE settings ADD COLUMN telemetry BOOLEAN DEFAULT TRUE; ================================================ FILE: crates/yaak-models/migrations/20240829131004_plugins.sql ================================================ CREATE TABLE plugins ( id TEXT NOT NULL PRIMARY KEY, model TEXT DEFAULT 'plugin' NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, checked_at DATETIME NULL, enabled BOOLEAN NOT NULL, directory TEXT NULL NOT NULL, url TEXT NULL ); ================================================ FILE: crates/yaak-models/migrations/20241003134208_response-state.sql ================================================ ALTER TABLE http_responses ADD COLUMN state TEXT DEFAULT 'closed' NOT NULL; ALTER TABLE grpc_connections ADD COLUMN state TEXT DEFAULT 'closed' NOT NULL; ================================================ FILE: crates/yaak-models/migrations/20241012181547_proxy-setting.sql ================================================ ALTER TABLE settings ADD COLUMN proxy TEXT; ================================================ FILE: crates/yaak-models/migrations/20241217204951_docs.sql ================================================ ALTER TABLE http_requests ADD COLUMN description TEXT DEFAULT '' NOT NULL; ALTER TABLE grpc_requests ADD COLUMN description TEXT DEFAULT '' NOT NULL; ALTER TABLE folders ADD COLUMN description TEXT DEFAULT '' NOT NULL; ================================================ FILE: crates/yaak-models/migrations/20241219140051_base-environments.sql ================================================ -- Add the new field ALTER TABLE environments ADD COLUMN environment_id TEXT REFERENCES environments (id) ON DELETE CASCADE; -- Create temporary column so we know which rows are meant to be base environments. We'll use this to update -- child environments to point to them. ALTER TABLE environments ADD COLUMN migrated_base_env BOOLEAN DEFAULT FALSE NOT NULL; -- Create a base environment for each workspace INSERT INTO environments (id, workspace_id, name, variables, migrated_base_env) SELECT ( -- This is the best way to generate a random string in SQLite, apparently 'ev_' || SUBSTR('abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23457789', (ABS(RANDOM()) % 57) + 1, 1) || SUBSTR('abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23457789', (ABS(RANDOM()) % 57) + 1, 1) || SUBSTR('abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23457789', (ABS(RANDOM()) % 57) + 1, 1) || SUBSTR('abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23457789', (ABS(RANDOM()) % 57) + 1, 1) || SUBSTR('abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23457789', (ABS(RANDOM()) % 57) + 1, 1) || SUBSTR('abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23457789', (ABS(RANDOM()) % 57) + 1, 1) || SUBSTR('abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23457789', (ABS(RANDOM()) % 57) + 1, 1) || SUBSTR('abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23457789', (ABS(RANDOM()) % 57) + 1, 1) || SUBSTR('abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23457789', (ABS(RANDOM()) % 57) + 1, 1) || SUBSTR('abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23457789', (ABS(RANDOM()) % 57) + 1, 1) ), workspaces.id, 'Global Variables', variables, TRUE FROM workspaces; -- Update all non-base environments to point to newly created base environments UPDATE environments SET environment_id = ( SELECT base_env.id FROM environments AS base_env WHERE base_env.workspace_id = environments.workspace_id AND base_env.migrated_base_env IS TRUE ) WHERE migrated_base_env IS FALSE; -- Drop temporary column ALTER TABLE environments DROP COLUMN migrated_base_env; -- Drop the old variables column -- IMPORTANT: Skip to give the user the option to roll back to a previous app version. We can drop it once the migration working in the real world -- ALTER TABLE workspaces DROP COLUMN variables; ================================================ FILE: crates/yaak-models/migrations/20250102141937_sync.sql ================================================ ALTER TABLE workspaces ADD COLUMN setting_sync_dir TEXT; CREATE TABLE sync_states ( id TEXT NOT NULL PRIMARY KEY, model TEXT DEFAULT 'sync_state' NOT NULL, workspace_id TEXT NOT NULL REFERENCES workspaces ON DELETE CASCADE, created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, flushed_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, checksum TEXT NOT NULL, model_id TEXT NOT NULL, sync_dir TEXT NOT NULL, rel_path TEXT NOT NULL, UNIQUE (workspace_id, model_id) ); ================================================ FILE: crates/yaak-models/migrations/20250108035425_editor-keymap.sql ================================================ ALTER TABLE settings ADD COLUMN editor_keymap TEXT DEFAULT 'codemirror' NOT NULL; ================================================ FILE: crates/yaak-models/migrations/20250108205117_workspace-meta.sql ================================================ CREATE TABLE workspace_metas ( id TEXT NOT NULL PRIMARY KEY, model TEXT DEFAULT 'workspace_meta' NOT NULL, workspace_id TEXT NOT NULL REFERENCES workspaces ON DELETE CASCADE, created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, setting_sync_dir TEXT ); ================================================ FILE: crates/yaak-models/migrations/20250114160022_remove-workspace-sync-setting.sql ================================================ -- This setting was moved to the new workspace_metas table ALTER TABLE workspaces DROP COLUMN setting_sync_dir; ================================================ FILE: crates/yaak-models/migrations/20250123192023_plugin-kv.sql ================================================ CREATE TABLE plugin_key_values ( model TEXT DEFAULT 'plugin_key_value' NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, deleted_at DATETIME, plugin_name TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, PRIMARY KEY (plugin_name, key) ); ================================================ FILE: crates/yaak-models/migrations/20250128155623_websockets.sql ================================================ CREATE TABLE websocket_requests ( id TEXT NOT NULL PRIMARY KEY, model TEXT DEFAULT 'websocket_request' NOT NULL, workspace_id TEXT NOT NULL REFERENCES workspaces ON DELETE CASCADE, folder_id TEXT REFERENCES folders ON DELETE CASCADE, created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, deleted_at DATETIME, authentication TEXT DEFAULT '{}' NOT NULL, authentication_type TEXT, description TEXT NOT NULL, name TEXT NOT NULL, url TEXT NOT NULL, headers TEXT NOT NULL, message TEXT NOT NULL, sort_priority REAL NOT NULL, url_parameters TEXT DEFAULT '[]' NOT NULL ); CREATE TABLE websocket_connections ( id TEXT NOT NULL PRIMARY KEY, model TEXT DEFAULT 'websocket_connection' NOT NULL, workspace_id TEXT NOT NULL REFERENCES workspaces ON DELETE CASCADE, request_id TEXT NOT NULL REFERENCES websocket_requests ON DELETE CASCADE, created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, url TEXT NOT NULL, state TEXT NOT NULL, status INTEGER DEFAULT -1 NOT NULL, error TEXT NULL, elapsed INTEGER DEFAULT 0 NOT NULL, headers TEXT DEFAULT '{}' NOT NULL ); CREATE TABLE websocket_events ( id TEXT NOT NULL PRIMARY KEY, model TEXT DEFAULT 'websocket_event' NOT NULL, workspace_id TEXT NOT NULL REFERENCES workspaces ON DELETE CASCADE, request_id TEXT NOT NULL REFERENCES websocket_requests ON DELETE CASCADE, connection_id TEXT NOT NULL REFERENCES websocket_connections ON DELETE CASCADE, created_at DATETIME DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')) NOT NULL, updated_at DATETIME DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')) NOT NULL, is_server BOOLEAN NOT NULL, message_type TEXT NOT NULL, message BLOB NOT NULL ); ================================================ FILE: crates/yaak-models/migrations/20250302041707_hide-window-controls.sql ================================================ ALTER TABLE settings ADD COLUMN hide_window_controls BOOLEAN DEFAULT FALSE NOT NULL; ================================================ FILE: crates/yaak-models/migrations/20250326193143_key-value-id.sql ================================================ -- 1. Create the new table with `id` as the primary key CREATE TABLE key_values_new ( id TEXT PRIMARY KEY, model TEXT DEFAULT 'key_value' NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, deleted_at DATETIME, namespace TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL ); -- 2. Copy data from the old table INSERT INTO key_values_new (id, model, created_at, updated_at, deleted_at, namespace, key, value) SELECT ( -- This is the best way to generate a random string in SQLite, apparently 'kv_' || SUBSTR('abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23457789', (ABS(RANDOM()) % 57) + 1, 1) || SUBSTR('abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23457789', (ABS(RANDOM()) % 57) + 1, 1) || SUBSTR('abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23457789', (ABS(RANDOM()) % 57) + 1, 1) || SUBSTR('abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23457789', (ABS(RANDOM()) % 57) + 1, 1) || SUBSTR('abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23457789', (ABS(RANDOM()) % 57) + 1, 1) || SUBSTR('abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23457789', (ABS(RANDOM()) % 57) + 1, 1) || SUBSTR('abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23457789', (ABS(RANDOM()) % 57) + 1, 1) || SUBSTR('abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23457789', (ABS(RANDOM()) % 57) + 1, 1) || SUBSTR('abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23457789', (ABS(RANDOM()) % 57) + 1, 1) || SUBSTR('abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23457789', (ABS(RANDOM()) % 57) + 1, 1) ) AS id, model, created_at, updated_at, deleted_at, namespace, key, value FROM key_values; -- 3. Drop the old table DROP TABLE key_values; -- 4. Rename the new table ALTER TABLE key_values_new RENAME TO key_values; ================================================ FILE: crates/yaak-models/migrations/20250401122407_encrypted-key.sql ================================================ ALTER TABLE workspace_metas ADD COLUMN encryption_key TEXT NULL DEFAULT NULL; ================================================ FILE: crates/yaak-models/migrations/20250402144842_encryption-key-challenge.sql ================================================ ALTER TABLE workspaces ADD COLUMN encryption_key_challenge TEXT NULL; ================================================ FILE: crates/yaak-models/migrations/20250424152740_remove-fks.sql ================================================ -- NOTE: SQLite does not support dropping foreign keys, so we need to create new -- tables and copy data instead. To prevent cascade deletes from wrecking stuff, -- we start with the leaf tables and finish with the parent tables (eg. folder). ---------------------------- -- Remove http request FK -- ---------------------------- CREATE TABLE http_requests_dg_tmp ( id TEXT NOT NULL PRIMARY KEY, model TEXT DEFAULT 'http_request' NOT NULL, workspace_id TEXT NOT NULL REFERENCES workspaces ON DELETE CASCADE, created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, deleted_at DATETIME, name TEXT NOT NULL, url TEXT NOT NULL, method TEXT NOT NULL, headers TEXT NOT NULL, body_type TEXT, sort_priority REAL DEFAULT 0 NOT NULL, authentication TEXT DEFAULT '{}' NOT NULL, authentication_type TEXT, folder_id TEXT, body TEXT DEFAULT '{}' NOT NULL, url_parameters TEXT DEFAULT '[]' NOT NULL, description TEXT DEFAULT '' NOT NULL ); INSERT INTO http_requests_dg_tmp(id, model, workspace_id, created_at, updated_at, deleted_at, name, url, method, headers, body_type, sort_priority, authentication, authentication_type, folder_id, body, url_parameters, description) SELECT id, model, workspace_id, created_at, updated_at, deleted_at, name, url, method, headers, body_type, sort_priority, authentication, authentication_type, folder_id, body, url_parameters, description FROM http_requests; DROP TABLE http_requests; ALTER TABLE http_requests_dg_tmp RENAME TO http_requests; ---------------------------- -- Remove grpc request FK -- ---------------------------- CREATE TABLE grpc_requests_dg_tmp ( id TEXT NOT NULL PRIMARY KEY, model TEXT DEFAULT 'grpc_request' NOT NULL, workspace_id TEXT NOT NULL REFERENCES workspaces ON DELETE CASCADE, folder_id TEXT, created_at DATETIME DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')) NOT NULL, updated_at DATETIME DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')) NOT NULL, name TEXT NOT NULL, sort_priority REAL NOT NULL, url TEXT NOT NULL, service TEXT, method TEXT, message TEXT NOT NULL, authentication TEXT DEFAULT '{}' NOT NULL, authentication_type TEXT, metadata TEXT DEFAULT '[]' NOT NULL, description TEXT DEFAULT '' NOT NULL ); INSERT INTO grpc_requests_dg_tmp(id, model, workspace_id, folder_id, created_at, updated_at, name, sort_priority, url, service, method, message, authentication, authentication_type, metadata, description) SELECT id, model, workspace_id, folder_id, created_at, updated_at, name, sort_priority, url, service, method, message, authentication, authentication_type, metadata, description FROM grpc_requests; DROP TABLE grpc_requests; ALTER TABLE grpc_requests_dg_tmp RENAME TO grpc_requests; --------------------------------- -- Remove websocket request FK -- --------------------------------- CREATE TABLE websocket_requests_dg_tmp ( id TEXT NOT NULL PRIMARY KEY, model TEXT DEFAULT 'websocket_request' NOT NULL, workspace_id TEXT NOT NULL REFERENCES workspaces ON DELETE CASCADE, folder_id TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, deleted_at DATETIME, authentication TEXT DEFAULT '{}' NOT NULL, authentication_type TEXT, description TEXT NOT NULL, name TEXT NOT NULL, url TEXT NOT NULL, headers TEXT NOT NULL, message TEXT NOT NULL, sort_priority REAL NOT NULL, url_parameters TEXT DEFAULT '[]' NOT NULL ); INSERT INTO websocket_requests_dg_tmp(id, model, workspace_id, folder_id, created_at, updated_at, deleted_at, authentication, authentication_type, description, name, url, headers, message, sort_priority, url_parameters) SELECT id, model, workspace_id, folder_id, created_at, updated_at, deleted_at, authentication, authentication_type, description, name, url, headers, message, sort_priority, url_parameters FROM websocket_requests; DROP TABLE websocket_requests; ALTER TABLE websocket_requests_dg_tmp RENAME TO websocket_requests; --------------------------- -- Remove environment FK -- --------------------------- CREATE TABLE environments_dg_tmp ( id TEXT NOT NULL PRIMARY KEY, created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, deleted_at DATETIME, workspace_id TEXT NOT NULL REFERENCES workspaces ON DELETE CASCADE, name TEXT NOT NULL, variables DEFAULT '[]' NOT NULL, model TEXT DEFAULT 'environment', environment_id TEXT ); INSERT INTO environments_dg_tmp(id, created_at, updated_at, deleted_at, workspace_id, name, variables, model, environment_id) SELECT id, created_at, updated_at, deleted_at, workspace_id, name, variables, model, environment_id FROM environments; DROP TABLE environments; ALTER TABLE environments_dg_tmp RENAME TO environments; ---------------------- -- Remove folder FK -- ---------------------- CREATE TABLE folders_dg_tmp ( id TEXT NOT NULL PRIMARY KEY, model TEXT DEFAULT 'folder' NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, deleted_at DATETIME, workspace_id TEXT NOT NULL REFERENCES workspaces ON DELETE CASCADE, folder_id TEXT, name TEXT NOT NULL, sort_priority REAL DEFAULT 0 NOT NULL, description TEXT DEFAULT '' NOT NULL ); INSERT INTO folders_dg_tmp(id, model, created_at, updated_at, deleted_at, workspace_id, folder_id, name, sort_priority, description) SELECT id, model, created_at, updated_at, deleted_at, workspace_id, folder_id, name, sort_priority, description FROM folders; DROP TABLE folders; ALTER TABLE folders_dg_tmp RENAME TO folders; ================================================ FILE: crates/yaak-models/migrations/20250507140702_remove-ev-sync-states.sql ================================================ -- There used to be sync code that skipped over environments because we didn't -- want to sync potentially insecure data. With encryption, it is now possible -- to sync environments securely. However, there were already sync states in the -- DB that marked environments as "Synced". Running the sync code on these envs -- would mark them as deleted by FS (exist in SyncState but not on FS). -- -- To undo this mess, we have this migration to delete all environment-related -- sync states so we can sync from a clean slate. DELETE FROM sync_states WHERE model_id LIKE 'ev_%'; ================================================ FILE: crates/yaak-models/migrations/20250508161145_public-environments.sql ================================================ -- Add a public column to represent whether an environment can be shared or exported ALTER TABLE environments ADD COLUMN public BOOLEAN DEFAULT FALSE; -- Add a base column to represent whether an environment is a base or sub environment. We used to -- do this with environment_id, but we need a more flexible solution now that envs can be optionally -- synced. E.g., it's now possible to only import a sub environment from a different client without -- its base environment "parent." ALTER TABLE environments ADD COLUMN base BOOLEAN DEFAULT FALSE; -- SQLite doesn't support dynamic default values, so we update `base` based on the value of -- environment_id. UPDATE environments SET base = TRUE WHERE environment_id IS NULL; -- Finally, we drop the old `environment_id` column that will no longer be used ALTER TABLE environments DROP COLUMN environment_id; ================================================ FILE: crates/yaak-models/migrations/20250516182745_default-attrs.sql ================================================ -- Auth ALTER TABLE workspaces ADD COLUMN authentication TEXT NOT NULL DEFAULT '{}'; ALTER TABLE folders ADD COLUMN authentication TEXT NOT NULL DEFAULT '{}'; ALTER TABLE workspaces ADD COLUMN authentication_type TEXT; ALTER TABLE folders ADD COLUMN authentication_type TEXT; -- Headers ALTER TABLE workspaces ADD COLUMN headers TEXT NOT NULL DEFAULT '[]'; ALTER TABLE folders ADD COLUMN headers TEXT NOT NULL DEFAULT '[]'; ================================================ FILE: crates/yaak-models/migrations/20250530174021_graphql-introspection.sql ================================================ -- Clean up old key/values that are no longer used DELETE FROM key_values WHERE key LIKE 'graphql_introspection::%'; CREATE TABLE graphql_introspections ( id TEXT NOT NULL PRIMARY KEY, model TEXT DEFAULT 'graphql_introspection' NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, workspace_id TEXT NOT NULL REFERENCES workspaces ON DELETE CASCADE, request_id TEXT NULL REFERENCES http_requests ON DELETE CASCADE, content TEXT NULL ); ================================================ FILE: crates/yaak-models/migrations/20250531193722_sync-state-index.sql ================================================ -- Add sync_dir to the unique index, or else it will fail if the user disables sync -- and re-enables it for a different directory. -- Step 1: Rename the existing table ALTER TABLE sync_states RENAME TO sync_states_old; -- Step 2: Create the new table with the updated unique constraint CREATE TABLE sync_states ( id TEXT NOT NULL PRIMARY KEY, model TEXT DEFAULT 'sync_state' NOT NULL, workspace_id TEXT NOT NULL REFERENCES workspaces ON DELETE CASCADE, created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, flushed_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, checksum TEXT NOT NULL, model_id TEXT NOT NULL, sync_dir TEXT NOT NULL, rel_path TEXT NOT NULL ); CREATE UNIQUE INDEX idx_sync_states_unique ON sync_states (workspace_id, model_id, sync_dir); -- Step 3: Copy the data INSERT INTO sync_states (id, model, workspace_id, created_at, updated_at, flushed_at, checksum, model_id, sync_dir, rel_path) SELECT id, model, workspace_id, created_at, updated_at, flushed_at, checksum, model_id, sync_dir, rel_path FROM sync_states_old; -- Step 4: Drop the old table DROP TABLE sync_states_old; ================================================ FILE: crates/yaak-models/migrations/20250604102922_colored-methods-setting.sql ================================================ ALTER TABLE settings ADD COLUMN colored_methods BOOLEAN DEFAULT FALSE; ================================================ FILE: crates/yaak-models/migrations/20250608150053_font-settings.sql ================================================ ALTER TABLE settings ADD COLUMN interface_font TEXT; ALTER TABLE settings ADD COLUMN editor_font TEXT; ================================================ FILE: crates/yaak-models/migrations/20250611120000_environment-color.sql ================================================ ALTER TABLE environments ADD COLUMN color TEXT; ================================================ FILE: crates/yaak-models/migrations/20250727190746_autoupdate_setting.sql ================================================ ALTER TABLE settings ADD COLUMN autoupdate BOOLEAN DEFAULT true NOT NULL; ================================================ FILE: crates/yaak-models/migrations/20250918141129_request-folder-environments.sql ================================================ -- Create temporary table for migration CREATE TABLE environments__new ( id TEXT NOT NULL PRIMARY KEY, created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, deleted_at DATETIME, workspace_id TEXT NOT NULL REFERENCES workspaces ON DELETE CASCADE, name TEXT NOT NULL, variables TEXT DEFAULT '[]' NOT NULL, model TEXT DEFAULT 'environment', public BOOLEAN DEFAULT FALSE, color TEXT, -- NEW parent_model TEXT DEFAULT 'workspace' NOT NULL, parent_id TEXT ); -- Backfill the data from the old table -- - base=1 -> (workspace, NULL) -- - base=0 -> (environment, id_of_workspace_base) (fallback to workspace,NULL if none) INSERT INTO environments__new (id, created_at, updated_at, deleted_at, workspace_id, name, variables, model, public, color, parent_model, parent_id) SELECT e.id, e.created_at, e.updated_at, e.deleted_at, e.workspace_id, e.name, e.variables, e.model, e.public, e.color, CASE WHEN e.base = 1 THEN 'workspace' WHEN ( SELECT COUNT(1) FROM environments b WHERE b.workspace_id = e.workspace_id AND b.base = 1 ) > 0 THEN 'environment' ELSE 'workspace' END AS parent_model, CASE WHEN e.base = 1 THEN NULL ELSE ( SELECT b.id FROM environments b WHERE b.workspace_id = e.workspace_id AND b.base = 1 ORDER BY b.created_at ASC, b.id ASC LIMIT 1 ) END AS parent_id FROM environments e; -- Move everything to the new table DROP TABLE environments; ALTER TABLE environments__new RENAME TO environments; ================================================ FILE: crates/yaak-models/migrations/20250929132954_dismiss-license-badge.sql ================================================ ALTER TABLE settings ADD COLUMN hide_license_badge BOOLEAN DEFAULT FALSE; -- 2. Backfill based on old JSON UPDATE settings SET hide_license_badge = 1 WHERE EXISTS ( SELECT 1 FROM key_values kv WHERE kv.key = 'license_confirmation' AND JSON_EXTRACT(kv.value, '$.confirmedPersonalUse') = TRUE ); ================================================ FILE: crates/yaak-models/migrations/20251001082054_auto-download.sql ================================================ ALTER TABLE settings ADD COLUMN auto_download_updates BOOLEAN DEFAULT TRUE; ================================================ FILE: crates/yaak-models/migrations/20251028060300_check_notifications_setting.sql ================================================ ALTER TABLE settings ADD COLUMN check_notifications BOOLEAN DEFAULT true NOT NULL; ================================================ FILE: crates/yaak-models/migrations/20251029062024_aws-auth-name.sql ================================================ UPDATE http_requests SET authentication_type = 'awsv4' WHERE authentication_type = 'auth-aws-sig-v4'; UPDATE folders SET authentication_type = 'awsv4' WHERE authentication_type = 'auth-aws-sig-v4'; UPDATE workspaces SET authentication_type = 'awsv4' WHERE authentication_type = 'auth-aws-sig-v4'; ================================================ FILE: crates/yaak-models/migrations/20251031070515_environment-sort-priority.sql ================================================ ALTER TABLE environments ADD COLUMN sort_priority REAL DEFAULT 0 NOT NULL; ================================================ FILE: crates/yaak-models/migrations/20251202080000_use-native-titlebar.sql ================================================ -- Add a setting to force native window title bar / controls ALTER TABLE settings ADD COLUMN use_native_titlebar BOOLEAN DEFAULT FALSE NOT NULL; ================================================ FILE: crates/yaak-models/migrations/20251209000000_client-certificates.sql ================================================ ALTER TABLE settings ADD COLUMN client_certificates TEXT DEFAULT '[]' NOT NULL; ================================================ FILE: crates/yaak-models/migrations/20251219074602_default-workspace-headers.sql ================================================ -- Add default User-Agent header to workspaces that don't already have one (case-insensitive check) UPDATE workspaces SET headers = json_insert(headers, '$[#]', json('{"enabled":true,"name":"User-Agent","value":"yaak"}')) WHERE NOT EXISTS ( SELECT 1 FROM json_each(workspaces.headers) WHERE LOWER(json_extract(value, '$.name')) = 'user-agent' ); -- Add default Accept header to workspaces that don't already have one (case-insensitive check) UPDATE workspaces SET headers = json_insert(headers, '$[#]', json('{"enabled":true,"name":"Accept","value":"*/*"}')) WHERE NOT EXISTS ( SELECT 1 FROM json_each(workspaces.headers) WHERE LOWER(json_extract(value, '$.name')) = 'accept' ); ================================================ FILE: crates/yaak-models/migrations/20251220000000_response-request-headers.sql ================================================ -- Add request_headers and content_length_compressed columns to http_responses table ALTER TABLE http_responses ADD COLUMN request_headers TEXT NOT NULL DEFAULT '[]'; ALTER TABLE http_responses ADD COLUMN content_length_compressed INTEGER; ================================================ FILE: crates/yaak-models/migrations/20251221000000_http-response-events.sql ================================================ CREATE TABLE http_response_events ( id TEXT NOT NULL PRIMARY KEY, model TEXT DEFAULT 'http_response_event' NOT NULL, workspace_id TEXT NOT NULL REFERENCES workspaces ON DELETE CASCADE, response_id TEXT NOT NULL REFERENCES http_responses ON DELETE CASCADE, created_at DATETIME DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')) NOT NULL, updated_at DATETIME DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')) NOT NULL, event TEXT NOT NULL ); ================================================ FILE: crates/yaak-models/migrations/20251221100000_request-content-length.sql ================================================ ALTER TABLE http_responses ADD COLUMN request_content_length INTEGER; ================================================ FILE: crates/yaak-models/migrations/20260104000000_hotkeys.sql ================================================ ALTER TABLE settings ADD COLUMN hotkeys TEXT DEFAULT '{}' NOT NULL; ================================================ FILE: crates/yaak-models/migrations/20260111000000_dns-timing.sql ================================================ -- Add DNS resolution timing to http_responses ALTER TABLE http_responses ADD COLUMN elapsed_dns INTEGER DEFAULT 0 NOT NULL; ================================================ FILE: crates/yaak-models/migrations/20260112000000_dns-overrides.sql ================================================ -- Add DNS overrides setting to workspaces ALTER TABLE workspaces ADD COLUMN setting_dns_overrides TEXT DEFAULT '[]' NOT NULL; ================================================ FILE: crates/yaak-models/migrations/20260119045146_remove-default-workspace-headers.sql ================================================ -- Filter out headers that match the hardcoded defaults (User-Agent: yaak, Accept: */*), -- keeping any other custom headers the user may have added. UPDATE workspaces SET headers = ( SELECT json_group_array(json(value)) FROM json_each(headers) WHERE NOT ( (LOWER(json_extract(value, '$.name')) = 'user-agent' AND json_extract(value, '$.value') = 'yaak') OR (LOWER(json_extract(value, '$.name')) = 'accept' AND json_extract(value, '$.value') = '*/*') ) ) WHERE json_array_length(headers) > 0; ================================================ FILE: crates/yaak-models/migrations/20260216000000_model-changes.sql ================================================ CREATE TABLE model_changes ( id INTEGER PRIMARY KEY AUTOINCREMENT, model TEXT NOT NULL, model_id TEXT NOT NULL, change TEXT NOT NULL, update_source TEXT NOT NULL, payload TEXT NOT NULL, created_at DATETIME DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')) NOT NULL ); CREATE INDEX idx_model_changes_created_at ON model_changes (created_at); ================================================ FILE: crates/yaak-models/migrations/20260217000000_remove-legacy-faker-plugin.sql ================================================ -- Remove stale plugin rows left over from the brief period when faker shipped as bundled. DELETE FROM plugins WHERE directory LIKE '%template-function-faker'; ================================================ FILE: crates/yaak-models/migrations/20260301000000_plugin-source-and-unique-directory.sql ================================================ ALTER TABLE plugins ADD COLUMN source TEXT DEFAULT 'filesystem' NOT NULL; -- Existing registry installs have a URL; classify them first. UPDATE plugins SET source = 'registry' WHERE url IS NOT NULL; -- Best-effort bundled backfill for legacy rows. UPDATE plugins SET source = 'bundled' WHERE source = 'filesystem' AND ( -- Normalize separators so this also works for Windows paths. replace(directory, '\', '/') LIKE '%/vendored/plugins/%' OR replace(directory, '\', '/') LIKE '%/vendored-plugins/%' ); -- Keep one row per exact directory before adding uniqueness. -- Tie-break by recency. WITH ranked AS (SELECT id, ROW_NUMBER() OVER ( PARTITION BY directory ORDER BY updated_at DESC, created_at DESC ) AS row_num FROM plugins) DELETE FROM plugins WHERE id IN (SELECT id FROM ranked WHERE row_num > 1); CREATE UNIQUE INDEX IF NOT EXISTS idx_plugins_directory_unique ON plugins (directory); ================================================ FILE: crates/yaak-models/package.json ================================================ { "name": "@yaakapp-internal/models", "version": "1.0.0", "private": true, "main": "guest-js/index.ts" } ================================================ FILE: crates/yaak-models/src/blob_manager.rs ================================================ use crate::error::Result; use crate::util::generate_prefixed_id; use include_dir::{Dir, include_dir}; use log::{debug, info}; use r2d2::Pool; use r2d2_sqlite::SqliteConnectionManager; use rusqlite::{OptionalExtension, params}; use std::sync::{Arc, Mutex}; static BLOB_MIGRATIONS_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/blob_migrations"); /// A chunk of body data stored in the blob database. #[derive(Debug, Clone)] pub struct BodyChunk { pub id: String, pub body_id: String, pub chunk_index: i32, pub data: Vec, } impl BodyChunk { pub fn new(body_id: impl Into, chunk_index: i32, data: Vec) -> Self { Self { id: generate_prefixed_id("bc"), body_id: body_id.into(), chunk_index, data } } } /// Manages the blob database connection pool. #[derive(Debug, Clone)] pub struct BlobManager { pool: Arc>>, } impl BlobManager { pub fn new(pool: Pool) -> Self { Self { pool: Arc::new(Mutex::new(pool)) } } pub fn connect(&self) -> BlobContext { let conn = self .pool .lock() .expect("Failed to gain lock on blob DB") .get() .expect("Failed to get blob DB connection from pool"); BlobContext { conn } } } /// Context for blob database operations. pub struct BlobContext { conn: r2d2::PooledConnection, } impl BlobContext { /// Insert a single chunk. pub fn insert_chunk(&self, chunk: &BodyChunk) -> Result<()> { self.conn.execute( "INSERT INTO body_chunks (id, body_id, chunk_index, data) VALUES (?1, ?2, ?3, ?4)", params![chunk.id, chunk.body_id, chunk.chunk_index, chunk.data], )?; Ok(()) } /// Get all chunks for a body, ordered by chunk_index. pub fn get_chunks(&self, body_id: &str) -> Result> { let mut stmt = self.conn.prepare( "SELECT id, body_id, chunk_index, data FROM body_chunks WHERE body_id = ?1 ORDER BY chunk_index ASC", )?; let chunks = stmt .query_map(params![body_id], |row| { Ok(BodyChunk { id: row.get(0)?, body_id: row.get(1)?, chunk_index: row.get(2)?, data: row.get(3)?, }) })? .collect::, _>>()?; Ok(chunks) } /// Delete all chunks for a body. pub fn delete_chunks(&self, body_id: &str) -> Result<()> { self.conn.execute("DELETE FROM body_chunks WHERE body_id = ?1", params![body_id])?; Ok(()) } /// Delete all chunks matching a body_id prefix (e.g., "rs_abc123.%" to delete all bodies for a response). pub fn delete_chunks_like(&self, body_id_prefix: &str) -> Result<()> { self.conn .execute("DELETE FROM body_chunks WHERE body_id LIKE ?1", params![body_id_prefix])?; Ok(()) } } /// Get total size of a body without loading data. impl BlobContext { pub fn get_body_size(&self, body_id: &str) -> Result { let size: i64 = self .conn .query_row( "SELECT COALESCE(SUM(LENGTH(data)), 0) FROM body_chunks WHERE body_id = ?1", params![body_id], |row| row.get(0), ) .unwrap_or(0); Ok(size as usize) } /// Check if a body exists. pub fn body_exists(&self, body_id: &str) -> Result { let count: i64 = self .conn .query_row( "SELECT COUNT(*) FROM body_chunks WHERE body_id = ?1", params![body_id], |row| row.get(0), ) .unwrap_or(0); Ok(count > 0) } } /// Run migrations for the blob database. pub fn migrate_blob_db(pool: &Pool) -> Result<()> { info!("Running blob database migrations"); // Create migrations tracking table pool.get()?.execute( "CREATE TABLE IF NOT EXISTS _blob_migrations ( version TEXT PRIMARY KEY, description TEXT NOT NULL, applied_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL )", [], )?; // Read and sort all .sql files let mut entries: Vec<_> = BLOB_MIGRATIONS_DIR .entries() .iter() .filter(|e| e.path().extension().map(|ext| ext == "sql").unwrap_or(false)) .collect(); entries.sort_by_key(|e| e.path()); let mut ran_migrations = 0; for entry in &entries { let filename = entry.path().file_name().unwrap().to_str().unwrap(); let version = filename.split('_').next().unwrap(); // Check if already applied let already_applied: Option = pool .get()? .query_row("SELECT 1 FROM _blob_migrations WHERE version = ?", [version], |r| r.get(0)) .optional()?; if already_applied.is_some() { debug!("Skipping already applied blob migration: {}", filename); continue; } let sql = entry.as_file().unwrap().contents_utf8().expect("Failed to read blob migration file"); info!("Applying blob migration: {}", filename); let conn = pool.get()?; conn.execute_batch(sql)?; // Record migration conn.execute( "INSERT INTO _blob_migrations (version, description) VALUES (?, ?)", params![version, filename], )?; ran_migrations += 1; } if ran_migrations == 0 { info!("No blob migrations to run"); } else { info!("Ran {} blob migration(s)", ran_migrations); } Ok(()) } #[cfg(test)] mod tests { use super::*; fn create_test_pool() -> Pool { let manager = SqliteConnectionManager::memory(); let pool = Pool::builder().max_size(1).build(manager).unwrap(); migrate_blob_db(&pool).unwrap(); pool } #[test] fn test_insert_and_get_chunks() { let pool = create_test_pool(); let manager = BlobManager::new(pool); let ctx = manager.connect(); let body_id = "rs_test123.request"; let chunk1 = BodyChunk::new(body_id, 0, b"Hello, ".to_vec()); let chunk2 = BodyChunk::new(body_id, 1, b"World!".to_vec()); ctx.insert_chunk(&chunk1).unwrap(); ctx.insert_chunk(&chunk2).unwrap(); let chunks = ctx.get_chunks(body_id).unwrap(); assert_eq!(chunks.len(), 2); assert_eq!(chunks[0].chunk_index, 0); assert_eq!(chunks[0].data, b"Hello, "); assert_eq!(chunks[1].chunk_index, 1); assert_eq!(chunks[1].data, b"World!"); } #[test] fn test_get_chunks_ordered_by_index() { let pool = create_test_pool(); let manager = BlobManager::new(pool); let ctx = manager.connect(); let body_id = "rs_test123.request"; // Insert out of order ctx.insert_chunk(&BodyChunk::new(body_id, 2, b"C".to_vec())).unwrap(); ctx.insert_chunk(&BodyChunk::new(body_id, 0, b"A".to_vec())).unwrap(); ctx.insert_chunk(&BodyChunk::new(body_id, 1, b"B".to_vec())).unwrap(); let chunks = ctx.get_chunks(body_id).unwrap(); assert_eq!(chunks.len(), 3); assert_eq!(chunks[0].data, b"A"); assert_eq!(chunks[1].data, b"B"); assert_eq!(chunks[2].data, b"C"); } #[test] fn test_delete_chunks() { let pool = create_test_pool(); let manager = BlobManager::new(pool); let ctx = manager.connect(); let body_id = "rs_test123.request"; ctx.insert_chunk(&BodyChunk::new(body_id, 0, b"data".to_vec())).unwrap(); assert!(ctx.body_exists(body_id).unwrap()); ctx.delete_chunks(body_id).unwrap(); assert!(!ctx.body_exists(body_id).unwrap()); assert_eq!(ctx.get_chunks(body_id).unwrap().len(), 0); } #[test] fn test_delete_chunks_like() { let pool = create_test_pool(); let manager = BlobManager::new(pool); let ctx = manager.connect(); // Insert chunks for same response but different body types ctx.insert_chunk(&BodyChunk::new("rs_abc.request", 0, b"req".to_vec())).unwrap(); ctx.insert_chunk(&BodyChunk::new("rs_abc.response", 0, b"resp".to_vec())).unwrap(); ctx.insert_chunk(&BodyChunk::new("rs_other.request", 0, b"other".to_vec())).unwrap(); // Delete all bodies for rs_abc ctx.delete_chunks_like("rs_abc.%").unwrap(); // rs_abc bodies should be gone assert!(!ctx.body_exists("rs_abc.request").unwrap()); assert!(!ctx.body_exists("rs_abc.response").unwrap()); // rs_other should still exist assert!(ctx.body_exists("rs_other.request").unwrap()); } #[test] fn test_get_body_size() { let pool = create_test_pool(); let manager = BlobManager::new(pool); let ctx = manager.connect(); let body_id = "rs_test123.request"; ctx.insert_chunk(&BodyChunk::new(body_id, 0, b"Hello".to_vec())).unwrap(); ctx.insert_chunk(&BodyChunk::new(body_id, 1, b"World".to_vec())).unwrap(); let size = ctx.get_body_size(body_id).unwrap(); assert_eq!(size, 10); // "Hello" + "World" = 10 bytes } #[test] fn test_get_body_size_empty() { let pool = create_test_pool(); let manager = BlobManager::new(pool); let ctx = manager.connect(); let size = ctx.get_body_size("nonexistent").unwrap(); assert_eq!(size, 0); } #[test] fn test_body_exists() { let pool = create_test_pool(); let manager = BlobManager::new(pool); let ctx = manager.connect(); assert!(!ctx.body_exists("rs_test.request").unwrap()); ctx.insert_chunk(&BodyChunk::new("rs_test.request", 0, b"data".to_vec())).unwrap(); assert!(ctx.body_exists("rs_test.request").unwrap()); } #[test] fn test_multiple_bodies_isolated() { let pool = create_test_pool(); let manager = BlobManager::new(pool); let ctx = manager.connect(); ctx.insert_chunk(&BodyChunk::new("body1", 0, b"data1".to_vec())).unwrap(); ctx.insert_chunk(&BodyChunk::new("body2", 0, b"data2".to_vec())).unwrap(); let chunks1 = ctx.get_chunks("body1").unwrap(); let chunks2 = ctx.get_chunks("body2").unwrap(); assert_eq!(chunks1.len(), 1); assert_eq!(chunks1[0].data, b"data1"); assert_eq!(chunks2.len(), 1); assert_eq!(chunks2[0].data, b"data2"); } #[test] fn test_large_chunk() { let pool = create_test_pool(); let manager = BlobManager::new(pool); let ctx = manager.connect(); // 1MB chunk let large_data: Vec = (0..1024 * 1024).map(|i| (i % 256) as u8).collect(); let body_id = "rs_large.request"; ctx.insert_chunk(&BodyChunk::new(body_id, 0, large_data.clone())).unwrap(); let chunks = ctx.get_chunks(body_id).unwrap(); assert_eq!(chunks.len(), 1); assert_eq!(chunks[0].data, large_data); assert_eq!(ctx.get_body_size(body_id).unwrap(), 1024 * 1024); } } ================================================ FILE: crates/yaak-models/src/connection_or_tx.rs ================================================ use r2d2::PooledConnection; use r2d2_sqlite::SqliteConnectionManager; use rusqlite::{Connection, Statement, ToSql, Transaction}; pub enum ConnectionOrTx<'a> { Connection(PooledConnection), Transaction(&'a Transaction<'a>), } impl<'a> ConnectionOrTx<'a> { pub(crate) fn resolve(&self) -> &Connection { match self { ConnectionOrTx::Connection(c) => c, ConnectionOrTx::Transaction(c) => c, } } pub(crate) fn prepare(&self, sql: &str) -> rusqlite::Result> { self.resolve().prepare(sql) } pub(crate) fn execute(&self, sql: &str, params: &[&dyn ToSql]) -> rusqlite::Result { self.resolve().execute(sql, params) } } ================================================ FILE: crates/yaak-models/src/db_context.rs ================================================ use crate::connection_or_tx::ConnectionOrTx; use crate::error::Error::ModelNotFound; use crate::error::Result; use crate::models::{AnyModel, UpsertModelInfo}; use crate::util::{ModelChangeEvent, ModelPayload, UpdateSource}; use rusqlite::{OptionalExtension, params}; use sea_query::{ Asterisk, Expr, Func, IntoColumnRef, IntoIden, IntoTableRef, OnConflict, Query, SimpleExpr, SqliteQueryBuilder, }; use sea_query_rusqlite::RusqliteBinder; use std::fmt::Debug; use std::sync::mpsc; pub struct DbContext<'a> { pub(crate) _events_tx: mpsc::Sender, pub(crate) conn: ConnectionOrTx<'a>, } impl<'a> DbContext<'a> { pub(crate) fn find_one<'s, M>( &self, col: impl IntoColumnRef + IntoIden + Clone, value: impl Into + Debug, ) -> Result where M: Into + Clone + UpsertModelInfo, { let value_debug = format!("{:?}", value); let value_expr = value.into(); let (sql, params) = Query::select() .from(M::table_name()) .column(Asterisk) .cond_where(Expr::col(col.clone()).eq(value_expr)) .build_rusqlite(SqliteQueryBuilder); let mut stmt = self.conn.prepare(sql.as_str()).expect("Failed to prepare query"); match stmt.query_row(&*params.as_params(), M::from_row) { Ok(result) => Ok(result), Err(rusqlite::Error::QueryReturnedNoRows) => Err(ModelNotFound(format!( r#"table "{}" {} == {}"#, M::table_name().into_iden().to_string(), col.into_iden().to_string(), value_debug ))), Err(e) => Err(crate::error::Error::SqlError(e)), } } pub(crate) fn find_optional<'s, M>( &self, col: impl IntoColumnRef, value: impl Into, ) -> Option where M: Into + Clone + UpsertModelInfo, { let (sql, params) = Query::select() .from(M::table_name()) .column(Asterisk) .cond_where(Expr::col(col).eq(value)) .build_rusqlite(SqliteQueryBuilder); let mut stmt = self.conn.prepare(sql.as_str()).expect("Failed to prepare query"); stmt.query_row(&*params.as_params(), M::from_row) .optional() .expect("Failed to run find on DB") } pub(crate) fn find_all<'s, M>(&self) -> Result> where M: Into + Clone + UpsertModelInfo, { let (order_by_col, order_by_dir) = M::order_by(); let (sql, params) = Query::select() .from(M::table_name()) .column(Asterisk) .order_by(order_by_col, order_by_dir) .build_rusqlite(SqliteQueryBuilder); let mut stmt = self.conn.resolve().prepare(sql.as_str())?; let items = stmt.query_map(&*params.as_params(), M::from_row)?; Ok(items.map(|v| v.unwrap()).collect()) } pub(crate) fn find_many<'s, M>( &self, col: impl IntoColumnRef, value: impl Into, limit: Option, ) -> Result> where M: Into + Clone + UpsertModelInfo, { // TODO: Figure out how to do this conditional builder better let (order_by_col, order_by_dir) = M::order_by(); let (sql, params) = if let Some(limit) = limit { Query::select() .from(M::table_name()) .column(Asterisk) .cond_where(Expr::col(col).eq(value)) .limit(limit) .order_by(order_by_col, order_by_dir) .build_rusqlite(SqliteQueryBuilder) } else { Query::select() .from(M::table_name()) .column(Asterisk) .cond_where(Expr::col(col).eq(value)) .order_by(order_by_col, order_by_dir) .build_rusqlite(SqliteQueryBuilder) }; let mut stmt = self.conn.resolve().prepare(sql.as_str())?; let items = stmt.query_map(&*params.as_params(), M::from_row)?; Ok(items.map(|v| v.unwrap()).collect()) } pub(crate) fn upsert(&self, model: &M, source: &UpdateSource) -> Result where M: Into + From + UpsertModelInfo + Clone, { self.upsert_one( M::table_name(), M::id_column(), model.get_id().as_str(), model.clone().insert_values(source)?, M::update_columns(), source, ) } fn upsert_one( &self, table: impl IntoTableRef, id_col: impl IntoIden + Eq + Clone, id_val: &str, other_values: Vec<(impl IntoIden + Eq, impl Into)>, update_columns: Vec, source: &UpdateSource, ) -> Result where M: Into + From + UpsertModelInfo + Clone, { let id_iden = id_col.into_iden(); let mut column_vec = vec![id_iden.clone()]; let mut value_vec = vec![if id_val == "" { M::generate_id().into() } else { id_val.into() }]; for (col, val) in other_values { value_vec.push(val.into()); column_vec.push(col.into_iden()); } let on_conflict = OnConflict::column(id_iden).update_columns(update_columns).to_owned(); let (sql, params) = Query::insert() .into_table(table) .columns(column_vec) .values_panic(value_vec) .on_conflict(on_conflict) .returning(Query::returning().exprs(vec![ Expr::col(Asterisk), Expr::expr(Func::cust("last_insert_rowid")), Expr::col("rowid"), ])) .build_rusqlite(SqliteQueryBuilder); let mut stmt = self.conn.resolve().prepare(sql.as_str())?; let (m, created): (M, bool) = stmt.query_row(&*params.as_params(), |row| { M::from_row(row).and_then(|m| { let rowid: i64 = row.get("rowid")?; let last_rowid: i64 = row.get("last_insert_rowid()")?; Ok((m, rowid == last_rowid)) }) })?; let payload = ModelPayload { model: m.clone().into(), update_source: source.clone(), change: ModelChangeEvent::Upsert { created }, }; self.record_model_change(&payload)?; let _ = self._events_tx.send(payload); Ok(m) } pub(crate) fn delete<'s, M>(&self, m: &M, source: &UpdateSource) -> Result where M: Into + Clone + UpsertModelInfo, { let (sql, params) = Query::delete() .from_table(M::table_name()) .cond_where(Expr::col(M::id_column().into_iden()).eq(m.get_id())) .build_rusqlite(SqliteQueryBuilder); self.conn.execute(sql.as_str(), &*params.as_params())?; let payload = ModelPayload { model: m.clone().into(), update_source: source.clone(), change: ModelChangeEvent::Delete, }; self.record_model_change(&payload)?; let _ = self._events_tx.send(payload); Ok(m.clone()) } fn record_model_change(&self, payload: &ModelPayload) -> Result<()> { let payload_json = serde_json::to_string(payload)?; let source_json = serde_json::to_string(&payload.update_source)?; let change_json = serde_json::to_string(&payload.change)?; self.conn.resolve().execute( r#" INSERT INTO model_changes (model, model_id, change, update_source, payload) VALUES (?1, ?2, ?3, ?4, ?5) "#, params![ payload.model.model(), payload.model.id(), change_json, source_json, payload_json, ], )?; Ok(()) } } ================================================ FILE: crates/yaak-models/src/error.rs ================================================ use serde::{Serialize, Serializer}; use thiserror::Error; #[derive(Error, Debug)] pub enum Error { #[error("SQL error: {0}")] SqlError(#[from] rusqlite::Error), #[error("SQL Pool error: {0}")] SqlPoolError(#[from] r2d2::Error), #[error("Database error: {0}")] Database(String), #[error("IO error: {0}")] Io(#[from] std::io::Error), #[error("JSON error: {0}")] JsonError(#[from] serde_json::Error), #[error("Model not found: {0}")] ModelNotFound(String), #[error("Model serialization error: {0}")] ModelSerializationError(String), #[error("HTTP error: {0}")] GenericError(String), #[error("DB Migration Failed: {0}")] MigrationError(String), #[error("No base environment for {0}")] MissingBaseEnvironment(String), #[error("Multiple base environments for {0}. Delete duplicates before continuing.")] MultipleBaseEnvironments(String), #[error("unknown error")] Unknown, } impl Serialize for Error { fn serialize(&self, serializer: S) -> std::result::Result where S: Serializer, { serializer.serialize_str(self.to_string().as_ref()) } } pub type Result = std::result::Result; ================================================ FILE: crates/yaak-models/src/lib.rs ================================================ use crate::blob_manager::{BlobManager, migrate_blob_db}; use crate::error::{Error, Result}; use crate::migrate::migrate_db; use crate::query_manager::QueryManager; use crate::util::ModelPayload; use log::info; use r2d2::Pool; use r2d2_sqlite::SqliteConnectionManager; use std::fs::create_dir_all; use std::path::Path; use std::sync::mpsc; use std::time::Duration; pub mod blob_manager; mod connection_or_tx; pub mod db_context; pub mod error; pub mod migrate; pub mod models; pub mod queries; pub mod query_manager; pub mod render; pub mod util; /// Initialize the database managers for standalone (non-Tauri) usage. /// /// Returns a tuple of (QueryManager, BlobManager, event_receiver). /// The event_receiver can be used to listen for model change events. pub fn init_standalone( db_path: impl AsRef, blob_path: impl AsRef, ) -> Result<(QueryManager, BlobManager, mpsc::Receiver)> { let db_path = db_path.as_ref(); let blob_path = blob_path.as_ref(); // Create parent directories if needed if let Some(parent) = db_path.parent() { create_dir_all(parent)?; } if let Some(parent) = blob_path.parent() { create_dir_all(parent)?; } // Main database pool info!("Initializing app database {db_path:?}"); let manager = SqliteConnectionManager::file(db_path); let pool = Pool::builder() .max_size(100) .connection_timeout(Duration::from_secs(10)) .build(manager) .map_err(|e| Error::Database(e.to_string()))?; migrate_db(&pool)?; info!("Initializing blobs database {blob_path:?}"); // Blob database pool let blob_manager = SqliteConnectionManager::file(blob_path); let blob_pool = Pool::builder() .max_size(50) .connection_timeout(Duration::from_secs(10)) .build(blob_manager) .map_err(|e| Error::Database(e.to_string()))?; migrate_blob_db(&blob_pool)?; let (tx, rx) = mpsc::channel(); let query_manager = QueryManager::new(pool, tx); let blob_manager = BlobManager::new(blob_pool); Ok((query_manager, blob_manager, rx)) } /// Initialize the database managers with in-memory SQLite databases. /// Useful for testing and CI environments. pub fn init_in_memory() -> Result<(QueryManager, BlobManager, mpsc::Receiver)> { // Main database pool let manager = SqliteConnectionManager::memory(); let pool = Pool::builder() .max_size(1) // In-memory DB doesn't support multiple connections .build(manager) .map_err(|e| Error::Database(e.to_string()))?; migrate_db(&pool)?; // Blob database pool let blob_manager = SqliteConnectionManager::memory(); let blob_pool = Pool::builder() .max_size(1) .build(blob_manager) .map_err(|e| Error::Database(e.to_string()))?; migrate_blob_db(&blob_pool)?; let (tx, rx) = mpsc::channel(); let query_manager = QueryManager::new(pool, tx); let blob_manager = BlobManager::new(blob_pool); Ok((query_manager, blob_manager, rx)) } ================================================ FILE: crates/yaak-models/src/migrate.rs ================================================ use crate::error::Error::MigrationError; use crate::error::Result; use include_dir::{Dir, DirEntry, include_dir}; use log::{debug, info}; use r2d2::Pool; use r2d2_sqlite::SqliteConnectionManager; use rusqlite::{OptionalExtension, TransactionBehavior, params}; use sha2::{Digest, Sha384}; static MIGRATIONS_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/migrations"); pub fn migrate_db(pool: &Pool) -> Result<()> { info!("Running database migrations"); // Ensure the table exists // NOTE: Yaak used to use sqlx for migrations, so we need to mirror that table structure. We // are writing checksum but not verifying because we want to be able to change migrations after // a release in case something breaks. pool.get()?.execute( "CREATE TABLE IF NOT EXISTS _sqlx_migrations ( version BIGINT PRIMARY KEY, description TEXT NOT NULL, installed_on TIMESTAMP default CURRENT_TIMESTAMP NOT NULL, success BOOLEAN NOT NULL, checksum BLOB NOT NULL, execution_time BIGINT NOT NULL )", [], )?; // Read and sort all .sql files let mut entries = MIGRATIONS_DIR .entries() .into_iter() .filter(|e| e.path().extension().map(|ext| ext == "sql").unwrap_or(false)) .collect::>(); // Ensure they're in the correct order entries.sort_by_key(|e| e.path()); // Run each migration in a transaction let mut num_migrations = 0; let mut ran_migrations = 0; for entry in entries { num_migrations += 1; let mut conn = pool.get()?; let mut tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; match run_migration(entry, &mut tx) { Ok(ran) => { if ran { ran_migrations += 1; } tx.commit()? } Err(e) => { let msg = format!( "{} failed with {}", entry.path().file_name().unwrap().to_str().unwrap(), e.to_string() ); tx.rollback()?; return Err(MigrationError(msg)); } }; } if ran_migrations == 0 { info!("No migrations to run out of {}", num_migrations); } else { info!("Ran {}/{} migrations", ran_migrations, num_migrations); } Ok(()) } fn run_migration(migration_path: &DirEntry, tx: &mut rusqlite::Transaction) -> Result { let start = std::time::Instant::now(); let (version, description) = split_migration_filename(migration_path.path().to_str().unwrap()) .expect("Failed to parse migration filename"); // Skip if already applied let row: Option = tx .query_row("SELECT 1 FROM _sqlx_migrations WHERE version = ?", [version.clone()], |r| { r.get(0) }) .optional()?; if row.is_some() { debug!("Skipping already run migration {description}"); return Ok(false); // Migration was already run } let sql = migration_path.as_file().unwrap().contents_utf8().expect("Failed to read migration file"); info!("Applying migration {description}"); // Split on `;`? → optional depending on how your SQL is structured tx.execute_batch(&sql)?; let execution_time = start.elapsed().as_nanos() as i64; let checksum = sha384_hex_prefixed(sql.as_bytes()); // NOTE: The success column is never used. It's just there for sqlx compatibility. tx.execute( "INSERT INTO _sqlx_migrations (version, description, execution_time, checksum, success) VALUES (?, ?, ?, ?, ?)", params![version, description, execution_time, checksum, true], )?; Ok(true) } fn split_migration_filename(filename: &str) -> Option<(String, String)> { // Remove the .sql extension let trimmed = filename.strip_suffix(".sql")?; // Split on the first underscore let mut parts = trimmed.splitn(2, '_'); let version = parts.next()?.to_string(); let description = parts.next()?.to_string(); Some((version, description)) } fn sha384_hex_prefixed(input: &[u8]) -> String { let mut hasher = Sha384::new(); hasher.update(input); let result = hasher.finalize(); // Format as 0x... with uppercase hex format!("0x{}", hex::encode_upper(result)) } ================================================ FILE: crates/yaak-models/src/models.rs ================================================ use crate::error::Result; use crate::models::HttpRequestIden::{ Authentication, AuthenticationType, Body, BodyType, CreatedAt, Description, FolderId, Headers, Method, Name, SortPriority, UpdatedAt, Url, UrlParameters, WorkspaceId, }; use crate::util::{UpdateSource, generate_prefixed_id}; use chrono::{NaiveDateTime, Utc}; use rusqlite::Row; use schemars::JsonSchema; use sea_query::Order::Desc; use sea_query::{IntoColumnRef, IntoIden, IntoTableRef, Order, SimpleExpr, enum_def}; use serde::{Deserialize, Deserializer, Serialize}; use serde_json::Value; use std::collections::BTreeMap; use std::collections::HashMap; use std::fmt::{Debug, Display}; use std::str::FromStr; use ts_rs::TS; #[macro_export] macro_rules! impl_model { ($t:ty, $variant:ident) => { impl $crate::Model for $t { fn into_any(self) -> $crate::AnyModel { $crate::AnyModel::$variant(self) } } }; } #[derive(Debug, Clone, Serialize, Deserialize, TS)] #[serde(rename_all = "camelCase", tag = "type")] #[ts(export, export_to = "gen_models.ts")] pub enum ProxySetting { Enabled { http: String, https: String, auth: Option, // These were added later, so give them defaults #[serde(default)] bypass: String, #[serde(default)] disabled: bool, }, Disabled, } #[derive(Debug, Clone, Serialize, Deserialize, TS)] #[serde(rename_all = "camelCase")] #[ts(export, export_to = "gen_models.ts")] pub struct ProxySettingAuth { pub user: String, pub password: String, } #[derive(Debug, Clone, Serialize, Deserialize, TS)] #[serde(rename_all = "camelCase")] #[ts(export, export_to = "gen_models.ts")] pub struct ClientCertificate { pub host: String, #[serde(default)] pub port: Option, #[serde(default)] pub crt_file: Option, #[serde(default)] pub key_file: Option, #[serde(default)] pub pfx_file: Option, #[serde(default)] pub passphrase: Option, #[serde(default = "default_true")] #[ts(optional, as = "Option")] pub enabled: bool, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export, export_to = "gen_models.ts")] pub struct DnsOverride { pub hostname: String, #[serde(default)] pub ipv4: Vec, #[serde(default)] pub ipv6: Vec, #[serde(default = "default_true")] #[ts(optional, as = "Option")] pub enabled: bool, } #[derive(Debug, Clone, Serialize, Deserialize, TS)] #[serde(rename_all = "snake_case")] #[ts(export, export_to = "gen_models.ts")] pub enum EditorKeymap { Default, Vim, Vscode, Emacs, } impl FromStr for EditorKeymap { type Err = crate::error::Error; fn from_str(s: &str) -> Result { match s { "default" => Ok(Self::Default), "vscode" => Ok(Self::Vscode), "vim" => Ok(Self::Vim), "emacs" => Ok(Self::Emacs), _ => Ok(Self::default()), } } } impl Display for EditorKeymap { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let str = match self { EditorKeymap::Default => "default".to_string(), EditorKeymap::Vscode => "vscode".to_string(), EditorKeymap::Vim => "vim".to_string(), EditorKeymap::Emacs => "emacs".to_string(), }; write!(f, "{}", str) } } impl Default for EditorKeymap { fn default() -> Self { Self::Default } } #[derive(Debug, Clone, Serialize, Deserialize, Default, TS)] #[serde(default, rename_all = "camelCase")] #[ts(export, export_to = "gen_models.ts")] #[enum_def(table_name = "settings")] pub struct Settings { #[ts(type = "\"settings\"")] pub model: String, pub id: String, pub created_at: NaiveDateTime, pub updated_at: NaiveDateTime, pub appearance: String, pub client_certificates: Vec, pub colored_methods: bool, pub editor_font: Option, pub editor_font_size: i32, pub editor_keymap: EditorKeymap, pub editor_soft_wrap: bool, pub hide_window_controls: bool, // When true (primarily on Windows/Linux), use the native OS window title bar and controls pub use_native_titlebar: bool, pub interface_font: Option, pub interface_font_size: i32, pub interface_scale: f32, pub open_workspace_new_window: Option, pub proxy: Option, pub theme_dark: String, pub theme_light: String, pub update_channel: String, pub hide_license_badge: bool, pub autoupdate: bool, pub auto_download_updates: bool, pub check_notifications: bool, pub hotkeys: HashMap>, } impl UpsertModelInfo for Settings { fn table_name() -> impl IntoTableRef + IntoIden { SettingsIden::Table } fn id_column() -> impl IntoIden + Eq + Clone { SettingsIden::Id } fn generate_id() -> String { panic!("Settings does not have unique IDs") } fn order_by() -> (impl IntoColumnRef, Order) { (SettingsIden::CreatedAt, Desc) } fn get_id(&self) -> String { self.id.clone() } fn insert_values( self, source: &UpdateSource, ) -> Result)>> { use SettingsIden::*; let proxy = match self.proxy { None => None, Some(p) => Some(serde_json::to_string(&p)?), }; let client_certificates = serde_json::to_string(&self.client_certificates)?; let hotkeys = serde_json::to_string(&self.hotkeys)?; Ok(vec![ (CreatedAt, upsert_date(source, self.created_at)), (UpdatedAt, upsert_date(source, self.updated_at)), (Appearance, self.appearance.as_str().into()), (ClientCertificates, client_certificates.into()), (EditorFontSize, self.editor_font_size.into()), (EditorKeymap, self.editor_keymap.to_string().into()), (EditorSoftWrap, self.editor_soft_wrap.into()), (EditorFont, self.editor_font.into()), (InterfaceFont, self.interface_font.into()), (InterfaceFontSize, self.interface_font_size.into()), (InterfaceScale, self.interface_scale.into()), (HideWindowControls, self.hide_window_controls.into()), (UseNativeTitlebar, self.use_native_titlebar.into()), (OpenWorkspaceNewWindow, self.open_workspace_new_window.into()), (ThemeDark, self.theme_dark.as_str().into()), (ThemeLight, self.theme_light.as_str().into()), (UpdateChannel, self.update_channel.into()), (HideLicenseBadge, self.hide_license_badge.into()), (Autoupdate, self.autoupdate.into()), (AutoDownloadUpdates, self.auto_download_updates.into()), (ColoredMethods, self.colored_methods.into()), (CheckNotifications, self.check_notifications.into()), (Proxy, proxy.into()), (Hotkeys, hotkeys.into()), ]) } fn update_columns() -> Vec { vec![ SettingsIden::UpdatedAt, SettingsIden::Appearance, SettingsIden::ClientCertificates, SettingsIden::EditorFontSize, SettingsIden::EditorKeymap, SettingsIden::EditorSoftWrap, SettingsIden::EditorFont, SettingsIden::InterfaceFontSize, SettingsIden::InterfaceScale, SettingsIden::InterfaceFont, SettingsIden::HideWindowControls, SettingsIden::UseNativeTitlebar, SettingsIden::OpenWorkspaceNewWindow, SettingsIden::Proxy, SettingsIden::ThemeDark, SettingsIden::ThemeLight, SettingsIden::UpdateChannel, SettingsIden::HideLicenseBadge, SettingsIden::Autoupdate, SettingsIden::AutoDownloadUpdates, SettingsIden::ColoredMethods, SettingsIden::CheckNotifications, SettingsIden::Hotkeys, ] } fn from_row(row: &Row) -> rusqlite::Result where Self: Sized, { let proxy: Option = row.get("proxy")?; let client_certificates: String = row.get("client_certificates")?; let editor_keymap: String = row.get("editor_keymap")?; let hotkeys: String = row.get("hotkeys")?; Ok(Self { id: row.get("id")?, model: row.get("model")?, created_at: row.get("created_at")?, updated_at: row.get("updated_at")?, appearance: row.get("appearance")?, client_certificates: serde_json::from_str(&client_certificates).unwrap_or_default(), editor_font_size: row.get("editor_font_size")?, editor_font: row.get("editor_font")?, editor_keymap: EditorKeymap::from_str(editor_keymap.as_str()).unwrap(), editor_soft_wrap: row.get("editor_soft_wrap")?, interface_font_size: row.get("interface_font_size")?, interface_scale: row.get("interface_scale")?, interface_font: row.get("interface_font")?, use_native_titlebar: row.get("use_native_titlebar")?, open_workspace_new_window: row.get("open_workspace_new_window")?, proxy: proxy.map(|p| -> ProxySetting { serde_json::from_str(p.as_str()).unwrap() }), theme_dark: row.get("theme_dark")?, theme_light: row.get("theme_light")?, hide_window_controls: row.get("hide_window_controls")?, update_channel: row.get("update_channel")?, autoupdate: row.get("autoupdate")?, auto_download_updates: row.get("auto_download_updates")?, hide_license_badge: row.get("hide_license_badge")?, colored_methods: row.get("colored_methods")?, check_notifications: row.get("check_notifications")?, hotkeys: serde_json::from_str(&hotkeys).unwrap_or_default(), }) } } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, JsonSchema, TS)] #[serde(default, rename_all = "camelCase")] #[ts(export, export_to = "gen_models.ts")] #[enum_def(table_name = "workspaces")] pub struct Workspace { #[ts(type = "\"workspace\"")] pub model: String, pub id: String, pub created_at: NaiveDateTime, pub updated_at: NaiveDateTime, #[ts(type = "Record")] pub authentication: BTreeMap, pub authentication_type: Option, pub description: String, pub headers: Vec, pub name: String, pub encryption_key_challenge: Option, // Settings #[serde(default = "default_true")] pub setting_validate_certificates: bool, #[serde(default = "default_true")] pub setting_follow_redirects: bool, pub setting_request_timeout: i32, #[serde(default)] pub setting_dns_overrides: Vec, } impl UpsertModelInfo for Workspace { fn table_name() -> impl IntoTableRef + IntoIden { WorkspaceIden::Table } fn id_column() -> impl IntoIden + Eq + Clone { WorkspaceIden::Id } fn generate_id() -> String { generate_prefixed_id("wk") } fn order_by() -> (impl IntoColumnRef, Order) { (WorkspaceIden::CreatedAt, Desc) } fn get_id(&self) -> String { self.id.clone() } fn insert_values( self, source: &UpdateSource, ) -> Result)>> { use WorkspaceIden::*; Ok(vec![ (CreatedAt, upsert_date(source, self.created_at)), (UpdatedAt, upsert_date(source, self.updated_at)), (Name, self.name.trim().into()), (Authentication, serde_json::to_string(&self.authentication)?.into()), (AuthenticationType, self.authentication_type.into()), (Headers, serde_json::to_string(&self.headers)?.into()), (Description, self.description.into()), (EncryptionKeyChallenge, self.encryption_key_challenge.into()), (SettingFollowRedirects, self.setting_follow_redirects.into()), (SettingRequestTimeout, self.setting_request_timeout.into()), (SettingValidateCertificates, self.setting_validate_certificates.into()), (SettingDnsOverrides, serde_json::to_string(&self.setting_dns_overrides)?.into()), ]) } fn update_columns() -> Vec { vec![ WorkspaceIden::UpdatedAt, WorkspaceIden::Name, WorkspaceIden::Authentication, WorkspaceIden::AuthenticationType, WorkspaceIden::Headers, WorkspaceIden::Description, WorkspaceIden::EncryptionKeyChallenge, WorkspaceIden::SettingRequestTimeout, WorkspaceIden::SettingFollowRedirects, WorkspaceIden::SettingRequestTimeout, WorkspaceIden::SettingValidateCertificates, WorkspaceIden::SettingDnsOverrides, ] } fn from_row(row: &Row) -> rusqlite::Result where Self: Sized, { let headers: String = row.get("headers")?; let authentication: String = row.get("authentication")?; let setting_dns_overrides: String = row.get("setting_dns_overrides")?; Ok(Self { id: row.get("id")?, model: row.get("model")?, created_at: row.get("created_at")?, updated_at: row.get("updated_at")?, name: row.get("name")?, description: row.get("description")?, encryption_key_challenge: row.get("encryption_key_challenge")?, headers: serde_json::from_str(&headers).unwrap_or_default(), authentication: serde_json::from_str(&authentication).unwrap_or_default(), authentication_type: row.get("authentication_type")?, setting_follow_redirects: row.get("setting_follow_redirects")?, setting_request_timeout: row.get("setting_request_timeout")?, setting_validate_certificates: row.get("setting_validate_certificates")?, setting_dns_overrides: serde_json::from_str(&setting_dns_overrides).unwrap_or_default(), }) } } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, TS)] #[serde(default, rename_all = "camelCase")] #[ts(export, export_to = "gen_models.ts")] pub struct EncryptedKey { pub encrypted_key: String, } #[derive(Debug, Clone, Serialize, Deserialize, Default, TS)] #[serde(default, rename_all = "camelCase")] #[ts(export, export_to = "gen_models.ts")] #[enum_def(table_name = "workspace_metas")] pub struct WorkspaceMeta { #[ts(type = "\"workspace_meta\"")] pub model: String, pub id: String, pub workspace_id: String, pub created_at: NaiveDateTime, pub updated_at: NaiveDateTime, pub encryption_key: Option, pub setting_sync_dir: Option, } impl UpsertModelInfo for WorkspaceMeta { fn table_name() -> impl IntoTableRef + IntoIden { WorkspaceMetaIden::Table } fn id_column() -> impl IntoIden + Eq + Clone { WorkspaceMetaIden::Id } fn generate_id() -> String { generate_prefixed_id("wm") } fn order_by() -> (impl IntoColumnRef, Order) { (WorkspaceMetaIden::CreatedAt, Desc) } fn get_id(&self) -> String { self.id.clone() } fn insert_values( self, source: &UpdateSource, ) -> Result)>> { use WorkspaceMetaIden::*; Ok(vec![ (CreatedAt, upsert_date(source, self.created_at)), (UpdatedAt, upsert_date(source, self.updated_at)), (WorkspaceId, self.workspace_id.into()), (EncryptionKey, self.encryption_key.map(|e| serde_json::to_string(&e).unwrap()).into()), (SettingSyncDir, self.setting_sync_dir.into()), ]) } fn update_columns() -> Vec { vec![ WorkspaceMetaIden::UpdatedAt, WorkspaceMetaIden::EncryptionKey, WorkspaceMetaIden::SettingSyncDir, ] } fn from_row(row: &Row) -> rusqlite::Result where Self: Sized, { let encryption_key: Option = row.get("encryption_key")?; Ok(Self { id: row.get("id")?, workspace_id: row.get("workspace_id")?, model: row.get("model")?, created_at: row.get("created_at")?, updated_at: row.get("updated_at")?, encryption_key: encryption_key.map(|e| serde_json::from_str(&e).unwrap()), setting_sync_dir: row.get("setting_sync_dir")?, }) } } #[derive(Debug, Clone, Serialize, Deserialize, TS)] #[ts(export, export_to = "gen_models.ts")] pub enum CookieDomain { HostOnly(String), Suffix(String), NotPresent, Empty, } #[derive(Debug, Clone, Serialize, Deserialize, TS)] #[ts(export, export_to = "gen_models.ts")] pub enum CookieExpires { AtUtc(String), SessionEnd, } #[derive(Debug, Clone, Serialize, Deserialize, TS)] #[ts(export, export_to = "gen_models.ts")] pub struct Cookie { pub raw_cookie: String, pub domain: CookieDomain, pub expires: CookieExpires, pub path: (String, bool), } #[derive(Debug, Clone, Serialize, Deserialize, Default, TS)] #[serde(default, rename_all = "camelCase")] #[ts(export, export_to = "gen_models.ts")] #[enum_def(table_name = "cookie_jars")] pub struct CookieJar { #[ts(type = "\"cookie_jar\"")] pub model: String, pub id: String, pub created_at: NaiveDateTime, pub updated_at: NaiveDateTime, pub workspace_id: String, pub cookies: Vec, pub name: String, } impl UpsertModelInfo for CookieJar { fn table_name() -> impl IntoTableRef + IntoIden { CookieJarIden::Table } fn id_column() -> impl IntoIden + Eq + Clone { CookieJarIden::Id } fn generate_id() -> String { generate_prefixed_id("cj") } fn order_by() -> (impl IntoColumnRef, Order) { (CookieJarIden::CreatedAt, Desc) } fn get_id(&self) -> String { self.id.clone() } fn insert_values( self, source: &UpdateSource, ) -> Result)>> { use CookieJarIden::*; Ok(vec![ (CreatedAt, upsert_date(source, self.created_at)), (UpdatedAt, upsert_date(source, self.updated_at)), (WorkspaceId, self.workspace_id.into()), (Name, self.name.trim().into()), (Cookies, serde_json::to_string(&self.cookies)?.into()), ]) } fn update_columns() -> Vec { vec![ CookieJarIden::UpdatedAt, CookieJarIden::Name, CookieJarIden::Cookies, ] } fn from_row(row: &Row) -> rusqlite::Result where Self: Sized, { let cookies: String = row.get("cookies")?; Ok(Self { id: row.get("id")?, model: row.get("model")?, workspace_id: row.get("workspace_id")?, created_at: row.get("created_at")?, updated_at: row.get("updated_at")?, name: row.get("name")?, cookies: serde_json::from_str(cookies.as_str()).unwrap_or_default(), }) } } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, JsonSchema, TS)] #[serde(default, rename_all = "camelCase")] #[ts(export, export_to = "gen_models.ts")] #[enum_def(table_name = "environments")] pub struct Environment { #[ts(type = "\"environment\"")] pub model: String, pub id: String, pub workspace_id: String, pub created_at: NaiveDateTime, pub updated_at: NaiveDateTime, pub name: String, pub public: bool, #[deprecated( note = "parent_model is used instead. This field will be removed when schema field is added for sync/export." )] #[ts(skip)] pub base: bool, pub parent_model: String, pub parent_id: Option, /// Variables defined in this environment scope. /// Child environments override parent variables by name. pub variables: Vec, pub color: Option, pub sort_priority: f64, } impl UpsertModelInfo for Environment { fn table_name() -> impl IntoTableRef + IntoIden { EnvironmentIden::Table } fn id_column() -> impl IntoIden + Eq + Clone { EnvironmentIden::Id } fn generate_id() -> String { generate_prefixed_id("ev") } fn order_by() -> (impl IntoColumnRef, Order) { (EnvironmentIden::CreatedAt, Desc) } fn get_id(&self) -> String { self.id.clone() } fn insert_values( self, source: &UpdateSource, ) -> Result)>> { use EnvironmentIden::*; Ok(vec![ (CreatedAt, upsert_date(source, self.created_at)), (UpdatedAt, upsert_date(source, self.updated_at)), (WorkspaceId, self.workspace_id.into()), (ParentId, self.parent_id.into()), (ParentModel, self.parent_model.into()), (Color, self.color.into()), (Name, self.name.trim().into()), (Public, self.public.into()), (SortPriority, self.sort_priority.into()), (Variables, serde_json::to_string(&self.variables)?.into()), ]) } fn update_columns() -> Vec { vec![ EnvironmentIden::UpdatedAt, EnvironmentIden::ParentId, EnvironmentIden::ParentModel, EnvironmentIden::Color, EnvironmentIden::Name, EnvironmentIden::Public, EnvironmentIden::Variables, EnvironmentIden::SortPriority, ] } fn from_row(row: &Row) -> rusqlite::Result where Self: Sized, { let variables: String = row.get("variables")?; let parent_model = row.get("parent_model")?; let base = parent_model == "workspace"; Ok(Self { id: row.get("id")?, model: row.get("model")?, workspace_id: row.get("workspace_id")?, created_at: row.get("created_at")?, updated_at: row.get("updated_at")?, parent_id: row.get("parent_id")?, parent_model, color: row.get("color")?, name: row.get("name")?, public: row.get("public")?, variables: serde_json::from_str(variables.as_str()).unwrap_or_default(), sort_priority: row.get("sort_priority")?, // Deprecated field, but we need to keep it around for a couple of versions // for compatibility because sync/export don't have a schema field #[allow(deprecated)] base, }) } } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, JsonSchema, TS)] #[serde(default, rename_all = "camelCase")] #[ts(export, export_to = "gen_models.ts")] pub struct EnvironmentVariable { #[serde(default = "default_true")] #[ts(optional, as = "Option")] pub enabled: bool, pub name: String, pub value: String, #[ts(optional, as = "Option")] pub id: Option, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, TS)] #[serde(default, rename_all = "camelCase")] #[ts(export, export_to = "gen_models.ts")] pub struct ParentAuthentication { #[ts(type = "Record")] pub authentication: BTreeMap, pub authentication_type: Option, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, TS)] #[serde(default, rename_all = "camelCase")] #[ts(export, export_to = "gen_models.ts")] pub struct ParentHeaders { pub headers: Vec, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, TS)] #[serde(default, rename_all = "camelCase")] #[ts(export, export_to = "gen_models.ts")] #[enum_def(table_name = "folders")] pub struct Folder { #[ts(type = "\"folder\"")] pub model: String, pub id: String, pub created_at: NaiveDateTime, pub updated_at: NaiveDateTime, pub workspace_id: String, pub folder_id: Option, #[ts(type = "Record")] pub authentication: BTreeMap, pub authentication_type: Option, pub description: String, pub headers: Vec, pub name: String, pub sort_priority: f64, } impl UpsertModelInfo for Folder { fn table_name() -> impl IntoTableRef + IntoIden { FolderIden::Table } fn id_column() -> impl IntoIden + Eq + Clone { FolderIden::Id } fn generate_id() -> String { generate_prefixed_id("fl") } fn order_by() -> (impl IntoColumnRef, Order) { (FolderIden::CreatedAt, Desc) } fn get_id(&self) -> String { self.id.clone() } fn insert_values( self, source: &UpdateSource, ) -> Result)>> { use FolderIden::*; Ok(vec![ (CreatedAt, upsert_date(source, self.created_at)), (UpdatedAt, upsert_date(source, self.updated_at)), (WorkspaceId, self.workspace_id.into()), (FolderId, self.folder_id.into()), (Authentication, serde_json::to_string(&self.authentication)?.into()), (AuthenticationType, self.authentication_type.into()), (Headers, serde_json::to_string(&self.headers)?.into()), (Description, self.description.into()), (Name, self.name.trim().into()), (SortPriority, self.sort_priority.into()), ]) } fn update_columns() -> Vec { vec![ FolderIden::UpdatedAt, FolderIden::Name, FolderIden::Authentication, FolderIden::AuthenticationType, FolderIden::Headers, FolderIden::Description, FolderIden::FolderId, FolderIden::SortPriority, ] } fn from_row(row: &Row) -> rusqlite::Result where Self: Sized, { let headers: String = row.get("headers")?; let authentication: String = row.get("authentication")?; Ok(Self { id: row.get("id")?, model: row.get("model")?, sort_priority: row.get("sort_priority")?, workspace_id: row.get("workspace_id")?, created_at: row.get("created_at")?, updated_at: row.get("updated_at")?, folder_id: row.get("folder_id")?, name: row.get("name")?, description: row.get("description")?, headers: serde_json::from_str(&headers).unwrap_or_default(), authentication_type: row.get("authentication_type")?, authentication: serde_json::from_str(&authentication).unwrap_or_default(), }) } } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, JsonSchema, TS)] #[serde(default, rename_all = "camelCase")] #[ts(export, export_to = "gen_models.ts")] pub struct HttpRequestHeader { #[serde(default = "default_true")] #[ts(optional, as = "Option")] pub enabled: bool, pub name: String, pub value: String, #[ts(optional, as = "Option")] pub id: Option, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, JsonSchema, TS)] #[serde(default, rename_all = "camelCase")] #[ts(export, export_to = "gen_models.ts")] pub struct HttpUrlParameter { #[serde(default = "default_true")] #[ts(optional, as = "Option")] pub enabled: bool, /// Colon-prefixed parameters are treated as path parameters if they match, like `/users/:id` /// Other entries are appended as query parameters pub name: String, pub value: String, #[ts(optional, as = "Option")] pub id: Option, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, JsonSchema, TS)] #[serde(default, rename_all = "camelCase")] #[ts(export, export_to = "gen_models.ts")] #[enum_def(table_name = "http_requests")] pub struct HttpRequest { #[ts(type = "\"http_request\"")] pub model: String, pub id: String, pub created_at: NaiveDateTime, pub updated_at: NaiveDateTime, pub workspace_id: String, pub folder_id: Option, #[ts(type = "Record")] pub authentication: BTreeMap, pub authentication_type: Option, #[ts(type = "Record")] pub body: BTreeMap, pub body_type: Option, pub description: String, pub headers: Vec, #[serde(default = "default_http_method")] pub method: String, pub name: String, pub sort_priority: f64, pub url: String, /// URL parameters used for both path placeholders (`:id`) and query string entries. pub url_parameters: Vec, } impl UpsertModelInfo for HttpRequest { fn table_name() -> impl IntoTableRef + IntoIden { HttpRequestIden::Table } fn id_column() -> impl IntoIden + Eq + Clone { HttpRequestIden::Id } fn generate_id() -> String { generate_prefixed_id("rq") } fn order_by() -> (impl IntoColumnRef, Order) { (HttpResponseIden::CreatedAt, Desc) } fn get_id(&self) -> String { self.id.to_string() } fn insert_values( self, source: &UpdateSource, ) -> Result)>> { Ok(vec![ (CreatedAt, upsert_date(source, self.created_at)), (UpdatedAt, upsert_date(source, self.updated_at)), (WorkspaceId, self.workspace_id.into()), (FolderId, self.folder_id.into()), (Name, self.name.trim().into()), (Description, self.description.into()), (Url, self.url.into()), (UrlParameters, serde_json::to_string(&self.url_parameters)?.into()), (Method, self.method.into()), (Body, serde_json::to_string(&self.body)?.into()), (BodyType, self.body_type.into()), (Authentication, serde_json::to_string(&self.authentication)?.into()), (AuthenticationType, self.authentication_type.into()), (Headers, serde_json::to_string(&self.headers)?.into()), (SortPriority, self.sort_priority.into()), ]) } fn update_columns() -> Vec { vec![ UpdatedAt, WorkspaceId, Name, Description, FolderId, Method, Headers, Body, BodyType, Authentication, AuthenticationType, Url, UrlParameters, SortPriority, ] } fn from_row(row: &Row) -> rusqlite::Result { let url_parameters: String = row.get("url_parameters")?; let body: String = row.get("body")?; let authentication: String = row.get("authentication")?; let headers: String = row.get("headers")?; Ok(Self { id: row.get("id")?, model: row.get("model")?, workspace_id: row.get("workspace_id")?, created_at: row.get("created_at")?, updated_at: row.get("updated_at")?, authentication: serde_json::from_str(authentication.as_str()).unwrap_or_default(), authentication_type: row.get("authentication_type")?, body: serde_json::from_str(body.as_str()).unwrap_or_default(), body_type: row.get("body_type")?, description: row.get("description")?, folder_id: row.get("folder_id")?, headers: serde_json::from_str(headers.as_str()).unwrap_or_default(), method: row.get("method")?, name: row.get("name")?, sort_priority: row.get("sort_priority")?, url: row.get("url")?, url_parameters: serde_json::from_str(url_parameters.as_str()).unwrap_or_default(), }) } } #[derive(Debug, Clone, Serialize, Deserialize, TS)] #[serde(rename_all = "snake_case")] #[ts(export, export_to = "gen_models.ts")] pub enum WebsocketConnectionState { Initialized, Connected, Closing, Closed, } impl Default for WebsocketConnectionState { fn default() -> Self { Self::Initialized } } #[derive(Debug, Clone, Serialize, Deserialize, Default, TS)] #[serde(default, rename_all = "camelCase")] #[ts(export, export_to = "gen_models.ts")] #[enum_def(table_name = "websocket_connections")] pub struct WebsocketConnection { #[ts(type = "\"websocket_connection\"")] pub model: String, pub id: String, pub created_at: NaiveDateTime, pub updated_at: NaiveDateTime, pub workspace_id: String, pub request_id: String, pub elapsed: i32, pub error: Option, pub headers: Vec, pub state: WebsocketConnectionState, pub status: i32, pub url: String, } impl UpsertModelInfo for WebsocketConnection { fn table_name() -> impl IntoTableRef + IntoIden { WebsocketConnectionIden::Table } fn id_column() -> impl IntoIden + Eq + Clone { WebsocketConnectionIden::Id } fn generate_id() -> String { generate_prefixed_id("wc") } fn order_by() -> (impl IntoColumnRef, Order) { (WebsocketConnectionIden::CreatedAt, Desc) } fn get_id(&self) -> String { self.id.clone() } fn insert_values( self, source: &UpdateSource, ) -> Result)>> { use WebsocketConnectionIden::*; Ok(vec![ (CreatedAt, upsert_date(source, self.created_at)), (UpdatedAt, upsert_date(source, self.updated_at)), (WorkspaceId, self.workspace_id.into()), (RequestId, self.request_id.into()), (Elapsed, self.elapsed.into()), (Error, self.error.into()), (Headers, serde_json::to_string(&self.headers)?.into()), (State, serde_json::to_value(&self.state)?.as_str().into()), (Status, self.status.into()), (Url, self.url.into()), ]) } fn update_columns() -> Vec { vec![ WebsocketConnectionIden::UpdatedAt, WebsocketConnectionIden::Elapsed, WebsocketConnectionIden::Error, WebsocketConnectionIden::Headers, WebsocketConnectionIden::State, WebsocketConnectionIden::Status, WebsocketConnectionIden::Url, ] } fn from_row(row: &Row) -> rusqlite::Result where Self: Sized, { let headers: String = row.get("headers")?; let state: String = row.get("state")?; Ok(Self { id: row.get("id")?, model: row.get("model")?, workspace_id: row.get("workspace_id")?, request_id: row.get("request_id")?, created_at: row.get("created_at")?, updated_at: row.get("updated_at")?, url: row.get("url")?, headers: serde_json::from_str(headers.as_str()).unwrap_or_default(), elapsed: row.get("elapsed")?, error: row.get("error")?, state: serde_json::from_str(format!(r#""{state}""#).as_str()).unwrap(), status: row.get("status")?, }) } } #[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq, TS)] #[serde(rename_all = "snake_case")] #[ts(export, export_to = "gen_models.ts")] pub enum WebsocketMessageType { Text, Binary, } impl Default for WebsocketMessageType { fn default() -> Self { Self::Text } } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, JsonSchema, TS)] #[serde(default, rename_all = "camelCase")] #[ts(export, export_to = "gen_models.ts")] #[enum_def(table_name = "websocket_requests")] pub struct WebsocketRequest { #[ts(type = "\"websocket_request\"")] pub model: String, pub id: String, pub created_at: NaiveDateTime, pub updated_at: NaiveDateTime, pub workspace_id: String, pub folder_id: Option, #[ts(type = "Record")] pub authentication: BTreeMap, pub authentication_type: Option, pub description: String, pub headers: Vec, pub message: String, pub name: String, pub sort_priority: f64, pub url: String, /// URL parameters used for both path placeholders (`:id`) and query string entries. pub url_parameters: Vec, } impl UpsertModelInfo for WebsocketRequest { fn table_name() -> impl IntoTableRef + IntoIden { WebsocketRequestIden::Table } fn id_column() -> impl IntoIden + Eq + Clone { WebsocketRequestIden::Id } fn generate_id() -> String { generate_prefixed_id("wr") } fn order_by() -> (impl IntoColumnRef, Order) { (WebsocketRequestIden::CreatedAt, Desc) } fn get_id(&self) -> String { self.id.clone() } fn insert_values( self, source: &UpdateSource, ) -> Result)>> { use WebsocketRequestIden::*; Ok(vec![ (CreatedAt, upsert_date(source, self.created_at)), (UpdatedAt, upsert_date(source, self.updated_at)), (WorkspaceId, self.workspace_id.into()), (FolderId, self.folder_id.as_ref().map(|s| s.as_str()).into()), (Authentication, serde_json::to_string(&self.authentication)?.into()), (AuthenticationType, self.authentication_type.into()), (Description, self.description.into()), (Headers, serde_json::to_string(&self.headers)?.into()), (Message, self.message.into()), (Name, self.name.trim().into()), (SortPriority, self.sort_priority.into()), (Url, self.url.into()), (UrlParameters, serde_json::to_string(&self.url_parameters)?.into()), ]) } fn update_columns() -> Vec { vec![ WebsocketRequestIden::UpdatedAt, WebsocketRequestIden::WorkspaceId, WebsocketRequestIden::FolderId, WebsocketRequestIden::Authentication, WebsocketRequestIden::AuthenticationType, WebsocketRequestIden::Description, WebsocketRequestIden::Headers, WebsocketRequestIden::Message, WebsocketRequestIden::Name, WebsocketRequestIden::SortPriority, WebsocketRequestIden::Url, WebsocketRequestIden::UrlParameters, ] } fn from_row(row: &Row) -> rusqlite::Result where Self: Sized, { let url_parameters: String = row.get("url_parameters")?; let authentication: String = row.get("authentication")?; let headers: String = row.get("headers")?; Ok(Self { id: row.get("id")?, model: row.get("model")?, sort_priority: row.get("sort_priority")?, workspace_id: row.get("workspace_id")?, created_at: row.get("created_at")?, updated_at: row.get("updated_at")?, url: row.get("url")?, url_parameters: serde_json::from_str(url_parameters.as_str()).unwrap_or_default(), message: row.get("message")?, description: row.get("description")?, authentication: serde_json::from_str(authentication.as_str()).unwrap_or_default(), authentication_type: row.get("authentication_type")?, headers: serde_json::from_str(headers.as_str()).unwrap_or_default(), folder_id: row.get("folder_id")?, name: row.get("name")?, }) } } #[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq, TS)] #[serde(rename_all = "snake_case")] #[ts(export, export_to = "gen_models.ts")] pub enum WebsocketEventType { Binary, Close, Frame, Open, Ping, Pong, Text, } impl Default for WebsocketEventType { fn default() -> Self { Self::Text } } #[derive(Debug, Clone, Serialize, Deserialize, Default, TS)] #[serde(default, rename_all = "camelCase")] #[ts(export, export_to = "gen_models.ts")] #[enum_def(table_name = "websocket_events")] pub struct WebsocketEvent { #[ts(type = "\"websocket_event\"")] pub model: String, pub id: String, pub created_at: NaiveDateTime, pub updated_at: NaiveDateTime, pub workspace_id: String, pub request_id: String, pub connection_id: String, pub is_server: bool, pub message: Vec, pub message_type: WebsocketEventType, } impl UpsertModelInfo for WebsocketEvent { fn table_name() -> impl IntoTableRef + IntoIden { WebsocketEventIden::Table } fn id_column() -> impl IntoIden + Eq + Clone { WebsocketEventIden::Id } fn generate_id() -> String { generate_prefixed_id("we") } fn order_by() -> (impl IntoColumnRef, Order) { (WebsocketEventIden::CreatedAt, Desc) } fn get_id(&self) -> String { self.id.clone() } fn insert_values( self, source: &UpdateSource, ) -> Result)>> { use WebsocketEventIden::*; Ok(vec![ (CreatedAt, upsert_date(source, self.created_at)), (UpdatedAt, upsert_date(source, self.updated_at)), (WorkspaceId, self.workspace_id.into()), (ConnectionId, self.connection_id.into()), (RequestId, self.request_id.into()), (MessageType, serde_json::to_string(&self.message_type)?.into()), (IsServer, self.is_server.into()), (Message, self.message.into()), ]) } fn update_columns() -> Vec { vec![ WebsocketEventIden::UpdatedAt, WebsocketEventIden::MessageType, WebsocketEventIden::IsServer, WebsocketEventIden::Message, ] } fn from_row(row: &Row) -> rusqlite::Result where Self: Sized, { let message_type: String = row.get("message_type")?; Ok(Self { id: row.get("id")?, model: row.get("model")?, workspace_id: row.get("workspace_id")?, request_id: row.get("request_id")?, connection_id: row.get("connection_id")?, created_at: row.get("created_at")?, updated_at: row.get("updated_at")?, message: row.get("message")?, is_server: row.get("is_server")?, message_type: serde_json::from_str(message_type.as_str()).unwrap_or_default(), }) } } #[derive(Debug, Clone, Serialize, Deserialize, Default, TS)] #[serde(default, rename_all = "camelCase")] #[ts(export, export_to = "gen_models.ts")] pub struct HttpResponseHeader { pub name: String, pub value: String, } #[derive(Debug, Clone, Serialize, Deserialize, TS)] #[serde(rename_all = "snake_case")] #[ts(export, export_to = "gen_models.ts")] pub enum HttpResponseState { Initialized, Connected, Closed, } impl Default for HttpResponseState { fn default() -> Self { Self::Initialized } } #[derive(Debug, Clone, Serialize, Deserialize, Default, TS)] #[serde(default, rename_all = "camelCase")] #[ts(export, export_to = "gen_models.ts")] #[enum_def(table_name = "http_responses")] pub struct HttpResponse { #[ts(type = "\"http_response\"")] pub model: String, pub id: String, pub created_at: NaiveDateTime, pub updated_at: NaiveDateTime, pub workspace_id: String, pub request_id: String, pub body_path: Option, pub content_length: Option, pub content_length_compressed: Option, pub elapsed: i32, pub elapsed_headers: i32, pub elapsed_dns: i32, pub error: Option, pub headers: Vec, pub remote_addr: Option, pub request_content_length: Option, pub request_headers: Vec, pub status: i32, pub status_reason: Option, pub state: HttpResponseState, pub url: String, pub version: Option, } impl UpsertModelInfo for HttpResponse { fn table_name() -> impl IntoTableRef + IntoIden { HttpResponseIden::Table } fn id_column() -> impl IntoIden + Eq + Clone { HttpResponseIden::Id } fn generate_id() -> String { generate_prefixed_id("rs") } fn order_by() -> (impl IntoColumnRef, Order) { (HttpResponseIden::CreatedAt, Desc) } fn get_id(&self) -> String { self.id.clone() } fn insert_values( self, source: &UpdateSource, ) -> Result)>> { use HttpResponseIden::*; Ok(vec![ (CreatedAt, upsert_date(source, self.created_at)), (UpdatedAt, upsert_date(source, self.updated_at)), (RequestId, self.request_id.into()), (WorkspaceId, self.workspace_id.into()), (BodyPath, self.body_path.into()), (ContentLength, self.content_length.into()), (ContentLengthCompressed, self.content_length_compressed.into()), (Elapsed, self.elapsed.into()), (ElapsedHeaders, self.elapsed_headers.into()), (ElapsedDns, self.elapsed_dns.into()), (Error, self.error.into()), (Headers, serde_json::to_string(&self.headers)?.into()), (RemoteAddr, self.remote_addr.into()), (RequestHeaders, serde_json::to_string(&self.request_headers)?.into()), (State, serde_json::to_value(self.state)?.as_str().into()), (Status, self.status.into()), (StatusReason, self.status_reason.into()), (Url, self.url.into()), (Version, self.version.into()), (RequestContentLength, self.request_content_length.into()), ]) } fn update_columns() -> Vec { vec![ HttpResponseIden::UpdatedAt, HttpResponseIden::BodyPath, HttpResponseIden::ContentLength, HttpResponseIden::ContentLengthCompressed, HttpResponseIden::Elapsed, HttpResponseIden::ElapsedHeaders, HttpResponseIden::ElapsedDns, HttpResponseIden::Error, HttpResponseIden::Headers, HttpResponseIden::RemoteAddr, HttpResponseIden::RequestContentLength, HttpResponseIden::RequestHeaders, HttpResponseIden::State, HttpResponseIden::Status, HttpResponseIden::StatusReason, HttpResponseIden::Url, HttpResponseIden::Version, ] } fn from_row(r: &Row) -> rusqlite::Result where Self: Sized, { let headers: String = r.get("headers")?; let state: String = r.get("state")?; Ok(Self { id: r.get("id")?, model: r.get("model")?, workspace_id: r.get("workspace_id")?, request_id: r.get("request_id")?, created_at: r.get("created_at")?, updated_at: r.get("updated_at")?, error: r.get("error")?, url: r.get("url")?, content_length: r.get("content_length")?, content_length_compressed: r.get("content_length_compressed").unwrap_or_default(), version: r.get("version")?, elapsed: r.get("elapsed")?, elapsed_headers: r.get("elapsed_headers")?, elapsed_dns: r.get("elapsed_dns").unwrap_or_default(), remote_addr: r.get("remote_addr")?, status: r.get("status")?, status_reason: r.get("status_reason")?, state: serde_json::from_str(format!(r#""{state}""#).as_str()).unwrap(), body_path: r.get("body_path")?, headers: serde_json::from_str(headers.as_str()).unwrap_or_default(), request_content_length: r.get("request_content_length").unwrap_or_default(), request_headers: serde_json::from_str( r.get::<_, String>("request_headers").unwrap_or_default().as_str(), ) .unwrap_or_default(), }) } } /// Serializable representation of HTTP response events for DB storage. /// This mirrors `yaak_http::sender::HttpResponseEvent` but with serde support. /// The `From` impl is in yaak-http to avoid circular dependencies. #[derive(Debug, Clone, Serialize, Deserialize, TS)] #[serde(tag = "type", rename_all = "snake_case")] #[ts(export, export_to = "gen_models.ts")] pub enum HttpResponseEventData { Setting { name: String, value: String, }, Info { message: String, }, Redirect { url: String, status: u16, behavior: String, #[serde(default)] dropped_body: bool, #[serde(default)] dropped_headers: Vec, }, SendUrl { method: String, #[serde(default)] scheme: String, #[serde(default)] username: String, #[serde(default)] password: String, #[serde(default)] host: String, #[serde(default)] port: u16, path: String, #[serde(default)] query: String, #[serde(default)] fragment: String, }, ReceiveUrl { version: String, status: String, }, HeaderUp { name: String, value: String, }, HeaderDown { name: String, value: String, }, ChunkSent { bytes: usize, }, ChunkReceived { bytes: usize, }, DnsResolved { hostname: String, addresses: Vec, duration: u64, overridden: bool, }, } impl Default for HttpResponseEventData { fn default() -> Self { Self::Info { message: String::new() } } } #[derive(Debug, Clone, Serialize, Deserialize, Default, TS)] #[serde(default, rename_all = "camelCase")] #[ts(export, export_to = "gen_models.ts")] #[enum_def(table_name = "http_response_events")] pub struct HttpResponseEvent { #[ts(type = "\"http_response_event\"")] pub model: String, pub id: String, pub created_at: NaiveDateTime, pub updated_at: NaiveDateTime, pub workspace_id: String, pub response_id: String, pub event: HttpResponseEventData, } impl UpsertModelInfo for HttpResponseEvent { fn table_name() -> impl IntoTableRef + IntoIden { HttpResponseEventIden::Table } fn id_column() -> impl IntoIden + Eq + Clone { HttpResponseEventIden::Id } fn generate_id() -> String { generate_prefixed_id("re") } fn order_by() -> (impl IntoColumnRef, Order) { (HttpResponseEventIden::CreatedAt, Order::Asc) } fn get_id(&self) -> String { self.id.clone() } fn insert_values( self, source: &UpdateSource, ) -> Result)>> { use HttpResponseEventIden::*; Ok(vec![ (CreatedAt, upsert_date(source, self.created_at)), (UpdatedAt, upsert_date(source, self.updated_at)), (WorkspaceId, self.workspace_id.into()), (ResponseId, self.response_id.into()), (Event, serde_json::to_string(&self.event)?.into()), ]) } fn update_columns() -> Vec { vec![ HttpResponseEventIden::UpdatedAt, HttpResponseEventIden::Event, ] } fn from_row(r: &Row) -> rusqlite::Result where Self: Sized, { let event: String = r.get("event")?; Ok(Self { id: r.get("id")?, model: r.get("model")?, workspace_id: r.get("workspace_id")?, response_id: r.get("response_id")?, created_at: r.get("created_at")?, updated_at: r.get("updated_at")?, event: serde_json::from_str(&event).unwrap_or_default(), }) } } impl HttpResponseEvent { pub fn new(response_id: &str, workspace_id: &str, event: HttpResponseEventData) -> Self { Self { model: "http_response_event".to_string(), id: Self::generate_id(), created_at: Utc::now().naive_utc(), updated_at: Utc::now().naive_utc(), workspace_id: workspace_id.to_string(), response_id: response_id.to_string(), event, } } } #[derive(Debug, Clone, Serialize, Deserialize, Default, TS)] #[serde(default, rename_all = "camelCase")] #[ts(export, export_to = "gen_models.ts")] #[enum_def(table_name = "graphql_introspections")] pub struct GraphQlIntrospection { #[ts(type = "\"graphql_introspection\"")] pub model: String, pub id: String, pub created_at: NaiveDateTime, pub updated_at: NaiveDateTime, pub workspace_id: String, pub request_id: String, pub content: Option, } impl UpsertModelInfo for GraphQlIntrospection { fn table_name() -> impl IntoTableRef + IntoIden { GraphQlIntrospectionIden::Table } fn id_column() -> impl IntoIden + Eq + Clone { GraphQlIntrospectionIden::Id } fn generate_id() -> String { generate_prefixed_id("gi") } fn order_by() -> (impl IntoColumnRef, Order) { (GraphQlIntrospectionIden::CreatedAt, Desc) } fn get_id(&self) -> String { self.id.clone() } fn insert_values( self, source: &UpdateSource, ) -> Result)>> { use GraphQlIntrospectionIden::*; Ok(vec![ (CreatedAt, upsert_date(source, self.created_at)), (UpdatedAt, upsert_date(source, self.updated_at)), (WorkspaceId, self.workspace_id.into()), (RequestId, self.request_id.into()), (Content, self.content.into()), ]) } fn update_columns() -> Vec { vec![ GraphQlIntrospectionIden::UpdatedAt, GraphQlIntrospectionIden::Content, ] } fn from_row(r: &Row) -> rusqlite::Result where Self: Sized, { Ok(Self { id: r.get("id")?, model: r.get("model")?, created_at: r.get("created_at")?, updated_at: r.get("updated_at")?, workspace_id: r.get("workspace_id")?, request_id: r.get("request_id")?, content: r.get("content")?, }) } } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, JsonSchema, TS)] #[serde(default, rename_all = "camelCase")] #[ts(export, export_to = "gen_models.ts")] #[enum_def(table_name = "grpc_requests")] pub struct GrpcRequest { #[ts(type = "\"grpc_request\"")] pub model: String, pub id: String, pub created_at: NaiveDateTime, pub updated_at: NaiveDateTime, pub workspace_id: String, pub folder_id: Option, pub authentication_type: Option, #[ts(type = "Record")] pub authentication: BTreeMap, pub description: String, pub message: String, pub metadata: Vec, pub method: Option, pub name: String, pub service: Option, pub sort_priority: f64, /// Server URL (http for plaintext or https for secure) pub url: String, } impl UpsertModelInfo for GrpcRequest { fn table_name() -> impl IntoTableRef + IntoIden { GrpcRequestIden::Table } fn id_column() -> impl IntoIden + Eq + Clone { GrpcRequestIden::Id } fn generate_id() -> String { generate_prefixed_id("gr") } fn order_by() -> (impl IntoColumnRef, Order) { (GrpcRequestIden::CreatedAt, Desc) } fn get_id(&self) -> String { self.id.clone() } fn insert_values( self, source: &UpdateSource, ) -> Result)>> { use GrpcRequestIden::*; Ok(vec![ (CreatedAt, upsert_date(source, self.created_at)), (UpdatedAt, upsert_date(source, self.updated_at)), (Name, self.name.trim().into()), (Description, self.description.into()), (WorkspaceId, self.workspace_id.into()), (FolderId, self.folder_id.into()), (SortPriority, self.sort_priority.into()), (Url, self.url.into()), (Service, self.service.into()), (Method, self.method.into()), (Message, self.message.into()), (AuthenticationType, self.authentication_type.into()), (Authentication, serde_json::to_string(&self.authentication)?.into()), (Metadata, serde_json::to_string(&self.metadata)?.into()), ]) } fn update_columns() -> Vec { vec![ GrpcRequestIden::UpdatedAt, GrpcRequestIden::WorkspaceId, GrpcRequestIden::Name, GrpcRequestIden::Description, GrpcRequestIden::FolderId, GrpcRequestIden::SortPriority, GrpcRequestIden::Url, GrpcRequestIden::Service, GrpcRequestIden::Method, GrpcRequestIden::Message, GrpcRequestIden::AuthenticationType, GrpcRequestIden::Authentication, GrpcRequestIden::Metadata, ] } fn from_row(row: &Row) -> rusqlite::Result where Self: Sized, { let authentication: String = row.get("authentication")?; let metadata: String = row.get("metadata")?; Ok(Self { id: row.get("id")?, model: row.get("model")?, workspace_id: row.get("workspace_id")?, created_at: row.get("created_at")?, updated_at: row.get("updated_at")?, folder_id: row.get("folder_id")?, name: row.get("name")?, description: row.get("description")?, service: row.get("service")?, method: row.get("method")?, message: row.get("message")?, authentication_type: row.get("authentication_type")?, authentication: serde_json::from_str(authentication.as_str()).unwrap_or_default(), url: row.get("url")?, sort_priority: row.get("sort_priority")?, metadata: serde_json::from_str(metadata.as_str()).unwrap_or_default(), }) } } #[derive(Debug, Clone, Serialize, Deserialize, TS)] #[serde(rename_all = "snake_case")] #[ts(export, export_to = "gen_models.ts")] pub enum GrpcConnectionState { Initialized, Connected, Closed, } impl Default for GrpcConnectionState { fn default() -> Self { Self::Initialized } } #[derive(Debug, Clone, Serialize, Deserialize, Default, TS)] #[serde(default, rename_all = "camelCase")] #[ts(export, export_to = "gen_models.ts")] #[enum_def(table_name = "grpc_connections")] pub struct GrpcConnection { #[ts(type = "\"grpc_connection\"")] pub model: String, pub id: String, pub created_at: NaiveDateTime, pub updated_at: NaiveDateTime, pub workspace_id: String, pub request_id: String, pub elapsed: i32, pub error: Option, pub method: String, pub service: String, pub status: i32, pub state: GrpcConnectionState, pub trailers: BTreeMap, pub url: String, } impl UpsertModelInfo for GrpcConnection { fn table_name() -> impl IntoTableRef + IntoIden { GrpcConnectionIden::Table } fn id_column() -> impl IntoIden + Eq + Clone { GrpcConnectionIden::Id } fn generate_id() -> String { generate_prefixed_id("gc") } fn order_by() -> (impl IntoColumnRef, Order) { (GrpcConnectionIden::CreatedAt, Desc) } fn get_id(&self) -> String { self.id.clone() } fn insert_values( self, source: &UpdateSource, ) -> Result)>> { use GrpcConnectionIden::*; Ok(vec![ (CreatedAt, upsert_date(source, self.created_at)), (UpdatedAt, upsert_date(source, self.updated_at)), (WorkspaceId, self.workspace_id.into()), (RequestId, self.request_id.into()), (Service, self.service.into()), (Method, self.method.into()), (Elapsed, self.elapsed.into()), (State, serde_json::to_value(&self.state)?.as_str().into()), (Status, self.status.into()), (Error, self.error.as_ref().map(|s| s.as_str()).into()), (Trailers, serde_json::to_string(&self.trailers)?.into()), (Url, self.url.into()), ]) } fn update_columns() -> Vec { vec![ GrpcConnectionIden::UpdatedAt, GrpcConnectionIden::Service, GrpcConnectionIden::Method, GrpcConnectionIden::Elapsed, GrpcConnectionIden::Status, GrpcConnectionIden::State, GrpcConnectionIden::Error, GrpcConnectionIden::Trailers, GrpcConnectionIden::Url, ] } fn from_row(row: &Row) -> rusqlite::Result where Self: Sized, { let trailers: String = row.get("trailers")?; let state: String = row.get("state")?; Ok(Self { id: row.get("id")?, model: row.get("model")?, workspace_id: row.get("workspace_id")?, request_id: row.get("request_id")?, created_at: row.get("created_at")?, updated_at: row.get("updated_at")?, service: row.get("service")?, method: row.get("method")?, elapsed: row.get("elapsed")?, state: serde_json::from_str(format!(r#""{state}""#).as_str()).unwrap(), status: row.get("status")?, url: row.get("url")?, error: row.get("error")?, trailers: serde_json::from_str(trailers.as_str()).unwrap_or_default(), }) } } #[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq, TS)] #[serde(rename_all = "snake_case")] #[ts(export, export_to = "gen_models.ts")] pub enum GrpcEventType { Info, Error, ClientMessage, ServerMessage, ConnectionStart, ConnectionEnd, } impl Default for GrpcEventType { fn default() -> Self { GrpcEventType::Info } } #[derive(Debug, Clone, Serialize, Deserialize, Default, TS)] #[serde(default, rename_all = "camelCase")] #[ts(export, export_to = "gen_models.ts")] #[enum_def(table_name = "grpc_events")] pub struct GrpcEvent { #[ts(type = "\"grpc_event\"")] pub model: String, pub id: String, pub created_at: NaiveDateTime, pub updated_at: NaiveDateTime, pub workspace_id: String, pub request_id: String, pub connection_id: String, pub content: String, pub error: Option, pub event_type: GrpcEventType, pub metadata: BTreeMap, pub status: Option, } impl UpsertModelInfo for GrpcEvent { fn table_name() -> impl IntoTableRef + IntoIden { GrpcEventIden::Table } fn id_column() -> impl IntoIden + Eq + Clone { GrpcEventIden::Id } fn generate_id() -> String { generate_prefixed_id("ge") } fn order_by() -> (impl IntoColumnRef, Order) { (GrpcEventIden::CreatedAt, Desc) } fn get_id(&self) -> String { self.id.clone() } fn insert_values( self, source: &UpdateSource, ) -> Result)>> { use GrpcEventIden::*; Ok(vec![ (CreatedAt, upsert_date(source, self.created_at)), (UpdatedAt, upsert_date(source, self.updated_at)), (WorkspaceId, self.workspace_id.into()), (RequestId, self.request_id.into()), (ConnectionId, self.connection_id.into()), (Content, self.content.into()), (EventType, serde_json::to_string(&self.event_type)?.into()), (Metadata, serde_json::to_string(&self.metadata)?.into()), (Status, self.status.into()), (Error, self.error.into()), ]) } fn update_columns() -> Vec { vec![ GrpcEventIden::UpdatedAt, GrpcEventIden::Content, GrpcEventIden::EventType, GrpcEventIden::Metadata, GrpcEventIden::Status, GrpcEventIden::Error, ] } fn from_row(row: &Row) -> rusqlite::Result where Self: Sized, { let event_type: String = row.get("event_type")?; let metadata: String = row.get("metadata")?; Ok(Self { id: row.get("id")?, model: row.get("model")?, workspace_id: row.get("workspace_id")?, request_id: row.get("request_id")?, connection_id: row.get("connection_id")?, created_at: row.get("created_at")?, updated_at: row.get("updated_at")?, content: row.get("content")?, event_type: serde_json::from_str(event_type.as_str()).unwrap_or_default(), metadata: serde_json::from_str(metadata.as_str()).unwrap_or_default(), status: row.get("status")?, error: row.get("error")?, }) } } #[derive(Debug, Clone, Serialize, Deserialize, Default, TS)] #[serde(default, rename_all = "camelCase")] #[ts(export, export_to = "gen_models.ts")] #[enum_def(table_name = "plugins")] pub struct Plugin { #[ts(type = "\"plugin\"")] pub model: String, pub id: String, pub created_at: NaiveDateTime, pub updated_at: NaiveDateTime, pub checked_at: Option, pub directory: String, pub enabled: bool, pub url: Option, pub source: PluginSource, } #[derive(Debug, Clone, Serialize, Deserialize, TS)] #[serde(rename_all = "snake_case")] #[ts(export, export_to = "gen_models.ts")] pub enum PluginSource { Bundled, Filesystem, Registry, } impl FromStr for PluginSource { type Err = crate::error::Error; fn from_str(s: &str) -> Result { match s { "bundled" => Ok(Self::Bundled), "filesystem" => Ok(Self::Filesystem), "registry" => Ok(Self::Registry), _ => Ok(Self::default()), } } } impl Display for PluginSource { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let str = match self { PluginSource::Bundled => "bundled".to_string(), PluginSource::Filesystem => "filesystem".to_string(), PluginSource::Registry => "registry".to_string(), }; write!(f, "{}", str) } } impl Default for PluginSource { fn default() -> Self { Self::Filesystem } } impl UpsertModelInfo for Plugin { fn table_name() -> impl IntoTableRef + IntoIden { PluginIden::Table } fn id_column() -> impl IntoIden + Eq + Clone { PluginIden::Id } fn generate_id() -> String { generate_prefixed_id("pg") } fn order_by() -> (impl IntoColumnRef, Order) { (PluginIden::CreatedAt, Desc) } fn get_id(&self) -> String { self.id.clone() } fn insert_values( self, source: &UpdateSource, ) -> Result)>> { use PluginIden::*; Ok(vec![ (CreatedAt, upsert_date(source, self.created_at)), (UpdatedAt, upsert_date(source, self.updated_at)), (CheckedAt, self.checked_at.into()), (Directory, self.directory.into()), (Url, self.url.into()), (Enabled, self.enabled.into()), (Source, self.source.to_string().into()), ]) } fn update_columns() -> Vec { vec![ PluginIden::UpdatedAt, PluginIden::CheckedAt, PluginIden::Directory, PluginIden::Url, PluginIden::Enabled, PluginIden::Source, ] } fn from_row(row: &Row) -> rusqlite::Result where Self: Sized, { Ok(Self { id: row.get("id")?, model: row.get("model")?, created_at: row.get("created_at")?, updated_at: row.get("updated_at")?, checked_at: row.get("checked_at")?, url: row.get("url")?, directory: row.get("directory")?, enabled: row.get("enabled")?, source: PluginSource::from_str(row.get::<_, String>("source")?.as_str()).unwrap(), }) } } #[derive(Debug, Clone, Serialize, Deserialize, Default, TS)] #[serde(default, rename_all = "camelCase")] #[ts(export, export_to = "gen_models.ts")] #[enum_def(table_name = "sync_states")] pub struct SyncState { #[ts(type = "\"sync_state\"")] pub model: String, pub id: String, pub workspace_id: String, pub created_at: NaiveDateTime, pub updated_at: NaiveDateTime, pub flushed_at: NaiveDateTime, pub model_id: String, pub checksum: String, pub rel_path: String, pub sync_dir: String, } impl UpsertModelInfo for SyncState { fn table_name() -> impl IntoTableRef + IntoIden { SyncStateIden::Table } fn id_column() -> impl IntoIden + Eq + Clone { SyncStateIden::Id } fn generate_id() -> String { generate_prefixed_id("ss") } fn order_by() -> (impl IntoColumnRef, Order) { (SyncStateIden::CreatedAt, Desc) } fn get_id(&self) -> String { self.id.clone() } fn insert_values( self, source: &UpdateSource, ) -> Result)>> { use SyncStateIden::*; Ok(vec![ (CreatedAt, upsert_date(source, self.created_at)), (UpdatedAt, upsert_date(source, self.updated_at)), (WorkspaceId, self.workspace_id.into()), (FlushedAt, self.flushed_at.into()), (Checksum, self.checksum.into()), (ModelId, self.model_id.into()), (RelPath, self.rel_path.into()), (SyncDir, self.sync_dir.into()), ]) } fn update_columns() -> Vec { vec![ SyncStateIden::UpdatedAt, SyncStateIden::FlushedAt, SyncStateIden::Checksum, SyncStateIden::RelPath, SyncStateIden::SyncDir, ] } fn from_row(row: &Row) -> rusqlite::Result where Self: Sized, { Ok(Self { id: row.get("id")?, workspace_id: row.get("workspace_id")?, model: row.get("model")?, created_at: row.get("created_at")?, updated_at: row.get("updated_at")?, flushed_at: row.get("flushed_at")?, checksum: row.get("checksum")?, model_id: row.get("model_id")?, sync_dir: row.get("sync_dir")?, rel_path: row.get("rel_path")?, }) } } #[derive(Debug, Clone, Serialize, Deserialize, Default, TS)] #[serde(default, rename_all = "camelCase")] #[ts(export, export_to = "gen_models.ts")] #[enum_def(table_name = "key_values")] pub struct KeyValue { #[ts(type = "\"key_value\"")] pub model: String, pub id: String, pub created_at: NaiveDateTime, pub updated_at: NaiveDateTime, pub key: String, pub namespace: String, pub value: String, } impl UpsertModelInfo for KeyValue { fn table_name() -> impl IntoTableRef + IntoIden { KeyValueIden::Table } fn id_column() -> impl IntoIden + Eq + Clone { KeyValueIden::Id } fn generate_id() -> String { generate_prefixed_id("kv") } fn order_by() -> (impl IntoColumnRef, Order) { (KeyValueIden::CreatedAt, Desc) } fn get_id(&self) -> String { self.id.clone() } fn insert_values( self, source: &UpdateSource, ) -> Result)>> { use KeyValueIden::*; Ok(vec![ (CreatedAt, upsert_date(source, self.created_at)), (UpdatedAt, upsert_date(source, self.updated_at)), (Namespace, self.namespace.clone().into()), (Key, self.key.clone().into()), (Value, self.value.clone().into()), ]) } fn update_columns() -> Vec { vec![KeyValueIden::UpdatedAt, KeyValueIden::Value] } fn from_row(row: &Row) -> rusqlite::Result where Self: Sized, { Ok(Self { id: row.get("id")?, model: row.get("model")?, created_at: row.get("created_at")?, updated_at: row.get("updated_at")?, namespace: row.get("namespace")?, key: row.get("key")?, value: row.get("value")?, }) } } #[derive(Debug, Clone, Serialize, Deserialize, Default, TS)] #[serde(default, rename_all = "camelCase")] #[ts(export, export_to = "gen_models.ts")] #[enum_def(table_name = "plugin_key_values")] pub struct PluginKeyValue { #[ts(type = "\"plugin_key_value\"")] pub model: String, pub created_at: NaiveDateTime, pub updated_at: NaiveDateTime, pub plugin_name: String, pub key: String, pub value: String, } impl<'s> TryFrom<&Row<'s>> for PluginKeyValue { type Error = rusqlite::Error; fn try_from(r: &Row<'s>) -> std::result::Result { Ok(Self { model: r.get("model")?, created_at: r.get("created_at")?, updated_at: r.get("updated_at")?, plugin_name: r.get("plugin_name")?, key: r.get("key")?, value: r.get("value")?, }) } } fn default_true() -> bool { true } fn default_http_method() -> String { "GET".to_string() } #[macro_export] macro_rules! define_any_model { ($($type:ident),* $(,)?) => { #[derive(Debug, Clone, Serialize, TS)] #[serde(rename_all = "camelCase", untagged)] #[ts(export, export_to = "gen_models.ts")] pub enum AnyModel { $( $type($type), )* } impl AnyModel { #[inline] pub fn id(&self) -> &str { match self { $( AnyModel::$type(inner) => &inner.id, )* } } #[inline] pub fn model(&self) -> &str { match self { $( AnyModel::$type(inner) => &inner.model, )* } } } $( impl From<$type> for AnyModel { fn from(value: $type) -> Self { AnyModel::$type(value) } } impl From for $type { fn from(value: AnyModel) -> $type { match value { AnyModel::$type(inner) => inner, _ => panic!( // Should never happen because this macro also generates the enum variant "Tried to convert AnyModel into `{}`, but found a different variant", stringify!($type) ), } } } )* }; } define_any_model! { CookieJar, Environment, Folder, GraphQlIntrospection, GrpcConnection, GrpcEvent, GrpcRequest, HttpRequest, HttpResponse, HttpResponseEvent, KeyValue, Plugin, Settings, SyncState, WebsocketConnection, WebsocketEvent, WebsocketRequest, Workspace, WorkspaceMeta, } impl<'de> Deserialize<'de> for AnyModel { fn deserialize(deserializer: D) -> std::result::Result where D: Deserializer<'de>, { let value = Value::deserialize(deserializer)?; let model = value.as_object().unwrap(); use AnyModel::*; use serde_json::from_value as fv; let model = match model.get("model") { Some(m) if m == "cookie_jar" => CookieJar(fv(value).unwrap()), Some(m) if m == "environment" => Environment(fv(value).unwrap()), Some(m) if m == "folder" => Folder(fv(value).unwrap()), Some(m) if m == "graphql_introspection" => GraphQlIntrospection(fv(value).unwrap()), Some(m) if m == "grpc_connection" => GrpcConnection(fv(value).unwrap()), Some(m) if m == "grpc_event" => GrpcEvent(fv(value).unwrap()), Some(m) if m == "grpc_request" => GrpcRequest(fv(value).unwrap()), Some(m) if m == "http_request" => HttpRequest(fv(value).unwrap()), Some(m) if m == "http_response" => HttpResponse(fv(value).unwrap()), Some(m) if m == "http_response_event" => HttpResponseEvent(fv(value).unwrap()), Some(m) if m == "key_value" => KeyValue(fv(value).unwrap()), Some(m) if m == "plugin" => Plugin(fv(value).unwrap()), Some(m) if m == "settings" => Settings(fv(value).unwrap()), Some(m) if m == "sync_state" => SyncState(fv(value).unwrap()), Some(m) if m == "websocket_connection" => WebsocketConnection(fv(value).unwrap()), Some(m) if m == "websocket_event" => WebsocketEvent(fv(value).unwrap()), Some(m) if m == "websocket_request" => WebsocketRequest(fv(value).unwrap()), Some(m) if m == "workspace" => Workspace(fv(value).unwrap()), Some(m) if m == "workspace_meta" => WorkspaceMeta(fv(value).unwrap()), Some(m) => { return Err(serde::de::Error::custom(format!( "Failed to deserialize AnyModel {}", m ))); } None => { return Err(serde::de::Error::custom("Missing or invalid model")); } }; Ok(model) } } impl AnyModel { pub fn resolved_name(&self) -> String { let compute_name = |name: &str, url: &str, fallback: &str| -> String { if !name.is_empty() { return name.to_string(); } let without_variables = url.replace(r"\$\{\[\s*([^\]\s]+)\s*]}", "$1"); if without_variables.is_empty() { fallback.to_string() } else { without_variables } }; match self.clone() { AnyModel::CookieJar(v) => v.name, AnyModel::Environment(v) => v.name, AnyModel::Folder(v) => v.name, AnyModel::GrpcRequest(v) => compute_name(&v.name, &v.url, "gRPC Request"), AnyModel::HttpRequest(v) => compute_name(&v.name, &v.url, "HTTP Request"), AnyModel::WebsocketRequest(v) => compute_name(&v.name, &v.url, "WebSocket Request"), AnyModel::Workspace(v) => v.name, _ => "No Name".to_string(), } } } pub trait UpsertModelInfo { fn table_name() -> impl IntoTableRef + IntoIden; fn id_column() -> impl IntoIden + Eq + Clone; fn generate_id() -> String; fn order_by() -> (impl IntoColumnRef, Order); fn get_id(&self) -> String; fn insert_values( self, source: &UpdateSource, ) -> Result)>>; fn update_columns() -> Vec; fn from_row(row: &Row) -> rusqlite::Result where Self: Sized; } // Generate the created_at or updated_at timestamps for an upsert operation, depending on the ID // provided. fn upsert_date(update_source: &UpdateSource, dt: NaiveDateTime) -> SimpleExpr { match update_source { // Sync and import operations always preserve timestamps UpdateSource::Sync | UpdateSource::Import => { if dt.and_utc().timestamp() == 0 { // Sometimes data won't have timestamps (partial data) Utc::now().naive_utc().into() } else { dt.into() } } // Other sources will always update to the latest time _ => Utc::now().naive_utc().into(), } } ================================================ FILE: crates/yaak-models/src/queries/any_request.rs ================================================ use crate::db_context::DbContext; use crate::error::Result; use crate::models::{GrpcRequest, HttpRequest, WebsocketRequest}; pub enum AnyRequest { HttpRequest(HttpRequest), GrpcRequest(GrpcRequest), WebsocketRequest(WebsocketRequest), } impl<'a> DbContext<'a> { pub fn get_any_request(&self, id: &str) -> Result { if let Ok(http_request) = self.get_http_request(id) { Ok(AnyRequest::HttpRequest(http_request)) } else if let Ok(grpc_request) = self.get_grpc_request(id) { Ok(AnyRequest::GrpcRequest(grpc_request)) } else { Ok(AnyRequest::WebsocketRequest(self.get_websocket_request(id)?)) } } } ================================================ FILE: crates/yaak-models/src/queries/batch.rs ================================================ use crate::db_context::DbContext; use crate::error::Result; use crate::models::{Environment, Folder, GrpcRequest, HttpRequest, WebsocketRequest, Workspace}; use crate::util::{BatchUpsertResult, UpdateSource}; use log::info; impl<'a> DbContext<'a> { pub fn batch_upsert( &self, workspaces: Vec, environments: Vec, folders: Vec, http_requests: Vec, grpc_requests: Vec, websocket_requests: Vec, source: &UpdateSource, ) -> Result { let mut imported_resources = BatchUpsertResult::default(); if workspaces.len() > 0 { for v in workspaces { let x = self.upsert_workspace(&v, source)?; imported_resources.workspaces.push(x.clone()); } info!("Upserted {} workspaces", imported_resources.workspaces.len()); } if http_requests.len() > 0 { for v in http_requests { let x = self.upsert_http_request(&v, source)?; imported_resources.http_requests.push(x.clone()); } info!("Upserted Imported {} http_requests", imported_resources.http_requests.len()); } if grpc_requests.len() > 0 { for v in grpc_requests { let x = self.upsert_grpc_request(&v, source)?; imported_resources.grpc_requests.push(x.clone()); } info!("Upserted {} grpc_requests", imported_resources.grpc_requests.len()); } if websocket_requests.len() > 0 { for v in websocket_requests { let x = self.upsert_websocket_request(&v, source)?; imported_resources.websocket_requests.push(x.clone()); } info!("Upserted {} websocket_requests", imported_resources.websocket_requests.len()); } // Do folders after their children so the UI doesn't render empty folders before populating // immediately after. if folders.len() > 0 { for v in folders { let x = self.upsert_folder(&v, source)?; imported_resources.folders.push(x.clone()); } info!("Upserted {} folders", imported_resources.folders.len()); } // Do environments last because they can depend on many models (requests, folders, etc) if environments.len() > 0 { for x in environments { let x = self.upsert_environment(&x, source)?; imported_resources.environments.push(x.clone()); } info!("Upserted {} environments", imported_resources.environments.len()); } Ok(imported_resources) } } ================================================ FILE: crates/yaak-models/src/queries/cookie_jars.rs ================================================ use crate::db_context::DbContext; use crate::error::Result; use crate::models::{CookieJar, CookieJarIden}; use crate::util::UpdateSource; impl<'a> DbContext<'a> { pub fn get_cookie_jar(&self, id: &str) -> Result { self.find_one(CookieJarIden::Id, id) } pub fn list_cookie_jars(&self, workspace_id: &str) -> Result> { let mut cookie_jars = self.find_many(CookieJarIden::WorkspaceId, workspace_id, None)?; if cookie_jars.is_empty() { let jar = CookieJar { name: "Default".to_string(), workspace_id: workspace_id.to_string(), ..Default::default() }; cookie_jars.push(self.upsert_cookie_jar(&jar, &UpdateSource::Background)?); } Ok(cookie_jars) } pub fn delete_cookie_jar( &self, cookie_jar: &CookieJar, source: &UpdateSource, ) -> Result { self.delete(cookie_jar, source) } pub fn delete_cookie_jar_by_id(&self, id: &str, source: &UpdateSource) -> Result { let cookie_jar = self.get_cookie_jar(id)?; self.delete_cookie_jar(&cookie_jar, source) } pub fn upsert_cookie_jar( &self, cookie_jar: &CookieJar, source: &UpdateSource, ) -> Result { self.upsert(cookie_jar, source) } } ================================================ FILE: crates/yaak-models/src/queries/environments.rs ================================================ use crate::db_context::DbContext; use crate::error::Error::{MissingBaseEnvironment, MultipleBaseEnvironments}; use crate::error::Result; use crate::models::{Environment, EnvironmentIden, EnvironmentVariable}; use crate::util::UpdateSource; use log::{info, warn}; impl<'a> DbContext<'a> { pub fn get_environment(&self, id: &str) -> Result { self.find_one(EnvironmentIden::Id, id) } pub fn get_environment_by_folder_id(&self, folder_id: &str) -> Result> { let mut environments: Vec = self.find_many(EnvironmentIden::ParentId, folder_id, None)?; // Sort so we return the most recently updated environment environments.sort_by(|a, b| b.updated_at.cmp(&a.updated_at)); Ok(environments.get(0).cloned()) } pub fn get_base_environment(&self, workspace_id: &str) -> Result { let environments = self.list_environments_ensure_base(workspace_id)?; let base_environments = environments .into_iter() .filter(|e| e.parent_model == "workspace") .collect::>(); if base_environments.len() > 1 { return Err(MultipleBaseEnvironments(workspace_id.to_string())); } Ok(base_environments.first().cloned().ok_or( // Should never happen because one should be created above if it does not exist MissingBaseEnvironment(workspace_id.to_string()), )?) } /// Lists environments and will create a base environment if one doesn't exist pub fn list_environments_ensure_base(&self, workspace_id: &str) -> Result> { let mut environments = self.list_environments_dangerous(workspace_id)?; let base_environment = environments.iter().find(|e| e.parent_model == "workspace"); if let None = base_environment { let e = self.upsert_environment( &Environment { workspace_id: workspace_id.to_string(), name: "Global Variables".to_string(), parent_model: "workspace".to_string(), ..Default::default() }, &UpdateSource::Background, )?; info!("Created base environment {} for {workspace_id}", e.id); environments.push(e); } Ok(environments) } /// List environments for a workspace. Prefer list_environments_ensure_base() fn list_environments_dangerous(&self, workspace_id: &str) -> Result> { Ok(self.find_many::(EnvironmentIden::WorkspaceId, workspace_id, None)?) } pub fn delete_environment( &self, environment: &Environment, source: &UpdateSource, ) -> Result { let deleted_environment = self.delete(environment, source)?; // Recreate the base environment if we happened to delete it self.list_environments_ensure_base(&environment.workspace_id)?; Ok(deleted_environment) } pub fn delete_environment_by_id(&self, id: &str, source: &UpdateSource) -> Result { let environment = self.get_environment(id)?; self.delete_environment(&environment, source) } pub fn duplicate_environment( &self, environment: &Environment, source: &UpdateSource, ) -> Result { let mut environment = environment.clone(); environment.id = "".to_string(); self.upsert_environment(&environment, source) } /// Find other environments with the same parent folder fn list_duplicate_folder_environments(&self, environment: &Environment) -> Vec { if environment.parent_model != "folder" { return Vec::new(); } self.list_environments_dangerous(&environment.workspace_id) .unwrap_or_default() .into_iter() .filter(|e| { e.id != environment.id && e.parent_model == "folder" && e.parent_id == environment.parent_id }) .collect() } pub fn upsert_environment( &self, environment: &Environment, source: &UpdateSource, ) -> Result { let cleaned_variables = environment .variables .iter() .filter(|v| !v.name.is_empty() || !v.value.is_empty()) .cloned() .collect::>(); // Sometimes a new environment can be created via sync/import, so we'll just delete // the others when that happens. Not the best, but it's good for now. let duplicates = self.list_duplicate_folder_environments(environment); for duplicate in duplicates { warn!( "Deleting duplicate environment {} for folder {:?}", duplicate.id, environment.parent_id ); _ = self.delete(&duplicate, source); } // Automatically update the environment name based on the folder name let mut name = environment.name.clone(); match (environment.parent_model.as_str(), environment.parent_id.as_deref()) { ("folder", Some(folder_id)) => { if let Ok(folder) = self.get_folder(folder_id) { name = format!("{} Environment", folder.name); } } _ => {} } self.upsert( &Environment { name, variables: cleaned_variables, ..environment.clone() }, source, ) } pub fn resolve_environments( &self, workspace_id: &str, folder_id: Option<&str>, active_environment_id: Option<&str>, ) -> Result> { let mut environments = Vec::new(); if let Some(folder_id) = folder_id { let folder = self.get_folder(folder_id)?; // Add current folder's environment if let Some(e) = self.get_environment_by_folder_id(folder_id)? { environments.push(e); }; // Recurse up let ancestors = self.resolve_environments( workspace_id, folder.folder_id.as_deref(), active_environment_id, )?; environments.extend(ancestors); } else { // Add active and base environments if let Some(id) = active_environment_id { if let Ok(e) = self.get_environment(&id) { // Add active sub environment environments.push(e); }; }; // Add the base environment environments.push(self.get_base_environment(workspace_id)?); } Ok(environments) } } ================================================ FILE: crates/yaak-models/src/queries/folders.rs ================================================ use crate::connection_or_tx::ConnectionOrTx; use crate::db_context::DbContext; use crate::error::Result; use crate::models::{ Environment, EnvironmentIden, Folder, FolderIden, GrpcRequest, GrpcRequestIden, HttpRequest, HttpRequestHeader, HttpRequestIden, WebsocketRequest, WebsocketRequestIden, }; use crate::util::UpdateSource; use serde_json::Value; use std::collections::BTreeMap; impl<'a> DbContext<'a> { pub fn get_folder(&self, id: &str) -> Result { self.find_one(FolderIden::Id, id) } pub fn list_folders(&self, workspace_id: &str) -> Result> { self.find_many(FolderIden::WorkspaceId, workspace_id, None) } pub fn delete_folder(&self, folder: &Folder, source: &UpdateSource) -> Result { match self.conn { ConnectionOrTx::Connection(_) => {} ConnectionOrTx::Transaction(_) => {} } let fid = &folder.id; for m in self.find_many::(HttpRequestIden::FolderId, fid, None)? { self.delete_http_request(&m, source)?; } for m in self.find_many::(GrpcRequestIden::FolderId, fid, None)? { self.delete_grpc_request(&m, source)?; } for m in self.find_many::(WebsocketRequestIden::FolderId, fid, None)? { self.delete_websocket_request(&m, source)?; } for e in self.find_many(EnvironmentIden::ParentId, fid, None)? { self.delete_environment(&e, source)?; } // Recurse down into child folders for folder in self.find_many::(FolderIden::FolderId, fid, None)? { self.delete_folder(&folder, source)?; } self.delete(folder, source) } pub fn delete_folder_by_id(&self, id: &str, source: &UpdateSource) -> Result { let folder = self.get_folder(id)?; self.delete_folder(&folder, source) } pub fn upsert_folder(&self, folder: &Folder, source: &UpdateSource) -> Result { self.upsert(folder, source) } pub fn duplicate_folder(&self, src_folder: &Folder, source: &UpdateSource) -> Result { let fid = &src_folder.id; let new_folder = self.upsert_folder( &Folder { id: "".into(), sort_priority: src_folder.sort_priority + 0.001, ..src_folder.clone() }, source, )?; for m in self.find_many::(HttpRequestIden::FolderId, fid, None)? { self.upsert_http_request( &HttpRequest { id: "".into(), folder_id: Some(new_folder.id.clone()), ..m }, source, )?; } for m in self.find_many::(WebsocketRequestIden::FolderId, fid, None)? { self.upsert_websocket_request( &WebsocketRequest { id: "".into(), folder_id: Some(new_folder.id.clone()), ..m }, source, )?; } for m in self.find_many::(GrpcRequestIden::FolderId, fid, None)? { self.upsert_grpc_request( &GrpcRequest { id: "".into(), folder_id: Some(new_folder.id.clone()), ..m }, source, )?; } for m in self.find_many::(EnvironmentIden::ParentId, fid, None)? { self.upsert_environment( &Environment { id: "".into(), parent_id: Some(new_folder.id.clone()), ..m }, source, )?; } for m in self.find_many::(FolderIden::FolderId, fid, None)? { // Recurse down self.duplicate_folder(&Folder { folder_id: Some(new_folder.id.clone()), ..m }, source)?; } Ok(new_folder) } pub fn resolve_auth_for_folder( &self, folder: &Folder, ) -> Result<(Option, BTreeMap, String)> { if let Some(at) = folder.authentication_type.clone() { return Ok((Some(at), folder.authentication.clone(), folder.id.clone())); } if let Some(folder_id) = folder.folder_id.clone() { let folder = self.get_folder(&folder_id)?; return self.resolve_auth_for_folder(&folder); } let workspace = self.get_workspace(&folder.workspace_id)?; Ok(self.resolve_auth_for_workspace(&workspace)) } pub fn resolve_headers_for_folder(&self, folder: &Folder) -> Result> { let mut headers = Vec::new(); if let Some(folder_id) = folder.folder_id.clone() { let parent_folder = self.get_folder(&folder_id)?; let mut folder_headers = self.resolve_headers_for_folder(&parent_folder)?; // NOTE: Add parent headers first, so overrides are logical headers.append(&mut folder_headers); } else { let workspace = self.get_workspace(&folder.workspace_id)?; let mut workspace_headers = self.resolve_headers_for_workspace(&workspace); headers.append(&mut workspace_headers); } headers.append(&mut folder.headers.clone()); Ok(headers) } } ================================================ FILE: crates/yaak-models/src/queries/graphql_introspections.rs ================================================ use crate::db_context::DbContext; use crate::error::Result; use crate::models::{GraphQlIntrospection, GraphQlIntrospectionIden}; use crate::util::UpdateSource; use chrono::{Duration, Utc}; use sea_query::{Expr, Query, SqliteQueryBuilder}; use sea_query_rusqlite::RusqliteBinder; impl<'a> DbContext<'a> { pub fn get_graphql_introspection(&self, request_id: &str) -> Option { self.find_optional(GraphQlIntrospectionIden::RequestId, request_id) } pub fn upsert_graphql_introspection( &self, workspace_id: &str, request_id: &str, content: Option, source: &UpdateSource, ) -> Result { // Clean up old ones every time a new one is upserted self.delete_expired_graphql_introspections()?; match self.get_graphql_introspection(request_id) { None => self.upsert( &GraphQlIntrospection { content, request_id: request_id.to_string(), workspace_id: workspace_id.to_string(), ..Default::default() }, source, ), Some(introspection) => { self.upsert(&GraphQlIntrospection { content, ..introspection }, source) } } } pub fn delete_expired_graphql_introspections(&self) -> Result<()> { let cutoff = Utc::now().naive_utc() - Duration::days(7); let (sql, params) = Query::delete() .from_table(GraphQlIntrospectionIden::Table) .cond_where(Expr::col(GraphQlIntrospectionIden::UpdatedAt).lt(cutoff)) .build_rusqlite(SqliteQueryBuilder); let mut stmt = self.conn.resolve().prepare(sql.as_str())?; stmt.execute(&*params.as_params())?; Ok(()) } } ================================================ FILE: crates/yaak-models/src/queries/grpc_connections.rs ================================================ use crate::db_context::DbContext; use crate::error::Result; use crate::models::{GrpcConnection, GrpcConnectionIden, GrpcConnectionState}; use crate::queries::MAX_HISTORY_ITEMS; use crate::util::UpdateSource; use log::debug; use sea_query::{Expr, Query, SqliteQueryBuilder}; use sea_query_rusqlite::RusqliteBinder; impl<'a> DbContext<'a> { pub fn get_grpc_connection(&self, id: &str) -> Result { self.find_one(GrpcConnectionIden::Id, id) } pub fn delete_all_grpc_connections_for_request( &self, request_id: &str, source: &UpdateSource, ) -> Result<()> { let responses = self.list_grpc_connections_for_request(request_id, None)?; for m in responses { self.delete(&m, source)?; } Ok(()) } pub fn delete_all_grpc_connections_for_workspace( &self, workspace_id: &str, source: &UpdateSource, ) -> Result<()> { for m in self.list_grpc_connections(workspace_id)? { self.delete(&m, source)?; } Ok(()) } pub fn delete_grpc_connection( &self, m: &GrpcConnection, source: &UpdateSource, ) -> Result { self.delete(m, source) } pub fn delete_grpc_connection_by_id( &self, id: &str, source: &UpdateSource, ) -> Result { let grpc_connection = self.get_grpc_connection(id)?; self.delete_grpc_connection(&grpc_connection, source) } pub fn list_grpc_connections_for_request( &self, request_id: &str, limit: Option, ) -> Result> { self.find_many(GrpcConnectionIden::RequestId, request_id, limit) } pub fn list_grpc_connections(&self, workspace_id: &str) -> Result> { self.find_many(GrpcConnectionIden::WorkspaceId, workspace_id, None) } pub fn cancel_pending_grpc_connections(&self) -> Result<()> { let closed = serde_json::to_value(&GrpcConnectionState::Closed)?; let (sql, params) = Query::update() .table(GrpcConnectionIden::Table) .values([(GrpcConnectionIden::State, closed.as_str().into())]) .cond_where(Expr::col(GrpcConnectionIden::State).ne(closed.as_str())) .build_rusqlite(SqliteQueryBuilder); let mut stmt = self.conn.prepare(sql.as_str())?; stmt.execute(&*params.as_params())?; Ok(()) } pub fn upsert_grpc_connection( &self, grpc_connection: &GrpcConnection, source: &UpdateSource, ) -> Result { let connections = self.list_grpc_connections_for_request(grpc_connection.request_id.as_str(), None)?; for m in connections.iter().skip(MAX_HISTORY_ITEMS - 1) { debug!("Deleting old gRPC connection {}", grpc_connection.id); self.delete_grpc_connection(&m, source)?; } self.upsert(grpc_connection, source) } } ================================================ FILE: crates/yaak-models/src/queries/grpc_events.rs ================================================ use crate::db_context::DbContext; use crate::error::Result; use crate::models::{GrpcEvent, GrpcEventIden}; use crate::util::UpdateSource; impl<'a> DbContext<'a> { pub fn get_grpc_events(&self, id: &str) -> Result { self.find_one(GrpcEventIden::Id, id) } pub fn list_grpc_events(&self, connection_id: &str) -> Result> { self.find_many(GrpcEventIden::ConnectionId, connection_id, None) } pub fn upsert_grpc_event( &self, grpc_event: &GrpcEvent, source: &UpdateSource, ) -> Result { self.upsert(grpc_event, source) } } ================================================ FILE: crates/yaak-models/src/queries/grpc_requests.rs ================================================ use super::dedupe_headers; use crate::db_context::DbContext; use crate::error::Result; use crate::models::{Folder, FolderIden, GrpcRequest, GrpcRequestIden, HttpRequestHeader}; use crate::util::UpdateSource; use serde_json::Value; use std::collections::BTreeMap; impl<'a> DbContext<'a> { pub fn get_grpc_request(&self, id: &str) -> Result { self.find_one(GrpcRequestIden::Id, id) } pub fn list_grpc_requests(&self, workspace_id: &str) -> Result> { self.find_many(GrpcRequestIden::WorkspaceId, workspace_id, None) } pub fn list_grpc_requests_for_folder_recursive( &self, folder_id: &str, ) -> Result> { let mut children = Vec::new(); for folder in self.find_many::(FolderIden::FolderId, folder_id, None)? { children.extend(self.list_grpc_requests_for_folder_recursive(&folder.id)?); } for request in self.find_many::(GrpcRequestIden::FolderId, folder_id, None)? { children.push(request); } Ok(children) } pub fn delete_grpc_request( &self, m: &GrpcRequest, source: &UpdateSource, ) -> Result { self.delete_all_grpc_connections_for_request(m.id.as_str(), source)?; self.delete(m, source) } pub fn delete_grpc_request_by_id( &self, id: &str, source: &UpdateSource, ) -> Result { let request = self.get_grpc_request(id)?; self.delete_grpc_request(&request, source) } pub fn duplicate_grpc_request( &self, grpc_request: &GrpcRequest, source: &UpdateSource, ) -> Result { let mut request = grpc_request.clone(); request.id = "".to_string(); request.sort_priority = request.sort_priority + 0.001; self.upsert(&request, source) } pub fn upsert_grpc_request( &self, grpc_request: &GrpcRequest, source: &UpdateSource, ) -> Result { self.upsert(grpc_request, source) } pub fn resolve_auth_for_grpc_request( &self, grpc_request: &GrpcRequest, ) -> Result<(Option, BTreeMap, String)> { if let Some(at) = grpc_request.authentication_type.clone() { return Ok((Some(at), grpc_request.authentication.clone(), grpc_request.id.clone())); } if let Some(folder_id) = grpc_request.folder_id.clone() { let folder = self.get_folder(&folder_id)?; return self.resolve_auth_for_folder(&folder); } let workspace = self.get_workspace(&grpc_request.workspace_id)?; Ok(self.resolve_auth_for_workspace(&workspace)) } pub fn resolve_metadata_for_grpc_request( &self, grpc_request: &GrpcRequest, ) -> Result> { // Resolved headers should be from furthest to closest ancestor, to override logically. let mut metadata = Vec::new(); if let Some(folder_id) = grpc_request.folder_id.clone() { let parent_folder = self.get_folder(&folder_id)?; let mut folder_headers = self.resolve_headers_for_folder(&parent_folder)?; metadata.append(&mut folder_headers); } else { let workspace = self.get_workspace(&grpc_request.workspace_id)?; let mut workspace_metadata = self.resolve_headers_for_workspace(&workspace); metadata.append(&mut workspace_metadata); } metadata.append(&mut grpc_request.metadata.clone()); Ok(dedupe_headers(metadata)) } } ================================================ FILE: crates/yaak-models/src/queries/http_requests.rs ================================================ use super::dedupe_headers; use crate::db_context::DbContext; use crate::error::Result; use crate::models::{Folder, FolderIden, HttpRequest, HttpRequestHeader, HttpRequestIden}; use crate::util::UpdateSource; use serde_json::Value; use std::collections::BTreeMap; impl<'a> DbContext<'a> { pub fn get_http_request(&self, id: &str) -> Result { self.find_one(HttpRequestIden::Id, id) } pub fn list_http_requests(&self, workspace_id: &str) -> Result> { self.find_many(HttpRequestIden::WorkspaceId, workspace_id, None) } pub fn delete_http_request( &self, m: &HttpRequest, source: &UpdateSource, ) -> Result { self.delete_all_http_responses_for_request(m.id.as_str(), source)?; self.delete(m, source) } pub fn delete_http_request_by_id( &self, id: &str, source: &UpdateSource, ) -> Result { let http_request = self.get_http_request(id)?; self.delete_http_request(&http_request, source) } pub fn duplicate_http_request( &self, http_request: &HttpRequest, source: &UpdateSource, ) -> Result { let mut http_request = http_request.clone(); http_request.id = "".to_string(); http_request.sort_priority = http_request.sort_priority + 0.001; self.upsert(&http_request, source) } pub fn upsert_http_request( &self, http_request: &HttpRequest, source: &UpdateSource, ) -> Result { self.upsert(http_request, source) } pub fn resolve_auth_for_http_request( &self, http_request: &HttpRequest, ) -> Result<(Option, BTreeMap, String)> { if let Some(at) = http_request.authentication_type.clone() { return Ok((Some(at), http_request.authentication.clone(), http_request.id.clone())); } if let Some(folder_id) = http_request.folder_id.clone() { let folder = self.get_folder(&folder_id)?; return self.resolve_auth_for_folder(&folder); } let workspace = self.get_workspace(&http_request.workspace_id)?; Ok(self.resolve_auth_for_workspace(&workspace)) } pub fn resolve_headers_for_http_request( &self, http_request: &HttpRequest, ) -> Result> { // Resolved headers should be from furthest to closest ancestor, to override logically. let mut headers = Vec::new(); if let Some(folder_id) = http_request.folder_id.clone() { let parent_folder = self.get_folder(&folder_id)?; let mut folder_headers = self.resolve_headers_for_folder(&parent_folder)?; headers.append(&mut folder_headers); } else { let workspace = self.get_workspace(&http_request.workspace_id)?; let mut workspace_headers = self.resolve_headers_for_workspace(&workspace); headers.append(&mut workspace_headers); } headers.append(&mut http_request.headers.clone()); Ok(dedupe_headers(headers)) } pub fn list_http_requests_for_folder_recursive( &self, folder_id: &str, ) -> Result> { let mut children = Vec::new(); for m in self.find_many::(FolderIden::FolderId, folder_id, None)? { children.extend(self.list_http_requests_for_folder_recursive(&m.id)?); } for m in self.find_many::(FolderIden::FolderId, folder_id, None)? { children.push(m); } Ok(children) } } ================================================ FILE: crates/yaak-models/src/queries/http_response_events.rs ================================================ use crate::db_context::DbContext; use crate::error::Result; use crate::models::{HttpResponseEvent, HttpResponseEventIden}; use crate::util::UpdateSource; impl<'a> DbContext<'a> { pub fn list_http_response_events(&self, response_id: &str) -> Result> { self.find_many(HttpResponseEventIden::ResponseId, response_id, None) } pub fn upsert_http_response_event( &self, http_response_event: &HttpResponseEvent, source: &UpdateSource, ) -> Result { self.upsert(http_response_event, source) } } ================================================ FILE: crates/yaak-models/src/queries/http_responses.rs ================================================ use crate::blob_manager::BlobManager; use crate::db_context::DbContext; use crate::error::Result; use crate::models::{HttpResponse, HttpResponseIden, HttpResponseState}; use crate::queries::MAX_HISTORY_ITEMS; use crate::util::UpdateSource; use log::{debug, error}; use sea_query::{Expr, Query, SqliteQueryBuilder}; use sea_query_rusqlite::RusqliteBinder; use std::fs; impl<'a> DbContext<'a> { pub fn get_http_response(&self, id: &str) -> Result { self.find_one(HttpResponseIden::Id, id) } pub fn list_http_responses_for_request( &self, request_id: &str, limit: Option, ) -> Result> { self.find_many(HttpResponseIden::RequestId, request_id, limit) } pub fn list_http_responses( &self, workspace_id: &str, limit: Option, ) -> Result> { self.find_many(HttpResponseIden::WorkspaceId, workspace_id, limit) } pub fn delete_all_http_responses_for_request( &self, request_id: &str, source: &UpdateSource, ) -> Result<()> { let responses = self.list_http_responses_for_request(request_id, None)?; for m in responses { self.delete(&m, source)?; } Ok(()) } pub fn delete_all_http_responses_for_workspace( &self, workspace_id: &str, source: &UpdateSource, ) -> Result<()> { let responses = self.find_many::(HttpResponseIden::WorkspaceId, workspace_id, None)?; for m in responses { self.delete(&m, source)?; } Ok(()) } pub fn delete_http_response( &self, http_response: &HttpResponse, source: &UpdateSource, blob_manager: &BlobManager, ) -> Result { // Delete the body file if it exists if let Some(p) = http_response.body_path.clone() { if let Err(e) = fs::remove_file(p) { error!("Failed to delete body file: {}", e); }; } // Delete request body blobs (pattern: {response_id}.request) let blob_ctx = blob_manager.connect(); let body_id = format!("{}.request", http_response.id); if let Err(e) = blob_ctx.delete_chunks(&body_id) { error!("Failed to delete request body blobs: {}", e); } Ok(self.delete(http_response, source)?) } pub fn upsert_http_response( &self, http_response: &HttpResponse, source: &UpdateSource, blob_manager: &BlobManager, ) -> Result { let responses = self.list_http_responses_for_request(&http_response.request_id, None)?; for m in responses.iter().skip(MAX_HISTORY_ITEMS - 1) { debug!("Deleting old HTTP response {}", http_response.id); self.delete_http_response(&m, source, blob_manager)?; } self.upsert(http_response, source) } pub fn cancel_pending_http_responses(&self) -> Result<()> { let closed = serde_json::to_value(&HttpResponseState::Closed)?; let (sql, params) = Query::update() .table(HttpResponseIden::Table) .values([(HttpResponseIden::State, closed.as_str().into())]) .cond_where(Expr::col(HttpResponseIden::State).ne(closed.as_str())) .build_rusqlite(SqliteQueryBuilder); let mut stmt = self.conn.prepare(sql.as_str())?; stmt.execute(&*params.as_params())?; Ok(()) } pub fn update_http_response_if_id( &self, response: &HttpResponse, source: &UpdateSource, ) -> Result { if response.id.is_empty() { Ok(response.clone()) } else { self.upsert(response, source) } } } ================================================ FILE: crates/yaak-models/src/queries/key_values.rs ================================================ use crate::db_context::DbContext; use crate::error::Result; use crate::models::{KeyValue, KeyValueIden, UpsertModelInfo}; use crate::util::UpdateSource; use chrono::NaiveDateTime; use log::error; use sea_query::{Asterisk, Cond, Expr, Query, SqliteQueryBuilder}; use sea_query_rusqlite::RusqliteBinder; impl<'a> DbContext<'a> { pub fn list_key_values(&self) -> Result> { let (sql, params) = Query::select() .from(KeyValueIden::Table) .column(Asterisk) // Temporary clause to prevent bug when reverting to the previous version, before the // ID column was added. A previous version will not know about ID and will create // key/value entries that don't have one. This clause ensures they are not queried // TODO: Add migration to delete key/values with NULL IDs later on, then remove this .cond_where(Expr::col(KeyValueIden::Id).is_not_null()) .build_rusqlite(SqliteQueryBuilder); let mut stmt = self.conn.prepare(sql.as_str())?; let items = stmt.query_map(&*params.as_params(), KeyValue::from_row)?; Ok(items.map(|v| v.unwrap()).collect()) } pub fn get_key_value_str(&self, namespace: &str, key: &str, default: &str) -> String { match self.get_key_value_raw(namespace, key) { None => default.to_string(), Some(v) => { let result = serde_json::from_str(&v.value); match result { Ok(v) => v, Err(e) => { error!("Failed to parse string key value: {}", e); default.to_string() } } } } } pub fn get_key_value_dte( &self, namespace: &str, key: &str, default: NaiveDateTime, ) -> NaiveDateTime { match self.get_key_value_raw(namespace, key) { None => default, Some(v) => { let result = serde_json::from_str(&v.value); match result { Ok(v) => v, Err(e) => { error!("Failed to parse date key value: {}", e); default } } } } } pub fn get_key_value_int(&self, namespace: &str, key: &str, default: i32) -> i32 { match self.get_key_value_raw(namespace, key) { None => default.clone(), Some(v) => { let result = serde_json::from_str(&v.value); match result { Ok(v) => v, Err(e) => { error!("Failed to parse int key value: {}", e); default.clone() } } } } } pub fn get_key_value_raw(&self, namespace: &str, key: &str) -> Option { let (sql, params) = Query::select() .from(KeyValueIden::Table) .column(Asterisk) .cond_where( Cond::all() .add(Expr::col(KeyValueIden::Namespace).eq(namespace)) .add(Expr::col(KeyValueIden::Key).eq(key)), ) .build_rusqlite(SqliteQueryBuilder); self.conn.resolve().query_row(sql.as_str(), &*params.as_params(), KeyValue::from_row).ok() } pub fn set_key_value_dte( &self, namespace: &str, key: &str, value: NaiveDateTime, source: &UpdateSource, ) -> (KeyValue, bool) { let encoded = serde_json::to_string(&value).unwrap(); self.set_key_value_raw(namespace, key, &encoded, source) } pub fn set_key_value_str( &self, namespace: &str, key: &str, value: &str, source: &UpdateSource, ) -> (KeyValue, bool) { let encoded = serde_json::to_string(&value).unwrap(); self.set_key_value_raw(namespace, key, &encoded, source) } pub fn set_key_value_int( &self, namespace: &str, key: &str, value: i32, source: &UpdateSource, ) -> (KeyValue, bool) { let encoded = serde_json::to_string(&value).unwrap(); self.set_key_value_raw(namespace, key, &encoded, source) } pub fn set_key_value_raw( &self, namespace: &str, key: &str, value: &str, source: &UpdateSource, ) -> (KeyValue, bool) { match self.get_key_value_raw(namespace, key) { None => ( self.upsert_key_value( &KeyValue { namespace: namespace.to_string(), key: key.to_string(), value: value.to_string(), ..Default::default() }, source, ) .expect("Failed to create key value"), true, ), Some(kv) => ( self.upsert_key_value(&KeyValue { value: value.to_string(), ..kv }, source) .expect("Failed to update key value"), false, ), } } pub fn upsert_key_value( &self, key_value: &KeyValue, source: &UpdateSource, ) -> Result { self.upsert(key_value, source) } pub fn delete_key_value( &self, namespace: &str, key: &str, source: &UpdateSource, ) -> Result<()> { let kv = match self.get_key_value_raw(namespace, key) { None => return Ok(()), Some(m) => m, }; self.delete(&kv, source)?; Ok(()) } } ================================================ FILE: crates/yaak-models/src/queries/mod.rs ================================================ pub mod any_request; mod batch; mod cookie_jars; mod environments; mod folders; mod graphql_introspections; mod grpc_connections; mod grpc_events; mod grpc_requests; mod http_requests; mod http_response_events; mod http_responses; mod key_values; mod model_changes; mod plugin_key_values; mod plugins; mod settings; mod sync_states; mod websocket_connections; mod websocket_events; mod websocket_requests; mod workspace_metas; pub mod workspaces; pub use model_changes::PersistedModelChange; const MAX_HISTORY_ITEMS: usize = 20; use crate::models::HttpRequestHeader; use std::collections::HashMap; /// Deduplicate headers by name (case-insensitive), keeping the latest (most specific) value. /// Preserves the order of first occurrence for each header name. pub(crate) fn dedupe_headers(headers: Vec) -> Vec { let mut index_by_name: HashMap = HashMap::new(); let mut deduped: Vec = Vec::new(); for header in headers { let key = header.name.to_lowercase(); if let Some(&idx) = index_by_name.get(&key) { deduped[idx] = header; } else { index_by_name.insert(key, deduped.len()); deduped.push(header); } } deduped } ================================================ FILE: crates/yaak-models/src/queries/model_changes.rs ================================================ use crate::db_context::DbContext; use crate::error::Result; use crate::util::ModelPayload; use rusqlite::params; use rusqlite::types::Type; #[derive(Debug, Clone)] pub struct PersistedModelChange { pub id: i64, pub created_at: String, pub payload: ModelPayload, } impl<'a> DbContext<'a> { pub fn list_model_changes_after( &self, after_id: i64, limit: usize, ) -> Result> { let mut stmt = self.conn.prepare( r#" SELECT id, created_at, payload FROM model_changes WHERE id > ?1 ORDER BY id ASC LIMIT ?2 "#, )?; let items = stmt.query_map(params![after_id, limit as i64], |row| { let id: i64 = row.get(0)?; let created_at: String = row.get(1)?; let payload_raw: String = row.get(2)?; let payload = serde_json::from_str::(&payload_raw).map_err(|e| { rusqlite::Error::FromSqlConversionFailure(2, Type::Text, Box::new(e)) })?; Ok(PersistedModelChange { id, created_at, payload }) })?; Ok(items.collect::, rusqlite::Error>>()?) } pub fn list_model_changes_since( &self, since_created_at: &str, since_id: i64, limit: usize, ) -> Result> { let mut stmt = self.conn.prepare( r#" SELECT id, created_at, payload FROM model_changes WHERE created_at > ?1 OR (created_at = ?1 AND id > ?2) ORDER BY created_at ASC, id ASC LIMIT ?3 "#, )?; let items = stmt.query_map(params![since_created_at, since_id, limit as i64], |row| { let id: i64 = row.get(0)?; let created_at: String = row.get(1)?; let payload_raw: String = row.get(2)?; let payload = serde_json::from_str::(&payload_raw).map_err(|e| { rusqlite::Error::FromSqlConversionFailure(2, Type::Text, Box::new(e)) })?; Ok(PersistedModelChange { id, created_at, payload }) })?; Ok(items.collect::, rusqlite::Error>>()?) } pub fn prune_model_changes_older_than_days(&self, days: i64) -> Result { let offset = format!("-{days} days"); Ok(self.conn.resolve().execute( r#" DELETE FROM model_changes WHERE created_at < STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW', ?1) "#, params![offset], )?) } pub fn prune_model_changes_older_than_hours(&self, hours: i64) -> Result { let offset = format!("-{hours} hours"); Ok(self.conn.resolve().execute( r#" DELETE FROM model_changes WHERE created_at < STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW', ?1) "#, params![offset], )?) } } #[cfg(test)] mod tests { use super::*; use crate::init_in_memory; use crate::models::Workspace; use crate::util::{ModelChangeEvent, UpdateSource}; use serde_json::json; #[test] fn records_model_changes_for_upsert_and_delete() { let (query_manager, _blob_manager, _rx) = init_in_memory().expect("Failed to init DB"); let db = query_manager.connect(); let workspace = db .upsert_workspace( &Workspace { name: "Changes Test".to_string(), setting_follow_redirects: true, setting_validate_certificates: true, ..Default::default() }, &UpdateSource::Sync, ) .expect("Failed to upsert workspace"); let created_changes = db.list_model_changes_after(0, 10).expect("Failed to list changes"); assert_eq!(created_changes.len(), 1); assert_eq!(created_changes[0].payload.model.id(), workspace.id); assert_eq!(created_changes[0].payload.model.model(), "workspace"); assert!(matches!( created_changes[0].payload.change, ModelChangeEvent::Upsert { created: true } )); assert!(matches!(created_changes[0].payload.update_source, UpdateSource::Sync)); db.delete_workspace_by_id(&workspace.id, &UpdateSource::Sync) .expect("Failed to delete workspace"); let all_changes = db.list_model_changes_after(0, 10).expect("Failed to list changes"); assert_eq!(all_changes.len(), 2); assert!(matches!(all_changes[1].payload.change, ModelChangeEvent::Delete)); assert!(all_changes[1].id > all_changes[0].id); let changes_after_first = db .list_model_changes_after(all_changes[0].id, 10) .expect("Failed to list changes after cursor"); assert_eq!(changes_after_first.len(), 1); assert!(matches!(changes_after_first[0].payload.change, ModelChangeEvent::Delete)); } #[test] fn prunes_old_model_changes() { let (query_manager, _blob_manager, _rx) = init_in_memory().expect("Failed to init DB"); let db = query_manager.connect(); db.upsert_workspace( &Workspace { name: "Prune Test".to_string(), setting_follow_redirects: true, setting_validate_certificates: true, ..Default::default() }, &UpdateSource::Sync, ) .expect("Failed to upsert workspace"); let changes = db.list_model_changes_after(0, 10).expect("Failed to list changes"); assert_eq!(changes.len(), 1); db.conn .resolve() .execute( "UPDATE model_changes SET created_at = '2000-01-01 00:00:00.000' WHERE id = ?1", params![changes[0].id], ) .expect("Failed to age model change row"); let pruned = db.prune_model_changes_older_than_days(30).expect("Failed to prune model changes"); assert_eq!(pruned, 1); assert!(db.list_model_changes_after(0, 10).expect("Failed to list changes").is_empty()); } #[test] fn list_model_changes_since_uses_timestamp_with_id_tiebreaker() { let (query_manager, _blob_manager, _rx) = init_in_memory().expect("Failed to init DB"); let db = query_manager.connect(); let workspace = db .upsert_workspace( &Workspace { name: "Cursor Test".to_string(), setting_follow_redirects: true, setting_validate_certificates: true, ..Default::default() }, &UpdateSource::Sync, ) .expect("Failed to upsert workspace"); db.delete_workspace_by_id(&workspace.id, &UpdateSource::Sync) .expect("Failed to delete workspace"); let all = db.list_model_changes_after(0, 10).expect("Failed to list changes"); assert_eq!(all.len(), 2); let fixed_ts = "2026-02-16 00:00:00.000"; db.conn .resolve() .execute("UPDATE model_changes SET created_at = ?1", params![fixed_ts]) .expect("Failed to normalize timestamps"); let after_first = db.list_model_changes_since(fixed_ts, all[0].id, 10).expect("Failed to query cursor"); assert_eq!(after_first.len(), 1); assert_eq!(after_first[0].id, all[1].id); } #[test] fn prunes_old_model_changes_by_hours() { let (query_manager, _blob_manager, _rx) = init_in_memory().expect("Failed to init DB"); let db = query_manager.connect(); db.upsert_workspace( &Workspace { name: "Prune Hour Test".to_string(), setting_follow_redirects: true, setting_validate_certificates: true, ..Default::default() }, &UpdateSource::Sync, ) .expect("Failed to upsert workspace"); let changes = db.list_model_changes_after(0, 10).expect("Failed to list changes"); assert_eq!(changes.len(), 1); db.conn .resolve() .execute( "UPDATE model_changes SET created_at = STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW', '-2 hours') WHERE id = ?1", params![changes[0].id], ) .expect("Failed to age model change row"); let pruned = db.prune_model_changes_older_than_hours(1).expect("Failed to prune model changes"); assert_eq!(pruned, 1); } #[test] fn list_model_changes_deserializes_http_response_event_payload() { let (query_manager, _blob_manager, _rx) = init_in_memory().expect("Failed to init DB"); let db = query_manager.connect(); let payload = json!({ "model": { "model": "http_response_event", "id": "re_test", "createdAt": "2026-02-16T21:01:34.809162", "updatedAt": "2026-02-16T21:01:34.809163", "workspaceId": "wk_test", "responseId": "rs_test", "event": { "type": "info", "message": "hello" } }, "updateSource": { "type": "sync" }, "change": { "type": "upsert", "created": false } }); db.conn .resolve() .execute( r#" INSERT INTO model_changes (model, model_id, change, update_source, payload) VALUES (?1, ?2, ?3, ?4, ?5) "#, params![ "http_response_event", "re_test", r#"{"type":"upsert","created":false}"#, r#"{"type":"sync"}"#, payload.to_string(), ], ) .expect("Failed to insert model change row"); let changes = db.list_model_changes_after(0, 10).expect("Failed to list changes"); assert_eq!(changes.len(), 1); assert_eq!(changes[0].payload.model.model(), "http_response_event"); assert_eq!(changes[0].payload.model.id(), "re_test"); } } ================================================ FILE: crates/yaak-models/src/queries/plugin_key_values.rs ================================================ use crate::db_context::DbContext; use crate::error::Result; use crate::models::{PluginKeyValue, PluginKeyValueIden}; use sea_query::Keyword::CurrentTimestamp; use sea_query::{Asterisk, Cond, Expr, OnConflict, Query, SqliteQueryBuilder}; use sea_query_rusqlite::RusqliteBinder; impl<'a> DbContext<'a> { pub fn get_plugin_key_value(&self, plugin_name: &str, key: &str) -> Option { let (sql, params) = Query::select() .from(PluginKeyValueIden::Table) .column(Asterisk) .cond_where( Cond::all() .add(Expr::col(PluginKeyValueIden::PluginName).eq(plugin_name)) .add(Expr::col(PluginKeyValueIden::Key).eq(key)), ) .build_rusqlite(SqliteQueryBuilder); self.conn.resolve().query_row(sql.as_str(), &*params.as_params(), |row| row.try_into()).ok() } pub fn set_plugin_key_value( &self, plugin_name: &str, key: &str, value: &str, ) -> (PluginKeyValue, bool) { let existing = self.get_plugin_key_value(plugin_name, key); let (sql, params) = Query::insert() .into_table(PluginKeyValueIden::Table) .columns([ PluginKeyValueIden::CreatedAt, PluginKeyValueIden::UpdatedAt, PluginKeyValueIden::PluginName, PluginKeyValueIden::Key, PluginKeyValueIden::Value, ]) .values_panic([ CurrentTimestamp.into(), CurrentTimestamp.into(), plugin_name.into(), key.into(), value.into(), ]) .on_conflict( OnConflict::new() .update_columns([PluginKeyValueIden::UpdatedAt, PluginKeyValueIden::Value]) .to_owned(), ) .returning_all() .build_rusqlite(SqliteQueryBuilder); let mut stmt = self.conn.prepare(sql.as_str()).expect("Failed to prepare PluginKeyValue upsert"); let m: PluginKeyValue = stmt .query_row(&*params.as_params(), |row| row.try_into()) .expect("Failed to upsert KeyValue"); (m, existing.is_none()) } pub fn delete_plugin_key_value(&self, namespace: &str, key: &str) -> Result { if let None = self.get_plugin_key_value(namespace, key) { return Ok(false); }; let (sql, params) = Query::delete() .from_table(PluginKeyValueIden::Table) .cond_where( Cond::all() .add(Expr::col(PluginKeyValueIden::PluginName).eq(namespace)) .add(Expr::col(PluginKeyValueIden::Key).eq(key)), ) .build_rusqlite(SqliteQueryBuilder); self.conn.execute(sql.as_str(), &*params.as_params())?; Ok(true) } } ================================================ FILE: crates/yaak-models/src/queries/plugins.rs ================================================ use crate::db_context::DbContext; use crate::error::Result; use crate::models::{Plugin, PluginIden}; use crate::util::UpdateSource; impl<'a> DbContext<'a> { pub fn get_plugin(&self, id: &str) -> Result { self.find_one(PluginIden::Id, id) } pub fn get_plugin_by_directory(&self, directory: &str) -> Option { self.find_optional(PluginIden::Directory, directory) } pub fn list_plugins(&self) -> Result> { self.find_all() } pub fn delete_plugin(&self, plugin: &Plugin, source: &UpdateSource) -> Result { self.delete(plugin, source) } pub fn delete_plugin_by_id(&self, id: &str, source: &UpdateSource) -> Result { let plugin = self.get_plugin(id)?; self.delete_plugin(&plugin, source) } pub fn upsert_plugin(&self, plugin: &Plugin, source: &UpdateSource) -> Result { let mut plugin_to_upsert = plugin.clone(); if let Some(existing) = self.get_plugin_by_directory(&plugin.directory) { plugin_to_upsert.id = existing.id; } self.upsert(&plugin_to_upsert, source) } } ================================================ FILE: crates/yaak-models/src/queries/settings.rs ================================================ use std::collections::HashMap; use crate::db_context::DbContext; use crate::error::Result; use crate::models::{EditorKeymap, Settings, SettingsIden}; use crate::util::UpdateSource; impl<'a> DbContext<'a> { pub fn get_settings(&self) -> Settings { let id = "default".to_string(); if let Some(s) = self.find_optional::(SettingsIden::Id, &id) { return s; }; let settings = Settings { model: "settings".to_string(), id, created_at: Default::default(), updated_at: Default::default(), appearance: "system".to_string(), client_certificates: Vec::new(), editor_font_size: 12, editor_font: None, editor_keymap: EditorKeymap::Default, editor_soft_wrap: true, interface_font_size: 14, interface_scale: 1.0, interface_font: None, hide_window_controls: false, use_native_titlebar: false, open_workspace_new_window: None, proxy: None, theme_dark: "yaak-dark".to_string(), theme_light: "yaak-light".to_string(), update_channel: "stable".to_string(), autoupdate: true, colored_methods: false, hide_license_badge: false, auto_download_updates: true, check_notifications: true, hotkeys: HashMap::new(), }; self.upsert(&settings, &UpdateSource::Background).expect("Failed to upsert settings") } pub fn upsert_settings(&self, settings: &Settings, source: &UpdateSource) -> Result { self.upsert(settings, source) } } ================================================ FILE: crates/yaak-models/src/queries/sync_states.rs ================================================ use crate::db_context::DbContext; use crate::error::Result; use crate::models::{SyncState, SyncStateIden, UpsertModelInfo}; use crate::util::UpdateSource; use sea_query::{Asterisk, Cond, Expr, Query, SqliteQueryBuilder}; use sea_query_rusqlite::RusqliteBinder; use std::path::Path; impl<'a> DbContext<'a> { pub fn get_sync_state(&self, id: &str) -> Result { self.find_one(SyncStateIden::Id, id) } pub fn upsert_sync_state(&self, sync_state: &SyncState) -> Result { self.upsert(sync_state, &UpdateSource::Sync) } pub fn list_sync_states_for_workspace( &self, workspace_id: &str, sync_dir: &Path, ) -> Result> { let (sql, params) = Query::select() .from(SyncStateIden::Table) .column(Asterisk) .cond_where( Cond::all() .add(Expr::col(SyncStateIden::WorkspaceId).eq(workspace_id)) .add(Expr::col(SyncStateIden::SyncDir).eq(sync_dir.to_string_lossy())), ) .build_rusqlite(SqliteQueryBuilder); let mut stmt = self.conn.prepare(sql.as_str())?; let items = stmt.query_map(&*params.as_params(), SyncState::from_row)?; Ok(items.map(|v| v.unwrap()).collect()) } pub fn delete_sync_state(&self, sync_state: &SyncState) -> Result { self.delete(sync_state, &UpdateSource::Sync) } pub fn delete_sync_state_by_id(&self, id: &str) -> Result { let sync_state = self.get_sync_state(id)?; self.delete_sync_state(&sync_state) } } ================================================ FILE: crates/yaak-models/src/queries/websocket_connections.rs ================================================ use crate::db_context::DbContext; use crate::error::Result; use crate::models::{WebsocketConnection, WebsocketConnectionIden, WebsocketConnectionState}; use crate::queries::MAX_HISTORY_ITEMS; use crate::util::UpdateSource; use log::debug; use sea_query::{Expr, Query, SqliteQueryBuilder}; use sea_query_rusqlite::RusqliteBinder; impl<'a> DbContext<'a> { pub fn get_websocket_connection(&self, id: &str) -> Result { self.find_one(WebsocketConnectionIden::Id, id) } pub fn delete_all_websocket_connections_for_request( &self, request_id: &str, source: &UpdateSource, ) -> Result<()> { let responses = self.list_websocket_connections_for_request(request_id)?; for m in responses { self.delete(&m, source)?; } Ok(()) } pub fn delete_all_websocket_connections_for_workspace( &self, workspace_id: &str, source: &UpdateSource, ) -> Result<()> { let responses = self.list_websocket_connections(workspace_id)?; for m in responses { self.delete(&m, source)?; } Ok(()) } pub fn list_websocket_connections( &self, workspace_id: &str, ) -> Result> { self.find_many(WebsocketConnectionIden::WorkspaceId, workspace_id, None) } pub fn list_websocket_connections_for_request( &self, request_id: &str, ) -> Result> { self.find_many(WebsocketConnectionIden::RequestId, request_id, None) } pub fn delete_websocket_connection( &self, websocket_connection: &WebsocketConnection, source: &UpdateSource, ) -> Result { self.delete(websocket_connection, source) } pub fn delete_websocket_connection_by_id( &self, id: &str, source: &UpdateSource, ) -> Result { let websocket_connection = self.get_websocket_connection(id)?; self.delete_websocket_connection(&websocket_connection, source) } pub fn upsert_websocket_connection( &self, websocket_connection: &WebsocketConnection, source: &UpdateSource, ) -> Result { let connections = self.list_websocket_connections_for_request(&websocket_connection.request_id)?; for m in connections.iter().skip(MAX_HISTORY_ITEMS - 1) { debug!("Deleting old websocket connection {}", websocket_connection.id); self.delete_websocket_connection(&m, source)?; } self.upsert(websocket_connection, source) } pub fn cancel_pending_websocket_connections(&self) -> Result<()> { let closed = serde_json::to_value(&WebsocketConnectionState::Closed)?; let (sql, params) = Query::update() .table(WebsocketConnectionIden::Table) .values([(WebsocketConnectionIden::State, closed.as_str().into())]) .cond_where(Expr::col(WebsocketConnectionIden::State).ne(closed.as_str())) .build_rusqlite(SqliteQueryBuilder); let mut stmt = self.conn.prepare(sql.as_str())?; stmt.execute(&*params.as_params())?; Ok(()) } } ================================================ FILE: crates/yaak-models/src/queries/websocket_events.rs ================================================ use crate::db_context::DbContext; use crate::error::Result; use crate::models::{WebsocketEvent, WebsocketEventIden}; use crate::util::UpdateSource; impl<'a> DbContext<'a> { pub fn get_websocket_event(&self, id: &str) -> Result { self.find_one(WebsocketEventIden::Id, id) } pub fn list_websocket_events(&self, connection_id: &str) -> Result> { self.find_many(WebsocketEventIden::ConnectionId, connection_id, None) } pub fn upsert_websocket_event( &self, websocket_event: &WebsocketEvent, source: &UpdateSource, ) -> Result { self.upsert(websocket_event, source) } } ================================================ FILE: crates/yaak-models/src/queries/websocket_requests.rs ================================================ use super::dedupe_headers; use crate::db_context::DbContext; use crate::error::Result; use crate::models::{ Folder, FolderIden, HttpRequestHeader, WebsocketRequest, WebsocketRequestIden, }; use crate::util::UpdateSource; use serde_json::Value; use std::collections::BTreeMap; impl<'a> DbContext<'a> { pub fn get_websocket_request(&self, id: &str) -> Result { self.find_one(WebsocketRequestIden::Id, id) } pub fn list_websocket_requests(&self, workspace_id: &str) -> Result> { self.find_many(WebsocketRequestIden::WorkspaceId, workspace_id, None) } pub fn list_websocket_requests_for_folder_recursive( &self, folder_id: &str, ) -> Result> { let mut children = Vec::new(); for folder in self.find_many::(FolderIden::FolderId, folder_id, None)? { children.extend(self.list_websocket_requests_for_folder_recursive(&folder.id)?); } for request in self.find_many::(WebsocketRequestIden::FolderId, folder_id, None)? { children.push(request); } Ok(children) } pub fn delete_websocket_request( &self, websocket_request: &WebsocketRequest, source: &UpdateSource, ) -> Result { self.delete_all_websocket_connections_for_request(websocket_request.id.as_str(), source)?; self.delete(websocket_request, source) } pub fn delete_websocket_request_by_id( &self, id: &str, source: &UpdateSource, ) -> Result { let request = self.get_websocket_request(id)?; self.delete_websocket_request(&request, source) } pub fn duplicate_websocket_request( &self, websocket_request: &WebsocketRequest, source: &UpdateSource, ) -> Result { let mut websocket_request = websocket_request.clone(); websocket_request.id = "".to_string(); websocket_request.sort_priority = websocket_request.sort_priority + 0.001; self.upsert(&websocket_request, source) } pub fn upsert_websocket_request( &self, websocket_request: &WebsocketRequest, source: &UpdateSource, ) -> Result { self.upsert(websocket_request, source) } pub fn resolve_auth_for_websocket_request( &self, websocket_request: &WebsocketRequest, ) -> Result<(Option, BTreeMap, String)> { if let Some(at) = websocket_request.authentication_type.clone() { return Ok(( Some(at), websocket_request.authentication.clone(), websocket_request.id.clone(), )); } if let Some(folder_id) = websocket_request.folder_id.clone() { let folder = self.get_folder(&folder_id)?; return self.resolve_auth_for_folder(&folder); } let workspace = self.get_workspace(&websocket_request.workspace_id)?; Ok(self.resolve_auth_for_workspace(&workspace)) } pub fn resolve_headers_for_websocket_request( &self, websocket_request: &WebsocketRequest, ) -> Result> { let workspace = self.get_workspace(&websocket_request.workspace_id)?; // Resolved headers should be from furthest to closest ancestor, to override logically. let mut headers = Vec::new(); headers.append(&mut workspace.headers.clone()); if let Some(folder_id) = websocket_request.folder_id.clone() { let parent_folder = self.get_folder(&folder_id)?; let mut folder_headers = self.resolve_headers_for_folder(&parent_folder)?; headers.append(&mut folder_headers); } else { let workspace = self.get_workspace(&websocket_request.workspace_id)?; let mut workspace_headers = self.resolve_headers_for_workspace(&workspace); headers.append(&mut workspace_headers); } headers.append(&mut websocket_request.headers.clone()); Ok(dedupe_headers(headers)) } } ================================================ FILE: crates/yaak-models/src/queries/workspace_metas.rs ================================================ use crate::db_context::DbContext; use crate::error::Result; use crate::models::{WorkspaceMeta, WorkspaceMetaIden}; use crate::util::UpdateSource; use log::info; impl<'a> DbContext<'a> { pub fn get_workspace_meta(&self, workspace_id: &str) -> Option { self.find_optional(WorkspaceMetaIden::WorkspaceId, workspace_id) } pub fn list_workspace_metas(&self, workspace_id: &str) -> Result> { let mut workspace_metas = self.find_many(WorkspaceMetaIden::WorkspaceId, workspace_id, None)?; if workspace_metas.is_empty() { let wm = WorkspaceMeta { workspace_id: workspace_id.to_string(), ..Default::default() }; workspace_metas.push(self.upsert_workspace_meta(&wm, &UpdateSource::Background)?) } Ok(workspace_metas) } pub fn get_or_create_workspace_meta(&self, workspace_id: &str) -> Result { let workspace_meta = self.get_workspace_meta(workspace_id); if let Some(workspace_meta) = workspace_meta { return Ok(workspace_meta); } let workspace_meta = WorkspaceMeta { workspace_id: workspace_id.to_string(), ..Default::default() }; info!("Creating WorkspaceMeta for {workspace_id}"); self.upsert_workspace_meta(&workspace_meta, &UpdateSource::Background) } pub fn upsert_workspace_meta( &self, workspace_meta: &WorkspaceMeta, source: &UpdateSource, ) -> Result { self.upsert(workspace_meta, source) } } ================================================ FILE: crates/yaak-models/src/queries/workspaces.rs ================================================ use crate::db_context::DbContext; use crate::error::Result; use crate::models::{ EnvironmentIden, FolderIden, GrpcRequestIden, HttpRequestHeader, HttpRequestIden, WebsocketRequestIden, Workspace, WorkspaceIden, }; use crate::util::UpdateSource; use serde_json::Value; use std::collections::BTreeMap; impl<'a> DbContext<'a> { pub fn get_workspace(&self, id: &str) -> Result { self.find_one(WorkspaceIden::Id, id) } pub fn list_workspaces(&self) -> Result> { let mut workspaces = self.find_all()?; if workspaces.is_empty() { workspaces.push(self.upsert_workspace( &Workspace { name: "Yaak".to_string(), setting_follow_redirects: true, setting_validate_certificates: true, ..Default::default() }, &UpdateSource::Background, )?) } Ok(workspaces) } pub fn delete_workspace( &self, workspace: &Workspace, source: &UpdateSource, ) -> Result { for m in self.find_many(HttpRequestIden::WorkspaceId, &workspace.id, None)? { self.delete_http_request(&m, source)?; } for m in self.find_many(GrpcRequestIden::WorkspaceId, &workspace.id, None)? { self.delete_grpc_request(&m, source)?; } for m in self.find_many(WebsocketRequestIden::FolderId, &workspace.id, None)? { self.delete_websocket_request(&m, source)?; } for m in self.find_many(FolderIden::WorkspaceId, &workspace.id, None)? { self.delete_folder(&m, source)?; } for m in self.find_many(EnvironmentIden::WorkspaceId, &workspace.id, None)? { self.delete_environment(&m, source)?; } self.delete(workspace, source) } pub fn delete_workspace_by_id(&self, id: &str, source: &UpdateSource) -> Result { let workspace = self.get_workspace(id)?; self.delete_workspace(&workspace, source) } pub fn upsert_workspace(&self, w: &Workspace, source: &UpdateSource) -> Result { self.upsert(w, source) } pub fn resolve_auth_for_workspace( &self, workspace: &Workspace, ) -> (Option, BTreeMap, String) { ( workspace.authentication_type.clone(), workspace.authentication.clone(), workspace.id.clone(), ) } pub fn resolve_headers_for_workspace(&self, workspace: &Workspace) -> Vec { let mut headers = default_headers(); headers.extend(workspace.headers.clone()); headers } } /// Global default headers that are always sent with requests unless overridden. /// These are prepended to the inheritance chain so workspace/folder/request headers /// can override or disable them. pub fn default_headers() -> Vec { vec![ HttpRequestHeader { enabled: true, name: "User-Agent".to_string(), value: "yaak".to_string(), id: None, }, HttpRequestHeader { enabled: true, name: "Accept".to_string(), value: "*/*".to_string(), id: None, }, ] } ================================================ FILE: crates/yaak-models/src/query_manager.rs ================================================ use crate::connection_or_tx::ConnectionOrTx; use crate::db_context::DbContext; use crate::error::Error::GenericError; use crate::util::ModelPayload; use r2d2::Pool; use r2d2_sqlite::SqliteConnectionManager; use rusqlite::TransactionBehavior; use std::sync::{Arc, Mutex, mpsc}; #[derive(Debug, Clone)] pub struct QueryManager { pool: Arc>>, events_tx: mpsc::Sender, } impl QueryManager { pub fn new(pool: Pool, events_tx: mpsc::Sender) -> Self { QueryManager { pool: Arc::new(Mutex::new(pool)), events_tx } } pub fn connect(&self) -> DbContext<'_> { let conn = self .pool .lock() .expect("Failed to gain lock on DB") .get() .expect("Failed to get a new DB connection from the pool"); DbContext { _events_tx: self.events_tx.clone(), conn: ConnectionOrTx::Connection(conn) } } pub fn with_conn(&self, func: F) -> T where F: FnOnce(&DbContext) -> T, { let conn = self .pool .lock() .expect("Failed to gain lock on DB for transaction") .get() .expect("Failed to get new DB connection from the pool"); let db_context = DbContext { _events_tx: self.events_tx.clone(), conn: ConnectionOrTx::Connection(conn), }; func(&db_context) } pub fn with_tx( &self, func: impl FnOnce(&DbContext) -> std::result::Result, ) -> std::result::Result where E: From, { let mut conn = self .pool .lock() .expect("Failed to gain lock on DB for transaction") .get() .expect("Failed to get new DB connection from the pool"); let tx = conn .transaction_with_behavior(TransactionBehavior::Immediate) .expect("Failed to start DB transaction"); let db_context = DbContext { _events_tx: self.events_tx.clone(), conn: ConnectionOrTx::Transaction(&tx), }; match func(&db_context) { Ok(val) => { tx.commit() .map_err(|e| GenericError(format!("Failed to commit transaction {e:?}")))?; Ok(val) } Err(e) => { tx.rollback() .map_err(|e| GenericError(format!("Failed to rollback transaction {e:?}")))?; Err(e) } } } } ================================================ FILE: crates/yaak-models/src/render.rs ================================================ use crate::models::{Environment, EnvironmentVariable}; use std::collections::HashMap; pub fn make_vars_hashmap(environment_chain: Vec) -> HashMap { let mut variables = HashMap::new(); for e in environment_chain.iter().rev() { variables = add_variable_to_map(variables, &e.variables); } variables } fn add_variable_to_map( m: HashMap, variables: &Vec, ) -> HashMap { let mut map = m.clone(); for variable in variables { if !variable.enabled { continue; } let name = variable.name.as_str(); let value = variable.value.as_str(); map.insert(name.into(), value.into()); } map } ================================================ FILE: crates/yaak-models/src/util.rs ================================================ use crate::db_context::DbContext; use crate::error::Result; use crate::models::{ AnyModel, Environment, Folder, GrpcRequest, HttpRequest, UpsertModelInfo, WebsocketRequest, Workspace, WorkspaceIden, }; use chrono::{NaiveDateTime, Utc}; use nanoid::nanoid; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; use ts_rs::TS; use yaak_core::WorkspaceContext; pub fn generate_prefixed_id(prefix: &str) -> String { format!("{prefix}_{}", generate_id()) } pub fn generate_id() -> String { generate_id_of_length(10) } pub fn generate_id_of_length(n: usize) -> String { let alphabet: [char; 57] = [ '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'J', 'K', 'L', 'M', 'N', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', ]; nanoid!(n, &alphabet) } #[derive(Debug, Clone, Serialize, Deserialize, TS)] #[serde(rename_all = "camelCase")] #[ts(export, export_to = "gen_models.ts")] pub struct ModelPayload { pub model: AnyModel, pub update_source: UpdateSource, pub change: ModelChangeEvent, } #[derive(Debug, Clone, Serialize, Deserialize, TS)] #[serde(rename_all = "snake_case", tag = "type")] #[ts(export, export_to = "gen_models.ts")] pub enum ModelChangeEvent { Upsert { created: bool }, Delete, } #[derive(Debug, Clone, Serialize, Deserialize, TS)] #[serde(rename_all = "snake_case", tag = "type")] #[ts(export, export_to = "gen_models.ts")] pub enum UpdateSource { Background, Import, Plugin, Sync, Window { label: String }, } impl UpdateSource { pub fn from_window_label(label: impl Into) -> Self { Self::Window { label: label.into() } } } #[derive(Default, Debug, Deserialize, Serialize)] #[serde(default, rename_all = "camelCase")] pub struct WorkspaceExport { pub yaak_version: String, pub yaak_schema: i64, pub timestamp: NaiveDateTime, pub resources: BatchUpsertResult, } #[derive(Default, Debug, Deserialize, Serialize, TS)] #[serde(default, rename_all = "camelCase")] #[ts(export, export_to = "gen_util.ts")] pub struct BatchUpsertResult { pub workspaces: Vec, pub environments: Vec, pub folders: Vec, pub http_requests: Vec, pub grpc_requests: Vec, pub websocket_requests: Vec, } pub fn get_workspace_export_resources( db: &DbContext, yaak_version: &str, workspace_ids: Vec<&str>, include_private_environments: bool, ) -> Result { let mut data = WorkspaceExport { yaak_version: yaak_version.to_string(), yaak_schema: 4, timestamp: Utc::now().naive_utc(), resources: BatchUpsertResult { workspaces: Vec::new(), environments: Vec::new(), folders: Vec::new(), http_requests: Vec::new(), grpc_requests: Vec::new(), websocket_requests: Vec::new(), }, }; for workspace_id in workspace_ids { data.resources.workspaces.push(db.find_one(WorkspaceIden::Id, workspace_id)?); data.resources.environments.append( &mut db .list_environments_ensure_base(workspace_id)? .into_iter() .filter(|e| include_private_environments || e.public) .collect(), ); data.resources.folders.append(&mut db.list_folders(workspace_id)?); data.resources.http_requests.append(&mut db.list_http_requests(workspace_id)?); data.resources.grpc_requests.append(&mut db.list_grpc_requests(workspace_id)?); data.resources.websocket_requests.append(&mut db.list_websocket_requests(workspace_id)?); } Ok(data) } pub fn maybe_gen_id( ctx: &WorkspaceContext, id: &str, ids: &mut BTreeMap, ) -> String { if id == "CURRENT_WORKSPACE" { if let Some(wid) = &ctx.workspace_id { return wid.to_string(); } } if !id.starts_with("GENERATE_ID::") { return id.to_string(); } let unique_key = id.replace("GENERATE_ID", ""); if let Some(existing) = ids.get(unique_key.as_str()) { existing.to_string() } else { let new_id = M::generate_id(); ids.insert(unique_key, new_id.clone()); new_id } } pub fn maybe_gen_id_opt( ctx: &WorkspaceContext, id: Option, ids: &mut BTreeMap, ) -> Option { match id { Some(id) => Some(maybe_gen_id::(ctx, id.as_str(), ids)), None => None, } } ================================================ FILE: crates/yaak-plugins/Cargo.toml ================================================ [package] name = "yaak-plugins" version = "0.1.0" edition = "2024" publish = false [dependencies] base64 = "0.22.1" chrono = { workspace = true } dunce = "1.0.4" futures-util = "0.3.30" hex = { workspace = true } keyring = { workspace = true, features = ["apple-native", "windows-native", "sync-secret-service"] } log = { workspace = true } md5 = "0.7.0" path-slash = "0.2.1" rand = "0.9.0" reqwest = { workspace = true, features = ["json"] } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } sha2 = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true, features = ["macros", "rt-multi-thread", "process", "fs"] } tokio-tungstenite = "0.26.1" ts-rs = { workspace = true } yaak-common = { workspace = true } yaak-crypto = { workspace = true } yaak-models = { workspace = true } yaak-templates = { workspace = true } zip-extract = "0.4.0" ================================================ FILE: crates/yaak-plugins/bindings/gen_api.ts ================================================ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { PluginVersion } from "./gen_search"; export type PluginNameVersion = { name: string, version: string, }; export type PluginSearchResponse = { plugins: Array, }; export type PluginUpdatesResponse = { plugins: Array, }; ================================================ FILE: crates/yaak-plugins/bindings/gen_events.ts ================================================ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { AnyModel, Environment, Folder, GrpcRequest, HttpRequest, HttpResponse, WebsocketRequest, Workspace } from "./gen_models"; import type { JsonValue } from "./serde_json/JsonValue"; export type BootRequest = { dir: string, watch: boolean, }; export type CallFolderActionArgs = { folder: Folder, }; export type CallFolderActionRequest = { index: number, pluginRefId: string, args: CallFolderActionArgs, }; export type CallGrpcRequestActionArgs = { grpcRequest: GrpcRequest, protoFiles: Array, }; export type CallGrpcRequestActionRequest = { index: number, pluginRefId: string, args: CallGrpcRequestActionArgs, }; 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, }; export type CallHttpAuthenticationResponse = { /** * HTTP headers to add to the request. Existing headers will be replaced, while * new headers will be added. */ setHeaders?: Array, /** * Query parameters to add to the request. Existing params will be replaced, while * new params will be added. */ setQueryParameters?: Array, }; export type CallHttpRequestActionArgs = { httpRequest: HttpRequest, }; export type CallHttpRequestActionRequest = { index: number, pluginRefId: string, args: CallHttpRequestActionArgs, }; export type CallTemplateFunctionArgs = { purpose: RenderPurpose, values: { [key in string]?: JsonPrimitive }, }; export type CallTemplateFunctionRequest = { name: string, args: CallTemplateFunctionArgs, }; export type CallTemplateFunctionResponse = { value: string | null, error?: string, }; export type CallWebsocketRequestActionArgs = { websocketRequest: WebsocketRequest, }; export type CallWebsocketRequestActionRequest = { index: number, pluginRefId: string, args: CallWebsocketRequestActionArgs, }; export type CallWorkspaceActionArgs = { workspace: Workspace, }; export type CallWorkspaceActionRequest = { index: number, pluginRefId: string, args: CallWorkspaceActionArgs, }; 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 DeleteModelRequest = { model: string, id: string, }; export type DeleteModelResponse = { model: AnyModel, }; export type DialogSize = "sm" | "md" | "lg" | "full" | "dynamic"; export type EditorLanguage = "text" | "javascript" | "json" | "html" | "xml" | "graphql" | "markdown" | "c" | "clojure" | "csharp" | "go" | "http" | "java" | "kotlin" | "objective_c" | "ocaml" | "php" | "powershell" | "python" | "r" | "ruby" | "shell" | "swift"; 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, }; export type FilterRequest = { content: string, filter: string, }; export type FilterResponse = { content: string, error?: string, }; export type FindHttpResponsesRequest = { requestId: string, limit?: number, }; export type FindHttpResponsesResponse = { httpResponses: Array, }; export type FolderAction = { label: string, icon?: Icon, }; 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": "h_stack" } & FormInputHStack | { "type": "banner" } & FormInputBanner | { "type": "markdown" } & FormInputMarkdown | { "type": "key_value" } & FormInputKeyValue; export type FormInputAccordion = { label: string, inputs?: Array, hidden?: boolean, }; export type FormInputBanner = { inputs?: Array, 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, /** * Fixed number of visible rows */ rows?: number, completionOptions?: Array, /** * 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, /** * 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 FormInputHStack = { inputs?: Array, hidden?: boolean, }; 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 FormInputKeyValue = { /** * 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, /** * 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