Compare commits

...

34 Commits

Author SHA1 Message Date
Andrew Bastin
23afc201a1 chore: bump version to release/2023.8.4 2023-11-14 21:26:16 +05:30
Andrew Bastin
b1982d74a6 fix: make schema more lenient while parsing public data structures 2023-11-14 21:24:25 +05:30
Andrew Bastin
e93a37c711 fix: add i18n entries for oauth errors 2023-11-14 17:44:37 +05:30
Nivedin
8d7509cdea fix: interceptor error from extension issue (#3548) 2023-11-14 17:17:23 +05:30
Akash K
e24d0ce605 fix: oauth 2.0 authentication type is breaking (#3531) 2023-11-14 00:12:04 +05:30
Andrew Bastin
de725337d6 fix: window drag taking precedence on windows 2023-11-08 20:07:13 +05:30
Andrew Bastin
9d1d369f37 fix: performance issues due to mouse on header detection 2023-11-08 18:47:40 +05:30
Andrew Bastin
2bd925d441 chore: correct version of selfhost-desktop 2023-11-08 17:18:12 +05:30
Liyas Thomas
bb8dc6f7eb chore: updated brand assets (#3500)
Co-authored-by: Andrew Bastin <andrewbastin.k@gmail.com>
2023-11-08 15:47:35 +05:30
Anwarul Islam
be3e5ba7e7 fix: graphql query deprecation issue (#3506) 2023-11-08 14:51:39 +05:30
Andrew Bastin
663134839f feat: let platforms disable we are using cookies prompt 2023-11-07 20:49:07 +05:30
Andrew Bastin
736f83a70c fix: header inspector cookie inspection will not trigger if current interceptor supports cookies 2023-11-07 20:36:34 +05:30
Andrew Bastin
05d2175f43 fix: selfhost-desktop not running gql-codegen 2023-11-07 20:24:22 +05:30
Andrew Bastin
97bd808431 chore: set versions with a bump version 2023-11-07 16:04:02 +05:30
Andrew Bastin
a13c2fd4c1 chore: pin @codemirror/language to 6.9.0 2023-11-07 15:57:19 +05:30
Andrew Bastin
16044b5840 feat: desktop app
Co-authored-by: Vivek R <123vivekr@gmail.com>
Co-authored-by: Liyas Thomas <liyascthomas@gmail.com>
2023-11-07 14:20:03 +05:30
Andrew Bastin
4ebf850cb6 chore: bump version to 2023.8.3 2023-11-06 17:39:31 +05:30
Balu Babu
76af7d5e10 fix: mailer template issue (#3475) 2023-11-06 17:25:36 +05:30
Nicolas Merget
5428a73811 fix: add optional chaining for teamMembers to handle undefined team (#3484)
Co-authored-by: James George <jamesgeorge998001@gmail.com>
2023-11-06 11:25:39 +05:30
Anwarul Islam
4a154e6569 chore: fix spelling mistake on type import (#3487) 2023-11-06 11:25:03 +05:30
Liyas Thomas
0aa5825d8b fix: cleanup ui and improve consistency in input elements (#3494) 2023-11-06 10:56:15 +05:30
Andrew Bastin
bdb63e99d5 fix: pin @lezer/highlight to 1.1.4 to prevent page breaks 2023-11-03 23:30:46 +05:30
James George
8175ec640a chore(data): bump dependencies (#3473) 2023-11-02 23:53:52 +05:30
James George
b5307e4a89 chore(common): implement enforced pre-commit type checks for FE service files (#3472) 2023-11-02 23:37:27 +05:30
Akash K
19294802be fix: graphql page crashing and broken syntax highlighting (#3488) 2023-11-02 23:10:37 +05:30
Andrew Bastin
cbe3e14b47 refactor: versioning and migration mechanism for public data structures (#3457)
Co-authored-by: jamesgeorge007 <jamesgeorge998001@gmail.com>
2023-11-02 18:54:16 +05:30
Gaurav K P
01df1663ad fix(common): handle false negatives in url validation (#3465) 2023-11-01 22:23:33 +05:30
Nivedin
abd5288da8 refactor: move sentry to platform (#3451) 2023-11-01 18:17:55 +05:30
Michel Tomas
a89bc473f6 fix(self-hosted/web): add "useCredentials: true" to Vite PWA options (#3460) 2023-11-01 09:46:20 +05:30
Andrew Bastin
57cb59027b chore: bump codemirror dependencies 2023-10-19 13:37:07 +05:30
Joel Jacob Stephen
7a9f0c8756 refactor: improvements to the auth implementation in admin dashboard (#3444)
* refactor: abstract axios queries to a separate helper file

* chore: delete unnecessary file

* chore: remove unnecessary console logs

* refactor: updated urls for api and authquery helpers

* refactor: updated auth implementation

* refactor: use default axios instance

* chore: improve code readability

* refactor: separate instances for rest and gql calls

* refactor: removed async await from functions that do not need them

* refactor: removed probable login and probable user from the auth system

* refactor: better error handling in login component

* chore: deleted unnecessary files and restructured some files

* feat: new errors file with typed error message formats

* refactor: removed unwanted usage of async await

* refactor: optimizing the usage and return of promises in auth flow

* refactor: convey boolean return type in a better way

* chore: apply suggestions

* refactor: handle case when mailcatcher is not active

---------

Co-authored-by: nivedin <nivedinp@gmail.com>
Co-authored-by: James George <jamesgeorge998001@gmail.com>
2023-10-16 18:14:02 +05:30
Balu Babu
46caf9b198 refactor: removed all instances of rejectOnNotFound in prisma queries (#3377)
* chore: removed rejectOnNotFound property from prisma query in team-enviroment method

* chore: fixed issues with test cases in team-environment module

* chore: changed target of hoppscotch-old-backend service back to prod
2023-10-16 14:04:03 +05:30
Mir Arif Hasan
f5db54484c HBE-266 Update NestJS packages (#3389)
* build: update npm nest packages

* build: removed depricated apollo-server-plugin package

* build: pnpm-lock file added

* build: swc integrated

* Revert "build: swc integrated"

This reverts commit 803a01f38f210dfbcd603665893d29af565c8908.

* feat: upgrade graphql* packages version

* feat: upgrade point release

* feat: update pnpm-lock file
2023-10-16 12:23:55 +05:30
Mir Arif Hasan
8deb6471b9 HBE-270 Test-Case timestamp issue fix in backend (#3415)
test: timestamp issue fix in user-history
2023-10-16 12:11:15 +05:30
238 changed files with 15783 additions and 3604 deletions

View File

@@ -17,12 +17,12 @@
"types": "dist/index.d.ts",
"sideEffects": false,
"dependencies": {
"@codemirror/language": "^6.9.0",
"@lezer/highlight": "^1.1.6",
"@lezer/lr": "^1.3.10"
"@codemirror/language": "6.9.0",
"@lezer/highlight": "1.1.4",
"@lezer/lr": "^1.3.13"
},
"devDependencies": {
"@lezer/generator": "^1.5.0",
"@lezer/generator": "^1.5.1",
"mocha": "^9.2.2",
"rollup": "^3.29.3",
"rollup-plugin-dts": "^6.0.2",

View File

@@ -1,6 +1,6 @@
{
"name": "hoppscotch-backend",
"version": "2023.8.2",
"version": "2023.8.4-1",
"description": "",
"author": "",
"private": true,
@@ -24,18 +24,17 @@
"do-test": "pnpm run test"
},
"dependencies": {
"@nestjs-modules/mailer": "^1.8.1",
"@nestjs/apollo": "^10.1.6",
"@nestjs/common": "^9.2.1",
"@nestjs/core": "^9.2.1",
"@nestjs/graphql": "^10.1.6",
"@nestjs/jwt": "^10.0.1",
"@nestjs/passport": "^9.0.0",
"@nestjs/platform-express": "^9.2.1",
"@nestjs/throttler": "^4.0.0",
"@apollo/server": "^4.9.4",
"@nestjs-modules/mailer": "^1.9.1",
"@nestjs/apollo": "^12.0.9",
"@nestjs/common": "^10.2.6",
"@nestjs/core": "^10.2.6",
"@nestjs/graphql": "^12.0.9",
"@nestjs/jwt": "^10.1.1",
"@nestjs/passport": "^10.0.2",
"@nestjs/platform-express": "^10.2.6",
"@nestjs/throttler": "^5.0.0",
"@prisma/client": "^4.16.2",
"apollo-server-express": "^3.11.1",
"apollo-server-plugin-base": "^3.7.1",
"argon2": "^0.30.3",
"bcrypt": "^5.1.0",
"cookie": "^0.5.0",
@@ -43,9 +42,9 @@
"express": "^4.17.1",
"express-session": "^1.17.3",
"fp-ts": "^2.13.1",
"graphql": "^15.5.0",
"graphql": "^16.8.1",
"graphql-query-complexity": "^0.12.0",
"graphql-redis-subscriptions": "^2.5.0",
"graphql-redis-subscriptions": "^2.6.0",
"graphql-subscriptions": "^2.0.0",
"handlebars": "^4.7.7",
"io-ts": "^2.2.16",
@@ -63,10 +62,11 @@
"rxjs": "^7.6.0"
},
"devDependencies": {
"@nestjs/cli": "^9.1.5",
"@nestjs/schematics": "^9.0.3",
"@nestjs/testing": "^9.2.1",
"@nestjs/cli": "^10.1.18",
"@nestjs/schematics": "^10.0.2",
"@nestjs/testing": "^10.2.6",
"@relmify/jest-fp-ts": "^2.0.2",
"@types/argon2": "^0.15.0",
"@types/bcrypt": "^5.0.0",
"@types/cookie": "^0.5.1",
"@types/cookie-parser": "^1.4.3",

View File

@@ -74,7 +74,7 @@ export class AdminService {
try {
await this.mailerService.sendUserInvitationEmail(inviteeEmail, {
template: 'code-your-own',
template: 'user-invitation',
variables: {
inviteeEmail: inviteeEmail,
magicLink: `${process.env.VITE_BASE_URL}`,

View File

@@ -27,12 +27,7 @@ import { AppController } from './app.controller';
buildSchemaOptions: {
numberScalarMode: 'integer',
},
cors: {
origin: process.env.WHITELISTED_ORIGINS.split(','),
credentials: true,
},
playground: process.env.PRODUCTION !== 'true',
debug: process.env.PRODUCTION !== 'true',
autoSchemaFile: true,
installSubscriptionHandlers: true,
subscriptions: {
@@ -62,10 +57,12 @@ import { AppController } from './app.controller';
}),
driver: ApolloDriver,
}),
ThrottlerModule.forRoot({
ttl: +process.env.RATE_LIMIT_TTL,
limit: +process.env.RATE_LIMIT_MAX,
}),
ThrottlerModule.forRoot([
{
ttl: +process.env.RATE_LIMIT_TTL,
limit: +process.env.RATE_LIMIT_MAX,
},
]),
UserModule,
AuthModule,
AdminModule,

View File

@@ -229,7 +229,7 @@ export class AuthService {
}
await this.mailerService.sendEmail(email, {
template: 'code-your-own',
template: 'user-invitation',
variables: {
inviteeEmail: email,
magicLink: `${url}/enter?token=${generatedTokens.token}`,

View File

@@ -93,9 +93,7 @@ export async function emitGQLSchemaFile() {
numberScalarMode: 'integer',
});
const schemaString = printSchema(schema, {
commentDescriptions: true,
});
const schemaString = printSchema(schema);
logger.log(`Writing schema to GQL_SCHEMA_EMIT_LOCATION (${destination})`);

View File

@@ -3,8 +3,7 @@ import { Injectable } from '@nestjs/common';
@Injectable()
export class ThrottlerBehindProxyGuard extends ThrottlerGuard {
protected getTracker(req: Record<string, any>): string {
protected async getTracker(req: Record<string, any>): Promise<string> {
return req.ips.length ? req.ips[0] : req.ip; // individualize IP extraction to meet your own needs
// learn more: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-For#directives
}
}

View File

@@ -8,7 +8,7 @@ export type MailDescription = {
};
export type UserMagicLinkMailDescription = {
template: 'code-your-own';
template: 'user-invitation';
variables: {
inviteeEmail: string;
magicLink: string;
@@ -16,7 +16,7 @@ export type UserMagicLinkMailDescription = {
};
export type AdminUserInvitationMailDescription = {
template: 'code-your-own';
template: 'user-invitation';
variables: {
inviteeEmail: string;
magicLink: string;

View File

@@ -27,7 +27,7 @@ export class MailerService {
case 'team-invitation':
return `${mailDesc.variables.invitee} invited you to join ${mailDesc.variables.invite_team_name} in Hoppscotch`;
case 'code-your-own':
case 'user-invitation':
return 'Sign in to Hoppscotch';
}
}

View File

@@ -14,7 +14,7 @@
-->
<style type="text/css" rel="stylesheet" media="all">
/* Base ------------------------------ */
@import url("https://fonts.googleapis.com/css?family=Nunito+Sans:400,700&display=swap");
body {
width: 100% !important;
@@ -22,19 +22,19 @@
margin: 0;
-webkit-text-size-adjust: none;
}
a {
color: #3869D4;
}
a img {
border: none;
}
td {
word-break: break-word;
}
.preheader {
display: none !important;
visibility: hidden;
@@ -47,13 +47,13 @@
overflow: hidden;
}
/* Type ------------------------------ */
body,
td,
th {
font-family: "Nunito Sans", Helvetica, Arial, sans-serif;
}
h1 {
margin-top: 0;
color: #333333;
@@ -61,7 +61,7 @@
font-weight: bold;
text-align: left;
}
h2 {
margin-top: 0;
color: #333333;
@@ -69,7 +69,7 @@
font-weight: bold;
text-align: left;
}
h3 {
margin-top: 0;
color: #333333;
@@ -77,12 +77,12 @@
font-weight: bold;
text-align: left;
}
td,
th {
font-size: 16px;
}
p,
ul,
ol,
@@ -91,25 +91,25 @@
font-size: 16px;
line-height: 1.625;
}
p.sub {
font-size: 13px;
}
/* Utilities ------------------------------ */
.align-right {
text-align: right;
}
.align-left {
text-align: left;
}
.align-center {
text-align: center;
}
/* Buttons ------------------------------ */
.button {
background-color: #3869D4;
border-top: 10px solid #3869D4;
@@ -124,7 +124,7 @@
-webkit-text-size-adjust: none;
box-sizing: border-box;
}
.button--green {
background-color: #22BC66;
border-top: 10px solid #22BC66;
@@ -132,7 +132,7 @@
border-bottom: 10px solid #22BC66;
border-left: 18px solid #22BC66;
}
.button--red {
background-color: #FF6136;
border-top: 10px solid #FF6136;
@@ -140,7 +140,7 @@
border-bottom: 10px solid #FF6136;
border-left: 18px solid #FF6136;
}
@media only screen and (max-width: 500px) {
.button {
width: 100% !important;
@@ -148,21 +148,21 @@
}
}
/* Attribute list ------------------------------ */
.attributes {
margin: 0 0 21px;
}
.attributes_content {
background-color: #F4F4F7;
padding: 16px;
}
.attributes_item {
padding: 0;
}
/* Related Items ------------------------------ */
.related {
width: 100%;
margin: 0;
@@ -171,31 +171,31 @@
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
}
.related_item {
padding: 10px 0;
color: #CBCCCF;
font-size: 15px;
line-height: 18px;
}
.related_item-title {
display: block;
margin: .5em 0 0;
}
.related_item-thumb {
display: block;
padding-bottom: 10px;
}
.related_heading {
border-top: 1px solid #CBCCCF;
text-align: center;
padding: 25px 0 10px;
}
/* Discount Code ------------------------------ */
.discount {
width: 100%;
margin: 0;
@@ -206,33 +206,33 @@
background-color: #F4F4F7;
border: 2px dashed #CBCCCF;
}
.discount_heading {
text-align: center;
}
.discount_body {
text-align: center;
font-size: 15px;
}
/* Social Icons ------------------------------ */
.social {
width: auto;
}
.social td {
padding: 0;
width: auto;
}
.social_icon {
height: 20px;
margin: 0 8px 10px 8px;
padding: 0;
}
/* Data table ------------------------------ */
.purchase {
width: 100%;
margin: 0;
@@ -241,7 +241,7 @@
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
}
.purchase_content {
width: 100%;
margin: 0;
@@ -250,50 +250,50 @@
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
}
.purchase_item {
padding: 10px 0;
color: #51545E;
font-size: 15px;
line-height: 18px;
}
.purchase_heading {
padding-bottom: 8px;
border-bottom: 1px solid #EAEAEC;
}
.purchase_heading p {
margin: 0;
color: #85878E;
font-size: 12px;
}
.purchase_footer {
padding-top: 15px;
border-top: 1px solid #EAEAEC;
}
.purchase_total {
margin: 0;
text-align: right;
font-weight: bold;
color: #333333;
}
.purchase_total--label {
padding: 0 15px 0 0;
}
body {
background-color: #F2F4F6;
color: #51545E;
}
p {
color: #51545E;
}
.email-wrapper {
width: 100%;
margin: 0;
@@ -303,7 +303,7 @@
-premailer-cellspacing: 0;
background-color: #F2F4F6;
}
.email-content {
width: 100%;
margin: 0;
@@ -313,16 +313,16 @@
-premailer-cellspacing: 0;
}
/* Masthead ----------------------- */
.email-masthead {
padding: 25px 0;
text-align: center;
}
.email-masthead_logo {
width: 94px;
}
.email-masthead_name {
font-size: 16px;
font-weight: bold;
@@ -331,7 +331,7 @@
text-shadow: 0 1px 0 white;
}
/* Body ------------------------------ */
.email-body {
width: 100%;
margin: 0;
@@ -340,7 +340,7 @@
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
}
.email-body_inner {
width: 570px;
margin: 0 auto;
@@ -350,7 +350,7 @@
-premailer-cellspacing: 0;
background-color: #FFFFFF;
}
.email-footer {
width: 570px;
margin: 0 auto;
@@ -360,11 +360,11 @@
-premailer-cellspacing: 0;
text-align: center;
}
.email-footer p {
color: #A8AAAF;
}
.body-action {
width: 100%;
margin: 30px auto;
@@ -374,25 +374,25 @@
-premailer-cellspacing: 0;
text-align: center;
}
.body-sub {
margin-top: 25px;
padding-top: 25px;
border-top: 1px solid #EAEAEC;
}
.content-cell {
padding: 45px;
}
/*Media Queries ------------------------------ */
@media only screen and (max-width: 600px) {
.email-body_inner,
.email-footer {
width: 100% !important;
}
}
@media (prefers-color-scheme: dark) {
body,
.email-body,

View File

@@ -1,8 +1,9 @@
import { GraphQLSchemaHost } from '@nestjs/graphql';
import {
ApolloServerPlugin,
BaseContext,
GraphQLRequestListener,
} from 'apollo-server-plugin-base';
} from '@apollo/server';
import { Plugin } from '@nestjs/apollo';
import { GraphQLError } from 'graphql';
import {
@@ -17,7 +18,7 @@ const COMPLEXITY_LIMIT = 50;
export class GQLComplexityPlugin implements ApolloServerPlugin {
constructor(private gqlSchemaHost: GraphQLSchemaHost) {}
async requestDidStart(): Promise<GraphQLRequestListener> {
async requestDidStart(): Promise<GraphQLRequestListener<BaseContext>> {
const { schema } = this.gqlSchemaHost;
return {

View File

@@ -1,5 +1,5 @@
import { Team, TeamCollection as DBTeamCollection } from '@prisma/client';
import { mock, mockDeep, mockReset } from 'jest-mock-extended';
import { mockDeep, mockReset } from 'jest-mock-extended';
import {
TEAM_COLL_DEST_SAME,
TEAM_COLL_INVALID_JSON,
@@ -17,9 +17,6 @@ import { PrismaService } from 'src/prisma/prisma.service';
import { PubSubService } from 'src/pubsub/pubsub.service';
import { AuthUser } from 'src/types/AuthUser';
import { TeamCollectionService } from './team-collection.service';
import { TeamCollection } from './team-collection.model';
import { TeamCollectionModule } from './team-collection.module';
import * as E from 'fp-ts/Either';
const mockPrisma = mockDeep<PrismaService>();
const mockPubSub = mockDeep<PubSubService>();

View File

@@ -301,7 +301,7 @@ describe('TeamEnvironmentsService', () => {
describe('createDuplicateEnvironment', () => {
test('should successfully duplicate an existing team environment', async () => {
mockPrisma.teamEnvironment.findFirst.mockResolvedValueOnce(
mockPrisma.teamEnvironment.findFirstOrThrow.mockResolvedValueOnce(
teamEnvironment,
);
@@ -322,7 +322,9 @@ describe('TeamEnvironmentsService', () => {
});
test('should throw TEAM_ENVIRONMMENT_NOT_FOUND if provided id is invalid', async () => {
mockPrisma.teamEnvironment.findFirst.mockRejectedValue('NotFoundError');
mockPrisma.teamEnvironment.findFirstOrThrow.mockRejectedValue(
'NotFoundError',
);
const result = await teamEnvironmentsService.createDuplicateEnvironment(
teamEnvironment.id,
@@ -332,7 +334,7 @@ describe('TeamEnvironmentsService', () => {
});
test('should send pubsub message to "team_environment/<teamID>/created" if team environment is updated successfully', async () => {
mockPrisma.teamEnvironment.findFirst.mockResolvedValueOnce(
mockPrisma.teamEnvironment.findFirstOrThrow.mockResolvedValueOnce(
teamEnvironment,
);

View File

@@ -183,11 +183,10 @@ export class TeamEnvironmentsService {
*/
async createDuplicateEnvironment(id: string) {
try {
const environment = await this.prisma.teamEnvironment.findFirst({
const environment = await this.prisma.teamEnvironment.findFirstOrThrow({
where: {
id: id,
},
rejectOnNotFound: true,
});
const result = await this.prisma.teamEnvironment.create({

View File

@@ -142,13 +142,15 @@ describe('UserHistoryService', () => {
});
describe('createUserHistory', () => {
test('Should resolve right and create a REST request to users history and return a `UserHistory` object', async () => {
const executedOn = new Date();
mockPrisma.userHistory.create.mockResolvedValueOnce({
userUid: 'abc',
id: '1',
request: [{}],
responseMetadata: [{}],
reqType: ReqType.REST,
executedOn: new Date(),
executedOn,
isStarred: false,
});
@@ -158,7 +160,7 @@ describe('UserHistoryService', () => {
request: JSON.stringify([{}]),
responseMetadata: JSON.stringify([{}]),
reqType: ReqType.REST,
executedOn: new Date(),
executedOn,
isStarred: false,
};
@@ -172,13 +174,15 @@ describe('UserHistoryService', () => {
).toEqualRight(userHistory);
});
test('Should resolve right and create a GQL request to users history and return a `UserHistory` object', async () => {
const executedOn = new Date();
mockPrisma.userHistory.create.mockResolvedValueOnce({
userUid: 'abc',
id: '1',
request: [{}],
responseMetadata: [{}],
reqType: ReqType.GQL,
executedOn: new Date(),
executedOn,
isStarred: false,
});
@@ -188,7 +192,7 @@ describe('UserHistoryService', () => {
request: JSON.stringify([{}]),
responseMetadata: JSON.stringify([{}]),
reqType: ReqType.GQL,
executedOn: new Date(),
executedOn,
isStarred: false,
};
@@ -212,13 +216,15 @@ describe('UserHistoryService', () => {
).toEqualLeft(USER_HISTORY_INVALID_REQ_TYPE);
});
test('Should create a GQL request to users history and publish a created subscription', async () => {
const executedOn = new Date();
mockPrisma.userHistory.create.mockResolvedValueOnce({
userUid: 'abc',
id: '1',
request: [{}],
responseMetadata: [{}],
reqType: ReqType.GQL,
executedOn: new Date(),
executedOn,
isStarred: false,
});
@@ -228,7 +234,7 @@ describe('UserHistoryService', () => {
request: JSON.stringify([{}]),
responseMetadata: JSON.stringify([{}]),
reqType: ReqType.GQL,
executedOn: new Date(),
executedOn,
isStarred: false,
};
@@ -245,13 +251,15 @@ describe('UserHistoryService', () => {
);
});
test('Should create a REST request to users history and publish a created subscription', async () => {
const executedOn = new Date();
mockPrisma.userHistory.create.mockResolvedValueOnce({
userUid: 'abc',
id: '1',
request: [{}],
responseMetadata: [{}],
reqType: ReqType.REST,
executedOn: new Date(),
executedOn,
isStarred: false,
});
@@ -261,7 +269,7 @@ describe('UserHistoryService', () => {
request: JSON.stringify([{}]),
responseMetadata: JSON.stringify([{}]),
reqType: ReqType.REST,
executedOn: new Date(),
executedOn,
isStarred: false,
};
@@ -323,13 +331,15 @@ describe('UserHistoryService', () => {
).toEqualLeft(USER_HISTORY_NOT_FOUND);
});
test('Should star/unstar a request in the history and publish a updated subscription', async () => {
const executedOn = new Date();
mockPrisma.userHistory.findFirst.mockResolvedValueOnce({
userUid: 'abc',
id: '1',
request: [{}],
responseMetadata: [{}],
reqType: ReqType.REST,
executedOn: new Date(),
executedOn,
isStarred: false,
});
@@ -339,7 +349,7 @@ describe('UserHistoryService', () => {
request: [{}],
responseMetadata: [{}],
reqType: ReqType.REST,
executedOn: new Date(),
executedOn,
isStarred: true,
});
@@ -349,7 +359,7 @@ describe('UserHistoryService', () => {
request: JSON.stringify([{}]),
responseMetadata: JSON.stringify([{}]),
reqType: ReqType.REST,
executedOn: new Date(),
executedOn,
isStarred: true,
};

View File

@@ -1,5 +1,6 @@
{
"action": {
"add": "Add",
"autoscroll": "Autoscroll",
"cancel": "Cancel",
"choose_file": "Choose a file",
@@ -54,9 +55,28 @@
"new": "Add new",
"star": "Add star"
},
"cookies": {
"modal": {
"new_domain_name": "New domain name",
"set": "Set a cookie",
"cookie_string": "Cookie string",
"enter_cookie_string": "Enter cookie string",
"cookie_name": "Name",
"cookie_value": "Value",
"cookie_path": "Path",
"cookie_expires": "Expires",
"managed_tab": "Managed",
"raw_tab": "Raw",
"interceptor_no_support": "Your currently selected interceptor does not support cookies. Select a different Interceptor and try again.",
"empty_domains": "Domain list is empty",
"empty_domain": "Domain is empty",
"no_cookies_in_domain": "No cookies set for this domain"
}
},
"app": {
"chat_with_us": "Chat with us",
"contact_us": "Contact us",
"cookies": "Cookies",
"copy": "Copy",
"copy_user_id": "Copy User Auth Token",
"developer_option": "Developer options",
@@ -119,7 +139,21 @@
"password": "Password",
"token": "Token",
"type": "Authorization Type",
"username": "Username"
"username": "Username",
"oauth": {
"token_generation_oidc_discovery_failed": "Failure on token generation: OpenID Connect Discovery Failed",
"something_went_wrong_on_token_generation": "Something went wrong on token generation",
"redirect_auth_server_returned_error": "Auth Server returned an error state",
"redirect_no_auth_code": "No Authorization Code present in the redirect",
"redirect_invalid_state": "Invalid State value present in the redirect",
"redirect_no_token_endpoint": "No Token Endpoint Defined",
"redirect_no_client_id": "No Client ID defined",
"redirect_no_client_secret": "No Client Secret Defined",
"redirect_no_code_verifier": "No Code Verifier Defined",
"redirect_auth_token_request_failed": "Request to get the auth token failed",
"redirect_auth_token_request_invalid_response": "Invalid Response from the Token Endpoint when requesting for an auth token",
"something_went_wrong_on_oauth_redirect": "Something went wrong during OAuth Redirect"
}
},
"collection": {
"created": "Collection created",
@@ -237,6 +271,7 @@
"error": {
"browser_support_sse": "This browser doesn't seems to have Server Sent Events support.",
"check_console_details": "Check console log for details.",
"check_how_to_add_origin": "Check how you can add an origin",
"curl_invalid_format": "cURL is not formatted properly",
"danger_zone": "Danger zone",
"delete_account": "Your account is currently an owner in these teams:",
@@ -257,6 +292,7 @@
"no_environments_to_export": "No environments to export. Please create an environment to get started.",
"no_results_found": "No matches found",
"page_not_found": "This page could not be found",
"please_install_extension": "Please install the extension and add origin to the extension.",
"proxy_error": "Proxy error",
"script_fail": "Could not execute pre-request script",
"something_went_wrong": "Something went wrong",
@@ -764,7 +800,7 @@
"published_error": "Something went wrong while publishing msg: {topic} to topic: {message}",
"published_message": "Published message: {message} to topic: {topic}",
"reconnection_error": "Failed to reconnect",
"show":"Show",
"show": "Show",
"subscribed_failed": "Failed to subscribe to topic: {topic}",
"subscribed_success": "Successfully subscribed to topic: {topic}",
"unsubscribed_failed": "Failed to unsubscribe from topic: {topic}",

View File

@@ -1,7 +1,7 @@
{
"name": "@hoppscotch/common",
"private": true,
"version": "2023.8.2",
"version": "2023.8.4-1",
"scripts": {
"dev": "pnpm exec npm-run-all -p -l dev:*",
"test": "vitest --run",
@@ -17,22 +17,22 @@
"postinstall": "pnpm run gql-codegen",
"do-test": "pnpm run test",
"do-lint": "pnpm run prod-lint",
"do-typecheck": "pnpm run lint",
"do-typecheck": "node type-check.mjs",
"do-lintfix": "pnpm run lintfix"
},
"dependencies": {
"@apidevtools/swagger-parser": "^10.1.0",
"@codemirror/autocomplete": "^6.9.0",
"@codemirror/commands": "^6.2.4",
"@codemirror/lang-javascript": "^6.1.9",
"@codemirror/autocomplete": "^6.10.2",
"@codemirror/commands": "^6.3.0",
"@codemirror/lang-javascript": "^6.2.1",
"@codemirror/lang-json": "^6.0.1",
"@codemirror/lang-xml": "^6.0.2",
"@codemirror/language": "^6.9.0",
"@codemirror/language": "6.9.0",
"@codemirror/legacy-modes": "^6.3.3",
"@codemirror/lint": "^6.4.0",
"@codemirror/search": "^6.5.1",
"@codemirror/state": "^6.2.1",
"@codemirror/view": "^6.16.0",
"@codemirror/lint": "^6.4.2",
"@codemirror/search": "^6.5.4",
"@codemirror/state": "^6.3.1",
"@codemirror/view": "^6.22.0",
"@fontsource-variable/inter": "^5.0.8",
"@fontsource-variable/material-symbols-rounded": "^5.0.7",
"@fontsource-variable/roboto-mono": "^5.0.9",
@@ -41,9 +41,7 @@
"@hoppscotch/js-sandbox": "workspace:^",
"@hoppscotch/ui": "workspace:^",
"@hoppscotch/vue-toasted": "^0.1.0",
"@lezer/highlight": "^1.1.6",
"@sentry/tracing": "^7.64.0",
"@sentry/vue": "^7.64.0",
"@lezer/highlight": "1.1.4",
"@urql/core": "^4.1.1",
"@urql/devtools": "^2.0.3",
"@urql/exchange-auth": "^2.1.6",
@@ -54,6 +52,7 @@
"acorn-walk": "^8.2.0",
"axios": "^1.4.0",
"buffer": "^6.0.3",
"cookie-es": "^1.0.0",
"dioc": "workspace:^",
"esprima": "^4.0.1",
"events": "^3.3.0",
@@ -78,6 +77,8 @@
"process": "^0.11.10",
"qs": "^6.11.2",
"rxjs": "^7.8.1",
"set-cookie-parser": "^2.6.0",
"set-cookie-parser-es": "^1.0.5",
"socket.io-client-v2": "npm:socket.io-client@^2.4.0",
"socket.io-client-v3": "npm:socket.io-client@^3.1.3",
"socket.io-client-v4": "npm:socket.io-client@^4.4.1",
@@ -100,7 +101,8 @@
"wonka": "^6.3.4",
"workbox-window": "^7.0.0",
"xml-formatter": "^3.5.0",
"yargs-parser": "^21.1.1"
"yargs-parser": "^21.1.1",
"zod": "^3.22.4"
},
"devDependencies": {
"@esbuild-plugins/node-globals-polyfill": "^0.2.3",
@@ -138,6 +140,7 @@
"eslint": "^8.47.0",
"eslint-plugin-prettier": "^5.0.0",
"eslint-plugin-vue": "^9.17.0",
"glob": "^10.3.10",
"npm-run-all": "^4.1.5",
"openapi-types": "^12.1.3",
"rollup-plugin-polyfill-node": "^0.12.0",

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="156" height="32" fill="none"><rect width="156" height="32" fill="#000" rx="4"/><text xmlns="http://www.w3.org/2000/svg" x="50%" y="50%" fill="#fff" dominant-baseline="central" font-family="Helvetica,sans-serif" font-size="12" font-weight="bold" text-anchor="middle" text-rendering="geometricPrecision">▶ Run in Hoppscotch</text></svg>

After

Width:  |  Height:  |  Size: 386 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="156" height="32" fill="none"><rect width="156" height="32" fill="#fff" rx="4"/><text xmlns="http://www.w3.org/2000/svg" x="50%" y="50%" fill="#000" dominant-baseline="central" font-family="Helvetica,sans-serif" font-size="12" font-weight="bold" text-anchor="middle" text-rendering="geometricPrecision">▶ Run in Hoppscotch</text></svg>

After

Width:  |  Height:  |  Size: 386 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 926 KiB

After

Width:  |  Height:  |  Size: 354 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 462 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 400 B

After

Width:  |  Height:  |  Size: 624 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 871 B

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 510 KiB

After

Width:  |  Height:  |  Size: 360 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 535 KiB

After

Width:  |  Height:  |  Size: 385 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 178 KiB

View File

@@ -1 +1,50 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" fill="none"><path fill="#10B981" d="M0 0h512v512H0z"/><circle cx="197.76" cy="157.84" r="10" fill="#fff" fill-opacity=".75"/><circle cx="259.76" cy="161.84" r="12" fill="#fff" fill-opacity=".75"/><circle cx="319.76" cy="177.84" r="10" fill="#fff" fill-opacity=".75"/><path d="M344.963 235.676c2.075-12.698-38.872-29.804-90.967-38.094-52.09-8.296-96.404-4.665-98.48 8.033-.257 1.035 0 1.812.263 2.853-1.298-.521-76.714 211.212-76.714 211.212H364.14s-17.621-181.414-20.211-181.414c.515-.772 1.035-1.549 1.035-2.59Z" fill="url(#a)"/><path d="M314.902 227.386c-1.298 8.033-30.839 9.845-66.343 4.402-35.247-5.7-62.982-16.843-61.684-24.618.521-2.59 3.888-4.665 9.331-5.7-18.141.777-30.062 4.145-31.096 9.845-1.555 10.628 34.726 25.139 81.373 32.657 46.647 7.512 85.782 4.665 87.594-5.7 1.041-6.226-9.33-12.961-26.431-19.439 4.923 2.847 7.513 5.957 7.256 8.553Z" fill="#A7F3D0" fill-opacity=".5"/><path d="M333.557 157.413c-3.104-32.137-27.729-59.351-60.9-64.53-33.172-5.186-64.531 12.954-77.749 42.238 21.251 1.298 44.057 3.631 67.904 7.518 25.396 3.888 49.237 9.074 70.745 14.774Z" fill="url(#b)"/><path d="M74.142 158.002c-2.59 15.808 30.319 35.247 81.894 51.055-.257-1.04-.257-1.818-.257-2.853 2.07-12.698 46.127-16.328 98.48-8.032 52.347 8.29 93.037 25.396 90.961 38.094-.257 1.04-.514 1.818-1.035 2.589 53.645.778 90.968-7.512 93.557-23.32 3.625-24.104-74.638-56.498-174.93-72.306-100.555-15.808-185.045-9.331-188.67 14.773Zm115.586-1.298c.778-4.145 4.665-7.255 8.81-6.477 4.145.777 7.256 4.665 6.478 8.81-.52 4.145-4.665 6.998-8.81 6.478-4.145-.778-7.255-4.666-6.478-8.811Zm59.866 4.145c.777-5.7 6.22-9.587 11.92-8.547 5.7.778 9.588 6.215 8.553 11.921-1.041 5.442-6.478 9.33-11.92 8.553-5.706-.778-9.594-6.221-8.553-11.927Zm62.975 15.294c.778-4.145 4.665-7.255 8.81-6.478 4.145.778 7.255 4.666 6.478 8.811-.515 4.145-4.665 7.255-8.81 6.477-4.145-.777-7.256-4.665-6.478-8.81Z" fill="url(#c)"/><defs><radialGradient id="b" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="matrix(0 32.7063 -69.3245 0 264.232 124.706)"><stop stop-color="#047857"/><stop offset="1" stop-color="#064E3B"/></radialGradient><radialGradient id="c" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(255.837 186.754) scale(1389.61)"><stop stop-color="#047857"/><stop offset=".115" stop-color="#064E3B"/></radialGradient><linearGradient id="a" x1="224.998" y1="157.606" x2="224.998" y2="403.696" gradientUnits="userSpaceOnUse"><stop stop-color="#86EFAC" stop-opacity=".75"/><stop offset=".635" stop-color="#fff" stop-opacity=".2"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></linearGradient></defs></svg>
<svg width="824" height="824" viewBox="0 0 824 824" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="824" height="824" rx="184" fill="#08110F"/>
<rect width="824" height="824" rx="184" fill="url(#paint0_radial_0_21)" fill-opacity="0.5"/>
<path d="M435.425 463.217C429.441 476.657 411.033 481.515 394.309 474.07C377.585 466.624 368.879 449.693 374.863 436.253C380.846 422.813 399.254 417.954 415.978 425.4C432.702 432.846 441.409 449.777 435.425 463.217Z" fill="url(#paint1_linear_0_21)"/>
<path d="M435.425 463.217C429.441 476.657 411.033 481.515 394.309 474.07C377.585 466.624 368.879 449.693 374.863 436.253C380.846 422.813 399.254 417.954 415.978 425.4C432.702 432.846 441.409 449.777 435.425 463.217Z" fill="url(#paint2_radial_0_21)" style="mix-blend-mode:soft-light"/>
<path d="M535.563 521.172C553.071 526.191 570.536 518.856 574.571 504.789C578.606 490.722 567.684 475.251 550.175 470.232C532.666 465.213 515.201 472.548 511.166 486.615C507.131 500.682 518.054 516.153 535.563 521.172Z" fill="url(#paint3_linear_0_21)"/>
<path d="M535.563 521.172C553.071 526.191 570.536 518.856 574.571 504.789C578.606 490.722 567.684 475.251 550.175 470.232C532.666 465.213 515.201 472.548 511.166 486.615C507.131 500.682 518.054 516.153 535.563 521.172Z" fill="url(#paint4_radial_0_21)" style="mix-blend-mode:soft-light"/>
<path d="M292.782 355.633C308.227 365.286 314.462 383.173 306.709 395.584C298.955 407.995 280.149 410.231 264.704 400.578C249.258 390.924 243.023 373.037 250.777 360.626C258.53 348.215 277.337 345.98 292.782 355.633Z" fill="url(#paint5_linear_0_21)"/>
<path d="M292.782 355.633C308.227 365.286 314.462 383.173 306.709 395.584C298.955 407.995 280.149 410.231 264.704 400.578C249.258 390.924 243.023 373.037 250.777 360.626C258.53 348.215 277.337 345.98 292.782 355.633Z" fill="url(#paint6_radial_0_21)" style="mix-blend-mode:soft-light"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M502.355 231.325C581.373 266.506 632.095 343.263 634.119 429.03C680.633 465.639 726.858 516.883 705.36 565.168C681.25 619.319 595.382 617.091 497.781 589.689C450.767 615.718 392.444 620.168 339.689 596.68C286.934 573.192 251.229 526.908 239.1 474.517C153.428 420.321 94.3151 357.999 118.425 303.847C139.923 255.562 208.935 255.626 267.265 265.697C332.356 209.81 423.338 196.144 502.355 231.325ZM159.38 322.082C147.667 348.389 210.578 423.052 382.845 499.751C555.111 576.449 652.693 573.241 664.405 546.934C674.099 525.16 634.213 483.308 588.537 450.878C553.009 425.484 504.344 397.494 440.864 369.231C423.586 361.538 416.839 341.008 424.104 324.691C431.369 308.374 447.329 297.463 480.93 295.91C496.747 295.862 498.823 291.476 499.546 287.716C500.442 281.915 492.401 276.002 484.108 272.31C418.17 242.953 337.453 255.265 281.503 314.178C226.84 301.933 169.074 300.309 159.38 322.082Z" fill="url(#paint7_linear_0_21)"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M502.355 231.325C581.373 266.506 632.095 343.263 634.119 429.03C680.633 465.639 726.858 516.883 705.36 565.168C681.25 619.319 595.382 617.091 497.781 589.689C450.767 615.718 392.444 620.168 339.689 596.68C286.934 573.192 251.229 526.908 239.1 474.517C153.428 420.321 94.3151 357.999 118.425 303.847C139.923 255.562 208.935 255.626 267.265 265.697C332.356 209.81 423.338 196.144 502.355 231.325ZM159.38 322.082C147.667 348.389 210.578 423.052 382.845 499.751C555.111 576.449 652.693 573.241 664.405 546.934C674.099 525.16 634.213 483.308 588.537 450.878C553.009 425.484 504.344 397.494 440.864 369.231C423.586 361.538 416.839 341.008 424.104 324.691C431.369 308.374 447.329 297.463 480.93 295.91C496.747 295.862 498.823 291.476 499.546 287.716C500.442 281.915 492.401 276.002 484.108 272.31C418.17 242.953 337.453 255.265 281.503 314.178C226.84 301.933 169.074 300.309 159.38 322.082Z" fill="url(#paint8_radial_0_21)" style="mix-blend-mode:soft-light"/>
<defs>
<radialGradient id="paint0_radial_0_21" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(814.524 12.36) rotate(125.613) scale(1089.59 1210.34)">
<stop stop-color="#00D196" stop-opacity="0.5"/>
<stop offset="0.996771" stop-color="#00D196" stop-opacity="0"/>
</radialGradient>
<linearGradient id="paint1_linear_0_21" x1="411.893" y1="212" x2="411.893" y2="612" gradientUnits="userSpaceOnUse">
<stop stop-color="#00D196"/>
<stop offset="1" stop-color="#00B381"/>
</linearGradient>
<radialGradient id="paint2_radial_0_21" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(644.721 344.481) rotate(159.984) scale(631.37 385.135)">
<stop stop-color="white"/>
<stop offset="1" stop-color="white" stop-opacity="0"/>
</radialGradient>
<linearGradient id="paint3_linear_0_21" x1="411.893" y1="212" x2="411.893" y2="612" gradientUnits="userSpaceOnUse">
<stop stop-color="#00D196"/>
<stop offset="1" stop-color="#00B381"/>
</linearGradient>
<radialGradient id="paint4_radial_0_21" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(644.721 344.481) rotate(159.984) scale(631.37 385.135)">
<stop stop-color="white"/>
<stop offset="1" stop-color="white" stop-opacity="0"/>
</radialGradient>
<linearGradient id="paint5_linear_0_21" x1="411.893" y1="212" x2="411.893" y2="612" gradientUnits="userSpaceOnUse">
<stop stop-color="#00D196"/>
<stop offset="1" stop-color="#00B381"/>
</linearGradient>
<radialGradient id="paint6_radial_0_21" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(644.721 344.481) rotate(159.984) scale(631.37 385.135)">
<stop stop-color="white"/>
<stop offset="1" stop-color="white" stop-opacity="0"/>
</radialGradient>
<linearGradient id="paint7_linear_0_21" x1="411.893" y1="212" x2="411.893" y2="612" gradientUnits="userSpaceOnUse">
<stop stop-color="#00D196"/>
<stop offset="1" stop-color="#00B381"/>
</linearGradient>
<radialGradient id="paint8_radial_0_21" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(644.721 344.481) rotate(159.984) scale(631.37 385.135)">
<stop stop-color="white"/>
<stop offset="1" stop-color="white" stop-opacity="0"/>
</radialGradient>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

View File

@@ -58,6 +58,8 @@ declare module 'vue' {
CollectionsRequest: typeof import('./components/collections/Request.vue')['default']
CollectionsSaveRequest: typeof import('./components/collections/SaveRequest.vue')['default']
CollectionsTeamCollections: typeof import('./components/collections/TeamCollections.vue')['default']
CookiesAllModal: typeof import('./components/cookies/AllModal.vue')['default']
CookiesEditCookie: typeof import('./components/cookies/EditCookie.vue')['default']
Environments: typeof import('./components/environments/index.vue')['default']
EnvironmentsAdd: typeof import('./components/environments/Add.vue')['default']
EnvironmentsImportExport: typeof import('./components/environments/ImportExport.vue')['default']
@@ -90,13 +92,11 @@ declare module 'vue' {
HoppButtonPrimary: typeof import('@hoppscotch/ui')['HoppButtonPrimary']
HoppButtonSecondary: typeof import('@hoppscotch/ui')['HoppButtonSecondary']
HoppSmartAnchor: typeof import('@hoppscotch/ui')['HoppSmartAnchor']
HoppSmartAutoComplete: typeof import('@hoppscotch/ui')['HoppSmartAutoComplete']
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']
HoppSmartIntersection: typeof import('@hoppscotch/ui')['HoppSmartIntersection']
HoppSmartItem: typeof import('@hoppscotch/ui')['HoppSmartItem']
HoppSmartLink: typeof import('@hoppscotch/ui')['HoppSmartLink']
HoppSmartModal: typeof import('@hoppscotch/ui')['HoppSmartModal']
@@ -143,7 +143,6 @@ declare module 'vue' {
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']
IconLucideBrush: typeof import('~icons/lucide/brush')['default']
IconLucideCheckCircle: typeof import('~icons/lucide/check-circle')['default']
IconLucideChevronRight: typeof import('~icons/lucide/chevron-right')['default']
IconLucideGlobe: typeof import('~icons/lucide/globe')['default']
@@ -153,10 +152,9 @@ declare module 'vue' {
IconLucideLayers: typeof import('~icons/lucide/layers')['default']
IconLucideListEnd: typeof import('~icons/lucide/list-end')['default']
IconLucideMinus: typeof import('~icons/lucide/minus')['default']
IconLucideRss: typeof import('~icons/lucide/rss')['default']
IconLucideSearch: typeof import('~icons/lucide/search')['default']
IconLucideUsers: typeof import('~icons/lucide/users')['default']
IconLucideVerified: typeof import('~icons/lucide/verified')['default']
InterceptorsErrorPlaceholder: typeof import('./components/interceptors/ErrorPlaceholder.vue')['default']
InterceptorsExtensionSubtitle: typeof import('./components/interceptors/ExtensionSubtitle.vue')['default']
LensesHeadersRenderer: typeof import('./components/lenses/HeadersRenderer.vue')['default']
LensesHeadersRendererEntry: typeof import('./components/lenses/HeadersRendererEntry.vue')['default']
@@ -189,7 +187,6 @@ declare module 'vue' {
SmartEnvInput: typeof import('./components/smart/EnvInput.vue')['default']
SmartExpand: typeof import('./../../hoppscotch-ui/src/components/smart/Expand.vue')['default']
SmartFileChip: typeof import('./../../hoppscotch-ui/src/components/smart/FileChip.vue')['default']
SmartFontSizePicker: typeof import('./components/smart/FontSizePicker.vue')['default']
SmartInput: typeof import('./../../hoppscotch-ui/src/components/smart/Input.vue')['default']
SmartIntersection: typeof import('./../../hoppscotch-ui/src/components/smart/Intersection.vue')['default']
SmartItem: typeof import('./../../hoppscotch-ui/src/components/smart/Item.vue')['default']

View File

@@ -20,6 +20,12 @@
<AppInterceptor />
</template>
</tippy>
<HoppButtonSecondary
v-if="platform.platformFeatureFlags.cookiesEnabled ?? false"
:label="t('app.cookies')"
:icon="IconCookie"
@click="showCookiesModal = true"
/>
</div>
<div class="flex">
<tippy
@@ -195,12 +201,17 @@
:show="showDeveloperOptions"
@hide-modal="showDeveloperOptions = false"
/>
<CookiesAllModal
:show="showCookiesModal"
@hide-modal="showCookiesModal = false"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from "vue"
import { version } from "~/../package.json"
import IconCookie from "~icons/lucide/cookie"
import IconSidebar from "~icons/lucide/sidebar"
import IconZap from "~icons/lucide/zap"
import IconShare2 from "~icons/lucide/share-2"
@@ -223,7 +234,9 @@ import { invokeAction } from "@helpers/actions"
import { HoppSmartItem } from "@hoppscotch/ui"
const t = useI18n()
const showDeveloperOptions = ref(false)
const showCookiesModal = ref(false)
const EXPAND_NAVIGATION = useSetting("EXPAND_NAVIGATION")
const SIDEBAR = useSetting("SIDEBAR")

View File

@@ -1,7 +1,9 @@
<template>
<div>
<header
ref="headerRef"
class="flex items-center justify-between flex-1 flex-shrink-0 px-2 py-2 space-x-2 overflow-x-auto overflow-y-hidden"
@mousedown.prevent="platform.ui?.appHeader?.onHeaderAreaClick?.()"
>
<div
class="inline-flex items-center justify-start flex-1 space-x-2"

View File

@@ -258,7 +258,7 @@ const importFromJSON = () => {
inputChooseFileToImportFrom.value.value = ""
}
const exportJSON = () => {
const exportJSON = async () => {
const dataToWrite = collectionJson.value
const parsedCollections = JSON.parse(dataToWrite)
@@ -268,24 +268,32 @@ const exportJSON = () => {
}
const file = new Blob([dataToWrite], { type: "application/json" })
const a = document.createElement("a")
const url = URL.createObjectURL(file)
a.href = url
platform?.analytics?.logEvent({
type: "HOPP_EXPORT_COLLECTION",
exporter: "json",
platform: "gql",
const filename = `${url.split("/").pop()!.split("#")[0].split("?")[0]}.json`
URL.revokeObjectURL(url)
const result = await platform.io.saveFileWithDialog({
data: dataToWrite,
contentType: "application/json",
suggestedFilename: filename,
filters: [
{
name: "Hoppscotch Collection JSON file",
extensions: ["json"],
},
],
})
// TODO: get uri from meta
a.download = `${url.split("/").pop()!.split("#")[0].split("?")[0]}.json`
document.body.appendChild(a)
a.click()
toast.success(t("state.download_started").toString())
setTimeout(() => {
document.body.removeChild(a)
URL.revokeObjectURL(url)
}, 1000)
if (result.type === "unknown" || result.type === "saved") {
platform?.analytics?.logEvent({
type: "HOPP_EXPORT_COLLECTION",
exporter: "json",
platform: "gql",
})
toast.success(t("state.download_started").toString())
}
}
</script>

View File

@@ -18,13 +18,12 @@
"
>
<WorkspaceCurrent :section="t('tab.collections')" />
<HoppSmartInput
<input
v-model="filterTexts"
:placeholder="t('action.search')"
input-styles="py-2 pl-4 pr-2 bg-transparent !border-0"
type="search"
:autofocus="false"
autocomplete="off"
class="flex w-full p-4 py-2 bg-transparent h-8"
:placeholder="t('action.search')"
:disabled="collectionsType.type === 'team-collections'"
/>
</div>
@@ -1867,28 +1866,25 @@ const getJSONCollection = async () => {
* @param collectionJSON - JSON string of the collection
* @param name - Name of the collection set as the file name
*/
const initializeDownloadCollection = (
const initializeDownloadCollection = async (
collectionJSON: string,
name: string | null
) => {
const file = new Blob([collectionJSON], { type: "application/json" })
const a = document.createElement("a")
const url = URL.createObjectURL(file)
a.href = url
const result = await platform.io.saveFileWithDialog({
data: collectionJSON,
contentType: "application/json",
suggestedFilename: `${name ?? "collection"}.json`,
filters: [
{
name: "Hoppscotch Collection JSON file",
extensions: ["json"],
},
],
})
if (name) {
a.download = `${name}.json`
} else {
a.download = `${url.split("/").pop()!.split("#")[0].split("?")[0]}.json`
if (result.type === "unknown" || result.type === "saved") {
toast.success(t("state.download_started").toString())
}
document.body.appendChild(a)
a.click()
toast.success(t("state.download_started").toString())
setTimeout(() => {
document.body.removeChild(a)
URL.revokeObjectURL(url)
}, 1000)
}
/**
@@ -1917,11 +1913,14 @@ const exportData = async (
exportLoading.value = false
return
},
(coll) => {
async (coll) => {
const hoppColl = teamCollToHoppRESTColl(coll)
const collectionJSONString = JSON.stringify(hoppColl)
initializeDownloadCollection(collectionJSONString, hoppColl.name)
await initializeDownloadCollection(
collectionJSONString,
hoppColl.name
)
exportLoading.value = false
}
)

View File

@@ -0,0 +1,269 @@
<template>
<HoppSmartModal
v-if="show"
dialog
:title="t('app.cookies')"
aria-modal="true"
@close="hideModal"
>
<template #body>
<HoppSmartPlaceholder
v-if="!currentInterceptorSupportsCookies"
:text="t('cookies.modal.interceptor_no_support')"
>
<AppInterceptor class="p-2 border rounded border-dividerLight" />
</HoppSmartPlaceholder>
<div v-else class="flex flex-col">
<div
class="flex bg-primary space-x-2 border-b sticky border-dividerLight -mx-4 px-4 py-4 -mt-4"
style="top: calc(-1 * var(--line-height-body))"
>
<HoppSmartInput
v-model="newDomainText"
class="flex-1"
:placeholder="t('cookies.modal.new_domain_name')"
@keyup.enter="addNewDomain"
/>
<HoppButtonSecondary
outline
filled
:label="t('action.add')"
@click="addNewDomain"
/>
</div>
<div class="flex flex-col space-y-4">
<HoppSmartPlaceholder
v-if="workingCookieJar.size === 0"
:src="`/images/states/${colorMode.value}/blockchain.svg`"
:alt="`${t('cookies.modal.empty_domains')}`"
:text="t('cookies.modal.empty_domains')"
class="mt-6"
>
</HoppSmartPlaceholder>
<div
v-for="[domain, entries] in workingCookieJar.entries()"
v-else
:key="domain"
class="flex flex-col"
>
<div class="flex items-center justify-between flex-1">
<label for="cookiesList" class="p-4">
{{ domain }}
</label>
<div class="flex">
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.delete')"
:icon="IconTrash2"
@click="deleteDomain(domain)"
/>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('add.new')"
:icon="IconPlus"
@click="addCookieToDomain(domain)"
/>
</div>
</div>
<div class="border rounded border-divider">
<div class="divide-y divide-dividerLight">
<div
v-if="entries.length === 0"
class="flex flex-col gap-2 p-4 items-center"
>
{{ t("cookies.modal.no_cookies_in_domain") }}
</div>
<template v-else>
<div
v-for="(entry, entryIndex) in entries"
:key="`${entry}-${entryIndex}`"
class="flex divide-x divide-dividerLight"
>
<input
class="flex flex-1 px-4 py-2 bg-transparent"
:value="entry"
readonly
/>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.edit')"
:icon="IconEdit"
@click="editCookie(domain, entryIndex, entry)"
/>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.remove')"
:icon="IconTrash"
color="red"
@click="deleteCookie(domain, entryIndex)"
/>
</div>
</template>
</div>
</div>
</div>
</div>
</div>
</template>
<template v-if="currentInterceptorSupportsCookies" #footer>
<span class="flex space-x-2">
<HoppButtonPrimary
v-focus
:label="t('action.save')"
outline
@click="saveCookieChanges"
/>
<HoppButtonSecondary
:label="t('action.cancel')"
outline
filled
@click="cancelCookieChanges"
/>
</span>
<HoppButtonSecondary
:label="t('action.clear_all')"
outline
filled
@click="clearAllDomains"
/>
</template>
</HoppSmartModal>
<CookiesEditCookie
:show="!!showEditModalFor"
:entry="showEditModalFor"
@save-cookie="saveCookie"
@hide-modal="showEditModalFor = null"
/>
</template>
<script setup lang="ts">
import { useI18n } from "@composables/i18n"
import { useService } from "dioc/vue"
import { CookieJarService } from "~/services/cookie-jar.service"
import IconTrash from "~icons/lucide/trash"
import IconEdit from "~icons/lucide/edit"
import IconTrash2 from "~icons/lucide/trash-2"
import IconPlus from "~icons/lucide/plus"
import { cloneDeep } from "lodash-es"
import { ref, watch, computed } from "vue"
import { InterceptorService } from "~/services/interceptor.service"
import { EditCookieConfig } from "./EditCookie.vue"
import { useColorMode } from "@composables/theming"
import { useToast } from "@composables/toast"
const props = defineProps<{
show: boolean
}>()
const emit = defineEmits<{
(e: "hide-modal"): void
}>()
const t = useI18n()
const colorMode = useColorMode()
const toast = useToast()
const newDomainText = ref("")
const interceptorService = useService(InterceptorService)
const cookieJarService = useService(CookieJarService)
const workingCookieJar = ref(cloneDeep(cookieJarService.cookieJar.value))
const currentInterceptorSupportsCookies = computed(() => {
const currentInterceptor = interceptorService.currentInterceptor.value
if (!currentInterceptor) return true
return currentInterceptor.supportsCookies ?? false
})
function addNewDomain() {
if (newDomainText.value === "" || /^\s+$/.test(newDomainText.value)) {
toast.error(`${t("cookies.modal.empty_domain")}`)
return
}
workingCookieJar.value.set(newDomainText.value, [])
newDomainText.value = ""
}
function deleteDomain(domain: string) {
workingCookieJar.value.delete(domain)
}
function addCookieToDomain(domain: string) {
showEditModalFor.value = { type: "create", domain }
}
function clearAllDomains() {
workingCookieJar.value = new Map()
toast.success(`${t("state.cleared")}`)
}
watch(
() => props.show,
(show) => {
if (show) {
workingCookieJar.value = cloneDeep(cookieJarService.cookieJar.value)
}
}
)
const showEditModalFor = ref<EditCookieConfig | null>(null)
function saveCookieChanges() {
cookieJarService.cookieJar.value = workingCookieJar.value
hideModal()
}
function cancelCookieChanges() {
hideModal()
}
function editCookie(domain: string, entryIndex: number, cookieEntry: string) {
showEditModalFor.value = {
type: "edit",
domain,
entryIndex,
currentCookieEntry: cookieEntry,
}
}
function deleteCookie(domain: string, entryIndex: number) {
const entry = workingCookieJar.value.get(domain)
if (entry) {
entry.splice(entryIndex, 1)
}
}
function saveCookie(cookie: string) {
if (showEditModalFor.value?.type === "create") {
const { domain } = showEditModalFor.value
const entry = workingCookieJar.value.get(domain)!
entry.push(cookie)
showEditModalFor.value = null
return
}
if (showEditModalFor.value?.type !== "edit") return
const { domain, entryIndex } = showEditModalFor.value!
const entry = workingCookieJar.value.get(domain)
if (entry) {
entry[entryIndex] = cookie
}
showEditModalFor.value = null
}
const hideModal = () => {
emit("hide-modal")
}
</script>

View File

@@ -0,0 +1,195 @@
<template>
<HoppSmartModal
v-if="show"
dialog
:title="t('cookies.modal.set')"
@close="hideModal"
>
<template #body>
<div class="border rounded border-dividerLight">
<div class="flex flex-col">
<div class="flex items-center justify-between pl-4">
<label class="font-semibold truncate text-secondaryLight">
{{ t("cookies.modal.cookie_string") }}
</label>
<div class="flex items-center">
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.clear_all')"
:icon="IconTrash2"
@click="clearContent()"
/>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('state.linewrap')"
:class="{ '!text-accent': linewrapEnabled }"
:icon="IconWrapText"
@click.prevent="linewrapEnabled = !linewrapEnabled"
/>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip', allowHTML: true }"
:title="t('action.download_file')"
:icon="downloadIcon"
@click="downloadResponse"
/>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip', allowHTML: true }"
:title="t('action.copy')"
:icon="copyIcon"
@click="copyResponse"
/>
</div>
</div>
<div class="h-46">
<div
ref="cookieEditor"
class="h-full border-t rounded-b border-dividerLight"
></div>
</div>
</div>
</div>
</template>
<template #footer>
<div class="flex space-x-2">
<HoppButtonPrimary
v-focus
:label="t('action.save')"
outline
@click="saveCookieChange"
/>
<HoppButtonSecondary
:label="t('action.cancel')"
outline
filled
@click="cancelCookieChange"
/>
</div>
<span class="flex">
<HoppButtonSecondary
:icon="pasteIcon"
:label="`${t('action.paste')}`"
filled
outline
@click="handlePaste"
/>
</span>
</template>
</HoppSmartModal>
</template>
<script lang="ts">
export type EditCookieConfig =
| { type: "create"; domain: string }
| {
type: "edit"
domain: string
entryIndex: number
currentCookieEntry: string
}
</script>
<script setup lang="ts">
import { useI18n } from "@composables/i18n"
import { useCodemirror } from "~/composables/codemirror"
import { watch, ref, reactive } from "vue"
import { refAutoReset } from "@vueuse/core"
import IconWrapText from "~icons/lucide/wrap-text"
import IconClipboard from "~icons/lucide/clipboard"
import IconCheck from "~icons/lucide/check"
import IconTrash2 from "~icons/lucide/trash-2"
import { useToast } from "~/composables/toast"
import {
useCopyResponse,
useDownloadResponse,
} from "~/composables/lens-actions"
// TODO: Build Managed Mode!
const props = defineProps<{
show: boolean
entry: EditCookieConfig | null
}>()
const emit = defineEmits<{
(e: "save-cookie", cookie: string): void
(e: "hide-modal"): void
}>()
const t = useI18n()
const toast = useToast()
const cookieEditor = ref<HTMLElement>()
const rawCookieString = ref("")
const linewrapEnabled = ref(true)
useCodemirror(
cookieEditor,
rawCookieString,
reactive({
extendedEditorConfig: {
mode: "text/plain",
placeholder: `${t("cookies.modal.enter_cookie_string")}`,
lineWrapping: linewrapEnabled,
},
linter: null,
completer: null,
environmentHighlights: false,
})
)
const pasteIcon = refAutoReset<typeof IconClipboard | typeof IconCheck>(
IconClipboard,
1000
)
watch(
() => props.entry,
() => {
if (!props.entry) return
if (props.entry.type === "create") {
rawCookieString.value = ""
return
}
rawCookieString.value = props.entry.currentCookieEntry
}
)
function hideModal() {
emit("hide-modal")
}
function cancelCookieChange() {
hideModal()
}
async function handlePaste() {
try {
const text = await navigator.clipboard.readText()
if (text) {
rawCookieString.value = text
pasteIcon.value = IconCheck
}
} catch (e) {
console.error("Failed to copy: ", e)
toast.error(t("profile.no_permission").toString())
}
}
function saveCookieChange() {
emit("save-cookie", rawCookieString.value)
}
const { copyIcon, copyResponse } = useCopyResponse(rawCookieString)
const { downloadIcon, downloadResponse } = useDownloadResponse(
"",
rawCookieString
)
function clearContent() {
rawCookieString.value = ""
}
</script>

View File

@@ -375,7 +375,7 @@ const importFromPostman = ({
importFromHoppscotch(environments)
}
const exportJSON = () => {
const exportJSON = async () => {
const dataToWrite = environmentJson.value
const parsedCollections = JSON.parse(dataToWrite)
@@ -385,19 +385,27 @@ const exportJSON = () => {
}
const file = new Blob([dataToWrite], { type: "application/json" })
const a = document.createElement("a")
const url = URL.createObjectURL(file)
a.href = url
// TODO: get uri from meta
a.download = `${url.split("/").pop()!.split("#")[0].split("?")[0]}.json`
document.body.appendChild(a)
a.click()
toast.success(t("state.download_started").toString())
setTimeout(() => {
document.body.removeChild(a)
URL.revokeObjectURL(url)
}, 1000)
const filename = `${url.split("/").pop()!.split("#")[0].split("?")[0]}.json`
URL.revokeObjectURL(url)
const result = await platform.io.saveFileWithDialog({
data: dataToWrite,
contentType: "application/json",
suggestedFilename: filename,
filters: [
{
name: "JSON file",
extensions: ["json"],
},
],
})
if (result.type === "unknown" || result.type === "saved") {
toast.success(t("state.download_started").toString())
}
}
const getErrorMessage = (err: GQLError<string>) => {

View File

@@ -66,7 +66,7 @@
/>
<HoppSmartTabs
v-model="selectedEnvTab"
:styles="`sticky overflow-x-auto my-2 border border-divider rounded flex-shrink-0 z-0 top-0 bg-primary ${
:styles="`sticky overflow-x-auto my-2 border border-divider rounded flex-shrink-0 z-10 top-0 bg-primary ${
!isTeamSelected || workspace.type === 'personal'
? 'bg-primaryLight'
: ''

View File

@@ -1,50 +1,55 @@
<template>
<div>
<div class="field-title" :class="{ 'field-highlighted': isHighlighted }">
{{ fieldName }}
<span v-if="fieldArgs.length > 0">
(
<span v-for="(field, index) in fieldArgs" :key="`field-${index}`">
{{ field.name }}:
<GraphqlTypeLink
:gql-type="field.type"
:jump-type-callback="jumpTypeCallback"
/>
<span v-if="index !== fieldArgs.length - 1">, </span>
<div class="flex justify-between gap-2">
<div
class="field-title flex-1"
:class="{ 'field-highlighted': isHighlighted }"
>
{{ fieldName }}
<span v-if="fieldArgs.length > 0">
(
<span v-for="(field, index) in fieldArgs" :key="`field-${index}`">
{{ field.name }}:
<GraphqlTypeLink
:gql-type="field.type"
@jump-to-type="jumpToType"
/>
<span v-if="index !== fieldArgs.length - 1">, </span>
</span>
) </span
>:
<GraphqlTypeLink :gql-type="gqlField.type" @jump-to-type="jumpToType" />
</div>
<div v-if="gqlField.deprecationReason">
<span
v-tippy="{ theme: 'tomato' }"
class="!text-red-500 hover:!text-red-600 text-xs flex items-center gap-2 cursor-pointer"
:title="gqlField.deprecationReason"
>
<IconAlertTriangle /> {{ t("state.deprecated") }}
</span>
) </span
>:
<GraphqlTypeLink
:gql-type="gqlField.type"
:jump-type-callback="jumpTypeCallback"
/>
</div>
</div>
<div
v-if="gqlField.description"
class="py-2 text-secondaryLight field-desc"
class="field-desc py-2 text-secondaryLight"
>
{{ gqlField.description }}
</div>
<div
v-if="gqlField.isDeprecated"
class="inline-block px-2 py-1 my-1 text-black bg-yellow-200 rounded field-deprecated"
>
{{ t("state.deprecated") }}
</div>
<div v-if="fieldArgs.length > 0">
<h5 class="my-2">Arguments:</h5>
<div class="pl-4 border-l-2 border-divider">
<div class="border-l-2 border-divider pl-4">
<div v-for="(field, index) in fieldArgs" :key="`field-${index}`">
<span>
{{ field.name }}:
<GraphqlTypeLink
:gql-type="field.type"
:jump-type-callback="jumpTypeCallback"
@jump-to-type="jumpToType"
/>
</span>
<div
v-if="field.description"
class="py-2 text-secondaryLight field-desc"
class="field-desc py-2 text-secondaryLight"
>
{{ field.description }}
</div>
@@ -54,37 +59,41 @@
</div>
</template>
<script>
// TypeScript + Script Setup this :)
import { defineComponent } from "vue"
<script setup lang="ts">
import { useI18n } from "@composables/i18n"
import { GraphQLType } from "graphql"
import { computed } from "vue"
import IconAlertTriangle from "~icons/lucide/alert-triangle"
export default defineComponent({
props: {
gqlField: { type: Object, default: () => ({}) },
jumpTypeCallback: { type: Function, default: () => ({}) },
isHighlighted: { type: Boolean, default: false },
},
setup() {
return {
t: useI18n(),
}
},
computed: {
fieldName() {
return this.gqlField.name
},
const t = useI18n()
fieldArgs() {
return this.gqlField.args || []
},
},
})
const props = withDefaults(
defineProps<{
gqlField: any
isHighlighted: boolean
}>(),
{
gqlField: {},
isHighlighted: false,
}
)
const emit = defineEmits<{
(e: "jump-to-type", type: GraphQLType): void
}>()
const fieldName = computed(() => props.gqlField.name)
const fieldArgs = computed(() => props.gqlField.args || [])
const jumpToType = (type: GraphQLType) => {
emit("jump-to-type", type)
}
</script>
<style lang="scss" scoped>
.field-highlighted {
@apply border-accent border-b-2;
@apply border-b-2 border-accent;
}
.field-title {

View File

@@ -59,6 +59,7 @@ import { useToast } from "@composables/toast"
import { defineActionHandler } from "~/helpers/actions"
import { getPlatformSpecialKey as getSpecialKey } from "~/helpers/platformutils"
import { GQLResponseEvent } from "~/helpers/graphql/connection"
import { platform } from "~/platform"
const t = useI18n()
const toast = useToast()
@@ -111,21 +112,31 @@ const copyResponse = (str: string) => {
toast.success(`${t("state.copied_to_clipboard")}`)
}
const downloadResponse = (str: string) => {
const downloadResponse = async (str: string) => {
const dataToWrite = str
const file = new Blob([dataToWrite!], { type: "application/json" })
const a = document.createElement("a")
const url = URL.createObjectURL(file)
a.href = url
a.download = `${url.split("/").pop()!.split("#")[0].split("?")[0]}`
document.body.appendChild(a)
a.click()
downloadResponseIcon.value = IconCheck
toast.success(`${t("state.download_started")}`)
setTimeout(() => {
document.body.removeChild(a)
URL.revokeObjectURL(url)
}, 1000)
const filename = `${url.split("/").pop()!.split("#")[0].split("?")[0]}.json`
URL.revokeObjectURL(url)
const result = await platform.io.saveFileWithDialog({
data: dataToWrite,
contentType: "application/json",
suggestedFilename: filename,
filters: [
{
name: "JSON file",
extensions: ["json"],
},
],
})
if (result.type === "unknown" || result.type === "saved") {
downloadResponseIcon.value = IconCheck
toast.success(`${t("state.download_started")}`)
}
}
defineActionHandler(

View File

@@ -30,8 +30,8 @@
v-model="graphqlFieldsFilterText"
type="search"
autocomplete="off"
class="flex w-full p-4 py-2 bg-transparent h-8"
:placeholder="`${t('action.search')}`"
class="flex flex-1 p-4 py-2 bg-transparent"
/>
<div class="flex">
<HoppButtonSecondary
@@ -58,8 +58,8 @@
v-for="(field, index) in filteredQueryFields"
:key="`field-${index}`"
:gql-field="field"
:jump-type-callback="handleJumpToType"
class="p-4"
@jump-to-type="handleJumpToType"
/>
</HoppSmartTab>
<HoppSmartTab
@@ -72,8 +72,8 @@
v-for="(field, index) in filteredMutationFields"
:key="`field-${index}`"
:gql-field="field"
:jump-type-callback="handleJumpToType"
class="p-4"
@jump-to-type="handleJumpToType"
/>
</HoppSmartTab>
<HoppSmartTab
@@ -86,8 +86,8 @@
v-for="(field, index) in filteredSubscriptionFields"
:key="`field-${index}`"
:gql-field="field"
:jump-type-callback="handleJumpToType"
class="p-4"
@jump-to-type="handleJumpToType"
/>
</HoppSmartTab>
<HoppSmartTab
@@ -103,7 +103,7 @@
:gql-types="graphqlTypes"
:is-highlighted="isGqlTypeHighlighted(type)"
:highlighted-fields="getGqlTypeHighlightedFields(type)"
:jump-type-callback="handleJumpToType"
@jump-to-type="handleJumpToType"
/>
</HoppSmartTab>
</HoppSmartTabs>
@@ -202,6 +202,7 @@ import {
schemaString,
subscriptionFields,
} from "~/helpers/graphql/connection"
import { platform } from "~/platform"
type NavigationTabs = "history" | "collection" | "docs" | "schema"
type GqlTabs = "queries" | "mutations" | "subscriptions" | "types"
@@ -372,21 +373,33 @@ useCodemirror(
})
)
const downloadSchema = () => {
const dataToWrite = JSON.stringify(schemaString.value, null, 2)
const downloadSchema = async () => {
const dataToWrite = schemaString.value
const file = new Blob([dataToWrite], { type: "application/graphql" })
const a = document.createElement("a")
const url = URL.createObjectURL(file)
a.href = url
a.download = `${url.split("/").pop()!.split("#")[0].split("?")[0]}.graphql`
document.body.appendChild(a)
a.click()
downloadSchemaIcon.value = IconCheck
toast.success(`${t("state.download_started")}`)
setTimeout(() => {
document.body.removeChild(a)
URL.revokeObjectURL(url)
}, 1000)
const filename = `${
url.split("/").pop()!.split("#")[0].split("?")[0]
}.graphql`
URL.revokeObjectURL(url)
const result = await platform.io.saveFileWithDialog({
data: dataToWrite,
contentType: "application/graphql",
suggestedFilename: filename,
filters: [
{
name: "GraphQL Schema File",
extensions: ["graphql"],
},
],
})
if (result.type === "unknown" || result.type === "saved") {
downloadSchemaIcon.value = IconCheck
toast.success(`${t("state.download_started")}`)
}
}
const copySchema = () => {

View File

@@ -7,38 +7,31 @@
</span>
</template>
<script lang="ts">
import { defineComponent } from "vue"
import { GraphQLScalarType } from "graphql"
<script setup lang="ts">
import { GraphQLScalarType, GraphQLType } from "graphql"
import { computed } from "vue"
export default defineComponent({
props: {
// eslint-disable-next-line vue/require-default-prop
gqlType: null,
// (typeName: string) => void
// eslint-disable-next-line vue/require-default-prop
jumpTypeCallback: Function,
},
const props = defineProps<{
gqlType: GraphQLType
}>()
computed: {
typeString() {
return `${this.gqlType}`
},
isScalar() {
return this.resolveRootType(this.gqlType) instanceof GraphQLScalarType
},
},
const emit = defineEmits<{
(e: "jump-to-type", type: GraphQLType): void
}>()
methods: {
jumpToType() {
if (this.isScalar) return
this.jumpTypeCallback(this.gqlType)
},
resolveRootType(type) {
let t = type
while (t.ofType != null) t = t.ofType
return t
},
},
const typeString = computed(() => `${props.gqlType}`)
const isScalar = computed(() => {
return resolveRootType(props.gqlType) instanceof GraphQLScalarType
})
function resolveRootType(type: GraphQLType) {
let t = type as any
while (t.ofType != null) t = t.ofType
return t
}
function jumpToType() {
if (isScalar.value) return
emit("jump-to-type", props.gqlType)
}
</script>

View File

@@ -9,7 +9,7 @@
v-model="filterText"
type="search"
autocomplete="off"
class="flex flex-1 p-4 py-2 bg-transparent"
class="flex w-full p-4 py-2 bg-transparent h-8"
:placeholder="`${t('action.search')}`"
/>
<div class="flex">

View File

@@ -256,7 +256,7 @@ import * as E from "fp-ts/Either"
import * as O from "fp-ts/Option"
import * as A from "fp-ts/Array"
import draggable from "vuedraggable-es"
import { RequestOptionTabs } from "./RequestOptions.vue"
import { RESTOptionTabs } from "./RequestOptions.vue"
import { useCodemirror } from "@composables/codemirror"
import { commonHeaders } from "~/helpers/headers"
import { useI18n } from "@composables/i18n"
@@ -295,7 +295,7 @@ const deletionToast = ref<{ goAway: (delay: number) => void } | null>(null)
const props = defineProps<{ modelValue: HoppRESTRequest }>()
const emit = defineEmits<{
(e: "change-tab", value: RequestOptionTabs): void
(e: "change-tab", value: RESTOptionTabs): void
(e: "update:modelValue", value: HoppRESTRequest): void
}>()

View File

@@ -43,6 +43,7 @@ import { useI18n } from "@composables/i18n"
import { useToast } from "@composables/toast"
import { tokenRequest } from "~/helpers/oauth"
import { getCombinedEnvVariables } from "~/helpers/preRequest"
import * as E from "fp-ts/Either"
const t = useI18n()
const toast = useToast()
@@ -77,6 +78,15 @@ const clientSecret = pluckRef(auth, "clientSecret" as any)
const scope = pluckRef(auth, "scope")
function translateTokenRequestError(error: string) {
switch (error) {
case "OIDC_DISCOVERY_FAILED":
return t("authorization.oauth.token_generation_oidc_discovery_failed")
default:
return t("authorization.oauth.something_went_wrong_on_token_generation")
}
}
const handleAccessTokenRequest = async () => {
if (
oidcDiscoveryURL.value === "" &&
@@ -98,7 +108,11 @@ const handleAccessTokenRequest = async () => {
clientSecret: parseTemplateString(clientSecret.value, envVars),
scope: parseTemplateString(scope.value, envVars),
}
await tokenRequest(tokenReqParams)
const res = await tokenRequest(tokenReqParams)
if (res && E.isLeft(res)) {
toast.error(translateTokenRequestError(res.left))
}
} catch (e) {
toast.error(`${e}`)
}

View File

@@ -350,7 +350,6 @@ const newSendRequest = async () => {
const streamResult = await streamPromise
requestCancelFunc.value = cancel
if (E.isRight(streamResult)) {
subscribeToStream(
streamResult.right,
@@ -365,6 +364,20 @@ const newSendRequest = async () => {
loading.value = false
},
() => {
// TODO: Change this any to a proper type
const result = (streamResult.right as any).value
if (
result.type === "network_fail" &&
result.error?.error === "NO_PW_EXT_HOOK"
) {
const errorResponse: HoppRESTResponse = {
type: "extension_error",
error: result.error.humanMessage.heading,
component: result.error.component,
req: result.req,
}
updateRESTResponse(errorResponse)
}
loading.value = false
}
)

View File

@@ -11,6 +11,12 @@
<HoppSmartSpinner class="my-4" />
<span class="text-secondaryLight">{{ t("state.loading") }}</span>
</div>
<component
:is="response.component"
v-if="response.type === 'extension_error'"
class="flex-1"
/>
<HoppSmartPlaceholder
v-if="response.type === 'network_fail'"
:src="`/images/states/${colorMode.value}/youre_lost.svg`"

View File

@@ -0,0 +1,98 @@
<template>
<HoppSmartPlaceholder
:src="`/images/states/${colorMode.value}/youre_lost.svg`"
:alt="`${t('error.network_fail')}`"
:heading="t('error.network_fail')"
large
>
<div class="my-1 text-secondaryLight flex flex-col items-center">
<span>
{{ t("error.please_install_extension") }}
</span>
<span>
{{ t("error.check_how_to_add_origin") }}
<HoppSmartLink
blank
to="https://docs.hoppscotch.io/documentation/features/interceptor#browser-extension"
class="text-accent hover:text-accentDark"
>
here
</HoppSmartLink>
</span>
</div>
<div class="flex flex-col py-4 space-y-2">
<span>
<HoppSmartItem
to="https://chrome.google.com/webstore/detail/hoppscotch-browser-extens/amknoiejhlmhancpahfcfcfhllgkpbld"
blank
:icon="IconChrome"
label="Chrome"
:info-icon="hasChromeExtInstalled ? IconCheckCircle : null"
:active-info-icon="hasChromeExtInstalled"
outline
/>
</span>
<span>
<HoppSmartItem
to="https://addons.mozilla.org/en-US/firefox/addon/hoppscotch"
blank
:icon="IconFirefox"
label="Firefox"
:info-icon="hasFirefoxExtInstalled ? IconCheckCircle : null"
:active-info-icon="hasFirefoxExtInstalled"
outline
/>
</span>
</div>
<div class="py-4 space-y-4">
<div class="flex items-center">
<HoppSmartToggle
:on="extensionEnabled"
@change="extensionEnabled = !extensionEnabled"
>
{{ t("settings.extensions_use_toggle") }}
</HoppSmartToggle>
</div>
</div>
</HoppSmartPlaceholder>
</template>
<script setup lang="ts">
import IconChrome from "~icons/brands/chrome"
import IconFirefox from "~icons/brands/firefox"
import IconCheckCircle from "~icons/lucide/check-circle"
import { useI18n } from "@composables/i18n"
import { ExtensionInterceptorService } from "~/platform/std/interceptors/extension"
import { useService } from "dioc/vue"
import { computed } from "vue"
import { InterceptorService } from "~/services/interceptor.service"
import { platform } from "~/platform"
import { useColorMode } from "~/composables/theming"
const colorMode = useColorMode()
const t = useI18n()
const interceptorService = useService(InterceptorService)
const extensionService = useService(ExtensionInterceptorService)
const hasChromeExtInstalled = extensionService.chromeExtensionInstalled
const hasFirefoxExtInstalled = extensionService.firefoxExtensionInstalled
const extensionEnabled = computed({
get() {
return (
interceptorService.currentInterceptorID.value ===
extensionService.interceptorID
)
},
set(active) {
if (active) {
interceptorService.currentInterceptorID.value =
extensionService.interceptorID
} else {
interceptorService.currentInterceptorID.value =
platform.interceptors.default
}
},
})
</script>

View File

@@ -35,12 +35,12 @@
v-if="
!teamDetails.loading &&
E.isRight(teamDetails.data) &&
teamDetails.data.right.team.teamMembers
teamDetails.data.right.team?.teamMembers
"
class="border rounded border-divider"
>
<HoppSmartPlaceholder
v-if="teamDetails.data.right.team.teamMembers === 0"
v-if="teamDetails.data.right.team.teamMembers.length === 0"
:src="`/images/states/${colorMode.value}/add_group.svg`"
:alt="`${t('empty.members')}`"
:text="t('empty.members')"

View File

@@ -88,7 +88,7 @@
>
<div
v-for="(invitee, index) in pendingInvites.data.right.team
.teamInvitations"
?.teamInvitations"
:key="`invitee-${index}`"
class="flex divide-x divide-dividerLight"
>
@@ -122,7 +122,7 @@
<HoppSmartPlaceholder
v-if="
E.isRight(pendingInvites.data) &&
pendingInvites.data.right.team.teamInvitations.length === 0
pendingInvites.data.right.team?.teamInvitations.length === 0
"
:text="t('empty.pending_invites')"
>

View File

@@ -10,6 +10,7 @@ import { useI18n } from "./i18n"
import { refAutoReset } from "@vueuse/core"
import { copyToClipboard } from "@helpers/utils/clipboard"
import { HoppRESTResponse } from "@helpers/types/HoppRESTResponse"
import { platform } from "~/platform"
export function useCopyResponse(responseBodyText: Ref<any>) {
const toast = useToast()
@@ -40,15 +41,14 @@ export function useDownloadResponse(
const toast = useToast()
const t = useI18n()
const downloadResponse = () => {
const downloadResponse = async () => {
const dataToWrite = responseBody.value
const file = new Blob([dataToWrite], { type: contentType })
const a = document.createElement("a")
const url = URL.createObjectURL(file)
a.href = url
// TODO: get uri from meta
a.download = pipe(
// Guess extension and filename
const file = new Blob([dataToWrite], { type: contentType })
const url = URL.createObjectURL(file)
const filename = pipe(
url,
S.split("/"),
RNEA.last,
@@ -58,15 +58,24 @@ export function useDownloadResponse(
RNEA.head
)
document.body.appendChild(a)
a.click()
downloadIcon.value = IconCheck
toast.success(`${t("state.download_started")}`)
setTimeout(() => {
document.body.removeChild(a)
URL.revokeObjectURL(url)
}, 1000)
URL.revokeObjectURL(url)
console.log(filename)
// TODO: Look at the mime type and determine extension ?
const result = await platform.io.saveFileWithDialog({
data: dataToWrite,
contentType: contentType,
suggestedFilename: filename,
})
// Assume success if unknown as we cannot determine
if (result.type === "unknown" || result.type === "saved") {
downloadIcon.value = IconCheck
toast.success(`${t("state.download_started")}`)
}
}
return {
downloadIcon,
downloadResponse,

View File

@@ -152,12 +152,14 @@ export function useStreamSubscriber(): {
error?: (e: any) => void,
complete?: () => void
) => {
const sub = stream.subscribe({
let sub: Subscription | null = null
sub = stream.subscribe({
next,
error,
complete: () => {
if (complete) complete()
subs.splice(subs.indexOf(sub), 1)
if (sub) subs.splice(subs.indexOf(sub), 1)
},
})

View File

@@ -6,7 +6,7 @@ import { Ref, onBeforeUnmount, onMounted, reactive, watch } from "vue"
import { BehaviorSubject } from "rxjs"
import { HoppRESTDocument } from "./rest/document"
import { HoppGQLRequest, HoppRESTRequest } from "@hoppscotch/data"
import { RequestOptionTabs } from "~/components/http/RequestOptions.vue"
import { RESTOptionTabs } from "~/components/http/RequestOptions.vue"
import { HoppGQLSaveContext } from "./graphql/document"
import { GQLOptionTabs } from "~/components/graphql/RequestOptions.vue"
import { computed } from "vue"
@@ -113,7 +113,7 @@ type HoppActionArgsMap = {
request: HoppGQLRequest
}
"request.open-tab": {
tab: RequestOptionTabs | GQLOptionTabs
tab: RESTOptionTabs | GQLOptionTabs
}
"tab.duplicate-tab": {

View File

@@ -1,6 +1,7 @@
import { Environment } from "@hoppscotch/data"
import { TeamEnvironment } from "~/helpers/teams/TeamEnvironment"
import { cloneDeep } from "lodash-es"
import { platform } from "~/platform"
const getEnvironmentJson = (
environmentObj: TeamEnvironment | Environment,
@@ -32,17 +33,24 @@ export const exportAsJSON = (
if (!dataToWrite) return false
const file = new Blob([dataToWrite], { type: "application/json" })
const a = document.createElement("a")
const url = URL.createObjectURL(file)
a.href = url
// Extracts the path from url, removes fragment identifier and query parameters if any, appends the ".json" extension, and assigns it
a.download = `${url.split("/").pop()!.split("#")[0].split("?")[0]}.json`
document.body.appendChild(a)
a.click()
setTimeout(() => {
document.body.removeChild(a)
window.URL.revokeObjectURL(url)
}, 0)
URL.revokeObjectURL(url)
platform.io.saveFileWithDialog({
data: dataToWrite,
contentType: "application/json",
// Extracts the path from url, removes fragment identifier and query parameters if any, appends the ".json" extension, and assigns it
suggestedFilename: `${
url.split("/").pop()!.split("#")[0].split("?")[0]
}.json`,
filters: [
{
name: "JSON file",
extensions: ["json"],
},
],
})
return true
}

View File

@@ -4,7 +4,10 @@ import {
removeLocalConfig,
} from "~/newstore/localpersistence"
const redirectUri = `${window.location.origin}/`
import * as E from "fp-ts/Either"
import { z } from "zod"
const redirectUri = `${window.location.origin}/oauth`
// GENERAL HELPER FUNCTIONS
@@ -16,7 +19,7 @@ const redirectUri = `${window.location.origin}/`
* @returns {Object}
*/
const sendPostRequest = async (url, params) => {
const sendPostRequest = async (url: string, params: Record<string, string>) => {
const body = Object.keys(params)
.map((key) => `${key}=${params[key]}`)
.join("&")
@@ -30,9 +33,9 @@ const sendPostRequest = async (url, params) => {
try {
const response = await fetch(url, options)
const data = await response.json()
return data
return E.right(data)
} catch (e) {
console.error(e)
return E.left("AUTH_TOKEN_REQUEST_FAILED")
}
}
@@ -43,7 +46,7 @@ const sendPostRequest = async (url, params) => {
* @returns {Object}
*/
const parseQueryString = (searchQuery) => {
const parseQueryString = (searchQuery: string): Record<string, string> => {
if (searchQuery === "") {
return {}
}
@@ -61,7 +64,7 @@ const parseQueryString = (searchQuery) => {
* @returns {Object}
*/
const getTokenConfiguration = async (endpoint) => {
const getTokenConfiguration = async (endpoint: string) => {
const options = {
method: "GET",
headers: {
@@ -71,9 +74,9 @@ const getTokenConfiguration = async (endpoint) => {
try {
const response = await fetch(endpoint, options)
const config = await response.json()
return config
return E.right(config)
} catch (e) {
console.error(e)
return E.left("OIDC_DISCOVERY_FAILED")
}
}
@@ -97,7 +100,7 @@ const generateRandomString = () => {
* @returns {Promise<ArrayBuffer>}
*/
const sha256 = (plain) => {
const sha256 = (plain: string) => {
const encoder = new TextEncoder()
const data = encoder.encode(plain)
return window.crypto.subtle.digest("SHA-256", data)
@@ -111,15 +114,18 @@ const sha256 = (plain) => {
*/
const base64urlencode = (
str // Converts the ArrayBuffer to string using Uint8 array to convert to what btoa accepts.
) =>
str: ArrayBuffer // Converts the ArrayBuffer to string using Uint8 array to convert to what btoa accepts.
) => {
const hashArray = Array.from(new Uint8Array(str))
// btoa accepts chars only within ascii 0-255 and base64 encodes them.
// Then convert the base64 encoded to base64url encoded
// (replace + with -, replace / with _, trim trailing =)
btoa(String.fromCharCode.apply(null, new Uint8Array(str)))
return btoa(String.fromCharCode.apply(null, hashArray))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "")
}
/**
* Return the base64-urlencoded sha256 hash for the PKCE challenge
@@ -128,13 +134,23 @@ const base64urlencode = (
* @returns {String}
*/
const pkceChallengeFromVerifier = async (v) => {
const pkceChallengeFromVerifier = async (v: string) => {
const hashed = await sha256(v)
return base64urlencode(hashed)
}
// OAUTH REQUEST
type TokenRequestParams = {
oidcDiscoveryUrl: string
grantType: string
authUrl: string
accessTokenUrl: string
clientId: string
clientSecret: string
scope: string
}
/**
* Initiates PKCE Auth Code flow when requested
*
@@ -150,16 +166,28 @@ const tokenRequest = async ({
clientId,
clientSecret,
scope,
}) => {
}: TokenRequestParams) => {
// Check oauth configuration
if (oidcDiscoveryUrl !== "") {
// eslint-disable-next-line camelcase
const { authorization_endpoint, token_endpoint } =
await getTokenConfiguration(oidcDiscoveryUrl)
// eslint-disable-next-line camelcase
authUrl = authorization_endpoint
// eslint-disable-next-line camelcase
accessTokenUrl = token_endpoint
const res = await getTokenConfiguration(oidcDiscoveryUrl)
const OIDCConfigurationSchema = z.object({
authorization_endpoint: z.string(),
token_endpoint: z.string(),
})
if (E.isLeft(res)) {
return E.left("OIDC_DISCOVERY_FAILED" as const)
}
const parsedOIDCConfiguration = OIDCConfigurationSchema.safeParse(res.right)
if (!parsedOIDCConfiguration.success) {
return E.left("OIDC_DISCOVERY_FAILED" as const)
}
authUrl = parsedOIDCConfiguration.data.authorization_endpoint
accessTokenUrl = parsedOIDCConfiguration.data.token_endpoint
}
// Store oauth information
setLocalConfig("tokenEndpoint", accessTokenUrl)
@@ -190,7 +218,7 @@ const tokenRequest = async ({
)}&code_challenge_method=S256`
// Redirect to the authorization server
window.location = buildUrl()
window.location.assign(buildUrl())
}
// OAUTH REDIRECT HANDLING
@@ -202,44 +230,84 @@ const tokenRequest = async ({
* @returns {Promise<any | void>}
*/
const oauthRedirect = () => {
let tokenResponse = ""
const q = parseQueryString(window.location.search.substring(1))
const handleOAuthRedirect = async () => {
const queryParams = parseQueryString(window.location.search.substring(1))
// Check if the server returned an error string
if (q.error) {
alert(`Error returned from authorization server: ${q.error}`)
if (queryParams.error) {
return E.left("AUTH_SERVER_RETURNED_ERROR" as const)
}
if (!queryParams.code) {
return E.left("NO_AUTH_CODE" as const)
}
// If the server returned an authorization code, attempt to exchange it for an access token
if (q.code) {
// Verify state matches what we set at the beginning
if (getLocalConfig("pkce_state") !== q.state) {
alert("Invalid state")
Promise.reject(tokenResponse)
} else {
try {
// Exchange the authorization code for an access token
tokenResponse = sendPostRequest(getLocalConfig("tokenEndpoint"), {
grant_type: "authorization_code",
code: q.code,
client_id: getLocalConfig("client_id"),
client_secret: getLocalConfig("client_secret"),
redirect_uri: redirectUri,
code_verifier: getLocalConfig("pkce_codeVerifier"),
})
} catch (e) {
console.error(e)
return Promise.reject(tokenResponse)
}
}
// Clean these up since we don't need them anymore
removeLocalConfig("pkce_state")
removeLocalConfig("pkce_codeVerifier")
removeLocalConfig("tokenEndpoint")
removeLocalConfig("client_id")
removeLocalConfig("client_secret")
return tokenResponse
// Verify state matches what we set at the beginning
if (getLocalConfig("pkce_state") !== queryParams.state) {
return E.left("INVALID_STATE" as const)
}
return Promise.reject(tokenResponse)
const tokenEndpoint = getLocalConfig("tokenEndpoint")
const clientID = getLocalConfig("client_id")
const clientSecret = getLocalConfig("client_secret")
const codeVerifier = getLocalConfig("pkce_codeVerifier")
if (!tokenEndpoint) {
return E.left("NO_TOKEN_ENDPOINT" as const)
}
if (!clientID) {
return E.left("NO_CLIENT_ID" as const)
}
if (!clientSecret) {
return E.left("NO_CLIENT_SECRET" as const)
}
if (!codeVerifier) {
return E.left("NO_CODE_VERIFIER" as const)
}
// Exchange the authorization code for an access token
const tokenResponse: E.Either<string, any> = await sendPostRequest(
tokenEndpoint,
{
grant_type: "authorization_code",
code: queryParams.code,
client_id: clientID,
client_secret: clientSecret,
redirect_uri: redirectUri,
code_verifier: codeVerifier,
}
)
// Clean these up since we don't need them anymore
clearPKCEState()
if (E.isLeft(tokenResponse)) {
return E.left("AUTH_TOKEN_REQUEST_FAILED" as const)
}
const withAccessTokenSchema = z.object({
access_token: z.string(),
})
const parsedTokenResponse = withAccessTokenSchema.safeParse(
tokenResponse.right
)
return parsedTokenResponse.success
? E.right(parsedTokenResponse.data)
: E.left("AUTH_TOKEN_REQUEST_INVALID_RESPONSE" as const)
}
export { tokenRequest, oauthRedirect }
const clearPKCEState = () => {
removeLocalConfig("pkce_state")
removeLocalConfig("pkce_codeVerifier")
removeLocalConfig("tokenEndpoint")
removeLocalConfig("client_id")
removeLocalConfig("client_secret")
}
export { tokenRequest, handleOAuthRedirect }

View File

@@ -1,4 +1,5 @@
import { HoppRESTRequest } from "@hoppscotch/data"
import { Component } from "vue"
export type HoppRESTResponseHeader = { key: string; value: string }
@@ -39,3 +40,9 @@ export type HoppRESTResponse =
req: HoppRESTRequest
}
| {
type: "extension_error"
error: string
component: Component
req: HoppRESTRequest
}

View File

@@ -73,6 +73,7 @@ import { applySetting } from "~/newstore/settings"
import { getLocalConfig, setLocalConfig } from "~/newstore/localpersistence"
import { useToast } from "~/composables/toast"
import { useI18n } from "~/composables/i18n"
import { platform } from "~/platform"
const router = useRouter()
@@ -98,7 +99,10 @@ onBeforeMount(() => {
onMounted(() => {
const cookiesAllowed = getLocalConfig("cookiesAllowed") === "yes"
if (!cookiesAllowed) {
const platformAllowsCookiePrompts =
platform.platformFeatureFlags.promptAsUsingCookies ?? true
if (!cookiesAllowed && platformAllowsCookiePrompts) {
toast.show(`${t("app.we_use_cookies")}`, {
duration: 0,
action: [

View File

@@ -58,7 +58,13 @@ export const FALLBACK_LANG = pipe(
)
// A reference to the i18n instance
let i18nInstance: I18n<any, any, any> | null = null
let i18nInstance: I18n<
Record<string, unknown>,
Record<string, unknown>,
Record<string, unknown>,
string,
true
> | null = null
const resolveCurrentLocale = () =>
pipe(
@@ -119,7 +125,6 @@ export const changeAppLanguage = async (locale: string) => {
* Returns the i18n instance
*/
export function getI18n() {
// @ts-expect-error Something weird with the i18n errors
return i18nInstance!.global.t
}

View File

@@ -1,200 +0,0 @@
import { HoppModule } from "."
import * as Sentry from "@sentry/vue"
import { BrowserTracing } from "@sentry/tracing"
import { Route } from "@sentry/vue/types/router"
import { RouteLocationNormalized, Router } from "vue-router"
import { settingsStore } from "~/newstore/settings"
import { App } from "vue"
import { APP_IS_IN_DEV_MODE } from "~/helpers/dev"
import { gqlClientError$ } from "~/helpers/backend/GQLClient"
import { platform } from "~/platform"
/**
* The tag names we allow giving to Sentry
*/
type SentryTag = "BACKEND_OPERATIONS"
interface SentryVueRouter {
onError: (fn: (err: Error) => void) => void
beforeEach: (fn: (to: Route, from: Route, next: () => void) => void) => void
}
function normalizedRouteToSentryRoute(route: RouteLocationNormalized): Route {
return {
matched: route.matched,
// route.params' type translates just to a fancy version of this, hence assertion
params: route.params as Route["params"],
path: route.path,
// route.query's type translates just to a fancy version of this, hence assertion
query: route.query as Route["query"],
name: route.name,
}
}
function getInstrumentationVueRouter(router: Router): SentryVueRouter {
return <SentryVueRouter>{
onError: router.onError,
beforeEach(func) {
router.beforeEach((to, from, next) => {
func(
normalizedRouteToSentryRoute(to),
normalizedRouteToSentryRoute(from),
next
)
})
},
}
}
let sentryActive = false
function initSentry(dsn: string, router: Router, app: App) {
Sentry.init({
app,
dsn,
release: import.meta.env.VITE_SENTRY_RELEASE_TAG ?? undefined,
environment: APP_IS_IN_DEV_MODE
? "dev"
: import.meta.env.VITE_SENTRY_ENVIRONMENT,
integrations: [
new BrowserTracing({
routingInstrumentation: Sentry.vueRouterInstrumentation(
getInstrumentationVueRouter(router)
),
// TODO: We may want to limit this later on
tracingOrigins: [new URL(import.meta.env.VITE_BACKEND_GQL_URL).origin],
}),
],
tracesSampleRate: 0.8,
})
sentryActive = true
}
function deinitSentry() {
Sentry.close()
sentryActive = false
}
/**
* Reports a set of related errors to Sentry
* @param errs The errors to report
* @param tag The tag for the errord
* @param extraTags Additional tag data to add
* @param extras Extra information to attach
*/
function reportErrors(
errs: Error[],
tag: SentryTag,
extraTags: Record<string, string | number | boolean> | null = null,
extras: any = undefined
) {
if (sentryActive) {
Sentry.withScope((scope) => {
scope.setTag("tag", tag)
if (extraTags) {
Object.entries(extraTags).forEach(([key, value]) => {
scope.setTag(key, value)
})
}
if (extras !== null && extras === undefined) scope.setExtras(extras)
scope.addAttachment({
filename: "extras-dump.json",
data: JSON.stringify(extras),
contentType: "application/json",
})
errs.forEach((err) => Sentry.captureException(err))
})
}
}
/**
* Reports a specific error to Sentry
* @param err The error to report
* @param tag The tag for the error
* @param extraTags Additional tag data to add
* @param extras Extra information to attach
*/
function reportError(
err: Error,
tag: SentryTag,
extraTags: Record<string, string | number | boolean> | null = null,
extras: any = undefined
) {
reportErrors([err], tag, extraTags, extras)
}
/**
* Subscribes to events occuring in various subsystems in the app
* for personalized error reporting
*/
function subscribeToAppEventsForReporting() {
gqlClientError$.subscribe((ev) => {
switch (ev.type) {
case "SUBSCRIPTION_CONN_CALLBACK_ERR_REPORT":
reportErrors(ev.errors, "BACKEND_OPERATIONS", { from: ev.type })
break
case "CLIENT_REPORTED_ERROR":
reportError(
ev.error,
"BACKEND_OPERATIONS",
{ from: ev.type },
{ op: ev.op }
)
break
case "GQL_CLIENT_REPORTED_ERROR":
reportError(
new Error("Backend Query Failed"),
"BACKEND_OPERATIONS",
{ opType: ev.opType },
{
opResult: ev.opResult,
}
)
break
}
})
}
/**
* Subscribe to app system events for adding
* additional data tags for the error reporting
*/
function subscribeForAppDataTags() {
const currentUser$ = platform.auth.getCurrentUserStream()
currentUser$.subscribe((user) => {
if (sentryActive) {
Sentry.setTag("user_logged_in", !!user)
}
})
}
export default <HoppModule>{
onRouterInit(app, router) {
if (!import.meta.env.VITE_SENTRY_DSN) {
console.log(
"Sentry tracing is not enabled because 'VITE_SENTRY_DSN' env is not defined"
)
return
}
if (settingsStore.value.TELEMETRY_ENABLED) {
initSentry(import.meta.env.VITE_SENTRY_DSN, router, app)
}
settingsStore.subject$.subscribe(({ TELEMETRY_ENABLED }) => {
if (!TELEMETRY_ENABLED && sentryActive) {
deinitSentry()
} else if (TELEMETRY_ENABLED && !sentryActive) {
initSentry(import.meta.env.VITE_SENTRY_DSN!, router, app)
}
})
subscribeToAppEventsForReporting()
subscribeForAppDataTags()
},
}

View File

@@ -47,6 +47,9 @@ import { StorageLike, watchDebounced } from "@vueuse/core"
import { getService } from "~/modules/dioc"
import { RESTTabService } from "~/services/tab/rest"
import { GQLTabService } from "~/services/tab/graphql"
import { z } from "zod"
import { CookieJarService } from "~/services/cookie-jar.service"
import { watch } from "vue"
function checkAndMigrateOldSettings() {
if (window.localStorage.getItem("selectedEnvIndex")) {
@@ -182,6 +185,35 @@ function setupHistoryPersistence() {
})
}
const cookieSchema = z.record(z.array(z.string()))
function setupCookiesPersistence() {
const cookieJarService = getService(CookieJarService)
try {
const cookieData = JSON.parse(
window.localStorage.getItem("cookieJar") || "{}"
)
const parseResult = cookieSchema.safeParse(cookieData)
if (parseResult.success) {
for (const domain in parseResult.data) {
cookieJarService.bulkApplyCookiesToDomain(
parseResult.data[domain],
domain
)
}
}
} catch (e) {}
watch(cookieJarService.cookieJar, (cookieJar) => {
const data = JSON.stringify(Object.fromEntries(cookieJar.entries()))
window.localStorage.setItem("cookieJar", data)
})
}
function setupCollectionsPersistence() {
const restCollectionData = JSON.parse(
window.localStorage.getItem("collections") || "[]"
@@ -382,6 +414,8 @@ export function setupLocalPersistence() {
setupSocketIOPersistence()
setupSSEPersistence()
setupMQTTPersistence()
setupCookiesPersistence()
}
/**

View File

@@ -94,7 +94,7 @@
</template>
<script lang="ts" setup>
import { ref, onMounted, onBeforeUnmount, onBeforeMount } from "vue"
import { ref, onMounted, onBeforeUnmount } from "vue"
import { safelyExtractRESTRequest } from "@hoppscotch/data"
import { translateExtURLParams } from "~/helpers/RESTExtURLParams"
import { useRoute } from "vue-router"
@@ -114,7 +114,6 @@ import {
} from "rxjs"
import { useToast } from "~/composables/toast"
import { watchDebounced } from "@vueuse/core"
import { oauthRedirect } from "~/helpers/oauth"
import { useReadonlyStream } from "~/composables/stream"
import {
changeCurrentSyncStatus,
@@ -414,28 +413,6 @@ function setupTabStateSync() {
})
}
function oAuthURL() {
onBeforeMount(async () => {
try {
const tokenInfo = await oauthRedirect()
if (
typeof tokenInfo === "object" &&
tokenInfo.hasOwnProperty("access_token")
) {
if (
tabs.currentActiveTab.value.document.request.auth.authType ===
"oauth-2"
) {
tabs.currentActiveTab.value.document.request.auth.token =
tokenInfo.access_token
}
}
// eslint-disable-next-line no-empty
} catch (_) {}
})
}
defineActionHandler("contextmenu.open", ({ position, text }) => {
if (text) {
contextMenu.value = {
@@ -454,7 +431,6 @@ defineActionHandler("contextmenu.open", ({ position, text }) => {
setupTabStateSync()
bindRequestToURLParams()
oAuthURL()
defineActionHandler("rest.request.open", ({ doc }) => {
tabs.createNewTab(doc)

View File

@@ -0,0 +1,81 @@
<template>
<div class="flex justify-center items-center">
<HoppSmartSpinner />
</div>
</template>
<script setup lang="ts">
import { handleOAuthRedirect } from "~/helpers/oauth"
import { useToast } from "~/composables/toast"
import { useI18n } from "~/composables/i18n"
import * as E from "fp-ts/Either"
import { useService } from "dioc/vue"
import { RESTTabService } from "~/services/tab/rest"
import { onMounted } from "vue"
import { useRouter } from "vue-router"
const t = useI18n()
const router = useRouter()
const toast = useToast()
const tabs = useService(RESTTabService)
function translateOAuthRedirectError(error: string) {
switch (error) {
case "AUTH_SERVER_RETURNED_ERROR":
return t("authorization.oauth.redirect_auth_server_returned_error")
case "NO_AUTH_CODE":
return t("authorization.oauth.redirect_no_auth_code")
case "INVALID_STATE":
return t("authorization.oauth.redirect_invalid_state")
case "NO_TOKEN_ENDPOINT":
return t("authorization.oauth.redirect_no_token_endpoint")
case "NO_CLIENT_ID":
return t("authorization.oauth.redirect_no_client_id")
case "NO_CLIENT_SECRET":
return t("authorization.oauth.redirect_no_client_secret")
case "NO_CODE_VERIFIER":
return t("authorization.oauth.redirect_no_code_verifier")
case "AUTH_TOKEN_REQUEST_FAILED":
return t("authorization.oauth.redirect_auth_token_request_failed")
case "AUTH_TOKEN_REQUEST_INVALID_RESPONSE":
return t(
"authorization.oauth.redirect_auth_token_request_invalid_response"
)
default:
return t("authorization.oauth.something_went_wrong_on_oauth_redirect")
}
}
onMounted(async () => {
const tokenInfo = await handleOAuthRedirect()
if (E.isLeft(tokenInfo)) {
toast.error(translateOAuthRedirectError(tokenInfo.left))
router.push("/")
return
}
if (
tabs.currentActiveTab.value.document.request.auth.authType === "oauth-2"
) {
tabs.currentActiveTab.value.document.request.auth.token =
tokenInfo.right.access_token
router.push("/")
return
}
})
</script>

View File

@@ -60,6 +60,7 @@
<div class="py-4 space-y-4">
<div class="flex items-center">
<HoppSmartToggle
v-if="hasPlatformTelemetry"
:on="TELEMETRY_ENABLED"
@change="showConfirmModal"
>
@@ -134,6 +135,7 @@ import { InterceptorService } from "~/services/interceptor.service"
import { pipe } from "fp-ts/function"
import * as O from "fp-ts/Option"
import * as A from "fp-ts/Array"
import { platform } from "~/platform"
const t = useI18n()
const colorMode = useColorMode()
@@ -163,6 +165,8 @@ const TELEMETRY_ENABLED = useSetting("TELEMETRY_ENABLED")
const EXPAND_NAVIGATION = useSetting("EXPAND_NAVIGATION")
const SIDEBAR_ON_LEFT = useSetting("SIDEBAR_ON_LEFT")
const hasPlatformTelemetry = Boolean(platform.platformFeatureFlags.hasTelemetry)
const confirmRemove = ref(false)
const proxySettings = computed(() => ({

View File

@@ -9,12 +9,14 @@ import { AnalyticsPlatformDef } from "./analytics"
import { InterceptorsPlatformDef } from "./interceptors"
import { HoppModule } from "~/modules"
import { InspectorsPlatformDef } from "./inspectors"
import { IOPlatformDef } from "./io"
export type PlatformDef = {
ui?: UIPlatformDef
addedHoppModules?: HoppModule[]
auth: AuthPlatformDef
analytics?: AnalyticsPlatformDef
io: IOPlatformDef
sync: {
environments: EnvironmentsPlatformDef
collections: CollectionsPlatformDef
@@ -26,6 +28,21 @@ export type PlatformDef = {
additionalInspectors?: InspectorsPlatformDef
platformFeatureFlags: {
exportAsGIST: boolean
hasTelemetry: boolean
/**
* Whether the platform supports cookies (affects whether the cookies footer item is shown)
* If a value is not given, then the value is assumed to be false
*/
cookiesEnabled?: boolean
/**
* Whether the platform should prompt the user that cookies are being used.
* This will result in the user being notified a cookies advisory and is meant for web apps.
*
* If a value is not given, then the value is assumed to be true
*/
promptAsUsingCookies?: boolean
}
}

View File

@@ -0,0 +1,84 @@
/**
* Defines how to save a file to the user's filesystem.
*/
export type SaveFileWithDialogOptions = {
/**
* The data to be saved
*/
data: string | ArrayBuffer
/**
* The suggested filename for the file. This name will be shown in the
* save dialog by default when a save is initiated.
*/
suggestedFilename: string
/**
* The content type mime type of the data to be saved.
*
* NOTE: The usage of this data might be platform dependent.
* For example, this field is used in the web, but not in the desktop app.
*/
contentType: string
/**
* Defines the filters (like in Windows, on the right side, where you can
* select the file type) for the file dialog.
*
* NOTE: The usage of this data might be platform dependent.
* For example, this field is used in the web, but not in the desktop app.
*/
filters?: Array<{
/**
* The name of the filter (in Windows, if the filter looks
* like "Images (*.png, *.jpg)", the name would be "Images")
*/
name: string
/**
* The array of extensions that are supported, without the dot.
*/
extensions: string[]
}>
}
export type SaveFileResponse =
| {
/**
* The implementation was unable to determine the status of the save operation.
* This cannot be considered a success or a failure and should be handled as an uncertainity.
* The browser standard implementation (std) returns this value as there is no way to
* check if the user downloaded the file or not.
*/
type: "unknown"
}
| {
/**
* The result is known and the user cancelled the save.
*/
type: "cancelled"
}
| {
/**
* The result is known and the user saved the file.
*/
type: "saved"
/**
* The full path of where the file was saved
*/
path: string
}
/**
* Platform definitions for how to handle IO operations.
*/
export type IOPlatformDef = {
/**
* Defines how to save a file to the user's filesystem.
* The expected behaviour is for the browser to show a prompt to save the file.
*/
saveFileWithDialog: (
opts: SaveFileWithDialogOptions
) => Promise<SaveFileResponse>
}

View File

@@ -12,6 +12,7 @@ import { computed, readonly, ref } from "vue"
import { browserIsChrome, browserIsFirefox } from "~/helpers/utils/userAgent"
import SettingsExtension from "~/components/settings/Extension.vue"
import InterceptorsExtensionSubtitle from "~/components/interceptors/ExtensionSubtitle.vue"
import InterceptorsErrorPlaceholder from "~/components/interceptors/ErrorPlaceholder.vue"
export const defineSubscribableObject = <T extends object>(obj: T) => {
const proxyObject = {
@@ -217,6 +218,7 @@ export class ExtensionInterceptorService
description: () => "Heading not found",
},
error: "NO_PW_EXT_HOOK",
component: InterceptorsErrorPlaceholder,
})
}

View File

@@ -0,0 +1,37 @@
import { IOPlatformDef } from "../io"
import { pipe } from "fp-ts/function"
import * as S from "fp-ts/string"
import * as RNEA from "fp-ts/ReadonlyNonEmptyArray"
/**
* Implementation for how to handle IO operations in the browser.
*/
export const browserIODef: IOPlatformDef = {
saveFileWithDialog(opts) {
const file = new Blob([opts.data], { type: opts.contentType })
const a = document.createElement("a")
const url = URL.createObjectURL(file)
a.href = url
a.download = pipe(
url,
S.split("/"),
RNEA.last,
S.split("#"),
RNEA.head,
S.split("?"),
RNEA.head
)
document.body.appendChild(a)
a.click()
setTimeout(() => {
document.body.removeChild(a)
URL.revokeObjectURL(url)
}, 1000)
// Browsers provide no way for us to know the save went successfully.
return Promise.resolve({ type: "unknown" })
},
}

View File

@@ -20,6 +20,11 @@ export type UIPlatformDef = {
appHeader?: {
paddingTop?: Ref<string>
paddingLeft?: Ref<string>
/**
* A function which is called when the header area of the app receives a click event
*/
onHeaderAreaClick?: () => void
}
onCodemirrorInstanceMount?: (element: HTMLElement) => void

View File

@@ -72,6 +72,61 @@ describe("InterceptorService", () => {
expect(service.currentInterceptorID.value).not.toEqual("unknown")
})
it("currentInterceptor points to the instance of the currently selected interceptor", () => {
const container = new TestContainer()
const service = container.bind(InterceptorService)
const interceptor = {
interceptorID: "test",
name: () => "test interceptor",
selectable: { type: "selectable" as const },
runRequest: () => {
throw new Error("not implemented")
},
}
service.registerInterceptor(interceptor)
service.currentInterceptorID.value = "test"
expect(service.currentInterceptor.value).toBe(interceptor)
})
it("currentInterceptor updates when the currentInterceptorID changes", () => {
const container = new TestContainer()
const service = container.bind(InterceptorService)
const interceptor = {
interceptorID: "test",
name: () => "test interceptor",
selectable: { type: "selectable" as const },
runRequest: () => {
throw new Error("not implemented")
},
}
const interceptor_2 = {
interceptorID: "test2",
name: () => "test interceptor",
selectable: { type: "selectable" as const },
runRequest: () => {
throw new Error("not implemented")
},
}
service.registerInterceptor(interceptor)
service.registerInterceptor(interceptor_2)
service.currentInterceptorID.value = "test"
expect(service.currentInterceptor.value).toBe(interceptor)
service.currentInterceptorID.value = "test2"
expect(service.currentInterceptor.value).not.toBe(interceptor)
expect(service.currentInterceptor.value).toBe(interceptor_2)
})
describe("registerInterceptor", () => {
it("should register the interceptor", () => {
const container = new TestContainer()

View File

@@ -0,0 +1,69 @@
import { Service } from "dioc"
import { ref } from "vue"
import { parseString as setCookieParse } from "set-cookie-parser-es"
export type CookieDef = {
name: string
value: string
domain: string
path: string
expires: string
}
export class CookieJarService extends Service {
public static readonly ID = "COOKIE_JAR_SERVICE"
/**
* The cookie jar that stores all relevant cookie info.
* The keys correspond to the domain of the cookie.
* The cookie strings are stored as an array of strings corresponding to the domain
*/
public cookieJar = ref(new Map<string, string[]>())
constructor() {
super()
}
public parseSetCookieString(setCookieString: string) {
return setCookieParse(setCookieString)
}
public bulkApplyCookiesToDomain(cookies: string[], domain: string) {
const existingDomainEntries = this.cookieJar.value.get(domain) ?? []
existingDomainEntries.push(...cookies)
this.cookieJar.value.set(domain, existingDomainEntries)
}
public getCookiesForURL(url: URL) {
const relevantDomains = Array.from(this.cookieJar.value.keys()).filter(
(domain) => url.hostname.endsWith(domain)
)
return relevantDomains
.flatMap((domain) => {
// Assemble the list of cookie entries from all the relevant domains
const cookieStrings = this.cookieJar.value.get(domain)! // We know not nullable from how we filter above
return cookieStrings.map((cookieString) =>
this.parseSetCookieString(cookieString)
)
})
.filter((cookie) => {
// Perform the required checks on the cookies
const passesPathCheck = url.pathname.startsWith(cookie.path ?? "/")
const passesExpiresCheck = !cookie.expires
? true
: cookie.expires.getTime() >= new Date().getTime()
const passesSecureCheck = !cookie.secure
? true
: url.protocol === "https:"
return passesPathCheck && passesExpiresCheck && passesSecureCheck
})
}
}

View File

@@ -4,6 +4,7 @@ import { HeaderInspectorService } from "../header.inspector"
import { InspectionService } from "../../index"
import { getDefaultRESTRequest } from "~/helpers/rest/default"
import { ref } from "vue"
import { InterceptorService } from "~/services/interceptor.service"
vi.mock("~/modules/i18n", () => ({
__esModule: true,
@@ -58,5 +59,48 @@ describe("HeaderInspectorService", () => {
expect(result.value).toHaveLength(0)
})
it("should return an empty array when headers contain cookies but interceptor supports cookies", () => {
const container = new TestContainer()
container.bindMock(InterceptorService, {
currentInterceptor: ref({ supportsCookies: true }) as any,
})
const headerInspector = container.bind(HeaderInspectorService)
const req = ref({
...getDefaultRESTRequest(),
endpoint: "http://example.com/api/data",
headers: [{ key: "Cookie", value: "some-cookie", active: true }],
})
const result = headerInspector.getInspections(req)
expect(result.value).toHaveLength(0)
})
it("should return an inspector result when headers contain cookies and the current interceptor doesn't support cookies", () => {
const container = new TestContainer()
container.bindMock(InterceptorService, {
currentInterceptor: ref({ supportsCookies: false }) as any,
})
const headerInspector = container.bind(HeaderInspectorService)
const req = ref({
...getDefaultRESTRequest(),
endpoint: "http://example.com/api/data",
headers: [{ key: "Cookie", value: "some-cookie", active: true }],
})
const result = headerInspector.getInspections(req)
expect(result.value).not.toHaveLength(0)
expect(result.value).toContainEqual(
expect.objectContaining({ id: "header", isApplicable: true })
)
})
})
})

View File

@@ -4,6 +4,7 @@ import { getI18n } from "~/modules/i18n"
import { HoppRESTRequest } from "@hoppscotch/data"
import { Ref, computed, markRaw } from "vue"
import IconAlertTriangle from "~icons/lucide/alert-triangle"
import { InterceptorService } from "~/services/interceptor.service"
/**
* This inspector is responsible for inspecting the header of a request.
@@ -19,6 +20,7 @@ export class HeaderInspectorService extends Service implements Inspector {
public readonly inspectorID = "header"
private readonly inspection = this.bind(InspectionService)
private readonly interceptorService = this.bind(InterceptorService)
constructor() {
super()
@@ -42,7 +44,10 @@ export class HeaderInspectorService extends Service implements Inspector {
const isContainCookies = headerKeys.includes("Cookie")
if (isContainCookies) {
if (
isContainCookies &&
!this.interceptorService.currentInterceptor.value?.supportsCookies
) {
headerKeys.forEach((headerKey, index) => {
if (this.cookiesCheck(headerKey)) {
results.push({

View File

@@ -29,6 +29,7 @@ export type InterceptorError =
description: (t: ReturnType<typeof getI18n>) => string
}
error?: unknown
component?: Component
}
/**
@@ -85,6 +86,12 @@ export type Interceptor<Err extends InterceptorError = InterceptorError> = {
*/
name: (t: ReturnType<typeof getI18n>) => MaybeRef<string>
/**
* Defines whether the interceptor has support for cookies.
* If this field is undefined, it is assumed as not supporting cookies.
*/
supportsCookies?: boolean
/**
* Defines what to render in the Interceptor section of the Settings page.
* Use this space to define interceptor specific settings.
@@ -161,6 +168,16 @@ export class InterceptorService extends Service {
Array.from(this.interceptors.values())
)
/**
* Gives an instance to the current interceptor.
* NOTE: Do not update from here, this is only for reading.
*/
public currentInterceptor = computed(() => {
if (this.currentInterceptorID.value === null) return null
return this.interceptors.get(this.currentInterceptorID.value)
})
constructor() {
super()

View File

@@ -1,10 +1,10 @@
function generateREForProtocol(protocol) {
return [
new RegExp(
`${protocol}(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]).){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$`
`${protocol}(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]).){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])(:[0-9]+)?(\\/[^?#]*)?(\\?[^#]*)?(#.*)?$`
),
new RegExp(
`${protocol}(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]).)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9-]*[A-Za-z0-9/])$`
`${protocol}(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]).)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9-]*[A-Za-z0-9/])(:[0-9]+)?(\\/[^?#]*)?(\\?[^#]*)?(#.*)?$`
),
]
}

View File

@@ -0,0 +1,92 @@
import fs from "fs"
import { glob } from "glob"
import path from "path"
import ts from "typescript"
import vueTsc from "vue-tsc"
import { fileURLToPath } from "url"
/**
* Helper function to find files to perform type check on
*/
const findFilesToPerformTypeCheck = (directoryPaths, filePatterns) => {
const files = []
directoryPaths.forEach((directoryPath) => {
if (!fs.existsSync(directoryPath)) {
console.error(`Directory not found: ${directoryPath}`)
process.exit(1)
}
files.push(
...glob.sync(filePatterns, {
cwd: directoryPath,
ignore: ["**/__tests__/**", "**/*.d.ts"],
absolute: true,
})
)
})
return files
}
// Derive the current file's directory path `__dirname` from the URL of this module `__filename`
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
// Define the directory paths and file patterns to perform type checks on
const directoryPaths = [path.resolve(__dirname, "src", "services")]
const filePatterns = ["**/*.ts"]
const tsConfigFileName = path.resolve(__dirname, "tsconfig.json")
const tsConfig = ts.readConfigFile(tsConfigFileName, ts.sys.readFile)
const { options } = ts.parseJsonConfigFileContent(
tsConfig.config,
ts.sys,
__dirname
)
const files = findFilesToPerformTypeCheck(directoryPaths, filePatterns)
const host = ts.createCompilerHost(options)
const program = vueTsc.createProgram({
rootNames: files,
options: { ...options, noEmit: true },
host,
})
// Perform type checking
const diagnostics = ts
.getPreEmitDiagnostics(program)
// Filter diagnostics to include only errors from files in the specified directory
.filter(({ file }) => {
if (!file) {
return false
}
return directoryPaths.some((directoryPath) =>
path.resolve(file.fileName).includes(directoryPath)
)
})
if (!diagnostics.length) {
console.log("Type checking passed.")
// Success
process.exit(0)
}
console.log("TypeScript diagnostics:")
const formatHost = {
getCanonicalFileName: (fileName) => fileName,
getCurrentDirectory: host.getCurrentDirectory,
getNewLine: () => ts.sys.newLine,
}
const formattedDiagnostics = ts.formatDiagnosticsWithColorAndContext(
diagnostics,
formatHost
)
console.error(formattedDiagnostics)
// Failure
process.exit(1)

View File

@@ -6,7 +6,9 @@
"main": "dist/hoppscotch-data.cjs",
"module": "dist/hoppscotch-data.js",
"types": "./dist/index.d.ts",
"files": [ "dist/*" ],
"files": [
"dist/*"
],
"scripts": {
"build:code": "vite build",
"build:decl": "tsc --project tsconfig.decl.json",
@@ -32,14 +34,16 @@
},
"homepage": "https://github.com/hoppscotch/hoppscotch#readme",
"devDependencies": {
"@types/lodash": "^4.14.181",
"typescript": "^4.6.3",
"vite": "^3.2.3"
"@types/lodash": "^4.14.200",
"typescript": "^5.2.2",
"vite": "^4.5.0"
},
"dependencies": {
"fp-ts": "^2.11.10",
"io-ts": "^2.2.16",
"fp-ts": "^2.16.1",
"io-ts": "^2.2.20",
"lodash": "^4.17.21",
"parser-ts": "^0.6.16"
"parser-ts": "^0.7.0",
"verzod": "^0.2.2",
"zod": "^3.22.4"
}
}

View File

@@ -1,14 +1,22 @@
import { pipe } from "fp-ts/function"
import * as E from "fp-ts/Either"
import { pipe } from "fp-ts/function"
import { InferredEntity, createVersionedEntity } from "verzod"
export type Environment = {
id?: string
name: string
variables: {
key: string
value: string
}[]
}
import V0_VERSION from "./v/0"
export const Environment = createVersionedEntity({
latestVersion: 0,
versionMap: {
0: V0_VERSION
},
getVersion(x) {
return V0_VERSION.schema.safeParse(x).success
? 0
: null
}
})
export type Environment = InferredEntity<typeof Environment>
const REGEX_ENV_VAR = /<<([^>]*)>>/g // "<<myVariable>>"

View File

@@ -0,0 +1,18 @@
import { z } from "zod"
import { defineVersion } from "verzod"
export const V0_SCHEMA = z.object({
id: z.optional(z.string()),
name: z.string(),
variables: z.array(
z.object({
key: z.string(),
value: z.string(),
})
)
})
export default defineVersion({
initial: true,
schema: V0_SCHEMA
})

View File

@@ -1,43 +0,0 @@
export type HoppGQLAuthNone = {
authType: "none"
}
export type HoppGQLAuthBasic = {
authType: "basic"
username: string
password: string
}
export type HoppGQLAuthBearer = {
authType: "bearer"
token: string
}
export type HoppGQLAuthOAuth2 = {
authType: "oauth-2"
token: string
oidcDiscoveryURL: string
authURL: string
accessTokenURL: string
clientID: string
scope: string
}
export type HoppGQLAuthAPIKey = {
authType: "api-key"
key: string
value: string
addTo: string
}
export type HoppGQLAuth = { authActive: boolean } & (
| HoppGQLAuthNone
| HoppGQLAuthBasic
| HoppGQLAuthBearer
| HoppGQLAuthOAuth2
| HoppGQLAuthAPIKey
)

View File

@@ -1,51 +1,75 @@
import { HoppGQLAuth } from "./HoppGQLAuth"
import { InferredEntity, createVersionedEntity } from "verzod"
import { z } from "zod"
import V1_VERSION from "./v/1"
import V2_VERSION from "./v/2"
export * from "./HoppGQLAuth"
export { GQLHeader } from "./v/1"
export {
HoppGQLAuth,
HoppGQLAuthAPIKey,
HoppGQLAuthBasic,
HoppGQLAuthBearer,
HoppGQLAuthNone,
HoppGQLAuthOAuth2,
} from "./v/2"
export const GQL_REQ_SCHEMA_VERSION = 2
export type GQLHeader = {
key: string
value: string
active: boolean
}
const versionedObject = z.object({
v: z.number(),
})
export type HoppGQLRequest = {
id?: string
v: number
name: string
url: string
headers: GQLHeader[]
query: string
variables: string
auth: HoppGQLAuth
}
export const HoppGQLRequest = createVersionedEntity({
latestVersion: 2,
versionMap: {
1: V1_VERSION,
2: V2_VERSION,
},
getVersion(x) {
const result = versionedObject.safeParse(x)
export function translateToGQLRequest(x: any): HoppGQLRequest {
if (x.v && x.v === GQL_REQ_SCHEMA_VERSION) return x
return result.success ? result.data.v : null
},
})
// Old request
const name = x.name ?? "Untitled"
const url = x.url ?? ""
const headers = x.headers ?? []
const query = x.query ?? ""
const variables = x.variables ?? []
const auth = x.auth ?? {
authType: "none",
authActive: true,
export type HoppGQLRequest = InferredEntity<typeof HoppGQLRequest>
const DEFAULT_QUERY = `
query Request {
method
url
headers {
key
value
}
}`.trim()
export function getDefaultGQLRequest(): HoppGQLRequest {
return {
v: GQL_REQ_SCHEMA_VERSION,
name,
url,
headers,
query,
variables,
auth
name: "Untitled",
url: "https://echo.hoppscotch.io/graphql",
headers: [],
variables: `
{
"id": "1"
}`.trim(),
query: DEFAULT_QUERY,
auth: {
authType: "none",
authActive: true,
},
}
}
/**
* @deprecated This function is deprecated. Use `HoppGQLRequest` instead.
*/
export function translateToGQLRequest(x: unknown): HoppGQLRequest {
const result = HoppGQLRequest.safeParse(x)
return result.type === "ok" ? result.value : getDefaultGQLRequest()
}
export function makeGQLRequest(x: Omit<HoppGQLRequest, "v">): HoppGQLRequest {
return {
v: GQL_REQ_SCHEMA_VERSION,

View File

@@ -0,0 +1,24 @@
import { z } from "zod"
import { defineVersion } from "verzod"
export const GQLHeader = z.object({
key: z.string().catch(""),
value: z.string().catch(""),
active: z.boolean().catch(true)
})
export type GQLHeader = z.infer<typeof GQLHeader>
export const V1_SCHEMA = z.object({
v: z.literal(1),
name: z.string(),
url: z.string(),
headers: z.array(GQLHeader).catch([]),
query: z.string(),
variables: z.string(),
})
export default defineVersion({
initial: true,
schema: V1_SCHEMA
})

View File

@@ -0,0 +1,91 @@
import { z } from "zod"
import { defineVersion } from "verzod"
import { GQLHeader, V1_SCHEMA } from "./1"
export const HoppGQLAuthNone = z.object({
authType: z.literal("none")
})
export type HoppGQLAuthNone = z.infer<typeof HoppGQLAuthNone>
export const HoppGQLAuthBasic = z.object({
authType: z.literal("basic"),
username: z.string().catch(""),
password: z.string().catch("")
})
export type HoppGQLAuthBasic = z.infer<typeof HoppGQLAuthBasic>
export const HoppGQLAuthBearer = z.object({
authType: z.literal("bearer"),
token: z.string().catch("")
})
export type HoppGQLAuthBearer = z.infer<typeof HoppGQLAuthBearer>
export const HoppGQLAuthOAuth2 = z.object({
authType: z.literal("oauth-2"),
token: z.string().catch(""),
oidcDiscoveryURL: z.string().catch(""),
authURL: z.string().catch(""),
accessTokenURL: z.string().catch(""),
clientID: z.string().catch(""),
scope: z.string().catch("")
})
export type HoppGQLAuthOAuth2 = z.infer<typeof HoppGQLAuthOAuth2>
export const HoppGQLAuthAPIKey = z.object({
authType: z.literal("api-key"),
key: z.string().catch(""),
value: z.string().catch(""),
addTo: z.string().catch("Headers")
})
export type HoppGQLAuthAPIKey = z.infer<typeof HoppGQLAuthAPIKey>
export const HoppGQLAuth = z.discriminatedUnion("authType", [
HoppGQLAuthNone,
HoppGQLAuthBasic,
HoppGQLAuthBearer,
HoppGQLAuthOAuth2,
HoppGQLAuthAPIKey
]).and(
z.object({
authActive: z.boolean()
})
)
export type HoppGQLAuth = z.infer<typeof HoppGQLAuth>
const V2_SCHEMA = z.object({
id: z.optional(z.string()),
v: z.literal(2),
name: z.string(),
url: z.string(),
headers: z.array(GQLHeader).catch([]),
query: z.string(),
variables: z.string(),
auth: HoppGQLAuth
})
export default defineVersion({
initial: false,
schema: V2_SCHEMA,
up(old: z.infer<typeof V1_SCHEMA>) {
return <z.infer<typeof V2_SCHEMA>>{
...old,
v: 2,
auth: {
authActive: true,
authType: "none",
}
}
}
})

View File

@@ -1,43 +0,0 @@
export type HoppRESTAuthNone = {
authType: "none"
}
export type HoppRESTAuthBasic = {
authType: "basic"
username: string
password: string
}
export type HoppRESTAuthBearer = {
authType: "bearer"
token: string
}
export type HoppRESTAuthOAuth2 = {
authType: "oauth-2"
token: string
oidcDiscoveryURL: string
authURL: string
accessTokenURL: string
clientID: string
scope: string
}
export type HoppRESTAuthAPIKey = {
authType: "api-key"
key: string
value: string
addTo: string
}
export type HoppRESTAuth = { authActive: boolean } & (
| HoppRESTAuthNone
| HoppRESTAuthBasic
| HoppRESTAuthBearer
| HoppRESTAuthOAuth2
| HoppRESTAuthAPIKey
)

View File

@@ -11,3 +11,5 @@ export const knownContentTypes = {
}
export type ValidContentTypes = keyof typeof knownContentTypes
export const ValidContentTypesList = Object.keys(knownContentTypes) as ValidContentTypes[]

View File

@@ -1,77 +1,70 @@
import cloneDeep from "lodash/cloneDeep"
import * as Eq from "fp-ts/Eq"
import * as S from "fp-ts/string"
import { ValidContentTypes } from "./content-types"
import { HoppRESTAuth } from "./HoppRESTAuth"
import cloneDeep from "lodash/cloneDeep"
import V0_VERSION from "./v/0"
import V1_VERSION from "./v/1"
import { createVersionedEntity, InferredEntity } from "verzod"
import { lodashIsEqualEq, mapThenEq, undefinedEq } from "../utils/eq"
import {
HoppRESTAuth,
HoppRESTReqBody,
HoppRESTHeaders,
HoppRESTParams,
} from "./v/1"
import { z } from "zod"
export * from "./content-types"
export * from "./HoppRESTAuth"
export {
FormDataKeyValue,
HoppRESTReqBodyFormData,
HoppRESTAuth,
HoppRESTAuthAPIKey,
HoppRESTAuthBasic,
HoppRESTAuthBearer,
HoppRESTAuthNone,
HoppRESTAuthOAuth2,
HoppRESTReqBody,
} from "./v/1"
export const RESTReqSchemaVersion = "1"
const versionedObject = z.object({
// v is a stringified number
v: z.string().regex(/^\d+$/).transform(Number),
})
export type HoppRESTParam = {
key: string
value: string
active: boolean
}
export const HoppRESTRequest = createVersionedEntity({
latestVersion: 1,
versionMap: {
0: V0_VERSION,
1: V1_VERSION,
},
getVersion(data) {
// For V1 onwards we have the v string storing the number
const versionCheck = versionedObject.safeParse(data)
export type HoppRESTHeader = {
key: string
value: string
active: boolean
}
if (versionCheck.success) return versionCheck.data.v
export type FormDataKeyValue = {
key: string
active: boolean
} & ({ isFile: true; value: Blob[] } | { isFile: false; value: string })
// For V0 we have to check the schema
const result = V0_VERSION.schema.safeParse(data)
export type HoppRESTReqBodyFormData = {
contentType: "multipart/form-data"
body: FormDataKeyValue[]
}
return result.success ? 0 : null
},
})
export type HoppRESTReqBody =
| {
contentType: Exclude<ValidContentTypes, "multipart/form-data">
body: string
}
| HoppRESTReqBodyFormData
| {
contentType: null
body: null
}
export type HoppRESTRequest = InferredEntity<typeof HoppRESTRequest>
export interface HoppRESTRequest {
v: string
id?: string // Firebase Firestore ID
name: string
method: string
endpoint: string
params: HoppRESTParam[]
headers: HoppRESTHeader[]
preRequestScript: string
testScript: string
auth: HoppRESTAuth
body: HoppRESTReqBody
}
export const HoppRESTRequestEq = Eq.struct<HoppRESTRequest>({
// TODO: Handle the issue with the preRequestScript and testScript type check failures on pre-commit
const HoppRESTRequestEq = Eq.struct<HoppRESTRequest>({
id: undefinedEq(S.Eq),
v: S.Eq,
auth: lodashIsEqualEq,
body: lodashIsEqualEq,
endpoint: S.Eq,
headers: mapThenEq(
(arr) => arr.filter((h) => h.key !== "" && h.value !== ""),
(arr) => arr.filter((h: any) => h.key !== "" && h.value !== ""),
lodashIsEqualEq
),
params: mapThenEq(
(arr) => arr.filter((p) => p.key !== "" && p.value !== ""),
(arr) => arr.filter((p: any) => p.key !== "" && p.value !== ""),
lodashIsEqualEq
),
method: S.Eq,
@@ -80,6 +73,11 @@ export const HoppRESTRequestEq = Eq.struct<HoppRESTRequest>({
testScript: S.Eq,
})
export const RESTReqSchemaVersion = "1"
export type HoppRESTParam = HoppRESTRequest["params"][number]
export type HoppRESTHeader = HoppRESTRequest["headers"][number]
export const isEqualHoppRESTRequest = HoppRESTRequestEq.equals
/**
@@ -87,6 +85,9 @@ export const isEqualHoppRESTRequest = HoppRESTRequestEq.equals
* If we fail to detect certain bits, we just resolve it to the default value
* @param x The value to extract REST Request data from
* @param defaultReq The default REST Request to source from
*
* @deprecated Usage of this function is no longer recommended and is only here
* for legacy reasons and will be removed
*/
export function safelyExtractRESTRequest(
x: unknown,
@@ -94,40 +95,53 @@ export function safelyExtractRESTRequest(
): HoppRESTRequest {
const req = cloneDeep(defaultReq)
// TODO: A cleaner way to do this ?
if (!!x && typeof x === "object") {
if (x.hasOwnProperty("v") && typeof x.v === "string")
req.v = x.v
if ("id" in x && typeof x.id === "string") req.id = x.id
if (x.hasOwnProperty("id") && typeof x.id === "string")
req.id = x.id
if ("name" in x && typeof x.name === "string") req.name = x.name
if (x.hasOwnProperty("name") && typeof x.name === "string")
req.name = x.name
if ("method" in x && typeof x.method === "string") req.method = x.method
if (x.hasOwnProperty("method") && typeof x.method === "string")
req.method = x.method
if (x.hasOwnProperty("endpoint") && typeof x.endpoint === "string")
if ("endpoint" in x && typeof x.endpoint === "string")
req.endpoint = x.endpoint
if (x.hasOwnProperty("preRequestScript") && typeof x.preRequestScript === "string")
if ("preRequestScript" in x && typeof x.preRequestScript === "string")
req.preRequestScript = x.preRequestScript
if (x.hasOwnProperty("testScript") && typeof x.testScript === "string")
if ("testScript" in x && typeof x.testScript === "string")
req.testScript = x.testScript
if (x.hasOwnProperty("body") && typeof x.body === "object" && !!x.body)
req.body = x.body as any // TODO: Deep nested checks
if ("body" in x) {
const result = HoppRESTReqBody.safeParse(x.body)
if (x.hasOwnProperty("auth") && typeof x.auth === "object" && !!x.auth)
req.auth = x.auth as any // TODO: Deep nested checks
if (result.success) {
req.body = result.data
}
}
if (x.hasOwnProperty("params") && Array.isArray(x.params))
req.params = x.params // TODO: Deep nested checks
if ("auth" in x) {
const result = HoppRESTAuth.safeParse(x.auth)
if (x.hasOwnProperty("headers") && Array.isArray(x.headers))
req.headers = x.headers // TODO: Deep nested checks
if (result.success) {
req.auth = result.data
}
}
if ("params" in x) {
const result = HoppRESTParams.safeParse(x.params)
if (result.success) {
req.params = result.data
}
}
if ("headers" in x) {
const result = HoppRESTHeaders.safeParse(x.headers)
if (result.success) {
req.headers = result.data
}
}
}
return req
@@ -137,105 +151,51 @@ export function makeRESTRequest(
x: Omit<HoppRESTRequest, "v">
): HoppRESTRequest {
return {
...x,
v: RESTReqSchemaVersion,
...x,
}
}
export function isHoppRESTRequest(x: any): x is HoppRESTRequest {
return x && typeof x === "object" && "v" in x
}
function parseRequestBody(x: any): HoppRESTReqBody {
if (x.contentType === "application/json") {
return {
contentType: "application/json",
body: x.rawParams,
}
}
export function getDefaultRESTRequest(): HoppRESTRequest {
return {
contentType: "application/json",
body: "",
}
}
export function translateToNewRequest(x: any): HoppRESTRequest {
if (isHoppRESTRequest(x)) {
return x
} else {
// Old format
const endpoint: string = `${x?.url ?? ""}${x?.path ?? ""}`
const headers: HoppRESTHeader[] = x?.headers ?? []
// Remove old keys from params
const params: HoppRESTParam[] = (x?.params ?? []).map(
({
key,
value,
active,
}: {
key: string
value: string
active: boolean
}) => ({
key,
value,
active,
})
)
const name = x?.name ?? "Untitled request"
const method = x?.method ?? ""
const preRequestScript = x?.preRequestScript ?? ""
const testScript = x?.testScript ?? ""
const body = parseRequestBody(x)
const auth = parseOldAuth(x)
const result: HoppRESTRequest = {
name,
endpoint,
headers,
params,
method,
preRequestScript,
testScript,
body,
auth,
v: RESTReqSchemaVersion,
}
if (x.id) result.id = x.id
return result
}
}
export function parseOldAuth(x: any): HoppRESTAuth {
if (!x.auth || x.auth === "None")
return {
v: "1",
endpoint: "https://echo.hoppscotch.io",
name: "Untitled",
params: [],
headers: [],
method: "GET",
auth: {
authType: "none",
authActive: true,
}
if (x.auth === "Basic Auth")
return {
authType: "basic",
authActive: true,
username: x.httpUser,
password: x.httpPassword,
}
if (x.auth === "Bearer Token")
return {
authType: "bearer",
authActive: true,
token: x.bearerToken,
}
return { authType: "none", authActive: true }
},
preRequestScript: "",
testScript: "",
body: {
contentType: null,
body: null,
},
}
}
/**
* Checks if the given value is a HoppRESTRequest
* @param x The value to check
*
* @deprecated This function is no longer recommended and is only here for legacy reasons
* Use `HoppRESTRequest.is`/`HoppRESTRequest.isLatest` instead.
*/
export function isHoppRESTRequest(x: unknown): x is HoppRESTRequest {
return HoppRESTRequest.isLatest(x)
}
/**
* Safely parses a value into a HoppRESTRequest.
* @param x The value to check
*
* @deprecated This function is no longer recommended and is only here for
* legacy reasons. Use `HoppRESTRequest.safeParse` instead.
*/
export function translateToNewRequest(x: unknown): HoppRESTRequest {
const result = HoppRESTRequest.safeParse(x)
return result.type === "ok" ? result.value : getDefaultRESTRequest()
}

View File

@@ -0,0 +1,39 @@
import { defineVersion } from "verzod"
import { z } from "zod"
export const V0_SCHEMA = z.object({
id: z.optional(z.string()), // Firebase Firestore ID
url: z.string(),
path: z.string(),
headers: z.array(
z.object({
key: z.string(),
value: z.string(),
active: z.boolean()
})
),
params: z.array(
z.object({
key: z.string(),
value: z.string(),
active: z.boolean()
})
),
name: z.string(),
method: z.string(),
preRequestScript: z.string(),
testScript: z.string(),
contentType: z.string(),
body: z.string(),
rawParams: z.optional(z.string()),
auth: z.optional(z.string()),
httpUser: z.optional(z.string()),
httpPassword: z.optional(z.string()),
bearerToken: z.optional(z.string()),
})
export default defineVersion({
initial: true,
schema: V0_SCHEMA
})

View File

@@ -0,0 +1,209 @@
import { defineVersion } from "verzod"
import { z } from "zod"
import { V0_SCHEMA } from "./0"
export const FormDataKeyValue = z.object({
key: z.string(),
active: z.boolean()
}).and(
z.union([
z.object({
isFile: z.literal(true),
value: z.array(z.instanceof(Blob).nullable())
}),
z.object({
isFile: z.literal(false),
value: z.string()
})
])
)
export type FormDataKeyValue = z.infer<typeof FormDataKeyValue>
export const HoppRESTReqBodyFormData = z.object({
contentType: z.literal("multipart/form-data"),
body: z.array(FormDataKeyValue)
})
export type HoppRESTReqBodyFormData = z.infer<typeof HoppRESTReqBodyFormData>
export const HoppRESTReqBody = z.union([
z.object({
contentType: z.literal(null),
body: z.literal(null).catch(null)
}),
z.object({
contentType: z.literal("multipart/form-data"),
body: z.array(FormDataKeyValue).catch([])
}),
z.object({
contentType: z.union([
z.literal("application/json"),
z.literal("application/ld+json"),
z.literal("application/hal+json"),
z.literal("application/vnd.api+json"),
z.literal("application/xml"),
z.literal("application/x-www-form-urlencoded"),
z.literal("text/html"),
z.literal("text/plain"),
]),
body: z.string().catch("")
})
])
export type HoppRESTReqBody = z.infer<typeof HoppRESTReqBody>
export const HoppRESTAuthNone = z.object({
authType: z.literal("none")
})
export type HoppRESTAuthNone = z.infer<typeof HoppRESTAuthNone>
export const HoppRESTAuthBasic = z.object({
authType: z.literal("basic"),
username: z.string().catch(""),
password: z.string().catch(""),
})
export type HoppRESTAuthBasic = z.infer<typeof HoppRESTAuthBasic>
export const HoppRESTAuthBearer = z.object({
authType: z.literal("bearer"),
token: z.string().catch(""),
})
export type HoppRESTAuthBearer = z.infer<typeof HoppRESTAuthBearer>
export const HoppRESTAuthOAuth2 = z.object({
authType: z.literal("oauth-2"),
token: z.string().catch(""),
oidcDiscoveryURL: z.string().catch(""),
authURL: z.string().catch(""),
accessTokenURL: z.string().catch(""),
clientID: z.string().catch(""),
scope: z.string().catch(""),
})
export type HoppRESTAuthOAuth2 = z.infer<typeof HoppRESTAuthOAuth2>
export const HoppRESTAuthAPIKey = z.object({
authType: z.literal("api-key"),
key: z.string().catch(""),
value: z.string().catch(""),
addTo: z.string().catch("Headers"),
})
export type HoppRESTAuthAPIKey = z.infer<typeof HoppRESTAuthAPIKey>
export const HoppRESTAuth = z.discriminatedUnion("authType", [
HoppRESTAuthNone,
HoppRESTAuthBasic,
HoppRESTAuthBearer,
HoppRESTAuthOAuth2,
HoppRESTAuthAPIKey
]).and(
z.object({
authActive: z.boolean(),
})
)
export type HoppRESTAuth = z.infer<typeof HoppRESTAuth>
export const HoppRESTParams = z.array(
z.object({
key: z.string().catch(""),
value: z.string().catch(""),
active: z.boolean().catch(true)
})
)
export type HoppRESTParams = z.infer<typeof HoppRESTParams>
export const HoppRESTHeaders = z.array(
z.object({
key: z.string().catch(""),
value: z.string().catch(""),
active: z.boolean().catch(true)
})
)
export type HoppRESTHeaders = z.infer<typeof HoppRESTHeaders>
const V1_SCHEMA = z.object({
v: z.literal("1"),
id: z.optional(z.string()), // Firebase Firestore ID
name: z.string(),
method: z.string(),
endpoint: z.string(),
params: HoppRESTParams,
headers: HoppRESTHeaders,
preRequestScript: z.string().catch(""),
testScript: z.string().catch(""),
auth: HoppRESTAuth,
body: HoppRESTReqBody
})
function parseRequestBody(x: z.infer<typeof V0_SCHEMA>): z.infer<typeof V1_SCHEMA>["body"] {
return {
contentType: "application/json",
body: x.contentType === "application/json" ? x.rawParams ?? "" : "",
}
}
export function parseOldAuth(x: z.infer<typeof V0_SCHEMA>): z.infer<typeof V1_SCHEMA>["auth"] {
if (!x.auth || x.auth === "None")
return {
authType: "none",
authActive: true,
}
if (x.auth === "Basic Auth")
return {
authType: "basic",
authActive: true,
username: x.httpUser ?? "",
password: x.httpPassword ?? "",
}
if (x.auth === "Bearer Token")
return {
authType: "bearer",
authActive: true,
token: x.bearerToken ?? "",
}
return { authType: "none", authActive: true }
}
export default defineVersion({
initial: false,
schema: V1_SCHEMA,
up(old: z.infer<typeof V0_SCHEMA>) {
const { url, path, headers, params, name, method, preRequestScript, testScript } = old
const endpoint = `${url}${path}`
const body = parseRequestBody(old)
const auth = parseOldAuth(old)
const result: z.infer<typeof V1_SCHEMA> = {
v: "1",
endpoint,
headers,
params,
name,
method,
preRequestScript,
testScript,
body,
auth,
}
if (old.id) result.id = old.id
return result
},
})

View File

@@ -2,7 +2,7 @@
"compilerOptions": {
"target": "es2017",
"module": "esnext",
"lib": ["esnext"],
"lib": ["esnext", "DOM"],
"moduleResolution": "node",
"esModuleInterop": true,
"strict": true,

View File

@@ -2,13 +2,13 @@
"compilerOptions": {
"target": "es2017",
"module": "esnext",
"lib": ["esnext"],
"lib": ["esnext", "DOM"],
"moduleResolution": "node",
"esModuleInterop": true,
"strict": true,
"strictNullChecks": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"resolveJsonModule": true
},
"include": ["src/*.ts"]
}

View File

@@ -1,6 +1,6 @@
module.exports = {
preset: "ts-jest",
testEnvironment: "node",
testEnvironment: "jsdom",
collectCoverage: true,
setupFilesAfterEnv: ["./jest.setup.ts"],
}

View File

@@ -0,0 +1,30 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Sitemap
.sitemap-gen
# Backend Code generation
src/api/generated

View File

@@ -0,0 +1,7 @@
{
"recommendations": [
"Vue.volar",
"tauri-apps.tauri-vscode",
"rust-lang.rust-analyzer"
]
}

View File

@@ -0,0 +1,16 @@
# Tauri + Vue 3 + TypeScript
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
## Recommended IDE Setup
- [VS Code](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer)
## Type Support For `.vue` Imports in TS
Since TypeScript cannot handle type information for `.vue` imports, they are shimmed to be a generic Vue component type by default. In most cases this is fine if you don't really care about component prop types outside of templates. However, if you wish to get actual prop types in `.vue` imports (for example to get props validation when using manual `h(...)` calls), you can enable Volar's Take Over mode by following these steps:
1. Run `Extensions: Show Built-in Extensions` from VS Code's command palette, look for `TypeScript and JavaScript Language Features`, then right click and select `Disable (Workspace)`. By default, Take Over mode will enable itself if the default TypeScript extension is disabled.
2. Reload the VS Code window by running `Developer: Reload Window` from the command palette.
You can learn more about Take Over mode [here](https://github.com/johnsoncodehk/volar/discussions/471).

View File

@@ -0,0 +1,18 @@
overwrite: true
schema: "../../gql-gen/*.gql"
generates:
src/api/generated/graphql.ts:
documents: "src/**/*.graphql"
plugins:
- add:
content: >
/* eslint-disable */
// Auto-generated file (DO NOT EDIT!!!), refer gql-codegen.yml
- typescript
- typescript-operations
- typed-document-node
- typescript-urql-graphcache
src/api/generated/backend-schema.json:
plugins:
- urql-introspection

View File

@@ -0,0 +1,26 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Hoppscotch - Open source API development ecosystem</title>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="apple-touch-icon" href="/icon.png" />
</head>
<body>
<div id="app"></div>
<script>
// Shims to make swagger-parser package work
window.global = window
</script>
<script type="module">
import { Buffer } from "buffer"
import process from "process"
// // Shims to make postman-collection work
window.Buffer = Buffer
window.process = process
</script>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

Some files were not shown because too many files have changed in this diff Show More