diff --git a/doc/user_guide/scripting_user_guide.md b/doc/user_guide/scripting_user_guide.md new file mode 100644 index 00000000..844a50bf --- /dev/null +++ b/doc/user_guide/scripting_user_guide.md @@ -0,0 +1,545 @@ +# APIDash Scripting: Using the `ad` Object + +APIDash allows you to write JavaScript code that runs either **before** a request is sent (pre-request script) or **after** a response is received (post-response script). These scripts give you powerful control over the request/response lifecycle and allow for automation, dynamic data manipulation, and basic testing. + +The primary way to interact with APIDash data and functionality within these scripts is through the global `ad` object. This object provides structured access to request data, response data, environment variables, and logging utilities. + + +## `ad.request` (Available in Pre-request Scripts Only) + +Use `ad.request` to inspect and modify the request *before* it is sent. + +### `ad.request.headers` + +Manage request headers. Headers are stored internally as an array of objects: `[{name: "Header-Name", value: "HeaderValue"}, ...]`. Note that header name comparisons in these helper methods are **case-sensitive**. + +* **`ad.request.headers.set(key: string, value: string)`** + * Adds a new header or updates the value of the *first* existing header with the exact same `key`. Ensures the `value` is converted to a string. + * Example: + ```javascript + // Set an Authorization header + let token = ad.environment.get("authToken"); + if (token) { + ad.request.headers.set("Authorization", "Bearer " + token); + } + + // Update User-Agent + ad.request.headers.set("User-Agent", "APIDash-Script/1.0"); + ``` + +* **`ad.request.headers.get(key: string): string | undefined`** + * Retrieves the value of the *first* header matching the `key`. Returns `undefined` if not found. + * Example: + ```javascript + let contentType = ad.request.headers.get("Content-Type"); + if (!contentType) { + ad.console.log("Content-Type header is not set yet."); + } + ``` + +* **`ad.request.headers.remove(key: string)`** + * Removes *all* headers matching the exact `key`. + * Example: + ```javascript + // Remove default Accept header if it exists + ad.request.headers.remove("Accept"); + ``` + +* **`ad.request.headers.has(key: string): boolean`** + * Checks if at least one header with the exact `key` exists. + * Example: + ```javascript + if (!ad.request.headers.has("X-Request-ID")) { + ad.request.headers.set("X-Request-ID", Date.now()); // Simple example ID + } + ``` + +* **`ad.request.headers.clear()`** + * Removes all request headers. + * Example: + ```javascript + // Start with a clean slate for headers (use with caution) + // ad.request.headers.clear(); + // ad.request.headers.set("Authorization", "..."); // Add back essential ones + ``` + +### `ad.request.params` + +Manage URL query parameters. Parameters are stored internally as an array of objects: `[{name: "paramName", value: "paramValue"}, ...]`. Parameter name comparisons are **case-sensitive**. + +* **`ad.request.params.set(key: string, value: string)`** + * Adds a new query parameter or updates the value of the *first* existing parameter with the exact same `key`. Ensures the `value` is converted to a string. + * *Note:* HTTP allows duplicate query parameter keys. This `set` method replaces the first match or adds a new one. If you need duplicates, you might need to manipulate the underlying `request.params` array directly (use with care) or call `set` multiple times if the backend handles updates correctly. + * Example: + ```javascript + // Add or update a timestamp parameter + ad.request.params.set("ts", Date.now().toString()); + + // Set a user ID from the environment + let userId = ad.environment.get("activeUserId"); + if (userId) { + ad.request.params.set("userId", userId); + } + ``` + +* **`ad.request.params.get(key: string): string | undefined`** + * Retrieves the value of the *first* parameter matching the `key`. Returns `undefined` if not found. + * Example: + ```javascript + let existingFilter = ad.request.params.get("filter"); + ad.console.log("Current filter:", existingFilter); + ``` + +* **`ad.request.params.remove(key: string)`** + * Removes *all* parameters matching the exact `key`. + * Example: + ```javascript + // Remove any debug flags before sending + ad.request.params.remove("debug"); + ``` + +* **`ad.request.params.has(key: string): boolean`** + * Checks if at least one parameter with the exact `key` exists. + * Example: + ```javascript + if (!ad.request.params.has("page")) { + ad.request.params.set("page", "1"); + } + ``` + +* **`ad.request.params.clear()`** + * Removes all query parameters. + * Example: + ```javascript + // Clear existing params if rebuilding the query string + // ad.request.params.clear(); + // ad.request.params.set("newParam", "value"); + ``` + +### `ad.request.url` + +Access or modify the entire request URL. + +* **`ad.request.url.get(): string`** + * Returns the current request URL string. + * Example: + ```javascript + let currentUrl = ad.request.url.get(); + ad.console.log("Request URL before modification:", currentUrl); + ``` + +* **`ad.request.url.set(newUrl: string)`** + * Sets the request URL to the provided `newUrl` string. + * Example: + ```javascript + let baseUrl = ad.environment.get("apiBaseUrl"); + let resourcePath = "/users/me"; + if (baseUrl) { + ad.request.url.set(baseUrl + resourcePath); + } + ``` + +### `ad.request.body` + +Access or modify the request body. + +* **`ad.request.body.get(): string | null | undefined`** + * Returns the current request body as a string. For `multipart/form-data`, this might return an empty or non-representative string; use `request.formData` (accessed directly for now, potential future `ad.request.formData` helpers) for structured form data. + * Example: + ```javascript + let bodyContent = ad.request.body.get(); + ad.console.log("Current body:", bodyContent); + ``` + +* **`ad.request.body.set(newBody: string | object, contentType?: string)`** + * Sets the request body. + * If `newBody` is an object, it's automatically `JSON.stringify`-ed. + * If `newBody` is not an object, it's converted to a string. + * **Content-Type Handling:** + * If you provide the optional `contentType` argument (e.g., 'application/xml'), that value is used. + * If `contentType` is *not* provided: + * Defaults to `application/json` if `newBody` was an object. + * Defaults to `text/plain` otherwise. + * The calculated `Content-Type` is automatically added as a request header *unless* a `Content-Type` header already exists (case-insensitive check). + * **Side Effect:** Setting the body via this method will clear any existing `request.formData` entries, as they are mutually exclusive with a raw string/JSON body in the APIDash model. It also updates the internal `request.bodyContentType`. + * Example: + ```javascript + // Set a JSON body + let userData = { name: "Test User", email: "test@example.com" }; + ad.request.body.set(userData); // Automatically sets Content-Type: application/json if not already set + + // Set a plain text body + ad.request.body.set("This is plain text content.", "text/plain"); + + // Set an XML body (Content-Type needed) + let xmlString = "Test"; + ad.request.body.set(xmlString, "application/xml"); + ``` +### `ad.request.query` + + * Access or modify the GraphQL query string. This applies specifically to GraphQL requests, where the body typically includes a `query`, `variables`, and optionally `operationName`. + +* **`ad.request.query.get(): string`** + + * Returns the current GraphQL query string (query, mutation, or subscription). If not set, returns an empty string. + * Example: + + ```javascript + let gqlQuery = ad.request.query.get(); + ad.console.log("Current GraphQL query:", gqlQuery); + ``` + +* **`ad.request.query.set(newQuery: string)`** + + * Sets the GraphQL query string. + * Automatically sets the `Content-Type` header to `application/json` unless it's already set, ensuring correct handling by GraphQL servers. + * Does **not** set the entire request body, it is up to the user to include the full GraphQL payload inside the query. (e.g., `{ query, variables, operationName }`). + * Example: + + ```javascript + let newQuery = ` + query { + user(id: 1) { + id + username + email + address { + geo { + lat + lng + } + } + } + }`; + ad.request.query.set(newQuery); + ``` + +* **`ad.request.query.clear()`** + + * Clears the current GraphQL query string by setting it to an empty string. + * Example: + + ```javascript + ad.request.query.clear(); + ``` + +### `ad.request.method` + +Access or modify the HTTP request method (verb). + +* **`ad.request.method.get(): string`** + * Returns the current request method (e.g., "get", "post"). Usually lowercase. + * Example: + ```javascript + let method = ad.request.method.get(); + ad.console.log("Request method:", method); + ``` + +* **`ad.request.method.set(newMethod: string)`** + * Sets the request method. The provided `newMethod` string will be converted to lowercase (e.g., "PUT" becomes "put"). + * Example: + ```javascript + // Change method based on environment setting + let usePatch = ad.environment.get("usePatchForUpdates"); + if (ad.request.method.get() === "put" && usePatch) { + ad.request.method.set("PATCH"); + } + ``` + +## `ad.response` (Available in Post-response Scripts Only) + +Use `ad.response` to access details of the response received from the server. This object is **read-only**. + +* **`ad.response.status: number | undefined`** + * The HTTP status code (e.g., `200`, `404`). + * Example: + ```javascript + if (ad.response.status === 201) { + ad.console.log("Resource created successfully!"); + } else if (ad.response.status >= 400) { + ad.console.error("Request failed with status:", ad.response.status); + } + ``` + +* **`ad.response.body: string | undefined`** + * The response body as a string. For binary responses, this might be decoded text or potentially garbled data depending on the Content-Type and encoding. Use `bodyBytes` for raw data. + * Example: + ```javascript + let responseText = ad.response.body; + if (responseText && responseText.includes("error")) { + ad.console.warn("Response body might contain an error message."); + } + ``` + +* **`ad.response.formattedBody: string | undefined`** + * The response body pre-formatted by APIDash (e.g., pretty-printed JSON). Useful for logging structured data clearly. + * Example: + ```javascript + ad.console.log("Formatted Response Body:\n", ad.response.formattedBody); + ``` + +* **`ad.response.bodyBytes: number[] | undefined`** + * The raw response body as an array of byte values (integers 0-255). Useful for binary data, but be mindful of potential performance/memory impact for very large responses. Depends on Dart correctly serializing `Uint8List` to `List`. + * Example: + ```javascript + let bytes = ad.response.bodyBytes; + if (bytes) { + ad.console.log(`Received ${bytes.length} bytes.`); + // You might perform checks on byte sequences, e.g., magic numbers for file types + // if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4E && bytes[3] === 0x47) { + // ad.console.log("Looks like a PNG file."); + // } + } + ``` + +* **`ad.response.time: number | undefined`** + * The approximate time taken for the request-response cycle, in **milliseconds**. + * Example: + ```javascript + let duration = ad.response.time; + if (duration !== undefined) { + ad.console.log(`Request took ${duration.toFixed(2)} ms.`); + if (duration > 1000) { + ad.console.warn("Request took longer than 1 second."); + } + } + ``` + +* **`ad.response.headers: object | undefined`** + * An object containing the response headers. Header names are typically **lowercase** (due to processing by the underlying HTTP client). Values are strings. + * Example: + ```javascript + let headers = ad.response.headers; + if (headers) { + ad.console.log("Response Content-Type:", headers['content-type']); // Access using lowercase key + ad.console.log("Response Date Header:", headers.date); + } + ``` + +* **`ad.response.requestHeaders: object | undefined`** + * An object containing the headers that were actually *sent* with the request. Useful for debugging or verification. Header names are typically **lowercase**. + * Example: + ```javascript + let sentHeaders = ad.response.requestHeaders; + if (sentHeaders) { + ad.console.log("Sent User-Agent:", sentHeaders['user-agent']); + } + ``` + +* **`ad.response.json(): object | undefined`** + * Attempts to parse the `ad.response.body` as JSON. + * Returns the parsed JavaScript object/array if successful. + * Returns `undefined` if the body is empty or parsing fails (an error message is automatically logged to the APIDash console via `ad.console.error` in case of failure). + * Example: + ```javascript + let jsonData = ad.response.json(); + if (jsonData && jsonData.data && jsonData.data.token) { + ad.console.log("Found token in response."); + ad.environment.set("sessionToken", jsonData.data.token); + } else if (jsonData) { + ad.console.log("Parsed JSON, but expected structure not found."); + } + ``` + +* **`ad.response.getHeader(key: string): string | undefined`** + * Retrieves a specific response header's value. The lookup is **case-insensitive**. + * Example: + ```javascript + // These are equivalent because of case-insensitivity: + let contentType = ad.response.getHeader("Content-Type"); + let contentTypeLower = ad.response.getHeader("content-type"); + ad.console.log("Response Content-Type (case-insensitive get):", contentType); + + let correlationId = ad.response.getHeader("X-Correlation-ID"); + if (correlationId) { + ad.environment.set("lastCorrelationId", correlationId); + } + ``` + +## `ad.environment` (Available in Pre & Post-response Scripts) + +Use `ad.environment` to get, set, or remove variables within the currently active APIDash environment. Changes made here persist after the script runs and can be used by subsequent requests or other scripts using the same environment. + +* **`ad.environment.get(key: string): any`** + * Retrieves the value of the environment variable named `key`. Returns `undefined` if not found. + * Example: + ```javascript + let apiUrl = ad.environment.get("baseUrl"); + let apiKey = ad.environment.get("apiKey"); + ad.console.log("Using API URL:", apiUrl); + ``` + +* **`ad.environment.set(key: string, value: any)`** + * Sets an environment variable named `key` to the given `value`. The `value` should be JSON-serializable (string, number, boolean, object, array, null). + * Example: + ```javascript + // In a post-response script after login: + let responseData = ad.response.json(); + if (responseData && responseData.access_token) { + ad.environment.set("oauthToken", responseData.access_token); + ad.environment.set("tokenExpiry", Date.now() + (responseData.expires_in * 1000)); + ad.console.log("OAuth token saved to environment."); + } + + // Store complex object + ad.environment.set("userProfile", { id: 123, name: "Default User"}); + ``` + +* **`ad.environment.unset(key: string)`** + * Removes the environment variable named `key`. + * Example: + ```javascript + // Clear temporary token after use or logout + ad.environment.unset("sessionToken"); + ad.console.log("Session token cleared."); + ``` + +* **`ad.environment.has(key: string): boolean`** + * Checks if an environment variable named `key` exists. + * Example: + ```javascript + if (!ad.environment.has("userId")) { + ad.console.warn("Environment variable 'userId' is not set."); + // Maybe set a default or fetch it? + // ad.environment.set("userId", "default-001"); + } + ``` + +* **`ad.environment.clear()`** + * Removes *all* variables from the active environment. Use with extreme caution! + * Example: + ```javascript + // Usually used for resetting state during testing, careful! + // ad.environment.clear(); + // ad.console.warn("Cleared all variables in the active environment!"); + ``` + +## `ad.console` (Available in Pre & Post-response Scripts) + +Use `ad.console` to log messages to the APIDash console tab for the corresponding request. This is essential for debugging your scripts. + +* **`ad.console.log(...args: any[])`** + * Logs informational messages. Accepts multiple arguments, which will be JSON-stringified and displayed in the console. + * Example: + ```javascript + ad.console.log("Starting pre-request script..."); + let user = ad.environment.get("currentUser"); + ad.console.log("Current user from environment:", user); + ad.console.log("Request URL is:", ad.request.url.get(), "Method:", ad.request.method.get()); + ``` + +* **`ad.console.warn(...args: any[])`** + * Logs warning messages. Typically displayed differently (e.g., yellow background) in the console. + * Example: + ```javascript + if (!ad.environment.has("apiKey")) { + ad.console.warn("API Key environment variable is missing!"); + } + let responseTime = ad.response.time; + if (responseTime && responseTime > 2000) { + ad.console.warn("Request took over 2 seconds:", responseTime, "ms"); + } + ``` + +* **`ad.console.error(...args: any[])`** + * Logs error messages. Typically displayed prominently (e.g., red background) in the console. Also used internally by methods like `ad.response.json()` on failure. + * Example: + ```javascript + if (ad.response.status >= 500) { + ad.console.error("Server error detected!", "Status:", ad.response.status); + ad.console.error("Response Body:", ad.response.body); + } + try { + // Some operation that might fail + let criticalValue = ad.response.json().mustExist; + } catch (e) { + ad.console.error("Script failed to process response:", e.toString(), e.stack); + } + ``` + +### Key Notes: +1. **Pre/Post Script Context** + - Request manipulation only works in pre-request scripts + - Response access only works in post-response scripts + +2. **Environment Variables** + - Use `{{VAR_NAME}}` syntax in values for automatic substitution + - Changes to environment variables persist for subsequent requests + +3. **Data Types** + - All values are converted to strings when set in headers/params + - Use `ad.request.body.set()` with objects for proper JSON handling + +4. **Error Handling** + ```javascript + try { + // Potentially error-prone operations + } catch(e) { + ad.console.error('Operation failed:', e.message) + // Optionally re-throw to abort request + throw e + } + ``` + +5. **Best Practices** + - Always check for existence before accessing values + - Use environment variables for sensitive data + - Clean up temporary variables with `ad.environment.unset()` + - Use logging strategically to track script execution + +### Example Workflow + +### Example 1: Post-request - Extract Data and Check Status + +```javascript +// Post-response Script + +// Set URL: https://api.apidash.dev/auth/login +// 1. Check response status +if (ad.response.status < 200 || ad.response.status >= 300) { + ad.console.error(`Request failed with status ${ad.response.status}.`); + ad.console.error("Response:", ad.response.body); + // Optional: Could clear a related environment variable on failure + // ad.environment.unset("last_successful_item_id"); + return; // Stop processing if status indicates failure +} + +ad.console.log(`Request successful with status ${ad.response.status}.`); +ad.console.log(`Took ${ad.response.time} ms.`); + +// 2. Try to parse JSON response +const data = ad.response.json(); + +// 3. Extract and store data if available +if (data && data.access_token) { + ad.environment.set("current_session_id", data.access_token); + ad.console.log("Session ID saved to environment."); +} else { + ad.console.warn("Could not find 'access_token' in the response JSON."); +} +``` + +### Example 2: Pre-request - Set Auth Header + +```javascript +// Pre-request Script + +// Set URL : https://api.apidash.dev/profile +// 1. Get Auth token from environment +const token = ad.environment.get("access_token"); +if (token) { + ad.request.headers.set("Authorization", `Bearer ${token}`); + ad.console.log("Authorization header set."); +} else { + ad.console.warn("API token not found in environment!"); +} + +// 2. Try to parse JSON response +const data = ad.response.json(); + +// 3. Extract and store final User if available +if (data && data.user && data.user.id) { + ad.environment.set("logged_in_user_id", data.user.id); + ad.console.log(`User ID ${data.user.id} saved to environment.`); +} +``` \ No newline at end of file diff --git a/lib/consts.dart b/lib/consts.dart index 9b6fa475..3e9dcacd 100644 --- a/lib/consts.dart +++ b/lib/consts.dart @@ -6,6 +6,8 @@ import 'package:flutter/material.dart'; const kDiscordUrl = "https://bit.ly/heyfoss"; const kGitUrl = "https://github.com/foss42/apidash"; +const kLearnScriptingUrl = + "$kGitUrl/blob/main/doc/user_guide/scripting_user_guide.md"; const kIssueUrl = "$kGitUrl/issues"; const kDefaultUri = "api.apidash.dev"; @@ -442,9 +444,10 @@ const kUntitled = "untitled"; const kLabelRequest = "Request"; const kLabelHideCode = "Hide Code"; const kLabelViewCode = "View Code"; -const kLabelURLParams = "URL Params"; +const kLabelURLParams = "Params"; const kLabelHeaders = "Headers"; const kLabelBody = "Body"; +const kLabelScripts = "Scripts"; const kLabelQuery = "Query"; const kNameCheckbox = "Checkbox"; const kNameURLParam = "URL Parameter"; @@ -464,6 +467,8 @@ const kHintContent = "Enter content"; const kHintText = "Enter text"; const kHintJson = "Enter JSON"; const kHintQuery = "Enter Query"; +// TODO: CodeField widget does not allow this hint. To be solved. +const kHintScript = "// Use Javacript to modify this request dynamically"; // Response Pane const kLabelNotSent = "Not Sent"; const kLabelResponse = "Response"; diff --git a/lib/dashbot/widgets/content_renderer.dart b/lib/dashbot/widgets/content_renderer.dart index 500ebd74..a9dc3625 100644 --- a/lib/dashbot/widgets/content_renderer.dart +++ b/lib/dashbot/widgets/content_renderer.dart @@ -1,5 +1,6 @@ // lib/dashbot/widgets/content_renderer.dart import 'dart:convert'; +import 'package:apidash_design_system/apidash_design_system.dart'; import 'package:flutter/material.dart'; import 'package:flutter_highlighter/flutter_highlighter.dart'; import 'package:flutter_highlighter/themes/monokai-sublime.dart'; @@ -78,9 +79,8 @@ Widget _renderCodeBlock( color: Theme.of(context).colorScheme.surfaceContainerLow, child: SelectableText( prettyJson, - style: const TextStyle( - fontFamily: 'monospace', - fontSize: 12, + style: kCodeStyle.copyWith( + fontSize: Theme.of(context).textTheme.bodyMedium?.fontSize, ), ), ); @@ -96,9 +96,8 @@ Widget _renderCodeBlock( code, language: language, theme: monokaiSublimeTheme, - textStyle: const TextStyle( - fontFamily: 'monospace', - fontSize: 12, + textStyle: kCodeStyle.copyWith( + fontSize: Theme.of(context).textTheme.bodyMedium?.fontSize, ), ), ); @@ -117,9 +116,8 @@ Widget _renderFallbackCode( color: Theme.of(context).colorScheme.surfaceContainerLow, child: SelectableText( code, - style: const TextStyle( - fontFamily: 'monospace', - fontSize: 12, + style: kCodeStyle.copyWith( + fontSize: Theme.of(context).textTheme.bodyMedium?.fontSize, color: Colors.red, ), ), diff --git a/lib/dashbot/widgets/test_runner_widget.dart b/lib/dashbot/widgets/test_runner_widget.dart index 59aacbdf..ded2d4ef 100644 --- a/lib/dashbot/widgets/test_runner_widget.dart +++ b/lib/dashbot/widgets/test_runner_widget.dart @@ -1,5 +1,6 @@ import 'dart:convert'; import 'package:apidash_core/apidash_core.dart' as http; +import 'package:apidash_design_system/apidash_design_system.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -251,7 +252,7 @@ class _TestRunnerWidgetState extends ConsumerState { width: double.infinity, child: SelectableText( test['command'], - style: const TextStyle(fontFamily: 'monospace'), + style: kCodeStyle, ), ), if (hasResult) ...[ diff --git a/lib/main.dart b/lib/main.dart index d8a4dd7f..2b43ca94 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -11,6 +11,7 @@ void main() async { WidgetsFlutterBinding.ensureInitialized(); var settingsModel = await getSettingsFromSharedPrefs(); var onboardingStatus = await getOnboardingStatusFromSharedPrefs(); + initializeJsRuntime(); final initStatus = await initApp( kIsDesktop, settingsModel: settingsModel, diff --git a/lib/models/history_request_model.dart b/lib/models/history_request_model.dart index a895e945..1ffc5460 100644 --- a/lib/models/history_request_model.dart +++ b/lib/models/history_request_model.dart @@ -16,6 +16,8 @@ class HistoryRequestModel with _$HistoryRequestModel { required HistoryMetaModel metaData, required HttpRequestModel httpRequestModel, required HttpResponseModel httpResponseModel, + String? preRequestScript, + String? postRequestScript, }) = _HistoryRequestModel; factory HistoryRequestModel.fromJson(Map json) => diff --git a/lib/models/history_request_model.freezed.dart b/lib/models/history_request_model.freezed.dart index bb7e9492..ac43f3ee 100644 --- a/lib/models/history_request_model.freezed.dart +++ b/lib/models/history_request_model.freezed.dart @@ -24,6 +24,8 @@ mixin _$HistoryRequestModel { HistoryMetaModel get metaData => throw _privateConstructorUsedError; HttpRequestModel get httpRequestModel => throw _privateConstructorUsedError; HttpResponseModel get httpResponseModel => throw _privateConstructorUsedError; + String? get preRequestScript => throw _privateConstructorUsedError; + String? get postRequestScript => throw _privateConstructorUsedError; /// Serializes this HistoryRequestModel to a JSON map. Map toJson() => throw _privateConstructorUsedError; @@ -45,7 +47,9 @@ abstract class $HistoryRequestModelCopyWith<$Res> { {String historyId, HistoryMetaModel metaData, HttpRequestModel httpRequestModel, - HttpResponseModel httpResponseModel}); + HttpResponseModel httpResponseModel, + String? preRequestScript, + String? postRequestScript}); $HistoryMetaModelCopyWith<$Res> get metaData; $HttpRequestModelCopyWith<$Res> get httpRequestModel; @@ -71,6 +75,8 @@ class _$HistoryRequestModelCopyWithImpl<$Res, $Val extends HistoryRequestModel> Object? metaData = null, Object? httpRequestModel = null, Object? httpResponseModel = null, + Object? preRequestScript = freezed, + Object? postRequestScript = freezed, }) { return _then(_value.copyWith( historyId: null == historyId @@ -89,6 +95,14 @@ class _$HistoryRequestModelCopyWithImpl<$Res, $Val extends HistoryRequestModel> ? _value.httpResponseModel : httpResponseModel // ignore: cast_nullable_to_non_nullable as HttpResponseModel, + preRequestScript: freezed == preRequestScript + ? _value.preRequestScript + : preRequestScript // ignore: cast_nullable_to_non_nullable + as String?, + postRequestScript: freezed == postRequestScript + ? _value.postRequestScript + : postRequestScript // ignore: cast_nullable_to_non_nullable + as String?, ) as $Val); } @@ -135,7 +149,9 @@ abstract class _$$HistoryRequestModelImplCopyWith<$Res> {String historyId, HistoryMetaModel metaData, HttpRequestModel httpRequestModel, - HttpResponseModel httpResponseModel}); + HttpResponseModel httpResponseModel, + String? preRequestScript, + String? postRequestScript}); @override $HistoryMetaModelCopyWith<$Res> get metaData; @@ -162,6 +178,8 @@ class __$$HistoryRequestModelImplCopyWithImpl<$Res> Object? metaData = null, Object? httpRequestModel = null, Object? httpResponseModel = null, + Object? preRequestScript = freezed, + Object? postRequestScript = freezed, }) { return _then(_$HistoryRequestModelImpl( historyId: null == historyId @@ -180,6 +198,14 @@ class __$$HistoryRequestModelImplCopyWithImpl<$Res> ? _value.httpResponseModel : httpResponseModel // ignore: cast_nullable_to_non_nullable as HttpResponseModel, + preRequestScript: freezed == preRequestScript + ? _value.preRequestScript + : preRequestScript // ignore: cast_nullable_to_non_nullable + as String?, + postRequestScript: freezed == postRequestScript + ? _value.postRequestScript + : postRequestScript // ignore: cast_nullable_to_non_nullable + as String?, )); } } @@ -192,7 +218,9 @@ class _$HistoryRequestModelImpl implements _HistoryRequestModel { {required this.historyId, required this.metaData, required this.httpRequestModel, - required this.httpResponseModel}); + required this.httpResponseModel, + this.preRequestScript, + this.postRequestScript}); factory _$HistoryRequestModelImpl.fromJson(Map json) => _$$HistoryRequestModelImplFromJson(json); @@ -205,10 +233,14 @@ class _$HistoryRequestModelImpl implements _HistoryRequestModel { final HttpRequestModel httpRequestModel; @override final HttpResponseModel httpResponseModel; + @override + final String? preRequestScript; + @override + final String? postRequestScript; @override String toString() { - return 'HistoryRequestModel(historyId: $historyId, metaData: $metaData, httpRequestModel: $httpRequestModel, httpResponseModel: $httpResponseModel)'; + return 'HistoryRequestModel(historyId: $historyId, metaData: $metaData, httpRequestModel: $httpRequestModel, httpResponseModel: $httpResponseModel, preRequestScript: $preRequestScript, postRequestScript: $postRequestScript)'; } @override @@ -223,13 +255,17 @@ class _$HistoryRequestModelImpl implements _HistoryRequestModel { (identical(other.httpRequestModel, httpRequestModel) || other.httpRequestModel == httpRequestModel) && (identical(other.httpResponseModel, httpResponseModel) || - other.httpResponseModel == httpResponseModel)); + other.httpResponseModel == httpResponseModel) && + (identical(other.preRequestScript, preRequestScript) || + other.preRequestScript == preRequestScript) && + (identical(other.postRequestScript, postRequestScript) || + other.postRequestScript == postRequestScript)); } @JsonKey(includeFromJson: false, includeToJson: false) @override - int get hashCode => Object.hash( - runtimeType, historyId, metaData, httpRequestModel, httpResponseModel); + int get hashCode => Object.hash(runtimeType, historyId, metaData, + httpRequestModel, httpResponseModel, preRequestScript, postRequestScript); /// Create a copy of HistoryRequestModel /// with the given fields replaced by the non-null parameter values. @@ -250,11 +286,12 @@ class _$HistoryRequestModelImpl implements _HistoryRequestModel { abstract class _HistoryRequestModel implements HistoryRequestModel { const factory _HistoryRequestModel( - {required final String historyId, - required final HistoryMetaModel metaData, - required final HttpRequestModel httpRequestModel, - required final HttpResponseModel httpResponseModel}) = - _$HistoryRequestModelImpl; + {required final String historyId, + required final HistoryMetaModel metaData, + required final HttpRequestModel httpRequestModel, + required final HttpResponseModel httpResponseModel, + final String? preRequestScript, + final String? postRequestScript}) = _$HistoryRequestModelImpl; factory _HistoryRequestModel.fromJson(Map json) = _$HistoryRequestModelImpl.fromJson; @@ -267,6 +304,10 @@ abstract class _HistoryRequestModel implements HistoryRequestModel { HttpRequestModel get httpRequestModel; @override HttpResponseModel get httpResponseModel; + @override + String? get preRequestScript; + @override + String? get postRequestScript; /// Create a copy of HistoryRequestModel /// with the given fields replaced by the non-null parameter values. diff --git a/lib/models/history_request_model.g.dart b/lib/models/history_request_model.g.dart index 830d7a4c..705a2022 100644 --- a/lib/models/history_request_model.g.dart +++ b/lib/models/history_request_model.g.dart @@ -15,6 +15,8 @@ _$HistoryRequestModelImpl _$$HistoryRequestModelImplFromJson(Map json) => Map.from(json['httpRequestModel'] as Map)), httpResponseModel: HttpResponseModel.fromJson( Map.from(json['httpResponseModel'] as Map)), + preRequestScript: json['preRequestScript'] as String?, + postRequestScript: json['postRequestScript'] as String?, ); Map _$$HistoryRequestModelImplToJson( @@ -24,4 +26,6 @@ Map _$$HistoryRequestModelImplToJson( 'metaData': instance.metaData.toJson(), 'httpRequestModel': instance.httpRequestModel.toJson(), 'httpResponseModel': instance.httpResponseModel.toJson(), + 'preRequestScript': instance.preRequestScript, + 'postRequestScript': instance.postRequestScript, }; diff --git a/lib/models/request_model.dart b/lib/models/request_model.dart index bb9eefae..4d63c2ad 100644 --- a/lib/models/request_model.dart +++ b/lib/models/request_model.dart @@ -22,6 +22,8 @@ class RequestModel with _$RequestModel { HttpResponseModel? httpResponseModel, @JsonKey(includeToJson: false) @Default(false) bool isWorking, @JsonKey(includeToJson: false) DateTime? sendingTime, + String? preRequestScript, + String? postRequestScript, }) = _RequestModel; factory RequestModel.fromJson(Map json) => diff --git a/lib/models/request_model.freezed.dart b/lib/models/request_model.freezed.dart index b237e372..72f607bd 100644 --- a/lib/models/request_model.freezed.dart +++ b/lib/models/request_model.freezed.dart @@ -35,6 +35,8 @@ mixin _$RequestModel { bool get isWorking => throw _privateConstructorUsedError; @JsonKey(includeToJson: false) DateTime? get sendingTime => throw _privateConstructorUsedError; + String? get preRequestScript => throw _privateConstructorUsedError; + String? get postRequestScript => throw _privateConstructorUsedError; /// Serializes this RequestModel to a JSON map. Map toJson() => throw _privateConstructorUsedError; @@ -63,7 +65,9 @@ abstract class $RequestModelCopyWith<$Res> { String? message, HttpResponseModel? httpResponseModel, @JsonKey(includeToJson: false) bool isWorking, - @JsonKey(includeToJson: false) DateTime? sendingTime}); + @JsonKey(includeToJson: false) DateTime? sendingTime, + String? preRequestScript, + String? postRequestScript}); $HttpRequestModelCopyWith<$Res>? get httpRequestModel; $HttpResponseModelCopyWith<$Res>? get httpResponseModel; @@ -95,6 +99,8 @@ class _$RequestModelCopyWithImpl<$Res, $Val extends RequestModel> Object? httpResponseModel = freezed, Object? isWorking = null, Object? sendingTime = freezed, + Object? preRequestScript = freezed, + Object? postRequestScript = freezed, }) { return _then(_value.copyWith( id: null == id @@ -141,6 +147,14 @@ class _$RequestModelCopyWithImpl<$Res, $Val extends RequestModel> ? _value.sendingTime : sendingTime // ignore: cast_nullable_to_non_nullable as DateTime?, + preRequestScript: freezed == preRequestScript + ? _value.preRequestScript + : preRequestScript // ignore: cast_nullable_to_non_nullable + as String?, + postRequestScript: freezed == postRequestScript + ? _value.postRequestScript + : postRequestScript // ignore: cast_nullable_to_non_nullable + as String?, ) as $Val); } @@ -192,7 +206,9 @@ abstract class _$$RequestModelImplCopyWith<$Res> String? message, HttpResponseModel? httpResponseModel, @JsonKey(includeToJson: false) bool isWorking, - @JsonKey(includeToJson: false) DateTime? sendingTime}); + @JsonKey(includeToJson: false) DateTime? sendingTime, + String? preRequestScript, + String? postRequestScript}); @override $HttpRequestModelCopyWith<$Res>? get httpRequestModel; @@ -224,6 +240,8 @@ class __$$RequestModelImplCopyWithImpl<$Res> Object? httpResponseModel = freezed, Object? isWorking = null, Object? sendingTime = freezed, + Object? preRequestScript = freezed, + Object? postRequestScript = freezed, }) { return _then(_$RequestModelImpl( id: null == id @@ -269,6 +287,14 @@ class __$$RequestModelImplCopyWithImpl<$Res> ? _value.sendingTime : sendingTime // ignore: cast_nullable_to_non_nullable as DateTime?, + preRequestScript: freezed == preRequestScript + ? _value.preRequestScript + : preRequestScript // ignore: cast_nullable_to_non_nullable + as String?, + postRequestScript: freezed == postRequestScript + ? _value.postRequestScript + : postRequestScript // ignore: cast_nullable_to_non_nullable + as String?, )); } } @@ -288,7 +314,9 @@ class _$RequestModelImpl implements _RequestModel { this.message, this.httpResponseModel, @JsonKey(includeToJson: false) this.isWorking = false, - @JsonKey(includeToJson: false) this.sendingTime}); + @JsonKey(includeToJson: false) this.sendingTime, + this.preRequestScript, + this.postRequestScript}); factory _$RequestModelImpl.fromJson(Map json) => _$$RequestModelImplFromJson(json); @@ -321,10 +349,14 @@ class _$RequestModelImpl implements _RequestModel { @override @JsonKey(includeToJson: false) final DateTime? sendingTime; + @override + final String? preRequestScript; + @override + final String? postRequestScript; @override String toString() { - return 'RequestModel(id: $id, apiType: $apiType, name: $name, description: $description, requestTabIndex: $requestTabIndex, httpRequestModel: $httpRequestModel, responseStatus: $responseStatus, message: $message, httpResponseModel: $httpResponseModel, isWorking: $isWorking, sendingTime: $sendingTime)'; + return 'RequestModel(id: $id, apiType: $apiType, name: $name, description: $description, requestTabIndex: $requestTabIndex, httpRequestModel: $httpRequestModel, responseStatus: $responseStatus, message: $message, httpResponseModel: $httpResponseModel, isWorking: $isWorking, sendingTime: $sendingTime, preRequestScript: $preRequestScript, postRequestScript: $postRequestScript)'; } @override @@ -349,7 +381,11 @@ class _$RequestModelImpl implements _RequestModel { (identical(other.isWorking, isWorking) || other.isWorking == isWorking) && (identical(other.sendingTime, sendingTime) || - other.sendingTime == sendingTime)); + other.sendingTime == sendingTime) && + (identical(other.preRequestScript, preRequestScript) || + other.preRequestScript == preRequestScript) && + (identical(other.postRequestScript, postRequestScript) || + other.postRequestScript == postRequestScript)); } @JsonKey(includeFromJson: false, includeToJson: false) @@ -366,7 +402,9 @@ class _$RequestModelImpl implements _RequestModel { message, httpResponseModel, isWorking, - sendingTime); + sendingTime, + preRequestScript, + postRequestScript); /// Create a copy of RequestModel /// with the given fields replaced by the non-null parameter values. @@ -386,18 +424,19 @@ class _$RequestModelImpl implements _RequestModel { abstract class _RequestModel implements RequestModel { const factory _RequestModel( - {required final String id, - final APIType apiType, - final String name, - final String description, - @JsonKey(includeToJson: false) final dynamic requestTabIndex, - final HttpRequestModel? httpRequestModel, - final int? responseStatus, - final String? message, - final HttpResponseModel? httpResponseModel, - @JsonKey(includeToJson: false) final bool isWorking, - @JsonKey(includeToJson: false) final DateTime? sendingTime}) = - _$RequestModelImpl; + {required final String id, + final APIType apiType, + final String name, + final String description, + @JsonKey(includeToJson: false) final dynamic requestTabIndex, + final HttpRequestModel? httpRequestModel, + final int? responseStatus, + final String? message, + final HttpResponseModel? httpResponseModel, + @JsonKey(includeToJson: false) final bool isWorking, + @JsonKey(includeToJson: false) final DateTime? sendingTime, + final String? preRequestScript, + final String? postRequestScript}) = _$RequestModelImpl; factory _RequestModel.fromJson(Map json) = _$RequestModelImpl.fromJson; @@ -427,6 +466,10 @@ abstract class _RequestModel implements RequestModel { @override @JsonKey(includeToJson: false) DateTime? get sendingTime; + @override + String? get preRequestScript; + @override + String? get postRequestScript; /// Create a copy of RequestModel /// with the given fields replaced by the non-null parameter values. diff --git a/lib/models/request_model.g.dart b/lib/models/request_model.g.dart index 68b56395..d4f6ec20 100644 --- a/lib/models/request_model.g.dart +++ b/lib/models/request_model.g.dart @@ -27,6 +27,8 @@ _$RequestModelImpl _$$RequestModelImplFromJson(Map json) => _$RequestModelImpl( sendingTime: json['sendingTime'] == null ? null : DateTime.parse(json['sendingTime'] as String), + preRequestScript: json['preRequestScript'] as String?, + postRequestScript: json['postRequestScript'] as String?, ); Map _$$RequestModelImplToJson(_$RequestModelImpl instance) => @@ -39,6 +41,8 @@ Map _$$RequestModelImplToJson(_$RequestModelImpl instance) => 'responseStatus': instance.responseStatus, 'message': instance.message, 'httpResponseModel': instance.httpResponseModel?.toJson(), + 'preRequestScript': instance.preRequestScript, + 'postRequestScript': instance.postRequestScript, }; const _$APITypeEnumMap = { diff --git a/lib/providers/collection_providers.dart b/lib/providers/collection_providers.dart index 35bc4aa0..1bb9fba2 100644 --- a/lib/providers/collection_providers.dart +++ b/lib/providers/collection_providers.dart @@ -4,9 +4,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:apidash/consts.dart'; import 'providers.dart'; import '../models/models.dart'; -import '../services/services.dart' show hiveHandler, HiveHandler; -import '../utils/utils.dart' - show getNewUuid, collectionToHAR, substituteHttpRequestModel; +import '../services/services.dart'; +import '../utils/utils.dart'; final selectedIdStateProvider = StateProvider((ref) => null); @@ -223,6 +222,8 @@ class CollectionStateNotifier int? responseStatus, String? message, HttpResponseModel? httpResponseModel, + String? preRequestScript, + String? postRequestScript, }) { final rId = id ?? ref.read(selectedIdStateProvider); if (rId == null) { @@ -254,6 +255,8 @@ class CollectionStateNotifier responseStatus: responseStatus ?? currentModel.responseStatus, message: message ?? currentModel.message, httpResponseModel: httpResponseModel ?? currentModel.httpResponseModel, + preRequestScript: preRequestScript ?? currentModel.preRequestScript, + postRequestScript: postRequestScript ?? currentModel.postRequestScript, ); var map = {...state!}; @@ -266,6 +269,8 @@ class CollectionStateNotifier final requestId = ref.read(selectedIdStateProvider); ref.read(codePaneVisibleStateProvider.notifier).state = false; final defaultUriScheme = ref.read(settingsProvider).defaultUriScheme; + final EnvironmentModel? originalEnvironmentModel = + ref.read(selectedEnvironmentModelProvider); if (requestId == null || state == null) { return; @@ -276,6 +281,23 @@ class CollectionStateNotifier return; } + if (requestModel != null && + !requestModel.preRequestScript.isNullOrEmpty()) { + requestModel = await handlePreRequestScript( + requestModel, + originalEnvironmentModel, + (envModel, updatedValues) { + ref + .read(environmentsStateNotifierProvider.notifier) + .updateEnvironment( + envModel.id, + name: envModel.name, + values: updatedValues, + ); + }, + ); + } + APIType apiType = requestModel!.apiType; HttpRequestModel substitutedHttpRequestModel = getSubstitutedHttpRequestModel(requestModel.httpRequestModel!); @@ -297,7 +319,7 @@ class CollectionStateNotifier noSSL: noSSL, ); - late final RequestModel newRequestModel; + late RequestModel newRequestModel; if (responseRec.$1 == null) { newRequestModel = requestModel.copyWith( responseStatus: -1, @@ -331,7 +353,25 @@ class CollectionStateNotifier ), httpRequestModel: substitutedHttpRequestModel, httpResponseModel: httpResponseModel, + preRequestScript: requestModel.preRequestScript, + postRequestScript: requestModel.postRequestScript, ); + + if (!requestModel.postRequestScript.isNullOrEmpty()) { + newRequestModel = await handlePostResponseScript( + newRequestModel, + originalEnvironmentModel, + (envModel, updatedValues) { + ref + .read(environmentsStateNotifierProvider.notifier) + .updateEnvironment( + envModel.id, + name: envModel.name, + values: updatedValues, + ); + }, + ); + } ref.read(historyMetaStateNotifier.notifier).addHistoryRequest(model); } diff --git a/lib/screens/history/history_widgets/his_request_pane.dart b/lib/screens/history/history_widgets/his_request_pane.dart index f1435063..c1e8995d 100644 --- a/lib/screens/history/history_widgets/his_request_pane.dart +++ b/lib/screens/history/history_widgets/his_request_pane.dart @@ -5,6 +5,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:apidash/providers/providers.dart'; import 'package:apidash/widgets/widgets.dart'; import 'package:apidash/consts.dart'; +import 'his_scripts_tab.dart'; class HistoryRequestPane extends ConsumerWidget { const HistoryRequestPane({ @@ -38,6 +39,12 @@ class HistoryRequestPane extends ConsumerWidget { .select((value) => value?.httpRequestModel.hasQuery)) ?? false; + final scriptsLength = ref.watch(selectedHistoryRequestModelProvider + .select((value) => value?.preRequestScript?.length)) ?? + ref.watch(selectedHistoryRequestModelProvider + .select((value) => value?.postRequestScript?.length)) ?? + 0; + return switch (apiType) { APIType.rest => RequestPane( key: const Key("history-request-pane-rest"), @@ -52,11 +59,13 @@ class HistoryRequestPane extends ConsumerWidget { paramLength > 0, headerLength > 0, hasBody, + scriptsLength > 0 ], tabLabels: const [ kLabelURLParams, kLabelHeaders, kLabelBody, + kLabelScripts, ], children: [ RequestDataTable( @@ -68,6 +77,7 @@ class HistoryRequestPane extends ConsumerWidget { keyName: kNameHeader, ), const HisRequestBody(), + const HistoryScriptsTab(), ], ), APIType.graphql => RequestPane( @@ -79,13 +89,11 @@ class HistoryRequestPane extends ConsumerWidget { !codePaneVisible; }, showViewCodeButton: !isCompact, - showIndicators: [ - headerLength > 0, - hasQuery, - ], + showIndicators: [headerLength > 0, hasQuery, scriptsLength > 0], tabLabels: const [ kLabelHeaders, kLabelQuery, + kLabelScripts, ], children: [ RequestDataTable( @@ -93,6 +101,7 @@ class HistoryRequestPane extends ConsumerWidget { keyName: kNameHeader, ), const HisRequestBody(), + const HistoryScriptsTab(), ], ), _ => kSizedBoxEmpty, diff --git a/lib/screens/history/history_widgets/his_scripts_tab.dart b/lib/screens/history/history_widgets/his_scripts_tab.dart new file mode 100644 index 00000000..35f47728 --- /dev/null +++ b/lib/screens/history/history_widgets/his_scripts_tab.dart @@ -0,0 +1,83 @@ +import 'package:apidash_design_system/apidash_design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_code_editor/flutter_code_editor.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:highlight/languages/javascript.dart'; +import 'package:apidash/providers/providers.dart'; +import 'package:apidash/widgets/widgets.dart'; + +class HistoryScriptsTab extends ConsumerStatefulWidget { + const HistoryScriptsTab({super.key}); + + @override + ConsumerState createState() => _ScriptsCodePaneState(); +} + +class _ScriptsCodePaneState extends ConsumerState { + int _selectedTabIndex = 0; + @override + Widget build(BuildContext context) { + final hisRequestModel = ref.read(selectedHistoryRequestModelProvider); + final isDarkMode = + ref.watch(settingsProvider.select((value) => value.isDark)); + final preReqCodeController = CodeController( + text: hisRequestModel?.preRequestScript, + language: javascript, + ); + + final postResCodeController = CodeController( + text: hisRequestModel?.postRequestScript, + language: javascript, + ); + + preReqCodeController.addListener(() { + ref.read(collectionStateNotifierProvider.notifier).update( + preRequestScript: preReqCodeController.text, + ); + }); + + postResCodeController.addListener(() { + ref.read(collectionStateNotifierProvider.notifier).update( + postRequestScript: postResCodeController.text, + ); + }); + + final tabs = [(0, "Pre Request"), (1, "Post Response")]; + final content = [ + CodeEditor( + controller: preReqCodeController, + readOnly: true, + isDark: isDarkMode, + ), + CodeEditor( + controller: postResCodeController, + readOnly: true, + isDark: isDarkMode, + ), + ]; + + return Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: ADDropdownButton( + value: _selectedTabIndex, + values: tabs, + onChanged: (int? newValue) { + if (newValue != null) { + setState(() { + _selectedTabIndex = newValue; + }); + } + }, + ), + ), + Expanded( + child: content[_selectedTabIndex], + ), + ], + ); + } +} diff --git a/lib/screens/home_page/editor_pane/details_card/request_pane/request_pane_graphql.dart b/lib/screens/home_page/editor_pane/details_card/request_pane/request_pane_graphql.dart index beae2b6c..fb8d7bc9 100644 --- a/lib/screens/home_page/editor_pane/details_card/request_pane/request_pane_graphql.dart +++ b/lib/screens/home_page/editor_pane/details_card/request_pane/request_pane_graphql.dart @@ -5,6 +5,7 @@ import 'package:apidash/providers/providers.dart'; import 'package:apidash/widgets/widgets.dart'; import 'request_headers.dart'; import 'request_body.dart'; +import 'request_scripts.dart'; class EditGraphQLRequestPane extends ConsumerWidget { const EditGraphQLRequestPane({super.key}); @@ -21,7 +22,14 @@ class EditGraphQLRequestPane extends ConsumerWidget { final hasQuery = ref.watch(selectedRequestModelProvider .select((value) => value?.httpRequestModel?.hasQuery)) ?? false; - if (tabIndex >= 2) { + + final scriptsLength = ref.watch(selectedRequestModelProvider + .select((value) => value?.preRequestScript?.length)) ?? + ref.watch(selectedRequestModelProvider + .select((value) => value?.postRequestScript?.length)) ?? + 0; + + if (tabIndex >= 3) { tabIndex = 0; } return RequestPane( @@ -40,14 +48,17 @@ class EditGraphQLRequestPane extends ConsumerWidget { showIndicators: [ headerLength > 0, hasQuery, + scriptsLength > 0, ], tabLabels: const [ kLabelHeaders, kLabelQuery, + kLabelScripts, ], children: const [ EditRequestHeaders(), EditRequestBody(), + EditRequestScripts(), ], ); } diff --git a/lib/screens/home_page/editor_pane/details_card/request_pane/request_pane_rest.dart b/lib/screens/home_page/editor_pane/details_card/request_pane/request_pane_rest.dart index e323f83b..27d67567 100644 --- a/lib/screens/home_page/editor_pane/details_card/request_pane/request_pane_rest.dart +++ b/lib/screens/home_page/editor_pane/details_card/request_pane/request_pane_rest.dart @@ -6,6 +6,7 @@ import 'package:apidash/widgets/widgets.dart'; import 'request_headers.dart'; import 'request_params.dart'; import 'request_body.dart'; +import 'request_scripts.dart'; class EditRestRequestPane extends ConsumerWidget { const EditRestRequestPane({super.key}); @@ -27,6 +28,12 @@ class EditRestRequestPane extends ConsumerWidget { .select((value) => value?.httpRequestModel?.hasBody)) ?? false; + final scriptsLength = ref.watch(selectedRequestModelProvider + .select((value) => value?.preRequestScript?.length)) ?? + ref.watch(selectedRequestModelProvider + .select((value) => value?.postRequestScript?.length)) ?? + 0; + return RequestPane( selectedId: selectedId, codePaneVisible: codePaneVisible, @@ -44,16 +51,19 @@ class EditRestRequestPane extends ConsumerWidget { paramLength > 0, headerLength > 0, hasBody, + scriptsLength > 0, ], tabLabels: const [ kLabelURLParams, kLabelHeaders, kLabelBody, + kLabelScripts, ], children: const [ EditRequestURLParams(), EditRequestHeaders(), EditRequestBody(), + EditRequestScripts(), ], ); } diff --git a/lib/screens/home_page/editor_pane/details_card/request_pane/request_scripts.dart b/lib/screens/home_page/editor_pane/details_card/request_pane/request_scripts.dart new file mode 100644 index 00000000..b03eaca5 --- /dev/null +++ b/lib/screens/home_page/editor_pane/details_card/request_pane/request_scripts.dart @@ -0,0 +1,94 @@ +import 'package:apidash_design_system/apidash_design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_code_editor/flutter_code_editor.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:highlight/languages/javascript.dart'; +import 'package:apidash/providers/providers.dart'; +import 'package:apidash/widgets/widgets.dart'; +import 'package:apidash/consts.dart'; + +class EditRequestScripts extends ConsumerStatefulWidget { + const EditRequestScripts({super.key}); + + @override + ConsumerState createState() => _EditRequestScriptsState(); +} + +class _EditRequestScriptsState extends ConsumerState { + int _selectedTabIndex = 0; + @override + Widget build(BuildContext context) { + final requestModel = ref.read(selectedRequestModelProvider); + final isDarkMode = + ref.watch(settingsProvider.select((value) => value.isDark)); + final preReqCodeController = CodeController( + text: requestModel?.preRequestScript, + language: javascript, + ); + + final postResCodeController = CodeController( + text: requestModel?.postRequestScript, + language: javascript, + ); + + preReqCodeController.addListener(() { + ref.read(collectionStateNotifierProvider.notifier).update( + preRequestScript: preReqCodeController.text, + ); + }); + + postResCodeController.addListener(() { + ref.read(collectionStateNotifierProvider.notifier).update( + postRequestScript: postResCodeController.text, + ); + }); + + final tabs = [(0, "Pre Request"), (1, "Post Response")]; + final content = [ + CodeEditor( + controller: preReqCodeController, + isDark: isDarkMode, + ), + CodeEditor( + controller: postResCodeController, + isDark: isDarkMode, + ), + ]; + + return Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Padding( + padding: kPh8b6, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + ADDropdownButton( + isDense: true, + iconSize: kButtonIconSizeMedium, + value: _selectedTabIndex, + values: tabs, + onChanged: (int? newValue) { + if (newValue != null) { + setState(() { + _selectedTabIndex = newValue; + }); + } + }, + ), + LearnButton( + url: kLearnScriptingUrl, + ), + ], + ), + ), + Expanded( + child: Padding( + padding: kPt5o10, + child: content[_selectedTabIndex], + ), + ), + ], + ); + } +} diff --git a/lib/services/flutter_js_service.dart b/lib/services/flutter_js_service.dart new file mode 100644 index 00000000..d1ddcb18 --- /dev/null +++ b/lib/services/flutter_js_service.dart @@ -0,0 +1,275 @@ +// ignore_for_file: avoid_print + +import 'dart:convert'; +import 'dart:developer'; +import 'package:apidash_core/apidash_core.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_js/flutter_js.dart'; +import '../models/models.dart'; +import '../utils/utils.dart'; + +late JavascriptRuntime jsRuntime; + +void initializeJsRuntime() { + jsRuntime = getJavascriptRuntime(); + setupJsBridge(); +} + +void disposeJsRuntime() { + jsRuntime.dispose(); +} + +void evaluate(String code) { + try { + JsEvalResult jsResult = jsRuntime.evaluate(code); + log(jsResult.stringResult); + } on PlatformException catch (e) { + log('ERROR: ${e.details}'); + } +} + +// TODO: These log statements can be printed in a custom api dash terminal. +void setupJsBridge() { + jsRuntime.onMessage('consoleLog', (args) { + try { + if (args is List) { + print('[JS LOG]: ${args.map((e) => e.toString()).join(' ')}'); + } else { + print('[JS LOG]: $args'); + } + } catch (e) { + print('[JS LOG ERROR]: $args, Error: $e'); + } + }); + + jsRuntime.onMessage('consoleWarn', (args) { + try { + if (args is List) { + print('[JS WARN]: ${args.map((e) => e.toString()).join(' ')}'); + } else { + print('[JS WARN]: $args'); + } + } catch (e) { + print('[JS WARN ERROR]: $args, Error: $e'); + } + }); + + jsRuntime.onMessage('consoleError', (args) { + try { + if (args is List) { + print('[JS ERROR]: ${args.map((e) => e.toString()).join(' ')}'); + } else { + print('[JS ERROR]: $args'); + } + } catch (e) { + print('[JS ERROR ERROR]: $args, Error: $e'); + } + }); + + jsRuntime.onMessage('fatalError', (args) { + try { + // 'fatalError' message is constructed as a JSON object in setupScript + if (args is Map) { + print('[JS FATAL ERROR]: ${args['message']}'); + if (args['error'] != null) print(' Error: ${args['error']}'); + if (args['stack'] != null) print(' Stack: ${args['stack']}'); + } else { + print('[JS FATAL ERROR decoding error]: $args, Expected a Map.'); + } + } catch (e) { + print('[JS FATAL ERROR decoding error]: $args, Error: $e'); + } + }); +} + +Future< + ({ + HttpRequestModel updatedRequest, + Map updatedEnvironment + })> executePreRequestScript({ + required RequestModel currentRequestModel, + required Map activeEnvironment, +}) async { + if ((currentRequestModel.preRequestScript ?? "").trim().isEmpty) { + // No script, return original data + // return ( + // updatedRequest: currentRequestModel.httpRequestModel, + // updatedEnvironment: activeEnvironment + // ); + } + + final httpRequest = currentRequestModel.httpRequestModel; + final userScript = currentRequestModel.preRequestScript; + + // Prepare Data + final requestJson = jsonEncode(httpRequest?.toJson()); + final environmentJson = jsonEncode(activeEnvironment); + + // Inject data as JS variables + // Escape strings properly if embedding directly + final dataInjection = """ + var injectedRequestJson = ${jsEscapeString(requestJson)}; + var injectedEnvironmentJson = ${jsEscapeString(environmentJson)}; + var injectedResponseJson = null; // Not needed for pre-request + """; + + // Concatenate & Add Return + final fullScript = """ + (function() { + // --- Data Injection (now constants within the IIFE scope) --- + $dataInjection + + // --- Setup Script (will declare variables within the IIFE scope) --- + $kJSSetupScript + + // --- User Script (will execute within the IIFE scope)--- + $userScript + + // --- Return Result (accesses variables from the IIFE scope) --- + // Ensure 'request' and 'environment' are accessible here + return JSON.stringify({ request: request, environment: environment }); + })(); // Immediately invoke the function + """; + + // TODO: Do something better to avoid null check here. + HttpRequestModel resultingRequest = httpRequest!; + Map resultingEnvironment = Map.from(activeEnvironment); + + try { + // Execute + final JsEvalResult result = jsRuntime.evaluate(fullScript); + + // Process Results + if (result.isError) { + print("Pre-request script execution error: ${result.stringResult}"); + // Handle error - maybe show in UI, keep original request/env + } else if (result.stringResult.isNotEmpty) { + final resultMap = jsonDecode(result.stringResult); + if (resultMap is Map) { + // Deserialize Request + if (resultMap.containsKey('request') && resultMap['request'] is Map) { + try { + resultingRequest = HttpRequestModel.fromJson( + Map.from(resultMap['request'])); + } catch (e) { + print("Error deserializing modified request from script: $e"); + //TODO: Handle error - maybe keep original request? + } + } + // Get Environment Modifications + if (resultMap.containsKey('environment') && + resultMap['environment'] is Map) { + resultingEnvironment = + Map.from(resultMap['environment']); + } + } + } + } catch (e) { + print("Dart-level error during pre-request script execution: $e"); + } + + return ( + updatedRequest: resultingRequest, + updatedEnvironment: resultingEnvironment + ); +} + +Future< + ({ + HttpResponseModel updatedResponse, + Map updatedEnvironment + })> executePostResponseScript({ + required RequestModel currentRequestModel, + required Map activeEnvironment, +}) async { + if ((currentRequestModel.postRequestScript ?? "").trim().isEmpty) { + // No script, return original data + // return ( + // updatedRequest: currentRequestModel.httpRequestModel, + // updatedEnvironment: activeEnvironment + // ); + } + + final httpRequest = currentRequestModel.httpRequestModel; + final httpResponse = currentRequestModel.httpResponseModel; + final userScript = currentRequestModel.postRequestScript; + + // Prepare Data + final requestJson = jsonEncode(httpRequest?.toJson()); + final responseJson = jsonEncode(httpResponse?.toJson()); + final environmentJson = jsonEncode(activeEnvironment); + + // Inject data as JS variables + // Escape strings properly if embedding directly + final dataInjection = """ + var injectedRequestJson = ${jsEscapeString(requestJson)}; + var injectedEnvironmentJson = ${jsEscapeString(environmentJson)}; + var injectedResponseJson = ${jsEscapeString(responseJson)}; + """; + + // Concatenate & Add Return + final fullScript = """ + (function() { + // --- Data Injection (now constants within the IIFE scope) --- + $dataInjection + + // --- Setup Script (will declare variables within the IIFE scope) --- + $kJSSetupScript + + // --- User Script (will execute within the IIFE scope)--- + $userScript + + // --- Return Result (accesses variables from the IIFE scope) --- + return JSON.stringify({ response: response, environment: environment }); + })(); // Immediately invoke the function + """; + + // TODO: Do something better to avoid null check here. + // HttpRequestModel resultingRequest = httpRequest!; + HttpResponseModel resultingResponse = httpResponse!; + Map resultingEnvironment = Map.from(activeEnvironment); + + try { + // Execute + final JsEvalResult result = jsRuntime.evaluate(fullScript); + + // Process Results + if (result.isError) { + print("Post-Response script execution error: ${result.stringResult}"); + // TODO: Handle error - maybe show in UI, keep original request/env + } else if (result.stringResult.isNotEmpty) { + final resultMap = jsonDecode(result.stringResult); + if (resultMap is Map) { + // Deserialize Request + if (resultMap.containsKey('response') && resultMap['response'] is Map) { + try { + resultingResponse = HttpResponseModel.fromJson( + Map.from(resultMap['response'])); + } catch (e) { + print("Error deserializing modified response from script: $e"); + //TODO: Handle error - maybe keep original response? + } + } + // Get Environment Modifications + if (resultMap.containsKey('environment') && + resultMap['environment'] is Map) { + resultingEnvironment = + Map.from(resultMap['environment']); + } + } + } + } catch (e) { + print("Dart-level error during post-response script execution: $e"); + } + + return ( + updatedResponse: resultingResponse, + updatedEnvironment: resultingEnvironment + ); +} + +// Helper to properly escape strings for JS embedding +String jsEscapeString(String s) { + return jsonEncode( + s); // jsonEncode handles escaping correctly for JS string literals +} diff --git a/lib/services/services.dart b/lib/services/services.dart index 7fa8128d..a04ad176 100644 --- a/lib/services/services.dart +++ b/lib/services/services.dart @@ -1,3 +1,4 @@ +export 'flutter_js_service.dart'; export 'hive_services.dart'; export 'history_service.dart'; export 'window_services.dart'; diff --git a/lib/utils/js_utils.dart b/lib/utils/js_utils.dart new file mode 100644 index 00000000..250ae57d --- /dev/null +++ b/lib/utils/js_utils.dart @@ -0,0 +1,616 @@ +const String kJSSetupScript = r""" +// === APIDash Setup Script === + +// --- 1. Parse Injected Data --- +// These variables are expected to be populated by Dart before this script runs. +// Example: const injectedRequestJson = '{"method":"get", "url":"...", ...}'; + +let request = {}; // Will hold the RequestModel data +let response = {}; // Will hold the ResponseModel data (only for post-request) +let environment = {}; // Will hold the *active* environment variables as a simple {key: value} map + +// Note: Using 'let' because environment might be completely cleared/reassigned by ad.environment.clear(). + +try { + // 'injectedRequestJson' should always be provided + if (typeof injectedRequestJson !== 'undefined' && injectedRequestJson) { + request = JSON.parse(injectedRequestJson); + // Ensure essential arrays exist if they are null/undefined after parsing + request.headers = request.headers || []; + request.params = request.params || []; + request.formData = request.formData || []; + } else { + sendMessage('consoleError', JSON.stringify(['Setup Error: injectedRequestJson is missing or empty.'])); + } + + // 'injectedResponseJson' is only for post-response scripts + if (typeof injectedResponseJson !== 'undefined' && injectedResponseJson) { + response = JSON.parse(injectedResponseJson); + // Ensure response headers map exists + response.headers = response.headers || {}; + response.requestHeaders = response.requestHeaders || {}; + } + + // 'injectedEnvironmentJson' should always be provided + if (typeof injectedEnvironmentJson !== 'undefined' && injectedEnvironmentJson) { + const parsedEnvData = JSON.parse(injectedEnvironmentJson); + + environment = {}; // Initialize the target simple map + + if (parsedEnvData && Array.isArray(parsedEnvData.values)) { + parsedEnvData.values.forEach(variable => { + // Check if the variable object is valid and enabled + if (variable && typeof variable === 'object' && variable.enabled === true && typeof variable.key === 'string') { + // Add the key-value pair to our simplified 'environment' map + environment[variable.key] = variable.value; + } + }); + // sendMessage('consoleLog', JSON.stringify(['Successfully parsed environment variables.'])); + } else { + // Log a warning if the structure is not as expected, but continue with an empty env + sendMessage('consoleWarn', JSON.stringify([ + 'Setup Warning: injectedEnvironmentJson does not have the expected structure ({values: Array}). Using an empty environment.', + 'Received Data:', parsedEnvData // Log received data for debugging + ])); + environment = {}; // Ensure it's an empty object + } + + } else { + sendMessage('consoleError', JSON.stringify(['Setup Error: injectedEnvironmentJson is missing or empty.'])); + environment = {}; // Initialize to empty object to avoid errors later + } + +} catch (e) { + // Send error back to Dart if parsing fails catastrophically + sendMessage('fatalError', JSON.stringify({ + message: 'Failed to parse injected JSON data.', + error: e.toString(), + stack: e.stack + })); + // Optionally, re-throw to halt script execution immediately + // throw new Error('Failed to parse injected JSON data: ' + e.toString()); +} + + +// --- 2. Define APIDash Helper (`ad`) Object --- +// This object provides functions to interact with the request, response, +// environment, and the Dart host application. + +const ad = { + /** + * Functions to modify the request object *before* it is sent. + * Only available in pre-request scripts. + * Changes are made directly to the 'request' JS object. + */ + request: { + /** + * Access and modify request headers. Remember header names are case-insensitive in HTTP, + * but comparisons here might be case-sensitive unless handled carefully. + * Headers are represented as an array of objects: [{name: string, value: string}, ...] + */ + headers: { + /** + * Adds or updates a header. If a header with the same name (case-sensitive) + * already exists, it updates its value. Otherwise, adds a new header. + * Also updates the isHeaderEnabledList to include {true} by default + * @param {string} key The header name. + * @param {string} value The header value. + * @param {boolean} isHeaderEnabledList value. + */ + set: (key, value) => { + if (!request || typeof request !== 'object' || !Array.isArray(request.headers)) return; + if (typeof key !== 'string') return; + + const stringValue = String(value); + const existingHeaderIndex = request.headers.findIndex( + h => typeof h === 'object' && h.name === key + ); + + if (!Array.isArray(request.isHeaderEnabledList)) { + request.isHeaderEnabledList = []; + } + + if (existingHeaderIndex > -1) { + request.headers[existingHeaderIndex].value = stringValue; + request.isHeaderEnabledList[existingHeaderIndex] = true; + } else { + request.headers.push({ + name: key, + value: stringValue + }); + request.isHeaderEnabledList.push(true); + } + }, + /** + * Gets the value of the first header matching the key (case-sensitive). + * @param {string} key The header name. + * @returns {string|undefined} The header value or undefined if not found. + */ + + get: (key) => { + if (!request || typeof request !== 'object' || !Array.isArray(request.headers)) return undefined; + const header = request.headers.find(h => typeof h === 'object' && h.name === key); + return header ? header.value : undefined; + }, + + /** + * Removes all headers with the given name (case-sensitive). + * @param {string} key The header name to remove. + */ + + remove: (key) => { + if (!request || typeof request !== 'object' || !Array.isArray(request.headers)) return; + + if (!Array.isArray(request.isHeaderEnabledList)) { + request.isHeaderEnabledList = []; + } + + const indicesToRemove = []; + request.headers.forEach((h, index) => { + if (typeof h === 'object' && h.name === key) { + indicesToRemove.push(index); + } + }); + + // Remove from end to start to prevent index shifting + for (let i = indicesToRemove.length - 1; i >= 0; i--) { + const idx = indicesToRemove[i]; + request.headers.splice(idx, 1); + request.isHeaderEnabledList.splice(idx, 1); + } + }, + /** + * Checks if a header with the given name exists (case-sensitive). + * @param {string} key The header name. + * @returns {boolean} True if the header exists, false otherwise. + */ + + has: (key) => { + if (!request || typeof request !== 'object' || !Array.isArray(request.headers)) return false; + return request.headers.some(h => typeof h === 'object' && h.name === key); + }, + /** + * Clears all request headers along with isHeaderEnabledList. + */ + + clear: () => { + if (!request || typeof request !== 'object') return; + request.headers = []; + request.isHeaderEnabledList = []; + } + }, + + /** + * Access and modify URL query parameters. + * Params are represented as an array of objects: [{name: string, value: string}, ...] + */ + params: { + /** + * Adds or updates a query parameter. If a param with the same name (case-sensitive) + * already exists, it updates its value. Use multiple times for duplicate keys if needed by server. + * Consider URL encoding implications - values should likely be pre-encoded if necessary. + * @param {string} key The parameter name. + * @param {string} value The parameter value. + */ + set: (key, value) => { + if (!request || typeof request !== 'object' || !Array.isArray(request.params)) return; + if (typeof key !== 'string') return; + + const stringValue = String(value); + + if (!Array.isArray(request.isParamEnabledList)) { + request.isParamEnabledList = []; + } + + const existingParamIndex = request.params.findIndex(p => typeof p === 'object' && p.name === key); + + if (existingParamIndex > -1) { + request.params[existingParamIndex].value = stringValue; + request.isParamEnabledList[existingParamIndex] = true; + } else { + request.params.push({ + name: key, + value: stringValue + }); + request.isParamEnabledList.push(true); + } + }, + /** + * Gets the value of the first query parameter matching the key (case-sensitive). + * @param {string} key The parameter name. + * @returns {string|undefined} The parameter value or undefined if not found. + */ + get: (key) => { + if (!request || typeof request !== 'object' || !Array.isArray(request.params)) return undefined; // Safety check + const param = request.params.find(p => typeof p === 'object' && p.name === key); + return param ? param.value : undefined; + }, + /** + * Removes all query parameters with the given name (case-sensitive). + * @param {string} key The parameter name to remove. + */ + remove: (key) => { + if (!request || typeof request !== 'object' || !Array.isArray(request.params)) return; + + if (!Array.isArray(request.isParamEnabledList)) { + request.isParamEnabledList = []; + } + + const indicesToRemove = []; + request.params.forEach((p, index) => { + if (typeof p === 'object' && p.name === key) { + indicesToRemove.push(index); + } + }); + + for (let i = indicesToRemove.length - 1; i >= 0; i--) { + const idx = indicesToRemove[i]; + request.params.splice(idx, 1); + request.isParamEnabledList.splice(idx, 1); + } + }, + /** + * Checks if a query parameter with the given name exists (case-sensitive). + * @param {string} key The parameter name. + * @returns {boolean} True if the parameter exists, false otherwise. + */ + has: (key) => { + if (!request || typeof request !== 'object' || !Array.isArray(request.params)) return false; // Safety check + return request.params.some(p => typeof p === 'object' && p.name === key); + }, + /** + * Clears all query parameters. + */ + clear: () => { + if (!request || typeof request !== 'object') return; + request.params = []; + request.isParamEnabledList = []; + } + }, + + /** + * Access or modify the request URL. + */ + url: { + /** + * Gets the current request URL string. + * @returns {string} The URL. + */ + get: () => { + return (request && typeof request === 'object') ? request.url : ''; + }, + /** + * Sets the request URL string. + * @param {string} newUrl The new URL. + */ + set: (newUrl) => { + if (request && typeof request === 'object' && typeof newUrl === 'string') { + request.url = newUrl; + } + } + // Future: Could add methods to manipulate parts (host, path, query) if needed + }, + + /** + * Access or modify the request body. + */ + body: { + /** + * Gets the current request body content (string). + * Note: For form-data, this returns the raw string body (if any), not the structured data. Use `ad.request.formData` for that. + * @returns {string|null|undefined} The request body string. + */ + get: () => { + return (request && typeof request === 'object') ? request.body : undefined; + }, + /** + * Sets the request body content (string). + * Important: Also updates the Content-Type if setting JSON/Text, unless a Content-Type header is already explicitly set. + * Setting the body will clear form-data if the content type changes away from form-data. + * @param {string|object} newBody The new body content. If an object is provided, it's stringified as JSON. + * @param {string} [contentType] Optionally specify the Content-Type (e.g., 'application/json', 'text/plain'). If not set, defaults to 'text/plain' or 'application/json' if newBody is an object. + */ + set: (newBody, contentType) => { + if (!request || typeof request !== 'object') return; // Safety check fix: check !request or typeof !== object + + let finalBody = ''; + let finalContentType = contentType; + + if (typeof newBody === 'object' && newBody !== null) { + try { + finalBody = JSON.stringify(newBody); + finalContentType = contentType || 'application/json'; // Default to JSON if object + request.bodyContentType = 'json'; // Update internal model type + } catch (e) { + sendMessage('consoleError', JSON.stringify(['Error stringifying object for request body:', e.toString()])); + return; // Don't proceed if stringify fails + } + } else { + finalBody = String(newBody); // Ensure it's a string + finalContentType = contentType || 'text/plain'; // Default to text + request.bodyContentType = 'text'; // Update internal model type + } + + request.body = finalBody; + + // Clear form data if we are setting a string/json body + request.formData = []; + + // Set Content-Type header if not already set by user explicitly in headers + // Use case-insensitive check for existing Content-Type + const hasContentTypeHeader = request.headers.some(h => typeof h === 'object' && h.name.toLowerCase() === 'content-type'); + if (!hasContentTypeHeader && finalContentType) { + ad.request.headers.set('Content-Type', finalContentType); + } + } + // TODO: Add helpers for request.formData if needed (similar to headers/params) + }, + + + /** + * Access and modify GraphQL query string. + * For GraphQL requests, this represents the query/mutation/subscription. + */ + query: { + /** + * Gets the current GraphQL query string. + * @returns {string} The GraphQL query. + */ + get: () => { + return (request && typeof request === 'object') ? (request.query || '') : ''; + }, + /** + * Sets the GraphQL query string. + * @param {string} newQuery The GraphQL query/mutation/subscription. + */ + set: (newQuery) => { + if (request && typeof request === 'object' && typeof newQuery === 'string') { + request.query = newQuery; + ad.request.headers.set('Content-Type', 'application/json'); + } + }, + /** + * Clears the GraphQL query. + */ + clear: () => { + if (request && typeof request === 'object') { + request.query = ''; + } + } + }, + + /** + * Access or modify the request method (e.g., 'GET', 'POST'). + */ + method: { + /** + * Gets the current request method. + * @returns {string} The HTTP method (e.g., "get", "post"). + */ + get: () => { + return (request && typeof request === 'object') ? request.method : ''; + }, + /** + * Sets the request method. + * @param {string} newMethod The new HTTP method (e.g., "POST", "put"). Case might matter for the Dart model enum. + */ + set: (newMethod) => { + if (request && typeof request === 'object' && typeof newMethod === 'string') { + // Consider converting to lowercase to match HTTPVerb enum likely usage + request.method = newMethod.toLowerCase(); + } + } + } + }, + + /** + * Read-only access to the response data. + * Only available in post-response scripts. + */ + response: { + /** + * The HTTP status code of the response. + * @type {number|undefined} + */ + get status() { + return (response && typeof response === 'object') ? response.statusCode : undefined; + }, + + /** + * The response body as a string. If the response was binary, this might be decoded text + * based on Content-Type or potentially garbled. Use `bodyBytes` for raw binary data access (if provided). + * @type {string|undefined} + */ + get body() { + return (response && typeof response === 'object') ? response.body : undefined; + }, + + /** + * The response body automatically formatted (e.g., pretty-printed JSON). Provided by Dart. + * @type {string|undefined} + */ + get formattedBody() { + return (response && typeof response === 'object') ? response.formattedBody : undefined; + }, + + /** + * The raw response body as an array of bytes (numbers). + * Note: This relies on the Dart side serializing Uint8List correctly (e.g., as List). + * Accessing large byte arrays in JS might be memory-intensive. + * @type {number[]|undefined} + */ + get bodyBytes() { + return (response && typeof response === 'object') ? response.bodyBytes : undefined; + }, + + + /** + * The approximate time taken for the request-response cycle. Provided by Dart. + * Assumes Dart sends it as microseconds and converts it to milliseconds here. + * @type {number|undefined} Time in milliseconds. + */ + get time() { + // Assuming response.time is in microseconds from Dart's DurationConverter + return (response && typeof response === 'object' && typeof response.time === 'number') ? response.time / 1000 : undefined; + }, + + /** + * An object containing the response headers (keys are header names, values are header values). + * Header names are likely lowercase from the http package. + * @type {object|undefined} e.g., {'content-type': 'application/json', ...} + */ + get headers() { + return (response && typeof response === 'object') ? response.headers : undefined; + }, + + /** + * An object containing the request headers that were actually sent (useful for verification). + * Header names are likely lowercase. + * @type {object|undefined} e.g., {'user-agent': '...', ...} + */ + get requestHeaders() { + return (response && typeof response === 'object') ? response.requestHeaders : undefined; + }, + + + /** + * Attempts to parse the response body as JSON. + * @returns {object|undefined} The parsed JSON object, or undefined if parsing fails or body is empty. + */ + json: () => { + const bodyContent = ad.response.body; // Assign to variable first + if (!bodyContent) { // Check the variable + return undefined; + } + try { + return JSON.parse(bodyContent); // Parse the variable + } catch (e) { + ad.console.error("Failed to parse response body as JSON:", e.toString()); + return undefined; + } + }, + + /** + * Gets a specific response header value (case-insensitive key lookup). + * @param {string} key The header name. + * @returns {string|undefined} The header value or undefined if not found. + */ + getHeader: (key) => { + const headers = ad.response.headers; + if (!headers || typeof key !== 'string') return undefined; + const lowerKey = key.toLowerCase(); + // Find the key in the headers object case-insensitively + const headerKey = Object.keys(headers).find(k => k.toLowerCase() === lowerKey); + return headerKey ? headers[headerKey] : undefined; // Return the value using the found key + } + }, + + /** + * Access and modify environment variables for the active environment. + * Changes are made to the 'environment' JS object (simple {key: value} map) + * and sent back to Dart. Dart side will need to merge these changes back + * into the original structured format if needed. + */ + environment: { + /** + * Gets the value of an environment variable from the simplified map. + * @param {string} key The variable name. + * @returns {any} The variable value or undefined if not found. + */ + get: (key) => { + // Access the simplified 'environment' object directly + return (environment && typeof environment === 'object') ? environment[key] : undefined; + }, + /** + * Sets the value of an environment variable in the simplified map. + * @param {string} key The variable name. + * @param {any} value The variable value. Should be JSON-serializable (string, number, boolean, object, array). + */ + set: (key, value) => { + if (environment && typeof environment === 'object' && typeof key === 'string') { + // Modify the simplified 'environment' object + environment[key] = value; + } + }, + /** + * Removes an environment variable from the simplified map. + * @param {string} key The variable name to remove. + */ + unset: (key) => { + if (environment && typeof environment === 'object') { + // Modify the simplified 'environment' object + delete environment[key]; + } + }, + /** + * Checks if an environment variable exists in the simplified map. + * @param {string} key The variable name. + * @returns {boolean} True if the variable exists, false otherwise. + */ + has: (key) => { + // Check the simplified 'environment' object + return (environment && typeof environment === 'object') ? environment.hasOwnProperty(key) : false; + }, + /** + * Removes all variables from the simplified environment map scope. + */ + clear: () => { + if (environment && typeof environment === 'object') { + // Clear the simplified 'environment' object + for (const key in environment) { + if (environment.hasOwnProperty(key)) { + delete environment[key]; + } + } + // Alternatively, just reassign: environment = {}; + } + } + // Note: A separate 'globals' object could be added here if global variables are implemented distinctly. + }, + + /** + * Provides logging capabilities. Messages are sent back to Dart via the bridge. + */ + console: { + /** + * Logs informational messages. + * @param {...any} args Values to log. They will be JSON-stringified. + */ + log: (...args) => { + try { + sendMessage('consoleLog', JSON.stringify(args)); + } catch (e) { + /* Ignore stringify errors for console? Or maybe log the error itself? */ + } + }, + /** + * Logs warning messages. + * @param {...any} args Values to log. + */ + warn: (...args) => { + try { + sendMessage('consoleWarn', JSON.stringify(args)); + } catch (e) { + /* Ignore */ + } + }, + /** + * Logs error messages. + * @param {...any} args Values to log. + */ + error: (...args) => { + try { + sendMessage('consoleError', JSON.stringify(args)); + } catch (e) { + /* Ignore */ + } + } + }, + +}; + +// --- End of APIDash Setup Script --- + +// User's script will be appended below this line by Dart. +// Dart will also append the final JSON.stringify() call to return results. +"""; diff --git a/lib/utils/pre_post_script_utils.dart b/lib/utils/pre_post_script_utils.dart new file mode 100644 index 00000000..65dfbba7 --- /dev/null +++ b/lib/utils/pre_post_script_utils.dart @@ -0,0 +1,127 @@ +import 'package:apidash_core/apidash_core.dart'; +import 'package:flutter/foundation.dart'; +import '../models/models.dart'; +import '../services/services.dart'; + +Future handlePreRequestScript( + RequestModel requestModel, + EnvironmentModel? originalEnvironmentModel, + void Function(EnvironmentModel, List)? updateEnv, +) async { + final scriptResult = await executePreRequestScript( + currentRequestModel: requestModel, + activeEnvironment: originalEnvironmentModel?.toJson() ?? {}, + ); + final newRequestModel = + requestModel.copyWith(httpRequestModel: scriptResult.updatedRequest); + if (originalEnvironmentModel != null) { + final updatedEnvironmentMap = scriptResult.updatedEnvironment; + + final List newValues = []; + final Map mutableUpdatedEnv = + Map.from(updatedEnvironmentMap); + + for (final originalVariable in originalEnvironmentModel.values) { + if (mutableUpdatedEnv.containsKey(originalVariable.key)) { + final dynamic newValue = mutableUpdatedEnv[originalVariable.key]; + newValues.add( + originalVariable.copyWith( + value: newValue == null ? '' : newValue.toString(), + enabled: true, + ), + ); + + mutableUpdatedEnv.remove(originalVariable.key); + } else { + // Variable was removed by the script (unset/clear), don't add it to newValues. + // Alternatively, you could keep it but set enabled = false: + // newValues.add(originalVariable.copyWith(enabled: false)); + } + } + + for (final entry in mutableUpdatedEnv.entries) { + final dynamic newValue = entry.value; + newValues.add( + EnvironmentVariableModel( + key: entry.key, + value: newValue == null ? '' : newValue.toString(), + enabled: true, + type: EnvironmentVariableType.variable, + ), + ); + } + updateEnv?.call(originalEnvironmentModel, newValues); + } else { + debugPrint( + "Skipped environment update as originalEnvironmentModel was null."); + + if (scriptResult.updatedEnvironment.isNotEmpty) { + debugPrint( + "Warning: Pre-request script updated environment variables, but no active environment was selected to save them to."); + } + return requestModel; + } + return newRequestModel; +} + +Future handlePostResponseScript( + RequestModel requestModel, + EnvironmentModel? originalEnvironmentModel, + void Function(EnvironmentModel, List)? updateEnv, +) async { + final scriptResult = await executePostResponseScript( + currentRequestModel: requestModel, + activeEnvironment: originalEnvironmentModel?.toJson() ?? {}, + ); + + final newRequestModel = + requestModel.copyWith(httpResponseModel: scriptResult.updatedResponse); + + if (originalEnvironmentModel != null) { + final updatedEnvironmentMap = scriptResult.updatedEnvironment; + + final List newValues = []; + final Map mutableUpdatedEnv = + Map.from(updatedEnvironmentMap); + + for (final originalVariable in originalEnvironmentModel.values) { + if (mutableUpdatedEnv.containsKey(originalVariable.key)) { + final dynamic newValue = mutableUpdatedEnv[originalVariable.key]; + newValues.add( + originalVariable.copyWith( + value: newValue == null ? '' : newValue.toString(), + enabled: true, + ), + ); + + mutableUpdatedEnv.remove(originalVariable.key); + } else { + // Variable was removed by the script (unset/clear), don't add it to newValues. + // Alternatively, you could keep it but set enabled = false: + // newValues.add(originalVariable.copyWith(enabled: false)); + } + } + + for (final entry in mutableUpdatedEnv.entries) { + final dynamic newValue = entry.value; + newValues.add( + EnvironmentVariableModel( + key: entry.key, + value: newValue == null ? '' : newValue.toString(), + enabled: true, + type: EnvironmentVariableType.variable, + ), + ); + } + updateEnv?.call(originalEnvironmentModel, newValues); + } else { + debugPrint( + "Skipped environment update as originalEnvironmentModel was null."); + if (scriptResult.updatedEnvironment.isNotEmpty) { + debugPrint( + "Warning: Post-response script updated environment variables, but no active environment was selected to save them to."); + } + return requestModel; + } + return newRequestModel; +} diff --git a/lib/utils/utils.dart b/lib/utils/utils.dart index 5f45cc06..4ab96e6d 100644 --- a/lib/utils/utils.dart +++ b/lib/utils/utils.dart @@ -5,6 +5,8 @@ export 'har_utils.dart'; export 'header_utils.dart'; export 'history_utils.dart'; export 'http_utils.dart'; +export 'js_utils.dart'; +export 'pre_post_script_utils.dart'; export 'save_utils.dart'; export 'ui_utils.dart'; export 'window_utils.dart'; diff --git a/lib/widgets/button_learn.dart b/lib/widgets/button_learn.dart new file mode 100644 index 00000000..49b9d21c --- /dev/null +++ b/lib/widgets/button_learn.dart @@ -0,0 +1,38 @@ +import 'package:apidash_design_system/apidash_design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class LearnButton extends StatelessWidget { + const LearnButton({ + super.key, + this.label, + this.icon, + this.url, + }); + + final String? label; + final IconData? icon; + final String? url; + + @override + Widget build(BuildContext context) { + var textLabel = label ?? 'Learn'; + return SizedBox( + height: 24, + child: ADFilledButton( + icon: Icons.help, + iconSize: kButtonIconSizeSmall, + label: textLabel, + isTonal: true, + buttonStyle: ButtonStyle( + padding: WidgetStatePropertyAll(kP10), + ), + onPressed: () { + if (url != null) { + launchUrl(Uri.parse(url!)); + } + }, + ), + ); + } +} diff --git a/lib/widgets/editor_code.dart b/lib/widgets/editor_code.dart new file mode 100644 index 00000000..1e52ad9a --- /dev/null +++ b/lib/widgets/editor_code.dart @@ -0,0 +1,53 @@ +import 'package:apidash_design_system/apidash_design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_code_editor/flutter_code_editor.dart'; +import 'package:flutter_highlight/themes/monokai.dart'; +import 'package:flutter_highlight/themes/xcode.dart'; + +class CodeEditor extends StatelessWidget { + const CodeEditor({ + super.key, + required this.controller, + this.readOnly = false, + this.isDark = false, + }); + + final bool readOnly; + final CodeController controller; + final bool isDark; + + @override + Widget build(BuildContext context) { + return CodeTheme( + data: CodeThemeData( + styles: isDark ? monokaiTheme : xcodeTheme, + ), + child: CodeField( + expands: true, + decoration: BoxDecoration( + borderRadius: kBorderRadius8, + border: BoxBorder.all( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + ), + color: Theme.of(context).colorScheme.surfaceContainerLowest, + ), + readOnly: readOnly, + smartDashesType: SmartDashesType.enabled, + smartQuotesType: SmartQuotesType.enabled, + background: Theme.of(context).colorScheme.surfaceContainerLowest, + gutterStyle: GutterStyle( + width: 0, // TODO: Fix numbers size + margin: 2, + textAlign: TextAlign.left, + showFoldingHandles: false, + showLineNumbers: false, + ), + cursorColor: Theme.of(context).colorScheme.primary, + controller: controller, + textStyle: kCodeStyle.copyWith( + fontSize: Theme.of(context).textTheme.bodyMedium?.fontSize, + ), + ), + ); + } +} diff --git a/lib/widgets/widgets.dart b/lib/widgets/widgets.dart index 809eb561..aea1f5b1 100644 --- a/lib/widgets/widgets.dart +++ b/lib/widgets/widgets.dart @@ -3,6 +3,7 @@ export 'button_copy.dart'; export 'button_discord.dart'; export 'button_form_data_file.dart'; export 'button_group_filled.dart'; +export 'button_learn.dart'; export 'button_repo.dart'; export 'button_save_download.dart'; export 'button_send.dart'; @@ -23,6 +24,7 @@ export 'dropdown_content_type.dart'; export 'dropdown_formdata.dart'; export 'dropdown_http_method.dart'; export 'dropdown_import_format.dart'; +export 'editor_code.dart'; export 'editor_json.dart'; export 'editor.dart'; export 'error_message.dart'; diff --git a/packages/apidash_design_system/lib/tokens/measurements.dart b/packages/apidash_design_system/lib/tokens/measurements.dart index b06a9ce9..876c69fb 100644 --- a/packages/apidash_design_system/lib/tokens/measurements.dart +++ b/packages/apidash_design_system/lib/tokens/measurements.dart @@ -69,6 +69,11 @@ const kPh6b12 = EdgeInsets.only( right: 6.0, bottom: 12.0, ); +const kPh8b6 = EdgeInsets.only( + left: 8.0, + right: 8.0, + bottom: 6.0, +); const kPh60 = EdgeInsets.symmetric(horizontal: 60); const kPh60v60 = EdgeInsets.symmetric(vertical: 60, horizontal: 60); const kPt24l4 = EdgeInsets.only( @@ -88,6 +93,7 @@ const kPt20 = EdgeInsets.only(top: 20); const kPt24 = EdgeInsets.only(top: 24); const kPt28 = EdgeInsets.only(top: 28); const kPt32 = EdgeInsets.only(top: 32); +const kPb6 = EdgeInsets.only(bottom: 6); const kPb10 = EdgeInsets.only(bottom: 10); const kPb15 = EdgeInsets.only(bottom: 15); const kPb70 = EdgeInsets.only(bottom: 70); diff --git a/pubspec.lock b/pubspec.lock index d948739d..e4fecd66 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -79,6 +79,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.1.25" + autotrie: + dependency: transitive + description: + name: autotrie + sha256: "55da6faefb53cfcb0abb2f2ca8636123fb40e35286bb57440d2cf467568188f8" + url: "https://pub.dev" + source: hosted + version: "2.0.0" barcode: dependency: transitive description: @@ -523,11 +531,27 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_code_editor: + dependency: "direct main" + description: + name: flutter_code_editor + sha256: "18cc1200e7481fcf144bc970fdec4e75b83e3f523da60bbf55810a4e8dd6f5fb" + url: "https://pub.dev" + source: hosted + version: "0.3.3" flutter_driver: dependency: transitive description: flutter source: sdk version: "0.0.0" + flutter_highlight: + dependency: "direct main" + description: + name: flutter_highlight + sha256: "7b96333867aa07e122e245c033b8ad622e4e3a42a1a2372cbb098a2541d8782c" + url: "https://pub.dev" + source: hosted + version: "0.7.0" flutter_highlighter: dependency: "direct main" description: @@ -544,6 +568,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.21.2" + flutter_js: + dependency: "direct main" + description: + name: flutter_js + sha256: "6b777cd4e468546f046a2f114d078a4596143269f6fa6bad5c29611d5b896369" + url: "https://pub.dev" + source: hosted + version: "0.8.2" flutter_launcher_icons: dependency: "direct dev" description: @@ -678,6 +710,14 @@ packages: relative: true source: path version: "0.0.1" + highlight: + dependency: "direct main" + description: + name: highlight + sha256: "5353a83ffe3e3eca7df0abfb72dcf3fa66cc56b953728e7113ad4ad88497cf21" + url: "https://pub.dev" + source: hosted + version: "0.7.0" highlighter: dependency: "direct main" description: @@ -920,6 +960,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.1" + linked_scroll_controller: + dependency: transitive + description: + name: linked_scroll_controller + sha256: e6020062bcf4ffc907ee7fd090fa971e65d8dfaac3c62baf601a3ced0b37986a + url: "https://pub.dev" + source: hosted + version: "0.2.0" lints: dependency: transitive description: @@ -1000,6 +1048,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.0" + mocktail: + dependency: transitive + description: + name: mocktail + sha256: "890df3f9688106f25755f26b1c60589a92b3ab91a22b8b224947ad041bf172d8" + url: "https://pub.dev" + source: hosted + version: "1.0.4" mpv_dart: dependency: transitive description: @@ -1643,6 +1699,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.2" + tuple: + dependency: transitive + description: + name: tuple + sha256: a97ce2013f240b2f3807bcbaf218765b6f301c3eff91092bcfa23a039e7dd151 + url: "https://pub.dev" + source: hosted + version: "2.0.2" typed_data: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index fef25a17..f34e23ba 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -23,13 +23,17 @@ dependencies: extended_text_field: ^16.0.0 file_selector: ^1.0.3 flex_color_scheme: ^8.2.0 + flutter_code_editor: ^0.3.3 + flutter_highlight: ^0.7.0 flutter_highlighter: ^0.1.0 flutter_hooks: ^0.21.2 + flutter_js: ^0.8.2 flutter_markdown: ^0.7.6+2 flutter_portal: ^1.1.4 flutter_riverpod: ^2.5.1 flutter_svg: ^2.0.17 fvp: ^0.30.0 + highlight: ^0.7.0 highlighter: ^0.1.1 hive_flutter: ^1.1.0 hooks_riverpod: ^2.5.2 diff --git a/test/models/history_models.dart b/test/models/history_models.dart index e33c2f99..557b3a2e 100644 --- a/test/models/history_models.dart +++ b/test/models/history_models.dart @@ -58,6 +58,8 @@ final Map historyRequestModelJson1 = { "metaData": historyMetaModelJson1, "httpRequestModel": httpRequestModelGet4Json, "httpResponseModel": responseModelJson, + 'preRequestScript': null, + 'postRequestScript': null }; final Map historyMetaModelJson2 = { diff --git a/test/models/request_models.dart b/test/models/request_models.dart index 74d11376..1762706c 100644 --- a/test/models/request_models.dart +++ b/test/models/request_models.dart @@ -217,6 +217,8 @@ Map requestModelJson = { 'responseStatus': 200, 'message': null, 'httpResponseModel': responseModelJson, + 'preRequestScript': null, + 'postRequestScript': null }; /// Basic GET request model for apidash.dev