From 9797b8b1488c7eaf26165cff981526d9b6573bc2 Mon Sep 17 00:00:00 2001 From: zhom <2717306+zhom@users.noreply.github.com> Date: Mon, 12 Oct 2026 07:45:31 +0400 Subject: [PATCH] feat: SDK for new routes --- sdk/README.md | 2 +- sdk/api-paths.json | 67 +++++++- sdk/node/src/client.mts | 178 ++++++++++++++++++++- sdk/node/src/coverage.mts | 13 ++ sdk/node/src/types.mts | 132 ++++++++++++++++ sdk/node/test/requests.test.mts | 187 ++++++++++++++++++++++ sdk/python/src/donutbrowser/client.py | 198 +++++++++++++++++++++++- sdk/python/src/donutbrowser/coverage.py | 13 ++ sdk/python/src/donutbrowser/models.py | 151 +++++++++++++++++- sdk/python/tests/test_requests.py | 186 ++++++++++++++++++++++ 10 files changed, 1115 insertions(+), 12 deletions(-) diff --git a/sdk/README.md b/sdk/README.md index 9fd7acf9..0e8f5234 100644 --- a/sdk/README.md +++ b/sdk/README.md @@ -270,7 +270,7 @@ On top of that, one parameterised test per method drives it against a fake server and asserts the exact verb, path, query string and JSON body it sends. That is what ties the table to reality rather than to a comment. -Of the 71 published operations, 70 are wrapped. The one omission: +Of the 84 published operations, 83 are wrapped. The one omission: - `GET /v1/remote-sessions/{id}/cdp` is a WebSocket upgrade, not a request an HTTP client can make, and bundling a websocket implementation would end the diff --git a/sdk/api-paths.json b/sdk/api-paths.json index b684e385..3d3d2cbe 100644 --- a/sdk/api-paths.json +++ b/sdk/api-paths.json @@ -2,7 +2,7 @@ "source": "src-tauri/src/api_server.rs", "regenerate_with": "python3 sdk/tools/extract-api-paths.py", "description": "Every operation the desktop app publishes in its /openapi.json. The SDK test suites assert this list and their own coverage tables match exactly, so an endpoint added to the app fails the SDK tests until it is either wrapped or deliberately listed as omitted.", - "operation_count": 71, + "operation_count": 84, "operations": [ { "operation_id": "download_browser_api", @@ -174,6 +174,11 @@ "method": "POST", "path": "/v1/profiles/batch/stop" }, + { + "operation_id": "bulk_edit_profiles", + "method": "POST", + "path": "/v1/profiles/bulk-edit" + }, { "operation_id": "distribute_proxies", "method": "POST", @@ -264,6 +269,16 @@ "method": "POST", "path": "/v1/profiles/{id}/run-remote" }, + { + "operation_id": "export_storage_state", + "method": "GET", + "path": "/v1/profiles/{id}/storage-state" + }, + { + "operation_id": "import_storage_state", + "method": "POST", + "path": "/v1/profiles/{id}/storage-state" + }, { "operation_id": "get_proxies", "method": "GET", @@ -294,6 +309,11 @@ "method": "PUT", "path": "/v1/proxies/{id}" }, + { + "operation_id": "rotate_proxy", + "method": "POST", + "path": "/v1/proxies/{id}/rotate" + }, { "operation_id": "get_remote_hours", "method": "GET", @@ -319,11 +339,56 @@ "method": "GET", "path": "/v1/remote-sessions/{id}/cdp" }, + { + "operation_id": "get_statuses", + "method": "GET", + "path": "/v1/statuses" + }, + { + "operation_id": "create_status", + "method": "POST", + "path": "/v1/statuses" + }, + { + "operation_id": "delete_status", + "method": "DELETE", + "path": "/v1/statuses/{id}" + }, + { + "operation_id": "update_status", + "method": "PUT", + "path": "/v1/statuses/{id}" + }, { "operation_id": "get_tags", "method": "GET", "path": "/v1/tags" }, + { + "operation_id": "get_templates", + "method": "GET", + "path": "/v1/templates" + }, + { + "operation_id": "create_template", + "method": "POST", + "path": "/v1/templates" + }, + { + "operation_id": "delete_template", + "method": "DELETE", + "path": "/v1/templates/{id}" + }, + { + "operation_id": "update_template", + "method": "PUT", + "path": "/v1/templates/{id}" + }, + { + "operation_id": "create_profiles_from_template", + "method": "POST", + "path": "/v1/templates/{id}/profiles" + }, { "operation_id": "get_vpns", "method": "GET", diff --git a/sdk/node/src/client.mts b/sdk/node/src/client.mts index de870dc6..3b01b70d 100644 --- a/sdk/node/src/client.mts +++ b/sdk/node/src/client.mts @@ -12,15 +12,18 @@ import { DonutConnectionError, DonutError, errorForStatus } from "./errors.mts"; import type { AgentClick, AgentTyping, + ApiBulkCreateResponse, ApiGroupResponse, ApiProfileResponse, ApiProfilesResponse, ApiProxyResponse, ApiRemoteSessionsResponse, + ApiStatusResponse, ApiVpnExportResponse, ApiVpnResponse, BatchRunResponse, BatchStopResponse, + BulkEditResponse, CookieBotConflictCheck, CookieBotPresetList, CookieBotRun, @@ -46,14 +49,20 @@ import type { PerceptionPage, PickedElement, ProfileImportBatchResult, + ProfilePatch, + ProfileTemplate, ProxyPair, ProxySettings, RemoteHoursQuota, RemoteSessionState, + RotationResult, RunProfileResponse, RunRemoteResponse, SetCloudSyncResponse, StopRemoteResponse, + StorageState, + StorageStateImport, + TemplateSettings, WayfernConfig, } from "./types.mts"; @@ -286,6 +295,10 @@ export class DonutClient { * resolve names. */ dns_server?: string; + /** A status id from `listStatuses`. */ + status_id?: string; + /** At most 20 http(s) URLs; a bare host gets `https://`. */ + start_urls?: string[]; }): Promise { return this.#request("POST", "/v1/profiles", body({ ...request })); } @@ -315,6 +328,10 @@ export class DonutClient { clear_on_close?: boolean; /** Same formats as on create; `""` lets the proxy resolve names again. */ dns_server?: string; + /** `""` clears the status. */ + status_id?: string; + /** Replaces the start pages; `[]` removes them. */ + start_urls?: string[]; }, ): Promise { return this.#request("PUT", `/v1/profiles/${segment(profileId)}`, body({ ...request })); @@ -437,6 +454,53 @@ export class DonutClient { }); } + /** + * GET /v1/profiles/{id}/storage-state + * + * Playwright's `storageState`: each origin's Local Storage, plus the cookies + * when `include_cookies` is true. Readable while the browser runs, but writes + * from the last few seconds may not be on disk yet. + */ + exportStorageState( + profileId: string, + options: { include_cookies?: boolean } = {}, + ): Promise { + return this.#request( + "GET", + `/v1/profiles/${segment(profileId)}/storage-state`, + undefined, + query({ include_cookies: options.include_cookies }), + ); + } + + /** + * POST /v1/profiles/{id}/storage-state + * + * `state` is a Playwright `storageState`; what `context.storageState()` + * returns can be passed as is. An item replaces the one of the same name, + * and the origin's other items stay. The browser must be stopped. + */ + importStorageState(profileId: string, state: StorageState): Promise { + return this.#request("POST", `/v1/profiles/${segment(profileId)}/storage-state`, { + ...state, + }); + } + + /** + * POST /v1/profiles/bulk-edit + * + * Every profile is checked before any is saved, so a bad value, an unknown + * profile or a running browser changes nothing. `patch` is sent as given: a + * `null` in it clears that field. `changed` counts only the profiles that + * really changed. + */ + bulkEditProfiles(profileIds: string[], patch: ProfilePatch): Promise { + return this.#request("POST", "/v1/profiles/bulk-edit", { + profile_ids: profileIds, + patch, + }); + } + /** * POST /v1/profiles/distribute-proxies * @@ -772,6 +836,98 @@ export class DonutClient { return this.#request("GET", "/v1/tags"); } + // ------------------------------------------------------------------ + // Statuses + // ------------------------------------------------------------------ + + /** GET /v1/statuses */ + listStatuses(): Promise { + return this.#request("GET", "/v1/statuses"); + } + + /** + * POST /v1/statuses + * + * `name` is 1 to 40 characters, unique regardless of case, and `color` is + * `#rrggbb`. + */ + createStatus(request: { name: string; color: string }): Promise { + return this.#request("POST", "/v1/statuses", { ...request }); + } + + /** PUT /v1/statuses/{id} */ + updateStatus( + statusId: string, + request: { name: string; color: string }, + ): Promise { + return this.#request("PUT", `/v1/statuses/${segment(statusId)}`, { ...request }); + } + + /** DELETE /v1/statuses/{id}. The status is also taken off every profile that had it. */ + deleteStatus(statusId: string): Promise { + return this.#request("DELETE", `/v1/statuses/${segment(statusId)}`); + } + + // ------------------------------------------------------------------ + // Templates + // ------------------------------------------------------------------ + + /** GET /v1/templates */ + listTemplates(): Promise { + return this.#request("GET", "/v1/templates"); + } + + /** + * POST /v1/templates + * + * `name` is 1 to 60 characters, unique regardless of case. Device fields in + * `settings.wayfern_config` are dropped: every profile made from the + * template gets its own device. + */ + createTemplate(request: { + name: string; + settings?: TemplateSettings; + }): Promise { + return this.#request("POST", "/v1/templates", body({ ...request })); + } + + /** + * PUT /v1/templates/{id} + * + * Replaces the whole template. Settings left out are reset to empty, so to + * rename one, send its current `settings` back with the new name. + */ + updateTemplate( + templateId: string, + request: { name: string; settings?: TemplateSettings }, + ): Promise { + return this.#request("PUT", `/v1/templates/${segment(templateId)}`, body({ ...request })); + } + + /** DELETE /v1/templates/{id}. Profiles made from the template keep their settings. */ + deleteTemplate(templateId: string): Promise { + return this.#request("DELETE", `/v1/templates/${segment(templateId)}`); + } + + /** + * POST /v1/templates/{id}/profiles + * + * Makes `count` (1 to 100) profiles with the newest downloaded Wayfern, each + * with its own device. One profile takes `name`; several take it followed + * by numbers. When `error` is set, creation stopped early and `profiles` + * lists what was made. + */ + createProfilesFromTemplate( + templateId: string, + request: { count: number; name?: string }, + ): Promise { + return this.#request( + "POST", + `/v1/templates/${segment(templateId)}/profiles`, + body({ ...request }), + ); + } + // ------------------------------------------------------------------ // Proxies // ------------------------------------------------------------------ @@ -790,14 +946,21 @@ export class DonutClient { createProxy(request: { name: string; proxy_settings: ProxySettings; + /** The provider's http(s) "change IP" link, used by {@link rotateProxy}. */ + rotation_url?: string; }): Promise { - return this.#request("POST", "/v1/proxies", { ...request }); + return this.#request("POST", "/v1/proxies", body({ ...request })); } /** PUT /v1/proxies/{id} */ updateProxy( proxyId: string, - request: { name?: string; proxy_settings?: ProxySettings }, + request: { + name?: string; + proxy_settings?: ProxySettings; + /** `""` clears the "change IP" link. */ + rotation_url?: string; + }, ): Promise { return this.#request("PUT", `/v1/proxies/${segment(proxyId)}`, body({ ...request })); } @@ -807,6 +970,17 @@ export class DonutClient { return this.#request("DELETE", `/v1/proxies/${segment(proxyId)}`); } + /** + * POST /v1/proxies/{id}/rotate + * + * Calls the proxy's "change IP" link; every profile behind the proxy moves + * to the new IP. A proxy without a link throws `ValidationError`, and a + * provider that refuses or does not answer throws `BadGateway`. + */ + rotateProxy(proxyId: string): Promise { + return this.#request("POST", `/v1/proxies/${segment(proxyId)}/rotate`); + } + /** * POST /v1/proxies/import * diff --git a/sdk/node/src/coverage.mts b/sdk/node/src/coverage.mts index 4c005c74..f62c22fb 100644 --- a/sdk/node/src/coverage.mts +++ b/sdk/node/src/coverage.mts @@ -53,6 +53,7 @@ export const OPERATIONS: ReadonlyMap = new Map([ ["POST /v1/profiles", "createProfile"], ["POST /v1/profiles/batch/run", "batchRunProfiles"], ["POST /v1/profiles/batch/stop", "batchStopProfiles"], + ["POST /v1/profiles/bulk-edit", "bulkEditProfiles"], ["POST /v1/profiles/distribute-proxies", "distributeProxies"], ["POST /v1/profiles/import", "importProfiles"], ["GET /v1/profiles/import/detect", "detectImportProfiles"], @@ -71,17 +72,29 @@ export const OPERATIONS: ReadonlyMap = new Map([ ["POST /v1/profiles/{id}/open-url", "openUrl"], ["POST /v1/profiles/{id}/run", "runProfile"], ["POST /v1/profiles/{id}/run-remote", "runProfileRemote"], + ["GET /v1/profiles/{id}/storage-state", "exportStorageState"], + ["POST /v1/profiles/{id}/storage-state", "importStorageState"], ["GET /v1/proxies", "listProxies"], ["POST /v1/proxies", "createProxy"], ["POST /v1/proxies/import", "importProxies"], ["DELETE /v1/proxies/{id}", "deleteProxy"], ["GET /v1/proxies/{id}", "getProxy"], ["PUT /v1/proxies/{id}", "updateProxy"], + ["POST /v1/proxies/{id}/rotate", "rotateProxy"], ["GET /v1/remote-hours", "getRemoteHours"], ["GET /v1/remote-sessions", "listRemoteSessions"], ["DELETE /v1/remote-sessions/{id}", "stopRemoteSession"], ["GET /v1/remote-sessions/{id}", "getRemoteSession"], + ["GET /v1/statuses", "listStatuses"], + ["POST /v1/statuses", "createStatus"], + ["DELETE /v1/statuses/{id}", "deleteStatus"], + ["PUT /v1/statuses/{id}", "updateStatus"], ["GET /v1/tags", "listTags"], + ["GET /v1/templates", "listTemplates"], + ["POST /v1/templates", "createTemplate"], + ["DELETE /v1/templates/{id}", "deleteTemplate"], + ["PUT /v1/templates/{id}", "updateTemplate"], + ["POST /v1/templates/{id}/profiles", "createProfilesFromTemplate"], ["GET /v1/vpns", "listVpns"], ["POST /v1/vpns", "createVpn"], ["POST /v1/vpns/import", "importVpn"], diff --git a/sdk/node/src/types.mts b/sdk/node/src/types.mts index d60cf263..af3dcd36 100644 --- a/sdk/node/src/types.mts +++ b/sdk/node/src/types.mts @@ -43,6 +43,13 @@ export interface ApiProfile { * VPN. `null` when the proxy resolves them. */ dns_server?: string | null; + /** The profile's status id (see `listStatuses`), or `null`. */ + status_id?: string | null; + /** + * Pages a launch from the app opens, in order, instead of the last session. + * A run with a `url` opens only that URL. + */ + start_urls: string[]; extension_group_id?: string | null; ephemeral: boolean; temporary: boolean; @@ -75,10 +82,135 @@ export interface ApiGroupResponse { profile_count: number; } +/** A named, colored label for where a profile's account stands. */ +export interface ApiStatusResponse { + id: string; + name: string; + /** `#rrggbb`. */ + color: string; +} + +/** + * One change applied to many profiles by `bulkEditProfiles`. + * + * A property left out keeps that field as it is. For the fields that accept + * `null`, an explicit `null` clears the field, so the patch is sent exactly as + * given. An unknown property is refused with 400. + */ +export interface ProfilePatch { + status_id?: string | null; + tags_add?: string[]; + tags_remove?: string[]; + note?: string | null; + group_id?: string | null; + extension_group_id?: string | null; + /** `light`, `normal`, `pro`, `pro_plus`, `ultimate`, `custom`, or `null`. */ + dns_blocklist?: string | null; + dns_server?: string | null; + proxy_bypass_rules?: string[]; + start_urls?: string[]; + launch_hook?: string | null; + /** Left off ephemeral and password-protected profiles, where it means nothing. */ + clear_on_close?: boolean; + randomize_fingerprint_on_launch?: boolean; + /** `auto`, `tcp_only` or `block`; `null` clears it. */ + webrtc_mode?: string | null; + block_images?: boolean; + block_webgl?: boolean; + restore_session?: boolean; + /** Drop the stored device, so the next launch generates a new one. */ + new_device?: boolean; +} + +export interface BulkEditResponse { + /** How many of the profiles actually changed. */ + changed: number; +} + +/** What every profile made from a template starts with. Left out is empty. */ +export interface TemplateSettings { + /** What the profiles made from the template are named after. */ + name_prefix?: string | null; + proxy_id?: string | null; + vpn_id?: string | null; + /** Proxies to spread new profiles across, one each in turn. Wins over `proxy_id`. */ + proxy_ids?: string[]; + group_id?: string | null; + tags?: string[]; + status_id?: string | null; + note?: string | null; + extension_group_id?: string | null; + dns_blocklist?: string | null; + dns_server?: string | null; + proxy_bypass_rules?: string[]; + start_urls?: string[]; + launch_hook?: string | null; + ephemeral?: boolean; + clear_on_close?: boolean; + /** Fingerprint settings. The device fields are always dropped. */ + wayfern_config?: WayfernConfig | null; +} + +export interface ProfileTemplate { + id: string; + name: string; + settings: TemplateSettings; + position: number; + updated_at: number; +} + +export interface ApiBulkCreateResponse { + /** The profiles made, in order. */ + profiles: ApiProfile[]; + /** Why creation stopped before `count` (a `{"code": ...}` payload), or `null`. */ + error?: string | null; +} + +export interface StorageItem { + name: string; + value: string; +} + +export interface OriginStorage { + /** `https://example.com`, the way a page's `origin` reads. */ + origin: string; + localStorage?: StorageItem[]; +} + +/** Playwright's `storageState`: cookies and each origin's Local Storage. */ +export interface StorageState { + /** + * Playwright's own cookie objects (`name`, `value`, `domain`, `path`, + * `expires`, `httpOnly`, `secure`, `sameSite`), passed through untouched. + */ + cookies?: Record[]; + origins?: OriginStorage[]; +} + +export interface StorageStateImport { + origins: number; + items: number; + cookies_imported: number; + cookies_replaced: number; + /** Codes of the cookies that could not be imported. */ + cookie_errors: string[]; +} + export interface ApiProxyResponse { id: string; name: string; proxy_settings: ProxySettings; + /** The provider's "change IP" link, or `null`. `rotateProxy` uses it. */ + rotation_url?: string | null; +} + +export interface RotationResult { + proxy_id: string; + /** The provider's HTTP status, when it answered. */ + status?: number | null; + elapsed_ms: number; + /** A coded error when the IP change was not confirmed. */ + error?: string | null; } export interface ApiVpnResponse { diff --git a/sdk/node/test/requests.test.mts b/sdk/node/test/requests.test.mts index 5eb68842..0b7f2428 100644 --- a/sdk/node/test/requests.test.mts +++ b/sdk/node/test/requests.test.mts @@ -26,6 +26,13 @@ interface Case { const LOCATOR = { role: "button", name: "Sign in" }; +const STORAGE_STATE = { + cookies: [{ name: "sid", value: "1", domain: ".example.com", path: "/" }], + origins: [{ origin: "https://example.com", localStorage: [{ name: "k", value: "v" }] }], +}; + +const TEMPLATE_SETTINGS = { name_prefix: "Shop", tags: ["eu"], proxy_ids: ["x1", "x2"] }; + const CASES: Case[] = [ // -- profiles ------------------------------------------------------------ { @@ -60,6 +67,26 @@ const CASES: Case[] = [ body: { name: "Bare", browser: "wayfern" }, operation: "POST /v1/profiles", }, + { + method: "createProfile", + args: [ + { + name: "Started", + browser: "wayfern", + status_id: "st1", + start_urls: ["https://example.com"], + }, + ], + verb: "POST", + path: "/v1/profiles", + body: { + name: "Started", + browser: "wayfern", + status_id: "st1", + start_urls: ["https://example.com"], + }, + operation: "POST /v1/profiles", + }, { method: "updateProfile", args: [ @@ -81,6 +108,14 @@ const CASES: Case[] = [ }, operation: "PUT /v1/profiles/{id}", }, + { + method: "updateProfile", + args: ["p1", { status_id: "", start_urls: [] }], + verb: "PUT", + path: "/v1/profiles/p1", + body: { status_id: "", start_urls: [] }, + operation: "PUT /v1/profiles/{id}", + }, { method: "deleteProfile", args: ["p1"], @@ -202,6 +237,42 @@ const CASES: Case[] = [ body: { content: "[]" }, operation: "POST /v1/profiles/{id}/cookies/import", }, + { + method: "exportStorageState", + args: ["p1", { include_cookies: true }], + verb: "GET", + path: "/v1/profiles/p1/storage-state", + body: null, + query: { include_cookies: "true" }, + operation: "GET /v1/profiles/{id}/storage-state", + }, + { + method: "exportStorageState", + args: ["p1"], + verb: "GET", + path: "/v1/profiles/p1/storage-state", + body: null, + operation: "GET /v1/profiles/{id}/storage-state", + }, + { + method: "importStorageState", + args: ["p1", STORAGE_STATE], + verb: "POST", + path: "/v1/profiles/p1/storage-state", + body: STORAGE_STATE, + operation: "POST /v1/profiles/{id}/storage-state", + }, + { + method: "bulkEditProfiles", + args: [["p1", "p2"], { status_id: null, tags_add: ["eu"], new_device: true }], + verb: "POST", + path: "/v1/profiles/bulk-edit", + body: { + profile_ids: ["p1", "p2"], + patch: { status_id: null, tags_add: ["eu"], new_device: true }, + }, + operation: "POST /v1/profiles/bulk-edit", + }, // -- agent --------------------------------------------------------------- { method: "agentPerceive", @@ -458,6 +529,88 @@ const CASES: Case[] = [ body: null, operation: "GET /v1/tags", }, + // -- statuses ------------------------------------------------------------ + { + method: "listStatuses", + args: [], + verb: "GET", + path: "/v1/statuses", + body: null, + operation: "GET /v1/statuses", + }, + { + method: "createStatus", + args: [{ name: "Warming", color: "#ffaa00" }], + verb: "POST", + path: "/v1/statuses", + body: { name: "Warming", color: "#ffaa00" }, + operation: "POST /v1/statuses", + }, + { + method: "updateStatus", + args: ["st1", { name: "Ready", color: "#00aa55" }], + verb: "PUT", + path: "/v1/statuses/st1", + body: { name: "Ready", color: "#00aa55" }, + operation: "PUT /v1/statuses/{id}", + }, + { + method: "deleteStatus", + args: ["st1"], + verb: "DELETE", + path: "/v1/statuses/st1", + body: null, + operation: "DELETE /v1/statuses/{id}", + }, + // -- templates ----------------------------------------------------------- + { + method: "listTemplates", + args: [], + verb: "GET", + path: "/v1/templates", + body: null, + operation: "GET /v1/templates", + }, + { + method: "createTemplate", + args: [{ name: "Shop EU", settings: TEMPLATE_SETTINGS }], + verb: "POST", + path: "/v1/templates", + body: { name: "Shop EU", settings: TEMPLATE_SETTINGS }, + operation: "POST /v1/templates", + }, + { + method: "createTemplate", + args: [{ name: "Empty", settings: undefined }], + verb: "POST", + path: "/v1/templates", + body: { name: "Empty" }, + operation: "POST /v1/templates", + }, + { + method: "updateTemplate", + args: ["t1", { name: "Shop EU 2", settings: TEMPLATE_SETTINGS }], + verb: "PUT", + path: "/v1/templates/t1", + body: { name: "Shop EU 2", settings: TEMPLATE_SETTINGS }, + operation: "PUT /v1/templates/{id}", + }, + { + method: "deleteTemplate", + args: ["t1"], + verb: "DELETE", + path: "/v1/templates/t1", + body: null, + operation: "DELETE /v1/templates/{id}", + }, + { + method: "createProfilesFromTemplate", + args: ["t1", { count: 3, name: "Shop" }], + verb: "POST", + path: "/v1/templates/t1/profiles", + body: { count: 3, name: "Shop" }, + operation: "POST /v1/templates/{id}/profiles", + }, // -- proxies ------------------------------------------------------------- { method: "listProxies", @@ -483,6 +636,24 @@ const CASES: Case[] = [ body: { name: "EU", proxy_settings: { proxy_type: "http", host: "h", port: 8080 } }, operation: "POST /v1/proxies", }, + { + method: "createProxy", + args: [ + { + name: "Rotating", + proxy_settings: { proxy_type: "http", host: "h", port: 8080 }, + rotation_url: "https://provider.example/rotate?key=1", + }, + ], + verb: "POST", + path: "/v1/proxies", + body: { + name: "Rotating", + proxy_settings: { proxy_type: "http", host: "h", port: 8080 }, + rotation_url: "https://provider.example/rotate?key=1", + }, + operation: "POST /v1/proxies", + }, { method: "updateProxy", args: ["x1", { name: "EU 2" }], @@ -499,6 +670,22 @@ const CASES: Case[] = [ body: null, operation: "DELETE /v1/proxies/{id}", }, + { + method: "updateProxy", + args: ["x1", { rotation_url: "" }], + verb: "PUT", + path: "/v1/proxies/x1", + body: { rotation_url: "" }, + operation: "PUT /v1/proxies/{id}", + }, + { + method: "rotateProxy", + args: ["x1"], + verb: "POST", + path: "/v1/proxies/x1/rotate", + body: null, + operation: "POST /v1/proxies/{id}/rotate", + }, { method: "importProxies", args: [{ format: "txt", content: "h:1:u:p", name_prefix: "EU" }], diff --git a/sdk/python/src/donutbrowser/client.py b/sdk/python/src/donutbrowser/client.py index 9133c80b..aa8dde1d 100644 --- a/sdk/python/src/donutbrowser/client.py +++ b/sdk/python/src/donutbrowser/client.py @@ -22,15 +22,18 @@ from .errors import DonutConnectionError, DonutError, error_for_status from .models import ( AgentClick, AgentTyping, + ApiBulkCreateResponse, ApiGroupResponse, ApiProfileResponse, ApiProfilesResponse, ApiProxyResponse, ApiRemoteSessionsResponse, + ApiStatusResponse, ApiVpnExportResponse, ApiVpnResponse, BatchRunResponse, BatchStopResponse, + BulkEditResponse, CookieBotConflictCheck, CookieBotPresetList, CookieBotRun, @@ -56,14 +59,20 @@ from .models import ( PerceptionPage, PickedElement, ProfileImportBatchResult, + ProfilePatch, + ProfileTemplate, ProxyPair, ProxySettings, RemoteHoursQuota, RemoteSessionState, + RotationResult, RunProfileResponse, RunRemoteResponse, SetCloudSyncResponse, StopRemoteResponse, + StorageState, + StorageStateImport, + TemplateSettings, WayfernConfig, ) @@ -289,6 +298,8 @@ class DonutClient: ephemeral: Optional[bool] = None, temporary: Optional[bool] = None, dns_server: Optional[str] = None, + status_id: Optional[str] = None, + start_urls: Optional[Sequence[str]] = None, ) -> ApiProfileResponse: """POST /v1/profiles @@ -298,7 +309,8 @@ class DonutClient: names through the profile's proxy or VPN: ``https://host/path``, ``tls://host[:port]``, ``tcp://host[:port]`` or a bare ``host[:port]``; a bare address of a well-known resolver such as ``"1.1.1.1"`` uses its - DNS over HTTPS endpoint. + DNS over HTTPS endpoint. ``status_id`` comes from :meth:`list_statuses`; + ``start_urls`` is at most 20 http(s) URLs. """ return self._request( "POST", @@ -317,6 +329,8 @@ class DonutClient: ephemeral=ephemeral, temporary=temporary, dns_server=dns_server, + status_id=status_id, + start_urls=list(start_urls) if start_urls is not None else None, ), ) @@ -337,13 +351,16 @@ class DonutClient: sync_mode: Optional[str] = None, clear_on_close: Optional[bool] = None, dns_server: Optional[str] = None, + status_id: Optional[str] = None, + start_urls: Optional[Sequence[str]] = None, ) -> ApiProfileResponse: """PUT /v1/profiles/{id} A profile's browser engine is fixed at creation, so there is no ``browser`` argument. Pass ``proxy_id=""`` or ``vpn_id=""`` to detach one; leaving either unset changes nothing. ``dns_server=""`` lets the - proxy resolve names again. + proxy resolve names again, ``status_id=""`` clears the status, and + ``start_urls=[]`` removes the start pages. """ return self._request( "PUT", @@ -364,6 +381,8 @@ class DonutClient: sync_mode=sync_mode, clear_on_close=clear_on_close, dns_server=dns_server, + status_id=status_id, + start_urls=list(start_urls) if start_urls is not None else None, ), ) @@ -497,6 +516,51 @@ class DonutClient: body={"content": content}, ) + def export_storage_state( + self, profile_id: str, *, include_cookies: Optional[bool] = None + ) -> StorageState: + """GET /v1/profiles/{id}/storage-state + + Playwright's ``storageState``: each origin's Local Storage, plus the + cookies when ``include_cookies=True``. Readable while the browser runs, + but writes from the last few seconds may not be on disk yet. + """ + return self._request( + "GET", + f"/v1/profiles/{_segment(profile_id)}/storage-state", + query=_query(include_cookies=include_cookies), + ) + + def import_storage_state(self, profile_id: str, state: StorageState) -> StorageStateImport: + """POST /v1/profiles/{id}/storage-state + + ``state`` is a Playwright ``storageState``; what + ``context.storage_state()`` returns can be passed as is. An item + replaces the one of the same name, and the origin's other items stay. + The browser must be stopped. + """ + return self._request( + "POST", + f"/v1/profiles/{_segment(profile_id)}/storage-state", + body=dict(state), + ) + + def bulk_edit_profiles( + self, profile_ids: Sequence[str], patch: ProfilePatch + ) -> BulkEditResponse: + """POST /v1/profiles/bulk-edit + + Every profile is checked before any is saved, so a bad value, an + unknown profile or a running browser changes nothing. ``patch`` is sent + as given: a ``None`` in it clears that field rather than being dropped. + ``changed`` counts only the profiles that really changed. + """ + return self._request( + "POST", + "/v1/profiles/bulk-edit", + body={"profile_ids": list(profile_ids), "patch": dict(patch)}, + ) + def distribute_proxies(self, pairs: Sequence[ProxyPair]) -> DistributeProxiesResponse: """POST /v1/profiles/distribute-proxies @@ -849,6 +913,101 @@ class DonutClient: """GET /v1/tags""" return self._request("GET", "/v1/tags") + # ------------------------------------------------------------------ + # Statuses + # ------------------------------------------------------------------ + + def list_statuses(self) -> List[ApiStatusResponse]: + """GET /v1/statuses""" + return self._request("GET", "/v1/statuses") + + def create_status(self, *, name: str, color: str) -> ApiStatusResponse: + """POST /v1/statuses + + ``name`` is 1 to 40 characters, unique regardless of case, and + ``color`` is ``#rrggbb``. + """ + return self._request("POST", "/v1/statuses", body={"name": name, "color": color}) + + def update_status(self, status_id: str, *, name: str, color: str) -> ApiStatusResponse: + """PUT /v1/statuses/{id}""" + return self._request( + "PUT", + f"/v1/statuses/{_segment(status_id)}", + body={"name": name, "color": color}, + ) + + def delete_status(self, status_id: str) -> None: + """DELETE /v1/statuses/{id} + + The status is also taken off every profile that had it. + """ + self._request("DELETE", f"/v1/statuses/{_segment(status_id)}") + + # ------------------------------------------------------------------ + # Templates + # ------------------------------------------------------------------ + + def list_templates(self) -> List[ProfileTemplate]: + """GET /v1/templates""" + return self._request("GET", "/v1/templates") + + def create_template( + self, *, name: str, settings: Optional[TemplateSettings] = None + ) -> ProfileTemplate: + """POST /v1/templates + + ``name`` is 1 to 60 characters, unique regardless of case. Device + fields in ``settings["wayfern_config"]`` are dropped: every profile + made from the template gets its own device. + """ + return self._request( + "POST", + "/v1/templates", + body=_body( + name=name, settings=dict(settings) if settings is not None else None + ), + ) + + def update_template( + self, template_id: str, *, name: str, settings: Optional[TemplateSettings] = None + ) -> ProfileTemplate: + """PUT /v1/templates/{id} + + Replaces the whole template. Settings left out are reset to empty, so + to rename one, send its current ``settings`` back with the new name. + """ + return self._request( + "PUT", + f"/v1/templates/{_segment(template_id)}", + body=_body( + name=name, settings=dict(settings) if settings is not None else None + ), + ) + + def delete_template(self, template_id: str) -> None: + """DELETE /v1/templates/{id} + + Profiles made from the template keep their settings. + """ + self._request("DELETE", f"/v1/templates/{_segment(template_id)}") + + def create_profiles_from_template( + self, template_id: str, *, count: int, name: Optional[str] = None + ) -> ApiBulkCreateResponse: + """POST /v1/templates/{id}/profiles + + Makes ``count`` (1 to 100) profiles with the newest downloaded Wayfern, + each with its own device. One profile takes ``name``; several take it + followed by numbers. When ``error`` is set, creation stopped early and + ``profiles`` lists what was made. + """ + return self._request( + "POST", + f"/v1/templates/{_segment(template_id)}/profiles", + body=_body(count=count, name=name), + ) + # ------------------------------------------------------------------ # Proxies # ------------------------------------------------------------------ @@ -861,12 +1020,24 @@ class DonutClient: """GET /v1/proxies/{id}""" return self._request("GET", f"/v1/proxies/{_segment(proxy_id)}") - def create_proxy(self, *, name: str, proxy_settings: ProxySettings) -> ApiProxyResponse: - """POST /v1/proxies""" + def create_proxy( + self, + *, + name: str, + proxy_settings: ProxySettings, + rotation_url: Optional[str] = None, + ) -> ApiProxyResponse: + """POST /v1/proxies + + ``rotation_url`` is the provider's http(s) "change IP" link, used by + :meth:`rotate_proxy`. + """ return self._request( "POST", "/v1/proxies", - body={"name": name, "proxy_settings": dict(proxy_settings)}, + body=_body( + name=name, proxy_settings=dict(proxy_settings), rotation_url=rotation_url + ), ) def update_proxy( @@ -875,14 +1046,19 @@ class DonutClient: *, name: Optional[str] = None, proxy_settings: Optional[ProxySettings] = None, + rotation_url: Optional[str] = None, ) -> ApiProxyResponse: - """PUT /v1/proxies/{id}""" + """PUT /v1/proxies/{id} + + ``rotation_url=""`` clears the "change IP" link. + """ return self._request( "PUT", f"/v1/proxies/{_segment(proxy_id)}", body=_body( name=name, proxy_settings=dict(proxy_settings) if proxy_settings is not None else None, + rotation_url=rotation_url, ), ) @@ -890,6 +1066,16 @@ class DonutClient: """DELETE /v1/proxies/{id}""" self._request("DELETE", f"/v1/proxies/{_segment(proxy_id)}") + def rotate_proxy(self, proxy_id: str) -> RotationResult: + """POST /v1/proxies/{id}/rotate + + Calls the proxy's "change IP" link; every profile behind the proxy + moves to the new IP. A proxy without a link raises + :class:`~donutbrowser.errors.ValidationError`, and a provider that + refuses or does not answer raises :class:`~donutbrowser.errors.BadGateway`. + """ + return self._request("POST", f"/v1/proxies/{_segment(proxy_id)}/rotate") + def import_proxies( self, *, diff --git a/sdk/python/src/donutbrowser/coverage.py b/sdk/python/src/donutbrowser/coverage.py index d37ff609..d7be1068 100644 --- a/sdk/python/src/donutbrowser/coverage.py +++ b/sdk/python/src/donutbrowser/coverage.py @@ -60,6 +60,7 @@ OPERATIONS: Dict[Operation, str] = { ("POST", "/v1/profiles"): "create_profile", ("POST", "/v1/profiles/batch/run"): "batch_run_profiles", ("POST", "/v1/profiles/batch/stop"): "batch_stop_profiles", + ("POST", "/v1/profiles/bulk-edit"): "bulk_edit_profiles", ("POST", "/v1/profiles/distribute-proxies"): "distribute_proxies", ("POST", "/v1/profiles/import"): "import_profiles", ("GET", "/v1/profiles/import/detect"): "detect_import_profiles", @@ -78,17 +79,29 @@ OPERATIONS: Dict[Operation, str] = { ("POST", "/v1/profiles/{id}/open-url"): "open_url", ("POST", "/v1/profiles/{id}/run"): "run_profile", ("POST", "/v1/profiles/{id}/run-remote"): "run_profile_remote", + ("GET", "/v1/profiles/{id}/storage-state"): "export_storage_state", + ("POST", "/v1/profiles/{id}/storage-state"): "import_storage_state", ("GET", "/v1/proxies"): "list_proxies", ("POST", "/v1/proxies"): "create_proxy", ("POST", "/v1/proxies/import"): "import_proxies", ("DELETE", "/v1/proxies/{id}"): "delete_proxy", ("GET", "/v1/proxies/{id}"): "get_proxy", ("PUT", "/v1/proxies/{id}"): "update_proxy", + ("POST", "/v1/proxies/{id}/rotate"): "rotate_proxy", ("GET", "/v1/remote-hours"): "get_remote_hours", ("GET", "/v1/remote-sessions"): "list_remote_sessions", ("DELETE", "/v1/remote-sessions/{id}"): "stop_remote_session", ("GET", "/v1/remote-sessions/{id}"): "get_remote_session", + ("GET", "/v1/statuses"): "list_statuses", + ("POST", "/v1/statuses"): "create_status", + ("DELETE", "/v1/statuses/{id}"): "delete_status", + ("PUT", "/v1/statuses/{id}"): "update_status", ("GET", "/v1/tags"): "list_tags", + ("GET", "/v1/templates"): "list_templates", + ("POST", "/v1/templates"): "create_template", + ("DELETE", "/v1/templates/{id}"): "delete_template", + ("PUT", "/v1/templates/{id}"): "update_template", + ("POST", "/v1/templates/{id}/profiles"): "create_profiles_from_template", ("GET", "/v1/vpns"): "list_vpns", ("POST", "/v1/vpns"): "create_vpn", ("POST", "/v1/vpns/import"): "import_vpn", diff --git a/sdk/python/src/donutbrowser/models.py b/sdk/python/src/donutbrowser/models.py index e8be9ca9..b711fd16 100644 --- a/sdk/python/src/donutbrowser/models.py +++ b/sdk/python/src/donutbrowser/models.py @@ -17,14 +17,25 @@ passed straight into the next. from __future__ import annotations -from typing import Any, Dict, List, TypedDict +from typing import Any, Dict, List, Optional, TypedDict __all__ = [ "ApiProfile", "ApiProfilesResponse", "ApiProfileResponse", "ApiGroupResponse", + "ApiStatusResponse", + "ProfilePatch", + "BulkEditResponse", + "TemplateSettings", + "ProfileTemplate", + "ApiBulkCreateResponse", + "StorageItem", + "OriginStorage", + "StorageState", + "StorageStateImport", "ApiProxyResponse", + "RotationResult", "ApiVpnResponse", "ApiVpnExportResponse", "DownloadBrowserResponse", @@ -102,6 +113,7 @@ class _ApiProfileRequired(TypedDict): tags: List[str] is_running: bool proxy_bypass_rules: List[str] + start_urls: List[str] ephemeral: bool temporary: bool clear_on_close: bool @@ -118,6 +130,7 @@ class ApiProfile(_ApiProfileRequired, total=False): group_id: str vpn_id: str dns_server: str + status_id: str extension_group_id: str host_os: str fingerprint_os: str @@ -138,12 +151,146 @@ class ApiGroupResponse(TypedDict): profile_count: int -class ApiProxyResponse(TypedDict): +class ApiStatusResponse(TypedDict): + """A named, colored label for where a profile's account stands.""" + + id: str + name: str + color: str + + +class ProfilePatch(TypedDict, total=False): + """One change applied to many profiles by ``bulk_edit_profiles``. + + A key left out keeps that field as it is. For the fields typed + ``Optional``, an explicit ``None`` (JSON ``null``) clears the field, so + the patch is sent exactly as given. An unknown key is refused with 400. + """ + + status_id: Optional[str] + tags_add: List[str] + tags_remove: List[str] + note: Optional[str] + group_id: Optional[str] + extension_group_id: Optional[str] + dns_blocklist: Optional[str] + dns_server: Optional[str] + proxy_bypass_rules: List[str] + start_urls: List[str] + launch_hook: Optional[str] + clear_on_close: bool + randomize_fingerprint_on_launch: bool + webrtc_mode: Optional[str] + block_images: bool + block_webgl: bool + restore_session: bool + new_device: bool + + +class BulkEditResponse(TypedDict): + changed: int + + +class TemplateSettings(TypedDict, total=False): + """What every profile made from a template starts with. + + A key left out is empty. ``proxy_ids``, when not empty, wins over + ``proxy_id`` and spreads new profiles across those proxies in turn. + """ + + name_prefix: Optional[str] + proxy_id: Optional[str] + vpn_id: Optional[str] + proxy_ids: List[str] + group_id: Optional[str] + tags: List[str] + status_id: Optional[str] + note: Optional[str] + extension_group_id: Optional[str] + dns_blocklist: Optional[str] + dns_server: Optional[str] + proxy_bypass_rules: List[str] + start_urls: List[str] + launch_hook: Optional[str] + ephemeral: bool + clear_on_close: bool + wayfern_config: Optional[WayfernConfig] + + +class ProfileTemplate(TypedDict): + id: str + name: str + settings: TemplateSettings + position: int + updated_at: int + + +class _ApiBulkCreateResponseRequired(TypedDict): + profiles: List[ApiProfile] + + +class ApiBulkCreateResponse(_ApiBulkCreateResponseRequired, total=False): + """``error`` is a ``{"code": ...}`` payload when creation stopped early.""" + + error: str + + +class StorageItem(TypedDict): + name: str + value: str + + +class _OriginStorageRequired(TypedDict): + origin: str + + +class OriginStorage(_OriginStorageRequired, total=False): + # Playwright's own spelling. + localStorage: List[StorageItem] + + +class StorageState(TypedDict, total=False): + """Playwright's ``storageState``: cookies and each origin's Local Storage. + + Each cookie is Playwright's own object (``name``, ``value``, ``domain``, + ``path``, ``expires``, ``httpOnly``, ``secure``, ``sameSite``), passed + through untouched. + """ + + cookies: List[Dict[str, Any]] + origins: List[OriginStorage] + + +class StorageStateImport(TypedDict): + origins: int + items: int + cookies_imported: int + cookies_replaced: int + cookie_errors: List[str] + + +class _ApiProxyResponseRequired(TypedDict): id: str name: str proxy_settings: ProxySettings +class ApiProxyResponse(_ApiProxyResponseRequired, total=False): + rotation_url: str + + +class _RotationResultRequired(TypedDict): + proxy_id: str + elapsed_ms: int + + +class RotationResult(_RotationResultRequired, total=False): + """``status`` is the provider's HTTP status, when it answered.""" + + status: int + error: str + + class _ApiVpnRequired(TypedDict): id: str name: str diff --git a/sdk/python/tests/test_requests.py b/sdk/python/tests/test_requests.py index eb5e4c18..89a2dec5 100644 --- a/sdk/python/tests/test_requests.py +++ b/sdk/python/tests/test_requests.py @@ -31,6 +31,15 @@ Case = Tuple[ LOCATOR = {"role": "button", "name": "Sign in"} +STORAGE_STATE = { + "cookies": [{"name": "sid", "value": "1", "domain": ".example.com", "path": "/"}], + "origins": [ + {"origin": "https://example.com", "localStorage": [{"name": "k", "value": "v"}]} + ], +} + +TEMPLATE_SETTINGS = {"name_prefix": "Shop", "tags": ["eu"], "proxy_ids": ["x1", "x2"]} + CASES: List[Case] = [ # -- profiles ---------------------------------------------------------- ("list_profiles", (), {}, "GET", "/v1/profiles", None, {}, "/v1/profiles"), @@ -55,6 +64,26 @@ CASES: List[Case] = [ {}, "/v1/profiles", ), + ( + "create_profile", + (), + { + "name": "Started", + "browser": "wayfern", + "status_id": "st1", + "start_urls": ["https://example.com"], + }, + "POST", + "/v1/profiles", + { + "name": "Started", + "browser": "wayfern", + "status_id": "st1", + "start_urls": ["https://example.com"], + }, + {}, + "/v1/profiles", + ), ( "update_profile", ("p1",), @@ -65,6 +94,16 @@ CASES: List[Case] = [ {}, "/v1/profiles/{id}", ), + ( + "update_profile", + ("p1",), + {"status_id": "", "start_urls": []}, + "PUT", + "/v1/profiles/p1", + {"status_id": "", "start_urls": []}, + {}, + "/v1/profiles/{id}", + ), ("delete_profile", ("p1",), {}, "DELETE", "/v1/profiles/p1", None, {}, "/v1/profiles/{id}"), ( "run_profile", @@ -194,6 +233,49 @@ CASES: List[Case] = [ {}, "/v1/profiles/{id}/cookies/import", ), + ( + "export_storage_state", + ("p1",), + {"include_cookies": True}, + "GET", + "/v1/profiles/p1/storage-state", + None, + {"include_cookies": "true"}, + "/v1/profiles/{id}/storage-state", + ), + ( + "export_storage_state", + ("p1",), + {}, + "GET", + "/v1/profiles/p1/storage-state", + None, + {}, + "/v1/profiles/{id}/storage-state", + ), + ( + "import_storage_state", + ("p1", STORAGE_STATE), + {}, + "POST", + "/v1/profiles/p1/storage-state", + STORAGE_STATE, + {}, + "/v1/profiles/{id}/storage-state", + ), + ( + "bulk_edit_profiles", + (["p1", "p2"], {"status_id": None, "tags_add": ["eu"], "new_device": True}), + {}, + "POST", + "/v1/profiles/bulk-edit", + { + "profile_ids": ["p1", "p2"], + "patch": {"status_id": None, "tags_add": ["eu"], "new_device": True}, + }, + {}, + "/v1/profiles/bulk-edit", + ), # -- agent ------------------------------------------------------------- ( "agent_perceive", @@ -440,6 +522,72 @@ CASES: List[Case] = [ ), ("delete_group", ("g1",), {}, "DELETE", "/v1/groups/g1", None, {}, "/v1/groups/{id}"), ("list_tags", (), {}, "GET", "/v1/tags", None, {}, "/v1/tags"), + # -- statuses ---------------------------------------------------------- + ("list_statuses", (), {}, "GET", "/v1/statuses", None, {}, "/v1/statuses"), + ( + "create_status", + (), + {"name": "Warming", "color": "#ffaa00"}, + "POST", + "/v1/statuses", + {"name": "Warming", "color": "#ffaa00"}, + {}, + "/v1/statuses", + ), + ( + "update_status", + ("st1",), + {"name": "Ready", "color": "#00aa55"}, + "PUT", + "/v1/statuses/st1", + {"name": "Ready", "color": "#00aa55"}, + {}, + "/v1/statuses/{id}", + ), + ("delete_status", ("st1",), {}, "DELETE", "/v1/statuses/st1", None, {}, "/v1/statuses/{id}"), + # -- templates --------------------------------------------------------- + ("list_templates", (), {}, "GET", "/v1/templates", None, {}, "/v1/templates"), + ( + "create_template", + (), + {"name": "Shop EU", "settings": TEMPLATE_SETTINGS}, + "POST", + "/v1/templates", + {"name": "Shop EU", "settings": TEMPLATE_SETTINGS}, + {}, + "/v1/templates", + ), + ( + "create_template", + (), + {"name": "Empty"}, + "POST", + "/v1/templates", + {"name": "Empty"}, + {}, + "/v1/templates", + ), + ( + "update_template", + ("t1",), + {"name": "Shop EU 2", "settings": TEMPLATE_SETTINGS}, + "PUT", + "/v1/templates/t1", + {"name": "Shop EU 2", "settings": TEMPLATE_SETTINGS}, + {}, + "/v1/templates/{id}", + ), + ("delete_template", ("t1",), {}, "DELETE", "/v1/templates/t1", None, {}, "/v1/templates/{id}"), + ( + "create_profiles_from_template", + ("t1",), + {"count": 3, "name": "Shop"}, + "POST", + "/v1/templates/t1/profiles", + {"count": 3, "name": "Shop"}, + {}, + "/v1/templates/{id}/profiles", + ), # -- proxies ----------------------------------------------------------- ("list_proxies", (), {}, "GET", "/v1/proxies", None, {}, "/v1/proxies"), ("get_proxy", ("x1",), {}, "GET", "/v1/proxies/x1", None, {}, "/v1/proxies/{id}"), @@ -453,6 +601,24 @@ CASES: List[Case] = [ {}, "/v1/proxies", ), + ( + "create_proxy", + (), + { + "name": "Rotating", + "proxy_settings": {"proxy_type": "http", "host": "h", "port": 8080}, + "rotation_url": "https://provider.example/rotate?key=1", + }, + "POST", + "/v1/proxies", + { + "name": "Rotating", + "proxy_settings": {"proxy_type": "http", "host": "h", "port": 8080}, + "rotation_url": "https://provider.example/rotate?key=1", + }, + {}, + "/v1/proxies", + ), ( "update_proxy", ("x1",), @@ -464,6 +630,26 @@ CASES: List[Case] = [ "/v1/proxies/{id}", ), ("delete_proxy", ("x1",), {}, "DELETE", "/v1/proxies/x1", None, {}, "/v1/proxies/{id}"), + ( + "update_proxy", + ("x1",), + {"rotation_url": ""}, + "PUT", + "/v1/proxies/x1", + {"rotation_url": ""}, + {}, + "/v1/proxies/{id}", + ), + ( + "rotate_proxy", + ("x1",), + {}, + "POST", + "/v1/proxies/x1/rotate", + None, + {}, + "/v1/proxies/{id}/rotate", + ), ( "import_proxies", (),