From f21ed30e10fa45318dfbc394b01039ed0a15b5db Mon Sep 17 00:00:00 2001 From: Nivedin <53208152+nivedin@users.noreply.github.com> Date: Fri, 18 Aug 2023 01:37:21 +0530 Subject: [PATCH] feat: inspections (#3213) Co-authored-by: Liyas Thomas --- packages/hoppscotch-common/locales/en.json | 25 +++ .../hoppscotch-common/src/components.d.ts | 35 ++++ .../src/components/app/Inspection.vue | 112 ++++++++++++ .../src/components/http/Headers.vue | 57 +++++- .../src/components/http/Parameters.vue | 46 ++++- .../src/components/http/Request.vue | 21 ++- .../src/components/http/Response.vue | 2 +- .../src/components/http/ResponseMeta.vue | 26 ++- .../src/components/smart/EnvInput.vue | 20 ++- .../hoppscotch-common/src/pages/index.vue | 24 ++- .../inspection/__tests__/index.spec.ts | 54 ++++++ .../src/services/inspection/index.ts | 135 ++++++++++++++ .../__tests__/environment.inspector.spec.ts | 158 +++++++++++++++++ .../__tests__/header.inspector.spec.ts | 61 +++++++ .../__tests__/response.interceptor.spec.ts | 151 ++++++++++++++++ .../__tests__/url.inspector.spec.ts | 84 +++++++++ .../inspectors/environment.inspector.ts | 167 ++++++++++++++++++ .../inspection/inspectors/header.inspector.ts | 78 ++++++++ .../inspectors/response.inspector.ts | 73 ++++++++ .../inspection/inspectors/url.inspector.ts | 96 ++++++++++ 20 files changed, 1406 insertions(+), 19 deletions(-) create mode 100644 packages/hoppscotch-common/src/components/app/Inspection.vue create mode 100644 packages/hoppscotch-common/src/services/inspection/__tests__/index.spec.ts create mode 100644 packages/hoppscotch-common/src/services/inspection/index.ts create mode 100644 packages/hoppscotch-common/src/services/inspection/inspectors/__tests__/environment.inspector.spec.ts create mode 100644 packages/hoppscotch-common/src/services/inspection/inspectors/__tests__/header.inspector.spec.ts create mode 100644 packages/hoppscotch-common/src/services/inspection/inspectors/__tests__/response.interceptor.spec.ts create mode 100644 packages/hoppscotch-common/src/services/inspection/inspectors/__tests__/url.inspector.spec.ts create mode 100644 packages/hoppscotch-common/src/services/inspection/inspectors/environment.inspector.ts create mode 100644 packages/hoppscotch-common/src/services/inspection/inspectors/header.inspector.ts create mode 100644 packages/hoppscotch-common/src/services/inspection/inspectors/response.inspector.ts create mode 100644 packages/hoppscotch-common/src/services/inspection/inspectors/url.inspector.ts diff --git a/packages/hoppscotch-common/locales/en.json b/packages/hoppscotch-common/locales/en.json index 8854c45d4..27ed5ba67 100644 --- a/packages/hoppscotch-common/locales/en.json +++ b/packages/hoppscotch-common/locales/en.json @@ -192,6 +192,7 @@ "schema": "Connect to a GraphQL endpoint to view schema", "shortcodes": "Shortcodes are empty", "subscription": "Subscriptions are empty", + "suggestions": "No matching suggestions found", "team_name": "Team name empty", "teams": "You don't belong to any teams", "tests": "There are no tests for this request" @@ -304,6 +305,30 @@ "preview": "Hide Preview", "sidebar": "Collapse sidebar" }, + "inspections": { + "title": "Inspector", + "description": "Inspect possible errors", + "environment": { + "add_environment": "Add to Environment", + "not_found": "Environment variable “{environment}” not found." + }, + "header": { + "cookie": "The browser doesn't allow Hoppscotch to set the Cookie Header. While we're working on the Hoppscotch Desktop App (coming soon), please use the Authorization Header instead." + }, + "response": { + "401_error": "Please check your authentication credentials.", + "404_error": "Please check your request URL and method type.", + "network_error": "Please check your network connection.", + "cors_error": "Please check your Cross-Origin Resource Sharing configuration.", + "default_error": "Please check your request." + }, + "url": { + "extension_not_installed": "Extension not installed.", + "extention_not_enabled": "Extension not enabled.", + "extention_enable_action": "Enable Browser Extension", + "extension_unknown_origin": "Make sure you've added the API endpoint's origin to the Hoppscotch Browser Extension list." + } + }, "import": { "collections": "Import collections", "curl": "Import cURL", diff --git a/packages/hoppscotch-common/src/components.d.ts b/packages/hoppscotch-common/src/components.d.ts index d654eb975..44a7cbd5e 100644 --- a/packages/hoppscotch-common/src/components.d.ts +++ b/packages/hoppscotch-common/src/components.d.ts @@ -14,6 +14,7 @@ declare module '@vue/runtime-core' { AppFooter: typeof import('./components/app/Footer.vue')['default'] AppGitHubStarButton: typeof import('./components/app/GitHubStarButton.vue')['default'] AppHeader: typeof import('./components/app/Header.vue')['default'] + AppInspection: typeof import('./components/app/Inspection.vue')['default'] AppInterceptor: typeof import('./components/app/Interceptor.vue')['default'] AppLogo: typeof import('./components/app/Logo.vue')['default'] AppOptions: typeof import('./components/app/Options.vue')['default'] @@ -77,8 +78,27 @@ declare module '@vue/runtime-core' { History: typeof import('./components/history/index.vue')['default'] HistoryGraphqlCard: typeof import('./components/history/graphql/Card.vue')['default'] HistoryRestCard: typeof import('./components/history/rest/Card.vue')['default'] + HoppButtonPrimary: typeof import('@hoppscotch/ui')['HoppButtonPrimary'] HoppButtonSecondary: typeof import('@hoppscotch/ui')['HoppButtonSecondary'] + HoppSmartAnchor: typeof import('@hoppscotch/ui')['HoppSmartAnchor'] + HoppSmartCheckbox: typeof import('@hoppscotch/ui')['HoppSmartCheckbox'] + HoppSmartConfirmModal: typeof import('@hoppscotch/ui')['HoppSmartConfirmModal'] + HoppSmartExpand: typeof import('@hoppscotch/ui')['HoppSmartExpand'] + HoppSmartFileChip: typeof import('@hoppscotch/ui')['HoppSmartFileChip'] + HoppSmartInput: typeof import('@hoppscotch/ui')['HoppSmartInput'] + HoppSmartItem: typeof import('@hoppscotch/ui')['HoppSmartItem'] + HoppSmartLink: typeof import('@hoppscotch/ui')['HoppSmartLink'] + HoppSmartModal: typeof import('@hoppscotch/ui')['HoppSmartModal'] + HoppSmartPicture: typeof import('@hoppscotch/ui')['HoppSmartPicture'] + HoppSmartPlaceholder: typeof import('@hoppscotch/ui')['HoppSmartPlaceholder'] + HoppSmartProgressRing: typeof import('@hoppscotch/ui')['HoppSmartProgressRing'] + HoppSmartRadioGroup: typeof import('@hoppscotch/ui')['HoppSmartRadioGroup'] + HoppSmartSlideOver: typeof import('@hoppscotch/ui')['HoppSmartSlideOver'] HoppSmartSpinner: typeof import('@hoppscotch/ui')['HoppSmartSpinner'] + HoppSmartTab: typeof import('@hoppscotch/ui')['HoppSmartTab'] + HoppSmartTabs: typeof import('@hoppscotch/ui')['HoppSmartTabs'] + HoppSmartWindow: typeof import('@hoppscotch/ui')['HoppSmartWindow'] + HoppSmartWindows: typeof import('@hoppscotch/ui')['HoppSmartWindows'] HttpAuthorization: typeof import('./components/http/Authorization.vue')['default'] HttpAuthorizationApiKey: typeof import('./components/http/authorization/ApiKey.vue')['default'] HttpAuthorizationBasic: typeof import('./components/http/authorization/Basic.vue')['default'] @@ -105,6 +125,21 @@ declare module '@vue/runtime-core' { HttpTestResultReport: typeof import('./components/http/TestResultReport.vue')['default'] HttpTests: typeof import('./components/http/Tests.vue')['default'] HttpURLEncodedParams: typeof import('./components/http/URLEncodedParams.vue')['default'] + IconLucideActivity: typeof import('~icons/lucide/activity')['default'] + IconLucideAlertTriangle: typeof import('~icons/lucide/alert-triangle')['default'] + IconLucideArrowLeft: typeof import('~icons/lucide/arrow-left')['default'] + IconLucideArrowUpRight: typeof import('~icons/lucide/arrow-up-right')['default'] + IconLucideCheckCircle: typeof import('~icons/lucide/check-circle')['default'] + IconLucideChevronRight: typeof import('~icons/lucide/chevron-right')['default'] + IconLucideGlobe: typeof import('~icons/lucide/globe')['default'] + IconLucideHelpCircle: typeof import('~icons/lucide/help-circle')['default'] + IconLucideInbox: typeof import('~icons/lucide/inbox')['default'] + IconLucideInfo: typeof import('~icons/lucide/info')['default'] + IconLucideLayers: typeof import('~icons/lucide/layers')['default'] + IconLucideListEnd: typeof import('~icons/lucide/list-end')['default'] + IconLucideMinus: typeof import('~icons/lucide/minus')['default'] + IconLucideSearch: typeof import('~icons/lucide/search')['default'] + IconLucideUsers: typeof import('~icons/lucide/users')['default'] LensesHeadersRenderer: typeof import('./components/lenses/HeadersRenderer.vue')['default'] LensesHeadersRendererEntry: typeof import('./components/lenses/HeadersRendererEntry.vue')['default'] LensesRenderersAudioLensRenderer: typeof import('./components/lenses/renderers/AudioLensRenderer.vue')['default'] diff --git a/packages/hoppscotch-common/src/components/app/Inspection.vue b/packages/hoppscotch-common/src/components/app/Inspection.vue new file mode 100644 index 000000000..670508c35 --- /dev/null +++ b/packages/hoppscotch-common/src/components/app/Inspection.vue @@ -0,0 +1,112 @@ + + + diff --git a/packages/hoppscotch-common/src/components/http/Headers.vue b/packages/hoppscotch-common/src/components/http/Headers.vue index 4c2ccdf93..dffaabb3a 100644 --- a/packages/hoppscotch-common/src/components/http/Headers.vue +++ b/packages/hoppscotch-common/src/components/http/Headers.vue @@ -79,16 +79,13 @@ tabindex="-1" /> - { if (tab === "auth") emit("change-tab", "authorization") else emit("change-tab", "bodyParams") } + +const inspectionService = useService(InspectionService) + +const allTabResults = inspectionService.tabs + +const headerKeyResults = computed(() => { + return ( + allTabResults.value + .get(currentTabID.value) + .filter( + (result) => + result.locations.type === "header" && + result.locations.position === "key" + ) ?? [] + ) +}) +const headerValueResults = computed(() => { + return ( + allTabResults.value + .get(currentTabID.value) + .filter( + (result) => + result.locations.type === "header" && + result.locations.position === "value" + ) ?? [] + ) +}) + +const getInspectorResult = (results: InspectorResult[], index: number) => { + return results.filter((result) => { + if (result.locations.type === "url" || result.locations.type === "response") + return + return result.locations.index === index + }) +} diff --git a/packages/hoppscotch-common/src/components/http/Parameters.vue b/packages/hoppscotch-common/src/components/http/Parameters.vue index 381a57ff7..286422079 100644 --- a/packages/hoppscotch-common/src/components/http/Parameters.vue +++ b/packages/hoppscotch-common/src/components/http/Parameters.vue @@ -82,6 +82,9 @@ { bulkParams.value = "" } + +const inspectionService = useService(InspectionService) + +const allTabResults = inspectionService.tabs + +const parameterKeyResults = computed(() => { + return ( + allTabResults.value + .get(currentTabID.value) + .filter( + (result) => + result.locations.type === "parameter" && + result.locations.position === "key" + ) ?? [] + ) +}) +const parameterValueResults = computed(() => { + return ( + allTabResults.value + .get(currentTabID.value) + .filter( + (result) => + result.locations.type === "parameter" && + result.locations.position === "value" + ) ?? [] + ) +}) + +const getInspectorResult = (results: InspectorResult[], index: number) => { + return results.filter((result) => { + if (result.locations.type === "url" || result.locations.type === "response") + return + return result.locations.index === index + }) +} diff --git a/packages/hoppscotch-common/src/components/http/Request.vue b/packages/hoppscotch-common/src/components/http/Request.vue index 45afbb8b7..c245e46f4 100644 --- a/packages/hoppscotch-common/src/components/http/Request.vue +++ b/packages/hoppscotch-common/src/components/http/Request.vue @@ -53,9 +53,16 @@ v-model="tab.document.request.endpoint" :placeholder="`${t('request.url')}`" :auto-complete-source="userHistories" + :inspection-results="tabResults" @paste="onPasteUrl($event)" @enter="newSendRequest" - /> + > + +
@@ -259,12 +266,14 @@ import IconLink2 from "~icons/lucide/link-2" import IconRotateCCW from "~icons/lucide/rotate-ccw" import IconSave from "~icons/lucide/save" import IconShare2 from "~icons/lucide/share-2" -import { HoppRESTTab } from "~/helpers/rest/tab" +import { HoppRESTTab, currentTabID } from "~/helpers/rest/tab" import { getDefaultRESTRequest } from "~/helpers/rest/default" import { RESTHistoryEntry, restHistory$ } from "~/newstore/history" import { platform } from "~/platform" import { getCurrentStrategyID } from "~/helpers/network" import { HoppGQLRequest, HoppRESTRequest } from "@hoppscotch/data" +import { useService } from "dioc/vue" +import { InspectionService } from "~/services/inspection" const t = useI18n() @@ -628,4 +637,12 @@ const isCustomMethod = computed(() => { }) const COLUMN_LAYOUT = useSetting("COLUMN_LAYOUT") + +const inspectionService = useService(InspectionService) + +const allTabResults = inspectionService.tabs + +const tabResults = computed(() => { + return allTabResults.value.get(currentTabID.value) ?? [] +}) diff --git a/packages/hoppscotch-common/src/components/http/Response.vue b/packages/hoppscotch-common/src/components/http/Response.vue index 12fdbb5d0..62e06f5b8 100644 --- a/packages/hoppscotch-common/src/components/http/Response.vue +++ b/packages/hoppscotch-common/src/components/http/Response.vue @@ -1,5 +1,5 @@ @@ -80,6 +89,9 @@ import type { HoppRESTResponse } from "~/helpers/types/HoppRESTResponse" import { useI18n } from "@composables/i18n" import { useColorMode } from "@composables/theming" import { getStatusCodeReasonPhrase } from "~/helpers/utils/statusCodes" +import { useService } from "dioc/vue" +import { InspectionService } from "~/services/inspection" +import { currentTabID } from "~/helpers/rest/tab" const t = useI18n() const colorMode = useColorMode() @@ -128,4 +140,16 @@ const statusCategory = computed(() => { } return findStatusGroup(props.response.statusCode) }) + +const inspectionService = useService(InspectionService) + +const allTabResults = inspectionService.tabs + +const tabResults = computed(() => { + return ( + allTabResults.value + .get(currentTabID.value) + ?.filter((result) => result.locations.type === "response") ?? [] + ) +}) diff --git a/packages/hoppscotch-common/src/components/smart/EnvInput.vue b/packages/hoppscotch-common/src/components/smart/EnvInput.vue index 54aeb0ae5..231cba7ec 100644 --- a/packages/hoppscotch-common/src/components/smart/EnvInput.vue +++ b/packages/hoppscotch-common/src/components/smart/EnvInput.vue @@ -10,6 +10,7 @@ @keydown="handleKeystroke" @focusin="showSuggestionPopover = true" >
+
  • - - {{ t("empty.history_suggestions") }} +
    + +
    + + {{ t("empty.suggestions") }}
