From ffe659673deadabd1d5691a6345eb4486481848d Mon Sep 17 00:00:00 2001 From: Andrew Bastin Date: Sun, 23 Jan 2022 23:32:05 +0530 Subject: [PATCH] refactor: body type transition to ruleset based system --- .../helpers/rules/BodyTransition.ts | 196 +++++++++++++++++ packages/hoppscotch-app/helpers/typeutils.ts | 9 +- .../hoppscotch-app/newstore/RESTSession.ts | 202 +++++++++--------- 3 files changed, 303 insertions(+), 104 deletions(-) create mode 100644 packages/hoppscotch-app/helpers/rules/BodyTransition.ts diff --git a/packages/hoppscotch-app/helpers/rules/BodyTransition.ts b/packages/hoppscotch-app/helpers/rules/BodyTransition.ts new file mode 100644 index 000000000..13844e2ae --- /dev/null +++ b/packages/hoppscotch-app/helpers/rules/BodyTransition.ts @@ -0,0 +1,196 @@ +/** + * Defines how body should be updated for movement between different + * content-types + */ + +import { pipe } from "fp-ts/function" +import * as A from "fp-ts/Array" +import { + FormDataKeyValue, + HoppRESTReqBody, + ValidContentTypes, +} from "@hoppscotch/data" +import { + parseRawKeyValueEntries, + rawKeyValueEntriesToString, + RawKeyValueEntry, +} from "../rawKeyValue" + +const ANY_TYPE = Symbol("TRANSITION_RULESET_IGNORE_TYPE") +// eslint-disable-next-line no-redeclare +type ANY_TYPE = typeof ANY_TYPE + +type BodyType = + T extends ValidContentTypes + ? HoppRESTReqBody & { contentType: T } + : HoppRESTReqBody + +type TransitionDefinition< + FromType extends ValidContentTypes | null | ANY_TYPE, + ToType extends ValidContentTypes | null | ANY_TYPE +> = { + from: FromType + to: ToType + definition: ( + currentBody: BodyType, + targetType: BodyType["contentType"] + ) => BodyType +} + +const rule = < + FromType extends ValidContentTypes | null | ANY_TYPE, + ToType extends ValidContentTypes | null | ANY_TYPE +>( + input: TransitionDefinition +) => input + +// Use ANY_TYPE to ignore from/dest type +// Rules apply from top to bottom +const transitionRuleset = [ + rule({ + from: null, + to: "multipart/form-data", + definition: () => ({ + contentType: "multipart/form-data", + body: [], + }), + }), + rule({ + from: ANY_TYPE, + to: null, + definition: () => ({ + contentType: null, + body: null, + }), + }), + rule({ + from: null, + to: ANY_TYPE, + definition: (_, targetType) => ({ + contentType: targetType as unknown as Exclude< + // This is guaranteed by the above rules, we just can't tell TS this + ValidContentTypes, + "multipart/form-data" + >, + body: "", + }), + }), + rule({ + from: "multipart/form-data", + to: "application/x-www-form-urlencoded", + definition: (currentBody, targetType) => ({ + contentType: targetType, + body: pipe( + currentBody.body, + A.map( + ({ key, value, isFile, active }) => + { + key, + value: isFile ? "" : value, + active, + } + ), + rawKeyValueEntriesToString + ), + }), + }), + rule({ + from: "application/x-www-form-urlencoded", + to: "multipart/form-data", + definition: (currentBody, targetType) => ({ + contentType: targetType, + body: pipe( + currentBody.body, + parseRawKeyValueEntries, + A.map( + ({ key, value, active }) => + { + key, + value, + active, + isFile: false, + } + ) + ), + }), + }), + rule({ + from: ANY_TYPE, + to: "multipart/form-data", + definition: () => ({ + contentType: "multipart/form-data", + body: [], + }), + }), + rule({ + from: "multipart/form-data", + to: ANY_TYPE, + definition: (_, target) => ({ + contentType: target as Exclude, + body: "", + }), + }), + rule({ + from: "application/x-www-form-urlencoded", + to: ANY_TYPE, + definition: (_, target) => ({ + contentType: target as Exclude, + body: "", + }), + }), + rule({ + from: ANY_TYPE, + to: "application/x-www-form-urlencoded", + definition: () => ({ + contentType: "application/x-www-form-urlencoded", + body: "", + }), + }), + rule({ + from: ANY_TYPE, + to: ANY_TYPE, + definition: (curr, targetType) => ({ + contentType: targetType as Exclude< + // Above rules ensure this will be the case + ValidContentTypes, + "multipart/form-data" | "application/x-www-form-urlencoded" + >, + // Again, above rules ensure this will be the case, can't convince TS tho + body: ( + curr as HoppRESTReqBody & { + contentType: Exclude< + ValidContentTypes, + "multipart/form-data" | "application/x-www-form-urlencoded" + > + } + ).body, + }), + }), +] as const + +export const applyBodyTransition = ( + current: HoppRESTReqBody, + target: T +): HoppRESTReqBody & { contentType: T } => { + debugger + + if (current.contentType === target) { + console.warn( + `Tried to transition body from and to the same content-type '${target}'` + ) + return current as any + } + + const transitioner = transitionRuleset.find( + (def) => + (def.from === current.contentType || def.from === ANY_TYPE) && + (def.to === target || def.to === ANY_TYPE) + ) + + if (!transitioner) { + throw new Error("Body Type Transition Ruleset is invalid :(") + } + + // TypeScript won't be able to figure this out easily :( + return (transitioner.definition as any)(current, target) +} diff --git a/packages/hoppscotch-app/helpers/typeutils.ts b/packages/hoppscotch-app/helpers/typeutils.ts index 654932956..f8693bb5c 100644 --- a/packages/hoppscotch-app/helpers/typeutils.ts +++ b/packages/hoppscotch-app/helpers/typeutils.ts @@ -12,6 +12,9 @@ export const objectFieldIncludes = < field: K, values: V // eslint-disable-next-line -): obj is T & { [_x in K]: V[number] } => { - return values.includes(obj[field]) -} +): obj is T & { [_x in K]: V[number] } => values.includes(obj[field]) + +export const valueIncludes = ( + obj: T, + values: V +): obj is V[number] => values.includes(obj) diff --git a/packages/hoppscotch-app/newstore/RESTSession.ts b/packages/hoppscotch-app/newstore/RESTSession.ts index 2b060eba3..e1186528c 100644 --- a/packages/hoppscotch-app/newstore/RESTSession.ts +++ b/packages/hoppscotch-app/newstore/RESTSession.ts @@ -1,5 +1,3 @@ -import * as A from "fp-ts/Array" -import { pipe } from "fp-ts/function" import { pluck, distinctUntilChanged, map, filter } from "rxjs/operators" import { Ref } from "@nuxtjs/composition-api" import { @@ -17,11 +15,7 @@ import { HoppRESTResponse } from "~/helpers/types/HoppRESTResponse" import { useStream } from "~/helpers/utils/composables" import { HoppTestResult } from "~/helpers/types/HoppTestResult" import { HoppRequestSaveContext } from "~/helpers/types/HoppRequestSaveContext" -import { - parseRawKeyValueEntries, - rawKeyValueEntriesToString, - RawKeyValueEntry, -} from "~/helpers/rawKeyValue" +import { applyBodyTransition } from "~/helpers/rules/BodyTransition" type RESTSession = { request: HoppRESTRequest @@ -212,101 +206,107 @@ const dispatchers = defineDispatchers({ ) { // TODO: Cleaner implementation // TODO: persist body evenafter switching content typees - if (curr.request.body.contentType !== "multipart/form-data") { - if (newContentType === "multipart/form-data") { - // Preserve entries when comping from urlencoded to multipart - if ( - curr.request.body.contentType === "application/x-www-form-urlencoded" - ) { - return { - request: { - ...curr.request, - body: { - contentType: "multipart/form-data", - body: pipe( - curr.request.body.body, - parseRawKeyValueEntries, - A.map( - ({ key, value, active }) => - { key, value, active, isFile: false } - ) - ), - }, - }, - } - } - - // Going from non-formdata to form-data, discard contents and set empty array as body - return { - request: { - ...curr.request, - body: { - contentType: "multipart/form-data", - body: [], - }, - }, - } - } else { - // non-formdata to non-formdata, keep body and set content type - return { - request: { - ...curr.request, - body: { - contentType: newContentType, - body: - newContentType === null - ? null - : (curr.request.body as any)?.body ?? "", - }, - }, - } - } - } else if (newContentType !== "multipart/form-data") { - if (newContentType === "application/x-www-form-urlencoded") { - return { - request: { - ...curr.request, - body: { - contentType: newContentType, - body: pipe( - curr.request.body.body, - A.map( - ({ key, value, isFile, active }) => - { - key, - value: isFile ? "" : value, - active, - } - ), - rawKeyValueEntriesToString - ), - }, - }, - } - } - - // Going from formdata to non-formdata, discard contents and set empty string - return { - request: { - ...curr.request, - body: { - contentType: newContentType, - body: "", - }, - }, - } - } else { - // form-data to form-data ? just set the content type ¯\_(ツ)_/¯ - return { - request: { - ...curr.request, - body: { - contentType: newContentType, - body: curr.request.body.body, - }, - }, - } + return { + request: { + ...curr.request, + body: applyBodyTransition(curr.request.body, newContentType), + }, } + // if (curr.request.body.contentType !== "multipart/form-data") { + // if (newContentType === "multipart/form-data") { + // // Preserve entries when comping from urlencoded to multipart + // if ( + // curr.request.body.contentType === "application/x-www-form-urlencoded" + // ) { + // return { + // request: { + // ...curr.request, + // body: { + // contentType: "multipart/form-data", + // body: pipe( + // curr.request.body.body, + // parseRawKeyValueEntries, + // A.map( + // ({ key, value, active }) => + // { key, value, active, isFile: false } + // ) + // ), + // }, + // }, + // } + // } + + // // Going from non-formdata to form-data, discard contents and set empty array as body + // return { + // request: { + // ...curr.request, + // body: { + // contentType: "multipart/form-data", + // body: [], + // }, + // }, + // } + // } else { + // // non-formdata to non-formdata, keep body and set content type + // return { + // request: { + // ...curr.request, + // body: { + // contentType: newContentType, + // body: + // newContentType === null + // ? null + // : (curr.request.body as any)?.body ?? "", + // }, + // }, + // } + // } + // } else if (newContentType !== "multipart/form-data") { + // if (newContentType === "application/x-www-form-urlencoded") { + // return { + // request: { + // ...curr.request, + // body: { + // contentType: newContentType, + // body: pipe( + // curr.request.body.body, + // A.map( + // ({ key, value, isFile, active }) => + // { + // key, + // value: isFile ? "" : value, + // active, + // } + // ), + // rawKeyValueEntriesToString + // ), + // }, + // }, + // } + // } + + // // Going from formdata to non-formdata, discard contents and set empty string + // return { + // request: { + // ...curr.request, + // body: { + // contentType: newContentType, + // body: "", + // }, + // }, + // } + // } else { + // // form-data to form-data ? just set the content type ¯\_(ツ)_/¯ + // return { + // request: { + // ...curr.request, + // body: { + // contentType: newContentType, + // body: curr.request.body.body, + // }, + // }, + // } + // } }, addFormDataEntry(curr: RESTSession, { entry }: { entry: FormDataKeyValue }) { // Only perform update if the current content-type is formdata