fix(common): preserve collection tree on OpenAPI re-import (#6376)
Some checks failed
Node.js CI / Test (22) (push) Has been cancelled

This commit is contained in:
James George 2026-05-28 16:23:13 +05:30 committed by GitHub
parent 029aa9246c
commit e067ee0b32
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 1455 additions and 9 deletions

View File

@ -3,6 +3,7 @@ import {
HoppRESTRequest,
makeCollection,
parseRawKeyValueEntries,
rawKeyValueEntriesToString,
} from "@hoppscotch/data"
import { OpenAPIV3_1 } from "openapi-types"
@ -551,6 +552,199 @@ function convertResponses(
return result
}
const redactOAuth2Params = <T extends { value: string }>(params: T[]): T[] =>
params.map((param) => ({ ...param, value: "" }))
const redactOAuth2GrantToken = <T extends { token: string }>(grant: T): T => {
const redacted = { ...grant, token: "" } as T & { refreshToken?: string }
if ("refreshToken" in redacted) {
redacted.refreshToken = ""
}
return redacted
}
function sanitizeDroppedRequestAuth(auth: AuthLike): AuthLike {
switch (auth.authType) {
case "none":
case "inherit":
return auth
case "basic":
return { ...auth, username: "", password: "" }
case "bearer":
return { ...auth, token: "" }
case "api-key":
return { ...auth, value: "" }
case "oauth-2": {
const grantTypeInfo = auth.grantTypeInfo
switch (grantTypeInfo.grantType) {
case "AUTHORIZATION_CODE":
return {
...auth,
grantTypeInfo: {
...redactOAuth2GrantToken(grantTypeInfo),
clientSecret: "",
authRequestParams: redactOAuth2Params(
grantTypeInfo.authRequestParams
),
tokenRequestParams: redactOAuth2Params(
grantTypeInfo.tokenRequestParams
),
refreshRequestParams: redactOAuth2Params(
grantTypeInfo.refreshRequestParams
),
},
}
case "CLIENT_CREDENTIALS":
return {
...auth,
grantTypeInfo: {
...redactOAuth2GrantToken(grantTypeInfo),
clientSecret: "",
tokenRequestParams: redactOAuth2Params(
grantTypeInfo.tokenRequestParams
),
refreshRequestParams: redactOAuth2Params(
grantTypeInfo.refreshRequestParams
),
},
}
case "PASSWORD":
return {
...auth,
grantTypeInfo: {
...redactOAuth2GrantToken(grantTypeInfo),
clientSecret: "",
username: "",
password: "",
tokenRequestParams: redactOAuth2Params(
grantTypeInfo.tokenRequestParams
),
refreshRequestParams: redactOAuth2Params(
grantTypeInfo.refreshRequestParams
),
},
}
case "IMPLICIT":
return {
...auth,
grantTypeInfo: {
...redactOAuth2GrantToken(grantTypeInfo),
authRequestParams: redactOAuth2Params(
grantTypeInfo.authRequestParams
),
refreshRequestParams: redactOAuth2Params(
grantTypeInfo.refreshRequestParams
),
},
}
default:
// Unknown grant type — fall back to inherit rather than leaking partial fields.
return { authType: "inherit", authActive: auth.authActive }
}
}
case "aws-signature":
return {
...auth,
accessKey: "",
secretKey: "",
serviceToken: undefined,
signature: undefined,
}
case "digest":
return {
...auth,
username: "",
password: "",
nonce: "",
cnonce: "",
opaque: "",
}
case "hawk":
return {
...auth,
authId: "",
authKey: "",
user: undefined,
nonce: undefined,
ext: undefined,
app: undefined,
dlg: undefined,
timestamp: undefined,
}
case "akamai-eg":
return {
...auth,
accessToken: "",
clientToken: "",
clientSecret: "",
nonce: undefined,
timestamp: undefined,
}
case "jwt":
return {
...auth,
secret: "",
privateKey: "",
payload: "{}",
jwtHeaders: "{}",
}
}
}
function sanitizeDroppedRequestBody(
body: HoppRESTRequest["body"]
): HoppRESTRequest["body"] {
if (!body || body.contentType === null) return body
if (body.contentType === "multipart/form-data") {
return {
...body,
body: Array.isArray(body.body)
? body.body
.filter((entry) => entry.active && entry.key)
.map((entry) => (entry.isFile ? { ...entry, value: "" } : entry))
: [],
}
}
if (body.contentType === "application/x-www-form-urlencoded") {
const bodyStr = typeof body.body === "string" ? body.body : ""
return {
...body,
body: rawKeyValueEntriesToString(
parseRawKeyValueEntries(bodyStr).filter(
(entry) => entry.active && entry.key
)
),
}
}
return body
}
// Keep dropped-request payloads aligned with export's no-credentials/no-scripts lossiness.
function sanitizeRequestForDroppedExtension(
request: HoppRESTRequest
): HoppRESTRequest {
return {
...request,
auth: sanitizeDroppedRequestAuth(request.auth),
body: sanitizeDroppedRequestBody(request.body),
preRequestScript: "",
testScript: "",
params: request.params.filter((p) => p.active),
// Cookie often carries pasted auth but is not in SKIP_HEADER_NAMES.
headers: request.headers.filter((h) => {
const key = h.key.trim().toLowerCase()
return h.active && !SKIP_HEADER_NAMES.has(key) && key !== "cookie"
}),
requestVariables: request.requestVariables.filter((v) => v.active),
// Saved responses can embed original requests and raw secret payloads.
responses: {},
}
}
/**
* Convert a HoppCollection to an OpenAPI 3.1.0 document.
*/
@ -563,6 +757,11 @@ export function hoppCollectionToOpenAPI(collection: HoppCollection): {
const securitySchemes: Record<string, OpenAPIV3_1.SecuritySchemeObject> = {}
const servers = new Set<string>()
const usedOperationIds = new Set<string>()
// OpenAPI keeps one operation per (path, method); stash later collisions for re-import.
const droppedRequests: Array<{
tagPath: string | null
request: HoppRESTRequest
}> = []
/**
* Resolve a `<<varName>>` server placeholder to the variable's actual value
@ -682,14 +881,23 @@ export function hoppCollectionToOpenAPI(collection: HoppCollection): {
// emitting them would produce an invalid document.
if (!OPENAPI_METHODS.has(method)) continue
// Skip duplicate (path, method) — OpenAPI cannot represent two
// operations with the same key. Keep the first seen; drop the rest.
const pathItem = (paths[path] = (paths[path] ??
{}) as OpenAPIV3_1.PathItemObject) as Record<
string,
OpenAPIV3_1.OperationObject
>
if (pathItem[method]) continue
if (pathItem[method]) {
// Snapshot effective auth so restored duplicates don't lose inherited auth on unwrap.
const effectiveAuth = resolveEffectiveAuth(request.auth, inheritedAuth)
droppedRequests.push({
tagPath,
request: sanitizeRequestForDroppedExtension({
...request,
auth: effectiveAuth ?? request.auth,
}),
})
continue
}
if (server) servers.add(resolveServerPlaceholder(server, variableValues))
@ -732,7 +940,7 @@ export function hoppCollectionToOpenAPI(collection: HoppCollection): {
// Request-level headers
for (const header of request.headers) {
if (!header.active || !header.key) continue
if (SKIP_HEADER_NAMES.has(header.key.toLowerCase())) continue
if (SKIP_HEADER_NAMES.has(header.key.trim().toLowerCase())) continue
pushParam({
name: header.key,
in: "header",
@ -750,7 +958,7 @@ export function hoppCollectionToOpenAPI(collection: HoppCollection): {
for (const headers of inheritedHeaders) {
for (const header of headers) {
if (!header.active || !header.key) continue
if (SKIP_HEADER_NAMES.has(header.key.toLowerCase())) continue
if (SKIP_HEADER_NAMES.has(header.key.trim().toLowerCase())) continue
pushParam({
name: header.key,
in: "header",
@ -974,6 +1182,12 @@ export function hoppCollectionToOpenAPI(collection: HoppCollection): {
;(doc as Record<string, unknown>)["x-hoppscotch-folder-tags"] = "slash"
}
if (droppedRequests.length > 0) {
// Hoppscotch-only x-* payload; OpenAPI consumers should ignore it.
;(doc as Record<string, unknown>)["x-hoppscotch-dropped-requests"] =
droppedRequests
}
// Collection-level auth → global security (only when explicit and non-none)
if (rootInheritedAuth?.authActive) {
if (rootInheritedAuth.authType !== "none") {
@ -1017,5 +1231,8 @@ export function hoppCollectionsToOpenAPI(
preRequestScript: "",
testScript: "",
})
return hoppCollectionToOpenAPI(root)
const { doc } = hoppCollectionToOpenAPI(root)
// Mark the synthetic wrapper so import can restore root collections.
;(doc as Record<string, unknown>)["x-hoppscotch-workspace-root"] = true
return { doc }
}

View File

@ -21,6 +21,8 @@ import {
HoppRESTRequestResponses,
HoppRESTResponseOriginalRequest,
makeHoppRESTResponseOriginalRequest,
parseRawKeyValueEntries,
rawKeyValueEntriesToString,
} from "@hoppscotch/data"
import { pipe, flow } from "fp-ts/function"
import * as A from "fp-ts/Array"
@ -1316,6 +1318,187 @@ const convertPathToHoppReqs = (
* are kept flat to avoid mis-importing third-party docs.
*/
const HOPP_FOLDER_TAGS_MARKER = "x-hoppscotch-folder-tags"
const HOPP_DROPPED_REQUESTS_MARKER = "x-hoppscotch-dropped-requests"
const HOPP_WORKSPACE_ROOT_MARKER = "x-hoppscotch-workspace-root"
const docHasWorkspaceRootMarker = (doc: unknown): boolean =>
typeof doc === "object" &&
doc !== null &&
(doc as Record<string, unknown>)[HOPP_WORKSPACE_ROOT_MARKER] === true
type DroppedRequestEntry = {
tagPath: string | null
request: unknown
}
type OAuth2GrantTypeInfo = Extract<
HoppRESTAuth,
{ authType: "oauth-2" }
>["grantTypeInfo"]
const redactOAuth2RequestParams = (grantTypeInfo: OAuth2GrantTypeInfo) => {
const mutableGrantTypeInfo = grantTypeInfo as Record<string, unknown>
for (const key of [
"authRequestParams",
"tokenRequestParams",
"refreshRequestParams",
] as const) {
const params = mutableGrantTypeInfo[key]
if (!Array.isArray(params)) continue
mutableGrantTypeInfo[key] = params.map((param) =>
typeof param === "object" && param !== null
? { ...param, value: "" }
: param
)
}
}
const sanitizeImportedDroppedRequestAuth = (
auth: HoppRESTAuth
): HoppRESTAuth => {
const redacted = cloneDeep(auth)
switch (redacted.authType) {
case "none":
case "inherit":
return redacted
case "basic":
return { ...redacted, username: "", password: "" }
case "bearer":
return { ...redacted, token: "" }
case "api-key":
return { ...redacted, value: "" }
case "oauth-2": {
const grantTypeInfo = redacted.grantTypeInfo
grantTypeInfo.token = ""
if ("refreshToken" in grantTypeInfo) grantTypeInfo.refreshToken = ""
if ("clientSecret" in grantTypeInfo) grantTypeInfo.clientSecret = ""
if ("username" in grantTypeInfo) grantTypeInfo.username = ""
if ("password" in grantTypeInfo) grantTypeInfo.password = ""
redactOAuth2RequestParams(grantTypeInfo)
return redacted
}
case "aws-signature":
return {
...redacted,
accessKey: "",
secretKey: "",
serviceToken: undefined,
signature: undefined,
}
case "digest":
return {
...redacted,
username: "",
password: "",
nonce: "",
cnonce: "",
opaque: "",
}
case "hawk":
return {
...redacted,
authId: "",
authKey: "",
user: undefined,
nonce: undefined,
ext: undefined,
app: undefined,
dlg: undefined,
timestamp: undefined,
}
case "akamai-eg":
return {
...redacted,
accessToken: "",
clientToken: "",
clientSecret: "",
nonce: undefined,
timestamp: undefined,
}
case "jwt":
return {
...redacted,
secret: "",
privateKey: "",
payload: "{}",
jwtHeaders: "{}",
}
}
}
const sanitizeImportedDroppedRequestBody = (
body: HoppRESTReqBody
): HoppRESTReqBody => {
if (body.contentType === "multipart/form-data") {
return {
...body,
body: Array.isArray(body.body)
? body.body
.filter((entry) => entry.active && entry.key)
.map((entry) => (entry.isFile ? { ...entry, value: "" } : entry))
: [],
}
}
if (body.contentType === "application/x-www-form-urlencoded") {
const bodyStr = typeof body.body === "string" ? body.body : ""
return {
...body,
body: rawKeyValueEntriesToString(
parseRawKeyValueEntries(bodyStr).filter(
(entry) => entry.active && entry.key
)
),
}
}
return body
}
// Mirror the export-side sanitizer's header skip-set so untrusted dropped
// payloads can't restore headers the exporter would have filtered out.
const IMPORT_SKIP_HEADER_NAMES = new Set([
"content-type",
"accept",
"authorization",
"cookie",
])
// Symmetric defense for imports: a crafted OpenAPI doc could embed arbitrary
// credentials/scripts in `x-hoppscotch-dropped-requests[].request`. Preserve
// auth mode semantics, but redact credentials and raw execution surfaces.
const sanitizeImportedDroppedRequest = (
request: HoppRESTRequest
): HoppRESTRequest => ({
...request,
auth: sanitizeImportedDroppedRequestAuth(request.auth),
body: sanitizeImportedDroppedRequestBody(request.body),
params: request.params.filter((p) => p.active),
headers: request.headers.filter(
({ key, active }) =>
active && !IMPORT_SKIP_HEADER_NAMES.has(key.trim().toLowerCase())
),
requestVariables: request.requestVariables.filter((v) => v.active),
preRequestScript: "",
testScript: "",
responses: {},
})
const readDroppedRequests = (doc: unknown): DroppedRequestEntry[] => {
if (typeof doc !== "object" || doc === null) return []
const raw = (doc as Record<string, unknown>)[HOPP_DROPPED_REQUESTS_MARKER]
if (!Array.isArray(raw)) return []
return raw.filter((entry): entry is DroppedRequestEntry => {
if (typeof entry !== "object" || entry === null) return false
if (!("request" in entry)) return false
// Missing tagPath is intentional: restore at top level, same as explicit null.
if (!("tagPath" in entry)) return true
const { tagPath } = entry as DroppedRequestEntry
return typeof tagPath === "string" || tagPath === null
})
}
export const splitTagSegments = (tag: string): string[] =>
tag
@ -1472,7 +1655,7 @@ export const convertOpenApiDocsToHopp = (
}
}
const collections = docs.map((doc) => {
const collections = docs.flatMap((doc) => {
const paths = Object.entries(doc.paths ?? {}).flatMap(
([pathName, pathObj]) => convertPathToHoppReqs(doc, pathName, pathObj)
)
@ -1521,6 +1704,30 @@ export const convertOpenApiDocsToHopp = (
node.requests.push(cloneDeep(request))
}
// Rehydrate requests stashed because OpenAPI cannot represent duplicate (path, method).
for (const entry of readDroppedRequests(doc)) {
const parsed = HoppRESTRequest.safeParse(entry.request)
// Skip malformed entries rather than restoring a silent default request.
if (parsed.type !== "ok") continue
const restored = sanitizeImportedDroppedRequest(parsed.value)
// Match kept-request parameterisation so duplicates share the <<baseUrl>> form.
if (baseUrlValue && restored.endpoint.startsWith(baseUrlValue)) {
restored.endpoint =
"<<baseUrl>>" + restored.endpoint.slice(baseUrlValue.length)
}
if (entry.tagPath) {
const segs = tagNameToFolderSegments(entry.tagPath, shouldSplitTags)
if (segs.length === 0) {
requestsWithoutTags.push(restored)
continue
}
const node = getOrCreateFolderNode(root, segs)
node.requests.push(restored)
} else {
requestsWithoutTags.push(restored)
}
}
// Seed a `baseUrl` collection variable from the doc's resolved server URL
// so users have one place to switch hosts (staging/prod/local) instead of
// every endpoint hardcoding a literal URL.
@ -1541,7 +1748,7 @@ export const convertOpenApiDocsToHopp = (
]
: []
return makeCollection({
const importedCollection = makeCollection({
name: doc.info.title,
description: doc.info.description ?? null,
folders: [...root.children.values()].map(folderTreeNodeToCollection),
@ -1556,6 +1763,24 @@ export const convertOpenApiDocsToHopp = (
preRequestScript: "",
testScript: "",
})
// Unwrap only Hoppscotch workspace wrappers, not arbitrary folder-only docs.
if (
docHasWorkspaceRootMarker(doc) &&
importedCollection.requests.length === 0 &&
importedCollection.folders.length > 0
) {
// Preserve wrapper variables/auth so <<baseUrl>> and doc.security survive unwrapping.
// Clone per child so reactive mutations on one collection don't leak to siblings.
const wrapperVariables = importedCollection.variables
const wrapperAuth = importedCollection.auth
return importedCollection.folders.map((child) => ({
...child,
variables: cloneDeep(wrapperVariables),
auth: cloneDeep(wrapperAuth),
}))
}
return [importedCollection]
})
return TE.of(collections)