@@ -43,7 +47,7 @@ diff --git a/packages/hoppscotch-common/src/services/inspection/__tests__/index.spec.ts b/packages/hoppscotch-common/src/services/inspection/__tests__/index.spec.ts new file mode 100644 index 000000000..c4621af03 --- /dev/null +++ b/packages/hoppscotch-common/src/services/inspection/__tests__/index.spec.ts @@ -0,0 +1,54 @@ +import { describe, it, expect } from "vitest" +import { Inspector, InspectionService, InspectorResult } from "../" +import { TestContainer } from "dioc/testing" + +const inspectorResultMock: InspectorResult[] = [ + { + id: "result1", + text: { type: "text", text: "Sample Text" }, + icon: {}, + isApplicable: true, + severity: 2, + locations: { type: "url" }, + doc: { text: "Sample Doc", link: "https://example.com" }, + action: { + text: "Sample Action", + // eslint-disable-next-line @typescript-eslint/no-empty-function + apply: () => {}, + }, + }, +] + +const testInspector: Inspector = { + inspectorID: "inspector1", + getInspectorFor: () => inspectorResultMock, +} + +describe("InspectionService", () => { + describe("registerInspector", () => { + it("should register an inspector", () => { + const container = new TestContainer() + const service = container.bind(InspectionService) + + service.registerInspector(testInspector) + + expect(service.inspectors.has(testInspector.inspectorID)).toEqual(true) + }) + }) + + describe("deleteTabInspectorResult", () => { + it("should delete a tab's inspector results", () => { + const container = new TestContainer() + const service = container.bind(InspectionService) + + const tabID = "testTab" + service.tabs.value.set(tabID, inspectorResultMock) + + expect(service.tabs.value.has(tabID)).toEqual(true) + + service.deleteTabInspectorResult(tabID) + + expect(service.tabs.value.has(tabID)).toEqual(false) + }) + }) +}) diff --git a/packages/hoppscotch-common/src/services/inspection/index.ts b/packages/hoppscotch-common/src/services/inspection/index.ts new file mode 100644 index 000000000..c05cad194 --- /dev/null +++ b/packages/hoppscotch-common/src/services/inspection/index.ts @@ -0,0 +1,135 @@ +import { HoppRESTRequest } from "@hoppscotch/data" +import { Service } from "dioc" +import { Component, Ref, ref, watch } from "vue" +import { currentActiveTab, currentTabID } from "~/helpers/rest/tab" +import { HoppRESTResponse } from "~/helpers/types/HoppRESTResponse" + +/** + * Defines how to render the text in an Inspector Result + */ +export type InspectorTextType = + | { + type: "text" + text: string[] | string + } + | { + type: "custom" + component: T + componentProps: T extends Component ? Props : never + } + +export type InspectorLocation = + | { + type: "url" + } + | { + type: "header" + position: "key" | "value" + key?: string + index?: number + } + | { + type: "parameter" + position: "key" | "value" + key?: string + index?: number + } + | { + type: "body" + key: string + index: number + } + | { + type: "response" + } + +/** + * Defines info about an inspector result so the UI can render it + */ +export interface InspectorResult { + id: string + text: InspectorTextType + icon: object | Component + severity: number + isApplicable: boolean + action?: { + text: string + apply: () => void + } + doc: { + text: string + link: string + } + locations: InspectorLocation +} + +/** + * Defines the state of the inspector service + */ +export type InspectorState = { + results: InspectorResult[] +} + +/** + * Defines an inspector that can be registered with the inspector service + * Inspectors are used to perform checks on a request and return the results + */ +export interface Inspector { + /** + * The unique ID of the inspector + */ + inspectorID: string + /** + * Returns the inspector results for the request + * @param req The request to inspect + * @param res The response to inspect + * @returns The inspector results + */ + getInspectorFor: ( + req: HoppRESTRequest, + res?: HoppRESTResponse + ) => InspectorResult[] +} + +/** + * Defines the inspection service + * The service watches the current active tab and returns the inspector results for the request and response + */ +export class InspectionService extends Service { + public static readonly ID = "INSPECTION_SERVICE" + + private inspectors: Map = new Map() + + public tabs: Ref> = ref(new Map()) + + /** + * Registers a inspector with the inspection service + * @param inspector The inspector instance to register + */ + public registerInspector(inspector: Inspector) { + this.inspectors.set(inspector.inspectorID, inspector) + } + + public initializeTabInspectors() { + watch( + currentActiveTab.value, + (tab) => { + if (!tab) return + const req = currentActiveTab.value.document.request + const res = currentActiveTab.value.response + const inspectors = Array.from(this.inspectors.values()).map((x) => + x.getInspectorFor(req, res) + ) + this.tabs.value.set( + currentTabID.value, + inspectors.flatMap((x) => x) + ) + }, + { immediate: true, deep: true } + ) + } + + public deleteTabInspectorResult(tabID: string) { + this.tabs.value.delete(tabID) + } +} diff --git a/packages/hoppscotch-common/src/services/inspection/inspectors/__tests__/environment.inspector.spec.ts b/packages/hoppscotch-common/src/services/inspection/inspectors/__tests__/environment.inspector.spec.ts new file mode 100644 index 000000000..8300cae90 --- /dev/null +++ b/packages/hoppscotch-common/src/services/inspection/inspectors/__tests__/environment.inspector.spec.ts @@ -0,0 +1,158 @@ +import { TestContainer } from "dioc/testing" +import { describe, expect, it, vi } from "vitest" +import { EnvironmentInspectorService } from "../environment.inspector" +import { InspectionService } from "../../index" +import { getDefaultRESTRequest } from "~/helpers/rest/default" + +vi.mock("~/modules/i18n", () => ({ + __esModule: true, + getI18n: () => (x: string) => x, +})) + +vi.mock("~/newstore/environments", () => ({ + __esModule: true, + getAggregateEnvs: () => [{ key: "EXISTING_ENV_VAR", value: "test_value" }], +})) + +describe("EnvironmentInspectorService", () => { + it("registers with the inspection service upon initialization", () => { + const container = new TestContainer() + + const registerInspectorFn = vi.fn() + + container.bindMock(InspectionService, { + registerInspector: registerInspectorFn, + }) + + const envInspector = container.bind(EnvironmentInspectorService) + + expect(registerInspectorFn).toHaveBeenCalledOnce() + expect(registerInspectorFn).toHaveBeenCalledWith(envInspector) + }) + + describe("getInspectorFor", () => { + it("should return an inspector result when the URL contains undefined environment variables", () => { + const container = new TestContainer() + const envInspector = container.bind(EnvironmentInspectorService) + + const req = { + ...getDefaultRESTRequest(), + endpoint: "<>", + } + + const result = envInspector.getInspectorFor(req) + + expect(result).toContainEqual( + expect.objectContaining({ + id: "environment", + isApplicable: true, + text: { + type: "text", + text: "inspections.environment.not_found", + }, + }) + ) + }) + + it("should not return an inspector result when the URL contains defined environment variables", () => { + const container = new TestContainer() + const envInspector = container.bind(EnvironmentInspectorService) + + const req = { + ...getDefaultRESTRequest(), + endpoint: "<>", + } + + const result = envInspector.getInspectorFor(req) + + expect(result).toHaveLength(0) + }) + + it("should return an inspector result when the headers contain undefined environment variables", () => { + const container = new TestContainer() + const envInspector = container.bind(EnvironmentInspectorService) + + const req = { + ...getDefaultRESTRequest(), + endpoint: "http://example.com/api/data", + headers: [ + { key: "<>", value: "some-value", active: true }, + ], + } + + const result = envInspector.getInspectorFor(req) + + expect(result).toContainEqual( + expect.objectContaining({ + id: "environment", + isApplicable: true, + text: { + type: "text", + text: "inspections.environment.not_found", + }, + }) + ) + }) + + it("should not return an inspector result when the headers contain defined environment variables", () => { + const container = new TestContainer() + const envInspector = container.bind(EnvironmentInspectorService) + + const req = { + ...getDefaultRESTRequest(), + endpoint: "http://example.com/api/data", + headers: [ + { key: "<>", value: "some-value", active: true }, + ], + } + + const result = envInspector.getInspectorFor(req) + + expect(result).toHaveLength(0) + }) + + it("should return an inspector result when the params contain undefined environment variables", () => { + const container = new TestContainer() + const envInspector = container.bind(EnvironmentInspectorService) + + const req = { + ...getDefaultRESTRequest(), + endpoint: "http://example.com/api/data", + params: [ + { key: "<>", value: "some-value", active: true }, + ], + } + + const result = envInspector.getInspectorFor(req) + + expect(result).toContainEqual( + expect.objectContaining({ + id: "environment", + isApplicable: true, + text: { + type: "text", + text: "inspections.environment.not_found", + }, + }) + ) + }) + + it("should not return an inspector result when the params contain defined environment variables", () => { + const container = new TestContainer() + const envInspector = container.bind(EnvironmentInspectorService) + + const req = { + ...getDefaultRESTRequest(), + endpoint: "http://example.com/api/data", + headers: [], + params: [ + { key: "<>", value: "some-value", active: true }, + ], + } + + const result = envInspector.getInspectorFor(req) + + expect(result).toHaveLength(0) + }) + }) +}) diff --git a/packages/hoppscotch-common/src/services/inspection/inspectors/__tests__/header.inspector.spec.ts b/packages/hoppscotch-common/src/services/inspection/inspectors/__tests__/header.inspector.spec.ts new file mode 100644 index 000000000..d78cbf082 --- /dev/null +++ b/packages/hoppscotch-common/src/services/inspection/inspectors/__tests__/header.inspector.spec.ts @@ -0,0 +1,61 @@ +import { TestContainer } from "dioc/testing" +import { describe, expect, it, vi } from "vitest" +import { HeaderInspectorService } from "../header.inspector" +import { InspectionService } from "../../index" +import { getDefaultRESTRequest } from "~/helpers/rest/default" + +vi.mock("~/modules/i18n", () => ({ + __esModule: true, + getI18n: () => (x: string) => x, +})) + +describe("HeaderInspectorService", () => { + it("registers with the inspection service upon initialization", () => { + const container = new TestContainer() + + const registerInspectorFn = vi.fn() + + container.bindMock(InspectionService, { + registerInspector: registerInspectorFn, + }) + + const headerInspector = container.bind(HeaderInspectorService) + + expect(registerInspectorFn).toHaveBeenCalledOnce() + expect(registerInspectorFn).toHaveBeenCalledWith(headerInspector) + }) + + describe("getInspectorFor", () => { + it("should return an inspector result when headers contain cookies", () => { + const container = new TestContainer() + const headerInspector = container.bind(HeaderInspectorService) + + const req = { + ...getDefaultRESTRequest(), + endpoint: "http://example.com/api/data", + headers: [{ key: "Cookie", value: "some-cookie", active: true }], + } + + const result = headerInspector.getInspectorFor(req) + + expect(result).toContainEqual( + expect.objectContaining({ id: "header", isApplicable: true }) + ) + }) + + it("should return an empty array when headers do not contain cookies", () => { + const container = new TestContainer() + const headerInspector = container.bind(HeaderInspectorService) + + const req = { + ...getDefaultRESTRequest(), + endpoint: "http://example.com/api/data", + headers: [{ key: "Authorization", value: "Bearer abcd", active: true }], + } + + const result = headerInspector.getInspectorFor(req) + + expect(result).toHaveLength(0) + }) + }) +}) diff --git a/packages/hoppscotch-common/src/services/inspection/inspectors/__tests__/response.interceptor.spec.ts b/packages/hoppscotch-common/src/services/inspection/inspectors/__tests__/response.interceptor.spec.ts new file mode 100644 index 000000000..2ee552495 --- /dev/null +++ b/packages/hoppscotch-common/src/services/inspection/inspectors/__tests__/response.interceptor.spec.ts @@ -0,0 +1,151 @@ +import { TestContainer } from "dioc/testing" +import { describe, expect, it, vi } from "vitest" +import { ResponseInspectorService } from "../response.inspector" +import { InspectionService } from "../../index" +import { getDefaultRESTRequest } from "~/helpers/rest/default" + +vi.mock("~/modules/i18n", () => ({ + __esModule: true, + getI18n: () => (x: string) => x, +})) + +describe("ResponseInspectorService", () => { + it("registers with the inspection service upon initialization", () => { + const container = new TestContainer() + + const registerInspectorFn = vi.fn() + + container.bindMock(InspectionService, { + registerInspector: registerInspectorFn, + }) + + const responseInspector = container.bind(ResponseInspectorService) + + expect(registerInspectorFn).toHaveBeenCalledOnce() + expect(registerInspectorFn).toHaveBeenCalledWith(responseInspector) + }) + + describe("getInspectorFor", () => { + it("should return an empty array when response is undefined", () => { + const container = new TestContainer() + const responseInspector = container.bind(ResponseInspectorService) + + const req = { + ...getDefaultRESTRequest(), + endpoint: "http://example.com/api/data", + } + + const result = responseInspector.getInspectorFor(req, undefined) + + expect(result).toHaveLength(0) + }) + + it("should return an inspector result when response type is not success or status code is not 200", () => { + const container = new TestContainer() + const responseInspector = container.bind(ResponseInspectorService) + + const req = { + ...getDefaultRESTRequest(), + endpoint: "http://example.com/api/data", + } + const res = { type: "network_fail", statusCode: 400 } + + const result = responseInspector.getInspectorFor(req, res) + + expect(result).toContainEqual( + expect.objectContaining({ id: "url", isApplicable: true }) + ) + }) + + it("should handle network_fail responses", () => { + const container = new TestContainer() + const responseInspector = container.bind(ResponseInspectorService) + + const req = { + ...getDefaultRESTRequest(), + endpoint: "http://example.com/api/data", + } + const res = { type: "network_fail", statusCode: 500 } + + const result = responseInspector.getInspectorFor(req, res) + + expect(result).toContainEqual( + expect.objectContaining({ + text: { type: "text", text: "inspections.response.network_error" }, + }) + ) + }) + + it("should handle fail responses", () => { + const container = new TestContainer() + const responseInspector = container.bind(ResponseInspectorService) + + const req = { + ...getDefaultRESTRequest(), + endpoint: "http://example.com/api/data", + } + const res = { type: "fail", statusCode: 500 } + + const result = responseInspector.getInspectorFor(req, res) + + expect(result).toContainEqual( + expect.objectContaining({ + text: { type: "text", text: "inspections.response.default_error" }, + }) + ) + }) + + it("should handle 404 responses", () => { + const container = new TestContainer() + const responseInspector = container.bind(ResponseInspectorService) + + const req = { + ...getDefaultRESTRequest(), + endpoint: "http://example.com/api/data", + } + const res = { type: "success", statusCode: 404 } + + const result = responseInspector.getInspectorFor(req, res) + + expect(result).toContainEqual( + expect.objectContaining({ + text: { type: "text", text: "inspections.response.404_error" }, + }) + ) + }) + + it("should handle 401 responses", () => { + const container = new TestContainer() + const responseInspector = container.bind(ResponseInspectorService) + + const req = { + ...getDefaultRESTRequest(), + endpoint: "http://example.com/api/data", + } + const res = { type: "success", statusCode: 401 } + + const result = responseInspector.getInspectorFor(req, res) + + expect(result).toContainEqual( + expect.objectContaining({ + text: { type: "text", text: "inspections.response.401_error" }, + }) + ) + }) + + it("should handle successful responses", () => { + const container = new TestContainer() + const responseInspector = container.bind(ResponseInspectorService) + + const req = { + ...getDefaultRESTRequest(), + endpoint: "http://example.com/api/data", + } + const res = { type: "success", statusCode: 200 } + + const result = responseInspector.getInspectorFor(req, res) + + expect(result).toHaveLength(0) + }) + }) +}) diff --git a/packages/hoppscotch-common/src/services/inspection/inspectors/__tests__/url.inspector.spec.ts b/packages/hoppscotch-common/src/services/inspection/inspectors/__tests__/url.inspector.spec.ts new file mode 100644 index 000000000..5530936b4 --- /dev/null +++ b/packages/hoppscotch-common/src/services/inspection/inspectors/__tests__/url.inspector.spec.ts @@ -0,0 +1,84 @@ +import { TestContainer } from "dioc/testing" +import { describe, expect, it, vi } from "vitest" +import { URLInspectorService } from "../url.inspector" +import { InspectionService } from "../../index" +import { getDefaultRESTRequest } from "~/helpers/rest/default" + +vi.mock("~/modules/i18n", () => ({ + __esModule: true, + getI18n: () => (x: string) => x, +})) + +describe("URLInspectorService", () => { + it("registers with the inspection service upon initialization", () => { + const container = new TestContainer() + + const registerInspectorFn = vi.fn() + + container.bindMock(InspectionService, { + registerInspector: registerInspectorFn, + }) + + const urlInspector = container.bind(URLInspectorService) + + expect(registerInspectorFn).toHaveBeenCalledOnce() + expect(registerInspectorFn).toHaveBeenCalledWith(urlInspector) + }) + + describe("getInspectorFor", () => { + it("should return an inspector result when localhost is in URL and extension is not available", () => { + const container = new TestContainer() + const urlInspector = container.bind(URLInspectorService) + + const req = { + ...getDefaultRESTRequest(), + endpoint: "http://localhost:8000/api/data", + } + + const result = urlInspector.getInspectorFor(req) + + expect(result).toContainEqual( + expect.objectContaining({ id: "url", isApplicable: true }) + ) + }) + + it("should not return an inspector result when localhost is not in URL", () => { + const container = new TestContainer() + const urlInspector = container.bind(URLInspectorService) + + const req = { + ...getDefaultRESTRequest(), + endpoint: "http://example.com/api/data", + } + + const result = urlInspector.getInspectorFor(req) + + expect(result).toHaveLength(0) + }) + + it("should add the correct text to the results when extension is not installed", () => { + vi.mock("~/newstore/HoppExtension", async () => { + const { BehaviorSubject }: any = await vi.importActual("rxjs") + + return { + __esModule: true, + extensionStatus$: new BehaviorSubject("waiting"), + } + }) + const container = new TestContainer() + const urlInspector = container.bind(URLInspectorService) + + const req = { + ...getDefaultRESTRequest(), + endpoint: "http://localhost:8000/api/data", + } + + const result = urlInspector.getInspectorFor(req) + + expect(result).toHaveLength(1) + expect(result[0]).toMatchObject({ + text: { type: "text", text: "inspections.url.extension_not_installed" }, + }) + }) + }) +}) diff --git a/packages/hoppscotch-common/src/services/inspection/inspectors/environment.inspector.ts b/packages/hoppscotch-common/src/services/inspection/inspectors/environment.inspector.ts new file mode 100644 index 000000000..e88a5842d --- /dev/null +++ b/packages/hoppscotch-common/src/services/inspection/inspectors/environment.inspector.ts @@ -0,0 +1,167 @@ +import { getI18n } from "~/modules/i18n" +import { + InspectionService, + Inspector, + InspectorLocation, + InspectorResult, +} from ".." +import { Service } from "dioc" +import { Ref, markRaw, ref } from "vue" +import IconPlusCircle from "~icons/lucide/plus-circle" +import { HoppRESTRequest } from "@hoppscotch/data" +import { getAggregateEnvs } from "~/newstore/environments" +import { invokeAction } from "~/helpers/actions" + +const HOPP_ENVIRONMENT_REGEX = /(<<[a-zA-Z0-9-_]+>>)/g + +const isENVInString = (str: string) => { + return HOPP_ENVIRONMENT_REGEX.test(str) +} + +/** + * This inspector is responsible for inspecting the environment variables of a input. + * It checks if the environment variables are defined in the environment. + * It also provides an action to add the environment variable. + * + * NOTE: Initializing this service registers it as a inspector with the Inspection Service. + */ +export class EnvironmentInspectorService extends Service implements Inspector { + public static readonly ID = "ENVIRONMENT_INSPECTOR_SERVICE" + + private t = getI18n() + + public readonly inspectorID = "environment" + + private readonly inspection = this.bind(InspectionService) + + constructor() { + super() + + this.inspection.registerInspector(this) + } + + /** + * Validates the environment variables in the target array + * @param target The target array to validate + * @param results The results array to push the results to + * @param locations The location where results are to be displayed + * @returns The results array + */ + private validateEnvironmentVariables = ( + target: any[], + results: Ref, + locations: InspectorLocation + ) => { + const env = getAggregateEnvs() + const envKeys = env.map((e) => e.key) + + target.forEach((element, index) => { + if (isENVInString(element)) { + const extractedEnv = element.match(HOPP_ENVIRONMENT_REGEX) + + if (extractedEnv) { + extractedEnv.forEach((exEnv: string) => { + const formattedExEnv = exEnv.slice(2, -2) + let itemLocation: InspectorLocation + if (locations.type === "header") { + itemLocation = { + type: "header", + position: locations.position, + index: index, + key: element, + } + } else if (locations.type === "parameter") { + itemLocation = { + type: "parameter", + position: locations.position, + index: index, + key: element, + } + } else { + itemLocation = { + type: "url", + } + } + if (!envKeys.includes(formattedExEnv)) { + results.value.push({ + id: "environment", + text: { + type: "text", + text: this.t("inspections.environment.not_found", { + environment: exEnv, + }), + }, + icon: markRaw(IconPlusCircle), + action: { + text: this.t("inspections.environment.add_environment"), + apply: () => { + invokeAction("modals.environment.add", { + envName: "test", + variableName: formattedExEnv, + }) + }, + }, + severity: 3, + isApplicable: true, + locations: itemLocation, + doc: { + text: this.t("action.learn_more"), + link: "https://docs.hoppscotch.io/", + }, + }) + } + }) + } + } + }) + } + + /** + * Returns the inspector results for the request + * It checks if any env is used in the request ie, url, headers, params + * and checks if the env is defined in the environment using the validateEnvironmentVariables function + * @param req The request to inspect + * @returns The inspector results + */ + getInspectorFor(req: HoppRESTRequest): InspectorResult[] { + const results = ref([]) + + const headers = req.headers + + const params = req.params + + this.validateEnvironmentVariables([req.endpoint], results, { + type: "url", + }) + + const headerKeys = Object.values(headers).map((header) => header.key) + + this.validateEnvironmentVariables(headerKeys, results, { + type: "header", + position: "key", + }) + + const headerValues = Object.values(headers).map((header) => header.value) + + this.validateEnvironmentVariables(headerValues, results, { + type: "header", + position: "value", + }) + + const paramsKeys = Object.values(params).map((param) => param.key) + + this.validateEnvironmentVariables(paramsKeys, results, { + type: "parameter", + position: "key", + }) + + const paramsValues = Object.values(params).map((param) => param.value) + + this.validateEnvironmentVariables(paramsValues, results, { + type: "parameter", + position: "value", + }) + + return results.value + } +} diff --git a/packages/hoppscotch-common/src/services/inspection/inspectors/header.inspector.ts b/packages/hoppscotch-common/src/services/inspection/inspectors/header.inspector.ts new file mode 100644 index 000000000..57b1c8944 --- /dev/null +++ b/packages/hoppscotch-common/src/services/inspection/inspectors/header.inspector.ts @@ -0,0 +1,78 @@ +import { Service } from "dioc" +import { InspectionService, Inspector, InspectorResult } from ".." +import { getI18n } from "~/modules/i18n" +import { HoppRESTRequest } from "@hoppscotch/data" +import { markRaw, ref } from "vue" +import IconAlertTriangle from "~icons/lucide/alert-triangle" + +/** + * This inspector is responsible for inspecting the header of a request. + * It checks if the header contains cookies. + * + * NOTE: Initializing this service registers it as a inspector with the Inspection Service. + */ +export class HeaderInspectorService extends Service implements Inspector { + public static readonly ID = "HEADER_INSPECTOR_SERVICE" + + private t = getI18n() + + public readonly inspectorID = "header" + + private readonly inspection = this.bind(InspectionService) + + constructor() { + super() + + this.inspection.registerInspector(this) + } + + /** + * Checks if the header contains cookies + * @param req The request to inspect + * @returns The inspector results + */ + getInspectorFor(req: HoppRESTRequest): InspectorResult[] { + const results = ref([]) + + const cookiesCheck = (headerKey: string) => { + const cookieKeywords = ["Cookie", "Set-Cookie", "Cookie2", "Set-Cookie2"] + + return cookieKeywords.includes(headerKey) + } + + const headers = req.headers + + const headerKeys = Object.values(headers).map((header) => header.key) + + const isContainCookies = headerKeys.includes("Cookie") + + if (isContainCookies) { + headerKeys.forEach((headerKey, index) => { + if (cookiesCheck(headerKey)) { + results.value.push({ + id: "header", + icon: markRaw(IconAlertTriangle), + text: { + type: "text", + text: this.t("inspections.header.cookie"), + }, + severity: 2, + isApplicable: true, + locations: { + type: "header", + position: "key", + key: headerKey, + index: index, + }, + doc: { + text: this.t("action.learn_more"), + link: "https://docs.hoppscotch.io/", + }, + }) + } + }) + } + + return results.value + } +} diff --git a/packages/hoppscotch-common/src/services/inspection/inspectors/response.inspector.ts b/packages/hoppscotch-common/src/services/inspection/inspectors/response.inspector.ts new file mode 100644 index 000000000..c073b1849 --- /dev/null +++ b/packages/hoppscotch-common/src/services/inspection/inspectors/response.inspector.ts @@ -0,0 +1,73 @@ +import { Service } from "dioc" +import { InspectionService, Inspector, InspectorResult } from ".." +import { getI18n } from "~/modules/i18n" +import { HoppRESTRequest } from "@hoppscotch/data" +import { markRaw, ref } from "vue" +import IconAlertTriangle from "~icons/lucide/alert-triangle" +import { HoppRESTResponse } from "~/helpers/types/HoppRESTResponse" + +/** + * This inspector is responsible for inspecting the response of a request. + * It checks if the response is successful and if it contains errors. + * + * NOTE: Initializing this service registers it as a inspector with the Inspection Service. + */ +export class ResponseInspectorService extends Service implements Inspector { + public static readonly ID = "RESPONSE_INSPECTOR_SERVICE" + + private t = getI18n() + + public readonly inspectorID = "response" + + private readonly inspection = this.bind(InspectionService) + + constructor() { + super() + + this.inspection.registerInspector(this) + } + + getInspectorFor( + req: HoppRESTRequest, + res: HoppRESTResponse | undefined + ): InspectorResult[] { + const results = ref([]) + if (!res) return results.value + + const hasErrors = res && (res.type !== "success" || res.statusCode !== 200) + + let text + + if (res.type === "network_fail") { + text = this.t("inspections.response.network_error") + } else if (res.type === "fail") { + text = this.t("inspections.response.default_error") + } else if (res.type === "success" && res.statusCode === 404) { + text = this.t("inspections.response.404_error") + } else if (res.type === "success" && res.statusCode === 401) { + text = this.t("inspections.response.401_error") + } + + if (hasErrors && text) { + results.value.push({ + id: "url", + icon: markRaw(IconAlertTriangle), + text: { + type: "text", + text: text, + }, + severity: 2, + isApplicable: true, + locations: { + type: "response", + }, + doc: { + text: this.t("action.learn_more"), + link: "https://docs.hoppscotch.io/", + }, + }) + } + + return results.value + } +} diff --git a/packages/hoppscotch-common/src/services/inspection/inspectors/url.inspector.ts b/packages/hoppscotch-common/src/services/inspection/inspectors/url.inspector.ts new file mode 100644 index 000000000..943713533 --- /dev/null +++ b/packages/hoppscotch-common/src/services/inspection/inspectors/url.inspector.ts @@ -0,0 +1,96 @@ +import { Service } from "dioc" +import { InspectionService, Inspector, InspectorResult } from ".." +import { getI18n } from "~/modules/i18n" +import { HoppRESTRequest } from "@hoppscotch/data" +import { computed, markRaw, ref } from "vue" +import IconAlertTriangle from "~icons/lucide/alert-triangle" +import { useReadonlyStream } from "~/composables/stream" +import { extensionStatus$ } from "~/newstore/HoppExtension" +import { useSetting } from "~/composables/settings" +import { applySetting, toggleSetting } from "~/newstore/settings" + +/** + * This inspector is responsible for inspecting the URL of a request. + * It checks if the URL contains localhost and if the extension is installed. + * It also provides an action to enable the extension. + * + * NOTE: Initializing this service registers it as a inspector with the Inspection Service. + */ +export class URLInspectorService extends Service implements Inspector { + public static readonly ID = "URL_INSPECTOR_SERVICE" + + private t = getI18n() + + public readonly inspectorID = "url" + + private readonly inspection = this.bind(InspectionService) + + constructor() { + super() + + this.inspection.registerInspector(this) + } + + getInspectorFor(req: HoppRESTRequest): InspectorResult[] { + const PROXY_ENABLED = useSetting("PROXY_ENABLED") + + const currentExtensionStatus = useReadonlyStream(extensionStatus$, null) + + const isExtensionInstalled = computed(() => { + return currentExtensionStatus.value === "available" + }) + const EXTENSIONS_ENABLED = useSetting("EXTENSIONS_ENABLED") + + const results = ref([]) + + const url = req.endpoint + + const isContainLocalhost = url.includes("localhost") + + if ( + isContainLocalhost && + (!EXTENSIONS_ENABLED.value || !isExtensionInstalled.value) + ) { + let text + + if (!isExtensionInstalled.value) { + if (currentExtensionStatus.value === "unknown-origin") { + text = this.t("inspections.url.extension_unknown_origin") + } else { + text = this.t("inspections.url.extension_not_installed") + } + } else if (!EXTENSIONS_ENABLED.value) { + text = this.t("inspections.url.extention_not_enabled") + } else { + text = this.t("inspections.url.localhost") + } + + results.value.push({ + id: "url", + icon: markRaw(IconAlertTriangle), + text: { + type: "text", + text: text, + }, + action: { + text: this.t("inspections.url.extention_enable_action"), + apply: () => { + applySetting("EXTENSIONS_ENABLED", true) + if (PROXY_ENABLED.value) toggleSetting("PROXY_ENABLED") + }, + }, + severity: 2, + isApplicable: true, + locations: { + type: "url", + }, + doc: { + text: this.t("action.learn_more"), + link: "https://docs.hoppscotch.io/", + }, + }) + } + + return results.value + } +}