mirror of
https://github.com/hoppscotch/hoppscotch.git
synced 2026-06-04 21:05:33 +08:00
fix(common): preserve collection tree on OpenAPI re-import (#6376)
Some checks failed
Node.js CI / Test (22) (push) Has been cancelled
Some checks failed
Node.js CI / Test (22) (push) Has been cancelled
This commit is contained in:
parent
029aa9246c
commit
e067ee0b32
File diff suppressed because it is too large
Load Diff
@ -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 }
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user