From b7b8ae5f9459a95c198ef1684a1eb7bd9cd57fd9 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Thu, 27 Aug 2026 15:01:02 -0700 Subject: [PATCH] feat(settings): add HTTP version as an inherited request setting (#609) Co-authored-by: Claude Fable 5 --- .../components/ModelSettingsEditor.tsx | 204 ++++--- apps/yaak-client/lib/requestSettings.ts | 30 +- crates-server/yaak-web/bindings/gen_models.ts | 135 ++++- crates-server/yaak-web/src/send.rs | 1 + .../yaak-rpc-schema/bindings/gen_models.ts | 522 +++++++++++++++--- crates/yaak-git/bindings/gen_models.ts | 7 + crates/yaak-http/src/client.rs | 28 +- crates/yaak-http/src/manager.rs | 5 +- crates/yaak-models/bindings/gen_models.ts | 17 +- .../20260826000000_http-version.sql | 5 + crates/yaak-models/src/models.rs | 73 ++- crates/yaak-models/src/queries/folders.rs | 8 + .../yaak-models/src/queries/http_requests.rs | 86 ++- crates/yaak-models/src/queries/workspaces.rs | 8 +- crates/yaak-plugins/bindings/gen_models.ts | 17 +- crates/yaak-sync/bindings/gen_models.ts | 7 + crates/yaak/src/send.rs | 1 + .../src/bindings/gen_models.ts | 17 +- 18 files changed, 958 insertions(+), 213 deletions(-) create mode 100644 crates/yaak-models/migrations/20260826000000_http-version.sql diff --git a/apps/yaak-client/components/ModelSettingsEditor.tsx b/apps/yaak-client/components/ModelSettingsEditor.tsx index d75ad32b..f720fe44 100644 --- a/apps/yaak-client/components/ModelSettingsEditor.tsx +++ b/apps/yaak-client/components/ModelSettingsEditor.tsx @@ -2,7 +2,9 @@ import type { Folder, GrpcRequest, HttpRequest, + HttpVersion, InheritedBoolSetting, + InheritedHttpVersionSetting, InheritedIntSetting, WebsocketRequest, Workspace, @@ -13,6 +15,7 @@ import { modelSupportsSetting, type RequestSettingDefinition, SETTING_FOLLOW_REDIRECTS, + SETTING_HTTP_VERSION, SETTING_REQUEST_MESSAGE_SIZE, SETTING_REQUEST_TIMEOUT, SETTING_SEND_COOKIES, @@ -21,6 +24,7 @@ import { } from "../lib/requestSettings"; import { Checkbox } from "./core/Checkbox"; import { PlainInput } from "./core/PlainInput"; +import { Select } from "./core/Select"; import { SettingOverrideRow, SettingRow, @@ -38,37 +42,21 @@ interface Props { model: ModelWithSettings; } -type ModelWithSettings = - | Workspace - | Folder - | HttpRequest - | WebsocketRequest - | GrpcRequest; +type ModelWithSettings = Workspace | Folder | HttpRequest | WebsocketRequest | GrpcRequest; type ModelWithHttpSettings = Workspace | Folder | HttpRequest; -type ModelWithTlsSettings = - | Workspace - | Folder - | HttpRequest - | WebsocketRequest - | GrpcRequest; -type ModelWithCookieSettings = - | Workspace - | Folder - | HttpRequest - | WebsocketRequest; -type ModelWithMessageSizeSettings = - | Workspace - | Folder - | WebsocketRequest - | GrpcRequest; +type ModelWithTlsSettings = Workspace | Folder | HttpRequest | WebsocketRequest | GrpcRequest; +type ModelWithCookieSettings = Workspace | Folder | HttpRequest | WebsocketRequest; +type ModelWithMessageSizeSettings = Workspace | Folder | WebsocketRequest | GrpcRequest; type BooleanSetting = boolean | InheritedBoolSetting; type IntegerSetting = number | InheritedIntSetting; +type HttpVersionSetting = HttpVersion | InheritedHttpVersionSetting; type CookieSettingsPatch = { settingSendCookies?: ModelWithCookieSettings["settingSendCookies"]; settingStoreCookies?: ModelWithCookieSettings["settingStoreCookies"]; }; type HttpSettingsPatch = { settingFollowRedirects?: ModelWithHttpSettings["settingFollowRedirects"]; + settingHttpVersion?: ModelWithHttpSettings["settingHttpVersion"]; settingRequestTimeout?: ModelWithHttpSettings["settingRequestTimeout"]; }; type TlsSettingsPatch = { @@ -78,10 +66,7 @@ type MessageSizeSettingsPatch = { settingRequestMessageSize?: ModelWithMessageSizeSettings["settingRequestMessageSize"]; }; -export function ModelSettingsEditor({ - model, - showSectionTitles = false, -}: Props) { +export function ModelSettingsEditor({ model, showSectionTitles = false }: Props) { const ancestors = useModelAncestors(model); const supportsHttpSettings = modelSupportsHttpSettings(model); const supportsCookieSettings = modelSupportsCookieSettings(model); @@ -154,12 +139,26 @@ export function ModelSettingsEditor({ } /> )} + {supportsHttpSettings && ( + + patchHttpSettings(model, { + settingHttpVersion, + }) + } + /> + )} )} {supportsCookieSettings && ( - + isInheritedSetting(setting) && setting.enabled === true, - ).length; + return settings.filter((setting) => isInheritedSetting(setting) && setting.enabled === true) + .length; } -function patchCookieSettings( - model: ModelWithCookieSettings, - patch: Partial, -) { +function patchCookieSettings(model: ModelWithCookieSettings, patch: Partial) { switch (model.model) { case "workspace": return patchModel(model, patch as Partial); @@ -232,10 +231,7 @@ function patchCookieSettings( } } -function patchHttpSettings( - model: ModelWithHttpSettings, - patch: Partial, -) { +function patchHttpSettings(model: ModelWithHttpSettings, patch: Partial) { switch (model.model) { case "workspace": return patchModel(model, patch as Partial); @@ -246,10 +242,7 @@ function patchHttpSettings( } } -function patchTlsSettings( - model: ModelWithTlsSettings, - patch: Partial, -) { +function patchTlsSettings(model: ModelWithTlsSettings, patch: Partial) { switch (model.model) { case "workspace": return patchModel(model, patch as Partial); @@ -280,21 +273,15 @@ function patchMessageSizeSettings( } } -function modelSupportsHttpSettings( - model: ModelWithSettings, -): model is ModelWithHttpSettings { +function modelSupportsHttpSettings(model: ModelWithSettings): model is ModelWithHttpSettings { return modelSupportsSetting(model, SETTING_REQUEST_TIMEOUT); } -function modelSupportsCookieSettings( - model: ModelWithSettings, -): model is ModelWithCookieSettings { +function modelSupportsCookieSettings(model: ModelWithSettings): model is ModelWithCookieSettings { return modelSupportsSetting(model, SETTING_SEND_COOKIES); } -function modelSupportsTlsSettings( - model: ModelWithSettings, -): model is ModelWithTlsSettings { +function modelSupportsTlsSettings(model: ModelWithSettings): model is ModelWithTlsSettings { return modelSupportsSetting(model, SETTING_VALIDATE_CERTIFICATES); } @@ -317,11 +304,7 @@ function BooleanSettingRow({ }) { const inherited = isInheritedSetting(setting); const overridden = inherited ? setting.enabled === true : false; - const value = inherited - ? overridden - ? setting.value - : inheritedValue - : setting; + const value = inherited ? (overridden ? setting.value : inheritedValue) : setting; if (!inherited) { return ( @@ -352,6 +335,63 @@ function BooleanSettingRow({ ); } +const HTTP_VERSION_OPTIONS: { label: string; value: HttpVersion }[] = [ + { label: "Automatic", value: "auto" }, + { label: "HTTP/1.1", value: "http1" }, + { label: "HTTP/2", value: "http2" }, +]; + +function HttpVersionSettingRow({ + inheritedValue, + setting, + settingDefinition, + onChange, +}: { + inheritedValue: HttpVersion; + setting: HttpVersionSetting; + settingDefinition: RequestSettingDefinition<"settingHttpVersion">; + onChange: (setting: HttpVersionSetting) => void; +}) { + const inherited = isInheritedSetting(setting); + const overridden = inherited ? setting.enabled === true : false; + const value = inherited ? (overridden ? setting.value : inheritedValue) : setting; + + if (!inherited) { + return ( + + onChange({ ...setting, enabled: true, value })} + /> + + ); +} + function IntegerSettingRow({ inheritedValue, setting, @@ -365,18 +405,11 @@ function IntegerSettingRow({ }) { const inherited = isInheritedSetting(setting); const overridden = inherited ? setting.enabled === true : false; - const value = inherited - ? overridden - ? setting.value - : inheritedValue - : setting; + const value = inherited ? (overridden ? setting.value : inheritedValue) : setting; if (!inherited) { return ( - + + ; function formatMegabytes(bytes: number) { const megabytes = bytes / BYTES_PER_MB; - return Number.isInteger(megabytes) - ? `${megabytes}` - : megabytes.toFixed(3).replace(/\.?0+$/, ""); + return Number.isInteger(megabytes) ? `${megabytes}` : megabytes.toFixed(3).replace(/\.?0+$/, ""); } function parseMegabytes(value: string) { @@ -626,9 +656,5 @@ function isValidInteger(value: string) { function isValidMegabytes(value: string) { if (value === "") return true; const megabytes = Number(value); - return ( - Number.isFinite(megabytes) && - megabytes >= 0 && - megabytes <= MAX_MESSAGE_SIZE_MB - ); + return Number.isFinite(megabytes) && megabytes >= 0 && megabytes <= MAX_MESSAGE_SIZE_MB; } diff --git a/apps/yaak-client/lib/requestSettings.ts b/apps/yaak-client/lib/requestSettings.ts index 0fd8fa94..a6d907de 100644 --- a/apps/yaak-client/lib/requestSettings.ts +++ b/apps/yaak-client/lib/requestSettings.ts @@ -5,6 +5,7 @@ type ModelType = AnyModel["model"]; type WorkspaceRequestSettings = Pick< Workspace, | "settingFollowRedirects" + | "settingHttpVersion" | "settingRequestMessageSize" | "settingRequestTimeout" | "settingSendCookies" @@ -18,9 +19,7 @@ type ModelTypeWithSetting = { [M in ModelType]: K extends keyof ModelForType ? M : never; }[ModelType]; -export type RequestSettingDefinition< - K extends RequestSettingKey = RequestSettingKey, -> = { +export type RequestSettingDefinition = { defaultValue: WorkspaceRequestSettings[K]; description: string; modelKey: K; @@ -46,8 +45,7 @@ export const SETTING_REQUEST_TIMEOUT = defineRequestSetting({ export const SETTING_REQUEST_MESSAGE_SIZE = defineRequestSetting({ defaultValue: 64 * 1024 * 1024, - description: - "Maximum gRPC or WebSocket message size in MB. Set to 0 to disable.", + description: "Maximum gRPC or WebSocket message size in MB. Set to 0 to disable.", modelKey: "settingRequestMessageSize", models: ["workspace", "folder", "websocket_request", "grpc_request"], title: "Message Size Limit", @@ -57,13 +55,7 @@ export const SETTING_VALIDATE_CERTIFICATES = defineRequestSetting({ defaultValue: true, description: "When disabled, skip validation of server certificates.", modelKey: "settingValidateCertificates", - models: [ - "workspace", - "folder", - "http_request", - "websocket_request", - "grpc_request", - ], + models: ["workspace", "folder", "http_request", "websocket_request", "grpc_request"], title: "Validate TLS certificates", }); @@ -75,10 +67,17 @@ export const SETTING_FOLLOW_REDIRECTS = defineRequestSetting({ title: "Follow redirects", }); +export const SETTING_HTTP_VERSION = defineRequestSetting({ + defaultValue: "auto", + description: "Force HTTP/1.1 or HTTP/2 for servers that don't negotiate the version correctly.", + modelKey: "settingHttpVersion", + models: ["workspace", "folder", "http_request"], + title: "HTTP version", +}); + export const SETTING_SEND_COOKIES = defineRequestSetting({ defaultValue: true, - description: - "Attach matching cookies from the active cookie jar to outgoing requests.", + description: "Attach matching cookies from the active cookie jar to outgoing requests.", modelKey: "settingSendCookies", models: ["workspace", "folder", "http_request", "websocket_request"], title: "Automatically send cookies", @@ -86,8 +85,7 @@ export const SETTING_SEND_COOKIES = defineRequestSetting({ export const SETTING_STORE_COOKIES = defineRequestSetting({ defaultValue: true, - description: - "Save cookies from Set-Cookie response headers to the active cookie jar.", + description: "Save cookies from Set-Cookie response headers to the active cookie jar.", modelKey: "settingStoreCookies", models: ["workspace", "folder", "http_request", "websocket_request"], title: "Automatically store cookies", diff --git a/crates-server/yaak-web/bindings/gen_models.ts b/crates-server/yaak-web/bindings/gen_models.ts index 2263c04a..4e1936dd 100644 --- a/crates-server/yaak-web/bindings/gen_models.ts +++ b/crates-server/yaak-web/bindings/gen_models.ts @@ -1,48 +1,135 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type Cookie = { name: string, value: string, domain: CookieDomain, expires: CookieExpires, path: string, secure: boolean, httpOnly: boolean, sameSite: CookieSameSite | null, }; +export type Cookie = { + name: string; + value: string; + domain: CookieDomain; + expires: CookieExpires; + path: string; + secure: boolean; + httpOnly: boolean; + sameSite: CookieSameSite | null; +}; -export type CookieDomain = { "HostOnly": string } | { "Suffix": string } | "NotPresent" | "Empty"; +export type CookieDomain = { HostOnly: string } | { Suffix: string } | "NotPresent" | "Empty"; -export type CookieExpires = { "AtUtc": string } | "SessionEnd"; +export type CookieExpires = { AtUtc: string } | "SessionEnd"; export type CookieSameSite = "Strict" | "Lax" | "None"; -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, -/** - * URL parameters used for both path placeholders (`:id`) and query string entries. - */ -urlParameters: Array, settingSendCookies: InheritedBoolSetting, settingStoreCookies: InheritedBoolSetting, settingValidateCertificates: InheritedBoolSetting, settingFollowRedirects: InheritedBoolSetting, settingRequestTimeout: InheritedIntSetting, }; +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; + /** + * URL parameters used for both path placeholders (`:id`) and query string entries. + */ + urlParameters: Array; + settingSendCookies: InheritedBoolSetting; + settingStoreCookies: InheritedBoolSetting; + settingValidateCertificates: InheritedBoolSetting; + settingFollowRedirects: InheritedBoolSetting; + settingRequestTimeout: InheritedIntSetting; + settingHttpVersion: InheritedHttpVersionSetting; +}; -export type HttpRequestHeader = { enabled?: boolean, name: string, value: string, id?: string, }; +export type HttpRequestHeader = { enabled?: boolean; name: string; value: string; id?: string }; /** * 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, source_model?: string, source_id?: string, source_name?: 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 HttpResponseEventData = + | { + type: "setting"; + name: string; + value: string; + source_model?: string; + source_id?: string; + source_name?: 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 HttpResponseHeader = { name: string; value: string }; /** * The resolved send settings, values only: what an executor has to obey, with the sources * (which model each came from) left behind in [`ResolvedHttpRequestSettings`]. This is what * crosses from a tab to the Yaak server, and what the server reads. */ -export type HttpSendSettings = { validateCertificates: boolean, followRedirects: boolean, -/** - * Milliseconds. Zero or negative means no timeout. - */ -timeoutMs: number, sendCookies: boolean, storeCookies: boolean, }; +export type HttpSendSettings = { + validateCertificates: boolean; + followRedirects: boolean; + /** + * Milliseconds. Zero or negative means no timeout. + */ + timeoutMs: number; + sendCookies: boolean; + storeCookies: boolean; + httpVersion: HttpVersion; +}; -export type HttpUrlParameter = { enabled?: boolean, -/** - * Colon-prefixed parameters are treated as path parameters if they match, like `/users/:id` - * Other entries are appended as query parameters - */ -name: string, value: string, id?: string, }; +export type HttpUrlParameter = { + enabled?: boolean; + /** + * Colon-prefixed parameters are treated as path parameters if they match, like `/users/:id` + * Other entries are appended as query parameters + */ + name: string; + value: string; + id?: string; +}; -export type InheritedBoolSetting = { enabled?: boolean, value: boolean, }; +export type HttpVersion = "auto" | "http1" | "http2"; -export type InheritedIntSetting = { enabled?: boolean, value: number, }; +export type InheritedBoolSetting = { enabled?: boolean; value: boolean }; + +export type InheritedHttpVersionSetting = { enabled?: boolean; value: HttpVersion }; + +export type InheritedIntSetting = { enabled?: boolean; value: number }; diff --git a/crates-server/yaak-web/src/send.rs b/crates-server/yaak-web/src/send.rs index 814b06d8..e6010c79 100644 --- a/crates-server/yaak-web/src/send.rs +++ b/crates-server/yaak-web/src/send.rs @@ -157,6 +157,7 @@ impl PreparedSend { let (client, resolver) = HttpConnectionOptions { id: uuid::Uuid::new_v4().to_string(), validate_certificates: self.settings.validate_certificates, + http_version: self.settings.http_version, // The proxy connects directly. Going through a system proxy would move DNS, and // therefore the address check, somewhere this process can't see. proxy: HttpConnectionProxySetting::Disabled, diff --git a/crates/common/yaak-rpc-schema/bindings/gen_models.ts b/crates/common/yaak-rpc-schema/bindings/gen_models.ts index 9f73f1e7..500d9c94 100644 --- a/crates/common/yaak-rpc-schema/bindings/gen_models.ts +++ b/crates/common/yaak-rpc-schema/bindings/gen_models.ts @@ -1,127 +1,509 @@ // 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 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 ClientCertificate = { + host: string; + port: number | null; + crtFile: string | null; + keyFile: string | null; + pfxFile: string | null; + passphrase: string | null; + enabled?: boolean; +}; -export type Cookie = { name: string, value: string, domain: CookieDomain, expires: CookieExpires, path: string, secure: boolean, httpOnly: boolean, sameSite: CookieSameSite | null, }; +export type Cookie = { + name: string; + value: string; + domain: CookieDomain; + expires: CookieExpires; + path: string; + secure: boolean; + httpOnly: boolean; + sameSite: CookieSameSite | null; +}; -export type CookieDomain = { "HostOnly": string } | { "Suffix": string } | "NotPresent" | "Empty"; +export type CookieDomain = { HostOnly: string } | { Suffix: string } | "NotPresent" | "Empty"; -export type CookieExpires = { "AtUtc": string } | "SessionEnd"; +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 CookieJar = { + model: "cookie_jar"; + id: string; + createdAt: string; + updatedAt: string; + workspaceId: string; + cookies: Array; + name: string; +}; export type CookieSameSite = "Strict" | "Lax" | "None"; -export type DnsOverride = { hostname: string, ipv4: Array, ipv6: Array, enabled?: boolean, }; +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 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 defined in this environment scope. - * Child environments override parent variables by name. - */ -variables: Array, color: string | null, sortPriority: number, }; +export type Environment = { + model: "environment"; + id: string; + workspaceId: string; + createdAt: string; + updatedAt: string; + name: string; + public: boolean; + parentModel: string; + parentId: string | null; + /** + * Variables defined in this environment scope. + * Child environments override parent variables by name. + */ + variables: Array; + color: string | null; + sortPriority: number; +}; -export type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, }; +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, settingSendCookies: InheritedBoolSetting, settingStoreCookies: InheritedBoolSetting, settingValidateCertificates: InheritedBoolSetting, settingFollowRedirects: InheritedBoolSetting, settingRequestTimeout: InheritedIntSetting, settingRequestMessageSize: InheritedIntSetting, }; +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; + settingSendCookies: InheritedBoolSetting; + settingStoreCookies: InheritedBoolSetting; + settingValidateCertificates: InheritedBoolSetting; + settingFollowRedirects: InheritedBoolSetting; + settingRequestTimeout: InheritedIntSetting; + settingRequestMessageSize: InheritedIntSetting; + settingHttpVersion: InheritedHttpVersionSetting; +}; -export type GraphQlIntrospection = { model: "graphql_introspection", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, content: string | null, }; +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 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 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 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, -/** - * Server URL (http for plaintext or https for secure) - */ -url: string, settingValidateCertificates: InheritedBoolSetting, settingRequestMessageSize: InheritedIntSetting, }; +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; + /** + * Server URL (http for plaintext or https for secure) + */ + url: string; + settingValidateCertificates: InheritedBoolSetting; + settingRequestMessageSize: InheritedIntSetting; +}; -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, -/** - * URL parameters used for both path placeholders (`:id`) and query string entries. - */ -urlParameters: Array, settingSendCookies: InheritedBoolSetting, settingStoreCookies: InheritedBoolSetting, settingValidateCertificates: InheritedBoolSetting, settingFollowRedirects: InheritedBoolSetting, settingRequestTimeout: InheritedIntSetting, }; +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; + /** + * URL parameters used for both path placeholders (`:id`) and query string entries. + */ + urlParameters: Array; + settingSendCookies: InheritedBoolSetting; + settingStoreCookies: InheritedBoolSetting; + settingValidateCertificates: InheritedBoolSetting; + settingFollowRedirects: InheritedBoolSetting; + settingRequestTimeout: InheritedIntSetting; + settingHttpVersion: InheritedHttpVersionSetting; +}; -export type HttpRequestHeader = { enabled?: boolean, name: string, value: string, id?: string, }; +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, 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 HttpResponse = { + model: "http_response"; + id: string; + createdAt: string; + updatedAt: string; + workspaceId: string; + requestId: string; + 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, }; +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, source_model?: string, source_id?: string, source_name?: 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 HttpResponseEventData = + | { + type: "setting"; + name: string; + value: string; + source_model?: string; + source_id?: string; + source_name?: 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 HttpResponseHeader = { name: string; value: string }; export type HttpResponseState = "initialized" | "connected" | "closed"; -/** - * The resolved send settings, values only: what an executor has to obey, with the sources - * (which model each came from) left behind in [`ResolvedHttpRequestSettings`]. This is what - * crosses from a tab to the Yaak server, and what the server reads. - */ -export type HttpSendSettings = { validateCertificates: boolean, followRedirects: boolean, -/** - * Milliseconds. Zero or negative means no timeout. - */ -timeoutMs: number, sendCookies: boolean, storeCookies: boolean, }; +export type HttpUrlParameter = { + enabled?: boolean; + /** + * Colon-prefixed parameters are treated as path parameters if they match, like `/users/:id` + * Other entries are appended as query parameters + */ + name: string; + value: string; + id?: string; +}; -export type HttpUrlParameter = { enabled?: boolean, -/** - * Colon-prefixed parameters are treated as path parameters if they match, like `/users/:id` - * Other entries are appended as query parameters - */ -name: string, value: string, id?: string, }; +export type HttpVersion = "auto" | "http1" | "http2"; -export type InheritedBoolSetting = { enabled?: boolean, value: boolean, }; +export type InheritedBoolSetting = { enabled?: boolean; value: boolean }; -export type InheritedIntSetting = { enabled?: boolean, value: number, }; +export type InheritedHttpVersionSetting = { enabled?: boolean; value: HttpVersion }; -export type KeyValue = { model: "key_value", id: string, createdAt: string, updatedAt: string, key: string, namespace: string, value: string, }; +export type InheritedIntSetting = { enabled?: boolean; value: number }; -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 KeyValue = { + model: "key_value"; + id: string; + createdAt: string; + updatedAt: string; + key: string; + namespace: string; + value: string; +}; + +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 ProxySetting = { "type": "enabled", http: string, https: string, auth: ProxySettingAuth | null, bypass: string, disabled: boolean, } | { "type": "disabled" }; +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 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, promptFeedback: boolean, autoupdate: boolean, autoDownloadUpdates: boolean, checkNotifications: boolean, hotkeys: { [key in string]?: Array }, }; +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; + promptFeedback: boolean; + autoupdate: boolean; + autoDownloadUpdates: boolean; + checkNotifications: boolean; + hotkeys: { [key in string]?: Array }; +}; -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 SyncModel = + | ({ type: "workspace" } & Workspace) + | ({ type: "environment" } & Environment) + | ({ type: "folder" } & Folder) + | ({ type: "http_request" } & HttpRequest) + | ({ type: "grpc_request" } & GrpcRequest) + | ({ type: "websocket_request" } & WebsocketRequest); -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 SyncState = { + model: "sync_state"; + id: string; + workspaceId: string; + createdAt: string; + updatedAt: string; + flushedAt: string; + modelId: string; + checksum: string; + relPath: string; + syncDir: 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 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 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" | "error" | "frame" | "open" | "ping" | "pong" | "text"; +export type WebsocketEventType = + | "binary" + | "close" + | "error" + | "frame" + | "open" + | "ping" + | "pong" + | "text"; -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, -/** - * URL parameters used for both path placeholders (`:id`) and query string entries. - */ -urlParameters: Array, settingSendCookies: InheritedBoolSetting, settingStoreCookies: InheritedBoolSetting, settingValidateCertificates: InheritedBoolSetting, settingRequestMessageSize: InheritedIntSetting, }; +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; + /** + * URL parameters used for both path placeholders (`:id`) and query string entries. + */ + urlParameters: Array; + settingSendCookies: InheritedBoolSetting; + settingStoreCookies: InheritedBoolSetting; + settingValidateCertificates: InheritedBoolSetting; + settingRequestMessageSize: InheritedIntSetting; +}; -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, settingRequestMessageSize: number, settingDnsOverrides: Array, settingSendCookies: boolean, settingStoreCookies: boolean, }; +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; + settingRequestMessageSize: number; + settingDnsOverrides: Array; + settingSendCookies: boolean; + settingStoreCookies: boolean; + settingHttpVersion: HttpVersion; +}; -export type WorkspaceMeta = { model: "workspace_meta", id: string, workspaceId: string, createdAt: string, updatedAt: string, encryptionKey: EncryptedKey | null, settingSyncDir: string | null, }; +export type WorkspaceMeta = { + model: "workspace_meta"; + id: string; + workspaceId: string; + createdAt: string; + updatedAt: string; + encryptionKey: EncryptedKey | null; + settingSyncDir: string | null; +}; diff --git a/crates/yaak-git/bindings/gen_models.ts b/crates/yaak-git/bindings/gen_models.ts index fa90a9b6..2474d8ee 100644 --- a/crates/yaak-git/bindings/gen_models.ts +++ b/crates/yaak-git/bindings/gen_models.ts @@ -47,6 +47,7 @@ export type Folder = { settingFollowRedirects: InheritedBoolSetting; settingRequestTimeout: InheritedIntSetting; settingRequestMessageSize: InheritedIntSetting; + settingHttpVersion: InheritedHttpVersionSetting; }; export type GrpcRequest = { @@ -99,6 +100,7 @@ export type HttpRequest = { settingValidateCertificates: InheritedBoolSetting; settingFollowRedirects: InheritedBoolSetting; settingRequestTimeout: InheritedIntSetting; + settingHttpVersion: InheritedHttpVersionSetting; }; export type HttpRequestHeader = { enabled?: boolean; name: string; value: string; id?: string }; @@ -114,8 +116,12 @@ export type HttpUrlParameter = { id?: string; }; +export type HttpVersion = "auto" | "http1" | "http2"; + export type InheritedBoolSetting = { enabled?: boolean; value: boolean }; +export type InheritedHttpVersionSetting = { enabled?: boolean; value: HttpVersion }; + export type InheritedIntSetting = { enabled?: boolean; value: number }; export type SyncModel = @@ -169,4 +175,5 @@ export type Workspace = { settingDnsOverrides: Array; settingSendCookies: boolean; settingStoreCookies: boolean; + settingHttpVersion: HttpVersion; }; diff --git a/crates/yaak-http/src/client.rs b/crates/yaak-http/src/client.rs index 139357f7..93fe64b8 100644 --- a/crates/yaak-http/src/client.rs +++ b/crates/yaak-http/src/client.rs @@ -3,7 +3,7 @@ use crate::error::Result; use log::{debug, info, warn}; use reqwest::{Client, ClientBuilder, Proxy, redirect}; use std::sync::{Arc, Mutex}; -use yaak_models::models::DnsOverride; +use yaak_models::models::{DnsOverride, HttpVersion}; use yaak_tls::{ ClientCertificateConfig, NativeClientIdentity, get_tls_config, load_native_client_identity, }; @@ -39,6 +39,7 @@ impl ConfiguredClient { /// supports TLS 1.0+ for legacy servers. fn build_native_tls_connector( client_cert: Option, + http_version: HttpVersion, ) -> Result { let mut builder = native_tls::TlsConnector::builder(); builder.danger_accept_invalid_certs(true); @@ -46,7 +47,11 @@ fn build_native_tls_connector( builder.min_protocol_version(Some(native_tls::Protocol::Tlsv10)); // reqwest cannot add ALPN to a connector it did not build, so without this // the native path would silently negotiate HTTP/1.1 for every request. - builder.request_alpns(&["h2", "http/1.1"]); + match http_version { + HttpVersion::Auto => builder.request_alpns(&["h2", "http/1.1"]), + HttpVersion::Http1 => builder.request_alpns(&["http/1.1"]), + HttpVersion::Http2 => builder.request_alpns(&["h2"]), + }; if let Some(identity) = build_native_tls_identity(client_cert)? { builder.identity(identity); @@ -100,6 +105,7 @@ pub enum HttpConnectionProxySetting { pub struct HttpConnectionOptions { pub id: String, pub validate_certificates: bool, + pub http_version: HttpVersion, pub proxy: HttpConnectionProxySetting, pub client_certificate: Option, pub dns_overrides: Vec, @@ -128,14 +134,28 @@ impl HttpConnectionOptions { // This is needed so we can emit DNS timing events for each request .pool_max_idle_per_host(0); + match self.http_version { + HttpVersion::Auto => {} + HttpVersion::Http1 => client = client.http1_only(), + HttpVersion::Http2 => client = client.http2_prior_knowledge(), + } + // Configure TLS if self.validate_certificates { // Use rustls with platform certificate verification (TLS 1.2+ only) - let config = get_tls_config(true, true, self.client_certificate.clone())?; + let mut config = get_tls_config(true, true, self.client_certificate.clone())?; + // A forced version must also constrain ALPN, or the server may + // negotiate a protocol the client then refuses to speak + match self.http_version { + HttpVersion::Auto => {} + HttpVersion::Http1 => config.alpn_protocols = vec![b"http/1.1".to_vec()], + HttpVersion::Http2 => config.alpn_protocols = vec![b"h2".to_vec()], + } client = client.use_preconfigured_tls(config); } else { // Use native TLS for maximum compatibility (supports TLS 1.0+) - let connector = build_native_tls_connector(self.client_certificate.clone())?; + let connector = + build_native_tls_connector(self.client_certificate.clone(), self.http_version)?; client = client.use_preconfigured_tls(connector); } diff --git a/crates/yaak-http/src/manager.rs b/crates/yaak-http/src/manager.rs index cb9555d7..a4c3ba59 100644 --- a/crates/yaak-http/src/manager.rs +++ b/crates/yaak-http/src/manager.rs @@ -28,7 +28,10 @@ impl HttpConnectionManager { pub async fn get_client(&self, opt: &HttpConnectionOptions) -> Result { let mut connections = self.connections.write().await; - let id = opt.id.clone(); + // The key must include any per-request option that changes how the + // client is built, or a send after a settings change reuses a client + // built with the old value for up to the cache TTL. + let id = format!("{}::{}::{}", opt.id, opt.validate_certificates, opt.http_version); // Clean old connections connections.retain(|_, (_, last_used)| last_used.elapsed() <= self.ttl); diff --git a/crates/yaak-models/bindings/gen_models.ts b/crates/yaak-models/bindings/gen_models.ts index 21da46e7..e6981f82 100644 --- a/crates/yaak-models/bindings/gen_models.ts +++ b/crates/yaak-models/bindings/gen_models.ts @@ -110,6 +110,7 @@ export type Folder = { settingFollowRedirects: InheritedBoolSetting; settingRequestTimeout: InheritedIntSetting; settingRequestMessageSize: InheritedIntSetting; + settingHttpVersion: InheritedHttpVersionSetting; }; export type GraphQlIntrospection = { @@ -214,6 +215,7 @@ export type HttpRequest = { settingValidateCertificates: InheritedBoolSetting; settingFollowRedirects: InheritedBoolSetting; settingRequestTimeout: InheritedIntSetting; + settingHttpVersion: InheritedHttpVersionSetting; }; export type HttpRequestHeader = { enabled?: boolean; name: string; value: string; id?: string }; @@ -318,6 +320,7 @@ export type HttpSendSettings = { timeoutMs: number; sendCookies: boolean; storeCookies: boolean; + httpVersion: HttpVersion; }; export type HttpUrlParameter = { @@ -331,8 +334,12 @@ export type HttpUrlParameter = { id?: string; }; +export type HttpVersion = "auto" | "http1" | "http2"; + export type InheritedBoolSetting = { enabled?: boolean; value: boolean }; +export type InheritedHttpVersionSetting = { enabled?: boolean; value: HttpVersion }; + export type InheritedIntSetting = { enabled?: boolean; value: number }; export type KeyValue = { @@ -475,7 +482,14 @@ export type WebsocketEvent = { }; export type WebsocketEventType = - "binary" | "close" | "error" | "frame" | "open" | "ping" | "pong" | "text"; + | "binary" + | "close" + | "error" + | "frame" + | "open" + | "ping" + | "pong" + | "text"; export type WebsocketMessageType = "text" | "binary"; @@ -522,6 +536,7 @@ export type Workspace = { settingDnsOverrides: Array; settingSendCookies: boolean; settingStoreCookies: boolean; + settingHttpVersion: HttpVersion; }; export type WorkspaceMeta = { diff --git a/crates/yaak-models/migrations/20260826000000_http-version.sql b/crates/yaak-models/migrations/20260826000000_http-version.sql new file mode 100644 index 00000000..fd6fcbc2 --- /dev/null +++ b/crates/yaak-models/migrations/20260826000000_http-version.sql @@ -0,0 +1,5 @@ +ALTER TABLE workspaces ADD COLUMN setting_http_version TEXT DEFAULT 'auto' NOT NULL; + +ALTER TABLE folders ADD COLUMN setting_http_version TEXT DEFAULT '{"enabled":false,"value":"auto"}' NOT NULL; + +ALTER TABLE http_requests ADD COLUMN setting_http_version TEXT DEFAULT '{"enabled":false,"value":"auto"}' NOT NULL; diff --git a/crates/yaak-models/src/models.rs b/crates/yaak-models/src/models.rs index 98637b03..a03c91f5 100644 --- a/crates/yaak-models/src/models.rs +++ b/crates/yaak-models/src/models.rs @@ -1,9 +1,9 @@ use crate::error::Result; use crate::models::HttpRequestIden::{ Authentication, AuthenticationType, Body, BodyType, CreatedAt, Description, FolderId, Headers, - Method, Name, SettingFollowRedirects, SettingRequestTimeout, SettingSendCookies, - SettingStoreCookies, SettingValidateCertificates, SortPriority, UpdatedAt, Url, UrlParameters, - WorkspaceId, + Method, Name, SettingFollowRedirects, SettingHttpVersion, SettingRequestTimeout, + SettingSendCookies, SettingStoreCookies, SettingValidateCertificates, SortPriority, UpdatedAt, + Url, UrlParameters, WorkspaceId, }; use crate::util::generate_prefixed_id; use chrono::{NaiveDateTime, Utc}; @@ -143,6 +143,7 @@ pub struct ResolvedHttpRequestSettings { pub request_message_size: ResolvedSetting, pub send_cookies: ResolvedSetting, pub store_cookies: ResolvedSetting, + pub http_version: ResolvedSetting, } impl Default for ResolvedHttpRequestSettings { @@ -154,6 +155,7 @@ impl Default for ResolvedHttpRequestSettings { request_message_size: ResolvedSetting::default_source(DEFAULT_REQUEST_MESSAGE_SIZE), send_cookies: ResolvedSetting::default_source(true), store_cookies: ResolvedSetting::default_source(true), + http_version: ResolvedSetting::default_source(HttpVersion::Auto), } } } @@ -191,6 +193,7 @@ impl ResolvedHttpRequestSettings { event("timeout", timeout, &self.request_timeout), event("send_cookies", self.send_cookies.value.to_string(), &self.send_cookies), event("store_cookies", self.store_cookies.value.to_string(), &self.store_cookies), + event("http_version", self.http_version.value.to_string(), &self.http_version), ] } } @@ -208,6 +211,8 @@ pub struct HttpSendSettings { pub timeout_ms: i32, pub send_cookies: bool, pub store_cookies: bool, + #[serde(default)] + pub http_version: HttpVersion, } impl From<&ResolvedHttpRequestSettings> for HttpSendSettings { @@ -218,6 +223,7 @@ impl From<&ResolvedHttpRequestSettings> for HttpSendSettings { timeout_ms: s.request_timeout.value, send_cookies: s.send_cookies.value, store_cookies: s.store_cookies.value, + http_version: s.http_version.value, } } } @@ -255,6 +261,49 @@ impl Default for InheritedIntSetting { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export, export_to = "gen_models.ts")] +pub enum HttpVersion { + #[default] + Auto, + Http1, + Http2, +} + +impl FromStr for HttpVersion { + type Err = crate::error::Error; + + fn from_str(s: &str) -> Result { + match s { + "http1" => Ok(Self::Http1), + "http2" => Ok(Self::Http2), + _ => Ok(Self::Auto), + } + } +} + +impl Display for HttpVersion { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let str = match self { + HttpVersion::Auto => "auto", + HttpVersion::Http1 => "http1", + HttpVersion::Http2 => "http2", + }; + write!(f, "{}", str) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize, JsonSchema, TS)] +#[serde(default, rename_all = "camelCase")] +#[ts(export, export_to = "gen_models.ts")] +pub struct InheritedHttpVersionSetting { + #[serde(default)] + #[ts(optional, as = "Option")] + pub enabled: bool, + pub value: HttpVersion, +} + #[derive(Debug, Clone, Serialize, Deserialize, TS)] #[serde(rename_all = "snake_case")] #[ts(export, export_to = "gen_models.ts")] @@ -484,6 +533,7 @@ impl Default for Workspace { setting_dns_overrides: Vec::new(), setting_send_cookies: true, setting_store_cookies: true, + setting_http_version: HttpVersion::Auto, } } } @@ -516,6 +566,7 @@ pub struct Workspace { pub setting_dns_overrides: Vec, pub setting_send_cookies: bool, pub setting_store_cookies: bool, + pub setting_http_version: HttpVersion, } impl UpsertModelInfo for Workspace { @@ -560,6 +611,7 @@ impl UpsertModelInfo for Workspace { (SettingDnsOverrides, serde_json::to_string(&self.setting_dns_overrides)?.into()), (SettingSendCookies, self.setting_send_cookies.into()), (SettingStoreCookies, self.setting_store_cookies.into()), + (SettingHttpVersion, self.setting_http_version.to_string().into()), ]) } @@ -579,6 +631,7 @@ impl UpsertModelInfo for Workspace { WorkspaceIden::SettingDnsOverrides, WorkspaceIden::SettingSendCookies, WorkspaceIden::SettingStoreCookies, + WorkspaceIden::SettingHttpVersion, ] } @@ -589,6 +642,7 @@ impl UpsertModelInfo for Workspace { let headers: String = row.get("headers")?; let authentication: String = row.get("authentication")?; let setting_dns_overrides: String = row.get("setting_dns_overrides")?; + let setting_http_version: String = row.get("setting_http_version")?; Ok(Self { id: row.get("id")?, model: row.get("model")?, @@ -607,6 +661,7 @@ impl UpsertModelInfo for Workspace { setting_dns_overrides: serde_json::from_str(&setting_dns_overrides).unwrap_or_default(), setting_send_cookies: row.get("setting_send_cookies")?, setting_store_cookies: row.get("setting_store_cookies")?, + setting_http_version: setting_http_version.parse().unwrap_or_default(), }) } } @@ -1078,6 +1133,7 @@ impl Default for Folder { enabled: false, value: DEFAULT_REQUEST_MESSAGE_SIZE, }, + setting_http_version: InheritedHttpVersionSetting::default(), } } } @@ -1108,6 +1164,7 @@ pub struct Folder { pub setting_follow_redirects: InheritedBoolSetting, pub setting_request_timeout: InheritedIntSetting, pub setting_request_message_size: InheritedIntSetting, + pub setting_http_version: InheritedHttpVersionSetting, } impl UpsertModelInfo for Folder { @@ -1159,6 +1216,7 @@ impl UpsertModelInfo for Folder { SettingRequestMessageSize, serde_json::to_string(&self.setting_request_message_size)?.into(), ), + (SettingHttpVersion, serde_json::to_string(&self.setting_http_version)?.into()), ]) } @@ -1178,6 +1236,7 @@ impl UpsertModelInfo for Folder { FolderIden::SettingFollowRedirects, FolderIden::SettingRequestTimeout, FolderIden::SettingRequestMessageSize, + FolderIden::SettingHttpVersion, ] } @@ -1193,6 +1252,7 @@ impl UpsertModelInfo for Folder { let setting_follow_redirects: String = row.get("setting_follow_redirects")?; let setting_request_timeout: String = row.get("setting_request_timeout")?; let setting_request_message_size: String = row.get("setting_request_message_size")?; + let setting_http_version: String = row.get("setting_http_version")?; Ok(Self { id: row.get("id")?, model: row.get("model")?, @@ -1216,6 +1276,7 @@ impl UpsertModelInfo for Folder { .unwrap_or_default(), setting_request_message_size: serde_json::from_str(&setting_request_message_size) .unwrap_or_else(|_| default_request_message_size_setting()), + setting_http_version: serde_json::from_str(&setting_http_version).unwrap_or_default(), }) } } @@ -1283,6 +1344,7 @@ impl Default for HttpRequest { setting_validate_certificates: InheritedBoolSetting::default(), setting_follow_redirects: InheritedBoolSetting::default(), setting_request_timeout: InheritedIntSetting::default(), + setting_http_version: InheritedHttpVersionSetting::default(), } } } @@ -1319,6 +1381,7 @@ pub struct HttpRequest { pub setting_validate_certificates: InheritedBoolSetting, pub setting_follow_redirects: InheritedBoolSetting, pub setting_request_timeout: InheritedIntSetting, + pub setting_http_version: InheritedHttpVersionSetting, } impl UpsertModelInfo for HttpRequest { @@ -1370,6 +1433,7 @@ impl UpsertModelInfo for HttpRequest { ), (SettingFollowRedirects, serde_json::to_string(&self.setting_follow_redirects)?.into()), (SettingRequestTimeout, serde_json::to_string(&self.setting_request_timeout)?.into()), + (SettingHttpVersion, serde_json::to_string(&self.setting_http_version)?.into()), ]) } @@ -1394,6 +1458,7 @@ impl UpsertModelInfo for HttpRequest { SettingValidateCertificates, SettingFollowRedirects, SettingRequestTimeout, + SettingHttpVersion, ] } @@ -1407,6 +1472,7 @@ impl UpsertModelInfo for HttpRequest { let setting_validate_certificates: String = row.get("setting_validate_certificates")?; let setting_follow_redirects: String = row.get("setting_follow_redirects")?; let setting_request_timeout: String = row.get("setting_request_timeout")?; + let setting_http_version: String = row.get("setting_http_version")?; Ok(Self { id: row.get("id")?, model: row.get("model")?, @@ -1433,6 +1499,7 @@ impl UpsertModelInfo for HttpRequest { .unwrap_or_default(), setting_request_timeout: serde_json::from_str(&setting_request_timeout) .unwrap_or_default(), + setting_http_version: serde_json::from_str(&setting_http_version).unwrap_or_default(), }) } } diff --git a/crates/yaak-models/src/queries/folders.rs b/crates/yaak-models/src/queries/folders.rs index bc5bd389..ce6d0b63 100644 --- a/crates/yaak-models/src/queries/folders.rs +++ b/crates/yaak-models/src/queries/folders.rs @@ -208,6 +208,14 @@ impl<'a> ClientDb<'a> { } else { parent.store_cookies }, + http_version: if folder.setting_http_version.enabled { + ResolvedSetting::from_model( + folder.setting_http_version.value, + AnyModel::Folder(folder.clone()), + ) + } else { + parent.http_version + }, }) } } diff --git a/crates/yaak-models/src/queries/http_requests.rs b/crates/yaak-models/src/queries/http_requests.rs index d4e0d6d7..18fe9171 100644 --- a/crates/yaak-models/src/queries/http_requests.rs +++ b/crates/yaak-models/src/queries/http_requests.rs @@ -153,6 +153,14 @@ impl<'a> ClientDb<'a> { } else { parent.store_cookies }, + http_version: if http_request.setting_http_version.enabled { + ResolvedSetting::from_model( + http_request.setting_http_version.value, + AnyModel::HttpRequest(http_request.clone()), + ) + } else { + parent.http_version + }, }) } @@ -174,7 +182,10 @@ impl<'a> ClientDb<'a> { #[cfg(test)] mod tests { use crate::init_in_memory; - use crate::models::{HttpRequest, HttpRequestHeader}; + use crate::models::{ + Folder, HttpRequest, HttpRequestHeader, HttpVersion, InheritedHttpVersionSetting, Workspace, + }; + use crate::util::UpdateSource; #[test] fn request_resolution_preserves_duplicate_request_headers() { @@ -210,4 +221,77 @@ mod tests { assert_eq!(cookies[1].value, "optional=1"); assert!(!cookies[1].enabled); } + + #[test] + fn http_version_resolves_through_the_inheritance_chain() { + 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: "Test".to_string(), + setting_http_version: HttpVersion::Http2, + ..Default::default() + }, + &UpdateSource::Background, + ) + .expect("Failed to upsert workspace"); + + let folder = db + .upsert_folder( + &Folder { workspace_id: workspace.id.clone(), ..Default::default() }, + &UpdateSource::Background, + ) + .expect("Failed to upsert folder"); + + let request = db + .upsert_http_request( + &HttpRequest { + workspace_id: workspace.id.clone(), + folder_id: Some(folder.id.clone()), + ..Default::default() + }, + &UpdateSource::Background, + ) + .expect("Failed to upsert request"); + + // No overrides, so the workspace base value applies + let resolved = db.resolve_settings_for_http_request(&request).expect("Failed to resolve"); + assert_eq!(resolved.http_version.value, HttpVersion::Http2); + assert_eq!(resolved.http_version.source_model, "workspace"); + + // A folder override beats the workspace base + db.upsert_folder( + &Folder { + setting_http_version: InheritedHttpVersionSetting { + enabled: true, + value: HttpVersion::Http1, + }, + ..folder + }, + &UpdateSource::Background, + ) + .expect("Failed to update folder"); + let resolved = db.resolve_settings_for_http_request(&request).expect("Failed to resolve"); + assert_eq!(resolved.http_version.value, HttpVersion::Http1); + assert_eq!(resolved.http_version.source_model, "folder"); + + // A request override beats them both + let request = db + .upsert_http_request( + &HttpRequest { + setting_http_version: InheritedHttpVersionSetting { + enabled: true, + value: HttpVersion::Auto, + }, + ..request + }, + &UpdateSource::Background, + ) + .expect("Failed to update request"); + let resolved = db.resolve_settings_for_http_request(&request).expect("Failed to resolve"); + assert_eq!(resolved.http_version.value, HttpVersion::Auto); + assert_eq!(resolved.http_version.source_model, "http_request"); + } } diff --git a/crates/yaak-models/src/queries/workspaces.rs b/crates/yaak-models/src/queries/workspaces.rs index 6ed7a262..bf6ae03b 100644 --- a/crates/yaak-models/src/queries/workspaces.rs +++ b/crates/yaak-models/src/queries/workspaces.rs @@ -96,8 +96,8 @@ impl<'a> ClientDb<'a> { deleted } Err(e) => { - let _ = conn - .execute_batch("ROLLBACK TO delete_workspace; RELEASE delete_workspace"); + let _ = + conn.execute_batch("ROLLBACK TO delete_workspace; RELEASE delete_workspace"); return Err(e); } }; @@ -177,6 +177,10 @@ impl<'a> ClientDb<'a> { workspace.setting_store_cookies, AnyModel::Workspace(workspace.clone()), ), + http_version: ResolvedSetting::from_model( + workspace.setting_http_version, + AnyModel::Workspace(workspace.clone()), + ), } } } diff --git a/crates/yaak-plugins/bindings/gen_models.ts b/crates/yaak-plugins/bindings/gen_models.ts index 2f3542af..1f09c532 100644 --- a/crates/yaak-plugins/bindings/gen_models.ts +++ b/crates/yaak-plugins/bindings/gen_models.ts @@ -109,6 +109,7 @@ export type Folder = { settingFollowRedirects: InheritedBoolSetting; settingRequestTimeout: InheritedIntSetting; settingRequestMessageSize: InheritedIntSetting; + settingHttpVersion: InheritedHttpVersionSetting; }; export type GraphQlIntrospection = { @@ -213,6 +214,7 @@ export type HttpRequest = { settingValidateCertificates: InheritedBoolSetting; settingFollowRedirects: InheritedBoolSetting; settingRequestTimeout: InheritedIntSetting; + settingHttpVersion: InheritedHttpVersionSetting; }; export type HttpRequestHeader = { enabled?: boolean; name: string; value: string; id?: string }; @@ -314,8 +316,12 @@ export type HttpUrlParameter = { id?: string; }; +export type HttpVersion = "auto" | "http1" | "http2"; + export type InheritedBoolSetting = { enabled?: boolean; value: boolean }; +export type InheritedHttpVersionSetting = { enabled?: boolean; value: HttpVersion }; + export type InheritedIntSetting = { enabled?: boolean; value: number }; export type KeyValue = { @@ -378,6 +384,7 @@ export type Settings = { themeLight: string; updateChannel: string; hideLicenseBadge: boolean; + promptFeedback: boolean; autoupdate: boolean; autoDownloadUpdates: boolean; checkNotifications: boolean; @@ -428,7 +435,14 @@ export type WebsocketEvent = { }; export type WebsocketEventType = - "binary" | "close" | "error" | "frame" | "open" | "ping" | "pong" | "text"; + | "binary" + | "close" + | "error" + | "frame" + | "open" + | "ping" + | "pong" + | "text"; export type WebsocketRequest = { model: "websocket_request"; @@ -473,6 +487,7 @@ export type Workspace = { settingDnsOverrides: Array; settingSendCookies: boolean; settingStoreCookies: boolean; + settingHttpVersion: HttpVersion; }; export type WorkspaceMeta = { diff --git a/crates/yaak-sync/bindings/gen_models.ts b/crates/yaak-sync/bindings/gen_models.ts index 9f270475..09365161 100644 --- a/crates/yaak-sync/bindings/gen_models.ts +++ b/crates/yaak-sync/bindings/gen_models.ts @@ -47,6 +47,7 @@ export type Folder = { settingFollowRedirects: InheritedBoolSetting; settingRequestTimeout: InheritedIntSetting; settingRequestMessageSize: InheritedIntSetting; + settingHttpVersion: InheritedHttpVersionSetting; }; export type GrpcRequest = { @@ -99,6 +100,7 @@ export type HttpRequest = { settingValidateCertificates: InheritedBoolSetting; settingFollowRedirects: InheritedBoolSetting; settingRequestTimeout: InheritedIntSetting; + settingHttpVersion: InheritedHttpVersionSetting; }; export type HttpRequestHeader = { enabled?: boolean; name: string; value: string; id?: string }; @@ -114,8 +116,12 @@ export type HttpUrlParameter = { id?: string; }; +export type HttpVersion = "auto" | "http1" | "http2"; + export type InheritedBoolSetting = { enabled?: boolean; value: boolean }; +export type InheritedHttpVersionSetting = { enabled?: boolean; value: HttpVersion }; + export type InheritedIntSetting = { enabled?: boolean; value: number }; export type SyncModel = @@ -182,4 +188,5 @@ export type Workspace = { settingDnsOverrides: Array; settingSendCookies: boolean; settingStoreCookies: boolean; + settingHttpVersion: HttpVersion; }; diff --git a/crates/yaak/src/send.rs b/crates/yaak/src/send.rs index 121899e9..cc99144d 100644 --- a/crates/yaak/src/send.rs +++ b/crates/yaak/src/send.rs @@ -190,6 +190,7 @@ impl SendRequestExecutor for ConnectionManagerSendRequestExecutor<'_> { .get_client(&HttpConnectionOptions { id: self.plugin_context_id.clone(), validate_certificates: runtime_config.settings.validate_certificates.value, + http_version: runtime_config.settings.http_version.value, proxy: runtime_config.proxy.clone(), client_certificate, dns_overrides: runtime_config.dns_overrides.clone(), diff --git a/packages/plugin-runtime-types/src/bindings/gen_models.ts b/packages/plugin-runtime-types/src/bindings/gen_models.ts index 2f3542af..1f09c532 100644 --- a/packages/plugin-runtime-types/src/bindings/gen_models.ts +++ b/packages/plugin-runtime-types/src/bindings/gen_models.ts @@ -109,6 +109,7 @@ export type Folder = { settingFollowRedirects: InheritedBoolSetting; settingRequestTimeout: InheritedIntSetting; settingRequestMessageSize: InheritedIntSetting; + settingHttpVersion: InheritedHttpVersionSetting; }; export type GraphQlIntrospection = { @@ -213,6 +214,7 @@ export type HttpRequest = { settingValidateCertificates: InheritedBoolSetting; settingFollowRedirects: InheritedBoolSetting; settingRequestTimeout: InheritedIntSetting; + settingHttpVersion: InheritedHttpVersionSetting; }; export type HttpRequestHeader = { enabled?: boolean; name: string; value: string; id?: string }; @@ -314,8 +316,12 @@ export type HttpUrlParameter = { id?: string; }; +export type HttpVersion = "auto" | "http1" | "http2"; + export type InheritedBoolSetting = { enabled?: boolean; value: boolean }; +export type InheritedHttpVersionSetting = { enabled?: boolean; value: HttpVersion }; + export type InheritedIntSetting = { enabled?: boolean; value: number }; export type KeyValue = { @@ -378,6 +384,7 @@ export type Settings = { themeLight: string; updateChannel: string; hideLicenseBadge: boolean; + promptFeedback: boolean; autoupdate: boolean; autoDownloadUpdates: boolean; checkNotifications: boolean; @@ -428,7 +435,14 @@ export type WebsocketEvent = { }; export type WebsocketEventType = - "binary" | "close" | "error" | "frame" | "open" | "ping" | "pong" | "text"; + | "binary" + | "close" + | "error" + | "frame" + | "open" + | "ping" + | "pong" + | "text"; export type WebsocketRequest = { model: "websocket_request"; @@ -473,6 +487,7 @@ export type Workspace = { settingDnsOverrides: Array; settingSendCookies: boolean; settingStoreCookies: boolean; + settingHttpVersion: HttpVersion; }; export type WorkspaceMeta = {