openapi.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628
  1. import {
  2. OpenAPI,
  3. OpenAPIV2,
  4. OpenAPIV3,
  5. OpenAPIV3_1 as OpenAPIV31,
  6. } from "openapi-types"
  7. import SwaggerParser from "@apidevtools/swagger-parser"
  8. import yaml from "js-yaml"
  9. import {
  10. FormDataKeyValue,
  11. HoppRESTAuth,
  12. HoppRESTHeader,
  13. HoppRESTParam,
  14. HoppRESTReqBody,
  15. HoppRESTRequest,
  16. knownContentTypes,
  17. makeRESTRequest,
  18. HoppCollection,
  19. makeCollection,
  20. } from "@hoppscotch/data"
  21. import { pipe, flow } from "fp-ts/function"
  22. import * as A from "fp-ts/Array"
  23. import * as S from "fp-ts/string"
  24. import * as O from "fp-ts/Option"
  25. import * as TE from "fp-ts/TaskEither"
  26. import * as RA from "fp-ts/ReadonlyArray"
  27. import { step } from "../steps"
  28. import { defineImporter, IMPORTER_INVALID_FILE_FORMAT } from "."
  29. export const OPENAPI_DEREF_ERROR = "openapi/deref_error" as const
  30. // TODO: URL Import Support
  31. const safeParseJSON = (str: string) => O.tryCatch(() => JSON.parse(str))
  32. const safeParseYAML = (str: string) => O.tryCatch(() => yaml.load(str))
  33. const objectHasProperty = <T extends string>(
  34. obj: unknown,
  35. propName: T
  36. // eslint-disable-next-line
  37. ): obj is { [propName in T]: unknown } =>
  38. !!obj &&
  39. typeof obj === "object" &&
  40. Object.prototype.hasOwnProperty.call(obj, propName)
  41. type OpenAPIPathInfoType =
  42. | OpenAPIV2.PathItemObject<{}>
  43. | OpenAPIV3.PathItemObject<{}>
  44. | OpenAPIV31.PathItemObject<{}>
  45. type OpenAPIParamsType =
  46. | OpenAPIV2.ParameterObject
  47. | OpenAPIV3.ParameterObject
  48. | OpenAPIV31.ParameterObject
  49. type OpenAPIOperationType =
  50. | OpenAPIV2.OperationObject
  51. | OpenAPIV3.OperationObject
  52. | OpenAPIV31.OperationObject
  53. // Removes the OpenAPI Path Templating to the Hoppscotch Templating (<< ? >>)
  54. const replaceOpenApiPathTemplating = flow(
  55. S.replace(/{/g, "<<"),
  56. S.replace(/}/g, ">>")
  57. )
  58. const parseOpenAPIParams = (params: OpenAPIParamsType[]): HoppRESTParam[] =>
  59. pipe(
  60. params,
  61. A.filterMap(
  62. flow(
  63. O.fromPredicate((param) => param.in === "query"),
  64. O.map(
  65. (param) =>
  66. <HoppRESTParam>{
  67. key: param.name,
  68. value: "", // TODO: Can we do anything more ? (parse default values maybe)
  69. active: true,
  70. }
  71. )
  72. )
  73. )
  74. )
  75. const parseOpenAPIHeaders = (params: OpenAPIParamsType[]): HoppRESTHeader[] =>
  76. pipe(
  77. params,
  78. A.filterMap(
  79. flow(
  80. O.fromPredicate((param) => param.in === "header"),
  81. O.map(
  82. (header) =>
  83. <HoppRESTParam>{
  84. key: header.name,
  85. value: "", // TODO: Can we do anything more ? (parse default values maybe)
  86. active: true,
  87. }
  88. )
  89. )
  90. )
  91. )
  92. const parseOpenAPIV2Body = (op: OpenAPIV2.OperationObject): HoppRESTReqBody => {
  93. const obj = (op.consumes ?? [])[0] as string | undefined
  94. // Not a content-type Hoppscotch supports
  95. if (!obj || !(obj in knownContentTypes))
  96. return { contentType: null, body: null }
  97. // Textual Content Types, so we just parse it and keep
  98. if (
  99. obj !== "multipart/form-data" &&
  100. obj !== "application/x-www-form-urlencoded"
  101. )
  102. return { contentType: obj as any, body: "" }
  103. const formDataValues = pipe(
  104. (op.parameters ?? []) as OpenAPIV2.Parameter[],
  105. A.filterMap(
  106. flow(
  107. O.fromPredicate((param) => param.in === "body"),
  108. O.map(
  109. (param) =>
  110. <FormDataKeyValue>{
  111. key: param.name,
  112. isFile: false,
  113. value: "",
  114. active: true,
  115. }
  116. )
  117. )
  118. )
  119. )
  120. return obj === "application/x-www-form-urlencoded"
  121. ? {
  122. contentType: obj,
  123. body: formDataValues.map(({ key }) => `${key}: `).join("\n"),
  124. }
  125. : { contentType: obj, body: formDataValues }
  126. }
  127. const parseOpenAPIV3BodyFormData = (
  128. contentType: "multipart/form-data" | "application/x-www-form-urlencoded",
  129. mediaObj: OpenAPIV3.MediaTypeObject | OpenAPIV31.MediaTypeObject
  130. ): HoppRESTReqBody => {
  131. const schema = mediaObj.schema as
  132. | OpenAPIV3.SchemaObject
  133. | OpenAPIV31.SchemaObject
  134. | undefined
  135. if (!schema || schema.type !== "object") {
  136. return contentType === "application/x-www-form-urlencoded"
  137. ? { contentType, body: "" }
  138. : { contentType, body: [] }
  139. }
  140. const keys = Object.keys(schema.properties ?? {})
  141. if (contentType === "application/x-www-form-urlencoded") {
  142. return {
  143. contentType,
  144. body: keys.map((key) => `${key}: `).join("\n"),
  145. }
  146. } else {
  147. return {
  148. contentType,
  149. body: keys.map(
  150. (key) =>
  151. <FormDataKeyValue>{ key, value: "", isFile: false, active: true }
  152. ),
  153. }
  154. }
  155. }
  156. const parseOpenAPIV3Body = (
  157. op: OpenAPIV3.OperationObject | OpenAPIV31.OperationObject
  158. ): HoppRESTReqBody => {
  159. const objs = Object.entries(
  160. (
  161. op.requestBody as
  162. | OpenAPIV3.RequestBodyObject
  163. | OpenAPIV31.RequestBodyObject
  164. | undefined
  165. )?.content ?? {}
  166. )
  167. if (objs.length === 0) return { contentType: null, body: null }
  168. // We only take the first definition
  169. const [contentType, media]: [
  170. string,
  171. OpenAPIV3.MediaTypeObject | OpenAPIV31.MediaTypeObject
  172. ] = objs[0]
  173. return contentType in knownContentTypes
  174. ? contentType === "multipart/form-data" ||
  175. contentType === "application/x-www-form-urlencoded"
  176. ? parseOpenAPIV3BodyFormData(contentType, media)
  177. : { contentType: contentType as any, body: "" }
  178. : { contentType: null, body: null }
  179. }
  180. const isOpenAPIV3Operation = (
  181. doc: OpenAPI.Document,
  182. op: OpenAPIOperationType
  183. ): op is OpenAPIV3.OperationObject | OpenAPIV31.OperationObject =>
  184. objectHasProperty(doc, "openapi") &&
  185. typeof doc.openapi === "string" &&
  186. doc.openapi.startsWith("3.")
  187. const parseOpenAPIBody = (
  188. doc: OpenAPI.Document,
  189. op: OpenAPIOperationType
  190. ): HoppRESTReqBody =>
  191. isOpenAPIV3Operation(doc, op)
  192. ? parseOpenAPIV3Body(op)
  193. : parseOpenAPIV2Body(op)
  194. const resolveOpenAPIV3SecurityObj = (
  195. scheme: OpenAPIV3.SecuritySchemeObject | OpenAPIV31.SecuritySchemeObject,
  196. _schemeData: string[] // Used for OAuth to pass params
  197. ): HoppRESTAuth => {
  198. if (scheme.type === "http") {
  199. if (scheme.scheme === "basic") {
  200. // Basic
  201. return { authType: "basic", authActive: true, username: "", password: "" }
  202. } else if (scheme.scheme === "bearer") {
  203. // Bearer
  204. return { authType: "bearer", authActive: true, token: "" }
  205. } else {
  206. // Unknown/Unsupported Scheme
  207. return { authType: "none", authActive: true }
  208. }
  209. } else if (scheme.type === "apiKey") {
  210. if (scheme.in === "header") {
  211. return {
  212. authType: "api-key",
  213. authActive: true,
  214. addTo: "Headers",
  215. key: scheme.name,
  216. value: "",
  217. }
  218. } else if (scheme.in === "query") {
  219. return {
  220. authType: "api-key",
  221. authActive: true,
  222. addTo: "Query params",
  223. key: scheme.in,
  224. value: "",
  225. }
  226. }
  227. } else if (scheme.type === "oauth2") {
  228. // NOTE: We select flow on a first come basis on this order, authorizationCode > implicit > password > clientCredentials
  229. if (scheme.flows.authorizationCode) {
  230. return {
  231. authType: "oauth-2",
  232. authActive: true,
  233. accessTokenURL: scheme.flows.authorizationCode.tokenUrl ?? "",
  234. authURL: scheme.flows.authorizationCode.authorizationUrl ?? "",
  235. clientID: "",
  236. oidcDiscoveryURL: "",
  237. scope: _schemeData.join(" "),
  238. token: "",
  239. }
  240. } else if (scheme.flows.implicit) {
  241. return {
  242. authType: "oauth-2",
  243. authActive: true,
  244. authURL: scheme.flows.implicit.authorizationUrl ?? "",
  245. accessTokenURL: "",
  246. clientID: "",
  247. oidcDiscoveryURL: "",
  248. scope: _schemeData.join(" "),
  249. token: "",
  250. }
  251. } else if (scheme.flows.password) {
  252. return {
  253. authType: "oauth-2",
  254. authActive: true,
  255. authURL: "",
  256. accessTokenURL: scheme.flows.password.tokenUrl ?? "",
  257. clientID: "",
  258. oidcDiscoveryURL: "",
  259. scope: _schemeData.join(" "),
  260. token: "",
  261. }
  262. } else if (scheme.flows.clientCredentials) {
  263. return {
  264. authType: "oauth-2",
  265. authActive: true,
  266. accessTokenURL: scheme.flows.clientCredentials.tokenUrl ?? "",
  267. authURL: "",
  268. clientID: "",
  269. oidcDiscoveryURL: "",
  270. scope: _schemeData.join(" "),
  271. token: "",
  272. }
  273. } else {
  274. return {
  275. authType: "oauth-2",
  276. authActive: true,
  277. accessTokenURL: "",
  278. authURL: "",
  279. clientID: "",
  280. oidcDiscoveryURL: "",
  281. scope: _schemeData.join(" "),
  282. token: "",
  283. }
  284. }
  285. } else if (scheme.type === "openIdConnect") {
  286. return {
  287. authType: "oauth-2",
  288. authActive: true,
  289. accessTokenURL: "",
  290. authURL: "",
  291. clientID: "",
  292. oidcDiscoveryURL: scheme.openIdConnectUrl ?? "",
  293. scope: _schemeData.join(" "),
  294. token: "",
  295. }
  296. }
  297. return { authType: "none", authActive: true }
  298. }
  299. const resolveOpenAPIV3SecurityScheme = (
  300. doc: OpenAPIV3.Document | OpenAPIV31.Document,
  301. schemeName: string,
  302. schemeData: string[]
  303. ): HoppRESTAuth => {
  304. const scheme = doc.components?.securitySchemes?.[schemeName] as
  305. | OpenAPIV3.SecuritySchemeObject
  306. | undefined
  307. if (!scheme) return { authType: "none", authActive: true }
  308. else return resolveOpenAPIV3SecurityObj(scheme, schemeData)
  309. }
  310. const resolveOpenAPIV3Security = (
  311. doc: OpenAPIV3.Document | OpenAPIV31.Document,
  312. security:
  313. | OpenAPIV3.SecurityRequirementObject[]
  314. | OpenAPIV31.SecurityRequirementObject[]
  315. ): HoppRESTAuth => {
  316. // NOTE: Hoppscotch only considers the first security requirement
  317. const sec = security[0] as OpenAPIV3.SecurityRequirementObject | undefined
  318. if (!sec) return { authType: "none", authActive: true }
  319. // NOTE: We only consider the first security condition within the first condition
  320. const [schemeName, schemeData] = (Object.entries(sec)[0] ?? [
  321. undefined,
  322. undefined,
  323. ]) as [string | undefined, string[] | undefined]
  324. if (!schemeName || !schemeData) return { authType: "none", authActive: true }
  325. return resolveOpenAPIV3SecurityScheme(doc, schemeName, schemeData)
  326. }
  327. const parseOpenAPIV3Auth = (
  328. doc: OpenAPIV3.Document | OpenAPIV31.Document,
  329. op: OpenAPIV3.OperationObject | OpenAPIV31.OperationObject
  330. ): HoppRESTAuth => {
  331. const rootAuth = doc.security
  332. ? resolveOpenAPIV3Security(doc, doc.security)
  333. : undefined
  334. const opAuth = op.security
  335. ? resolveOpenAPIV3Security(doc, op.security)
  336. : undefined
  337. return opAuth ?? rootAuth ?? { authType: "none", authActive: true }
  338. }
  339. const resolveOpenAPIV2SecurityScheme = (
  340. scheme: OpenAPIV2.SecuritySchemeObject,
  341. _schemeData: string[]
  342. ): HoppRESTAuth => {
  343. if (scheme.type === "basic") {
  344. return { authType: "basic", authActive: true, username: "", password: "" }
  345. } else if (scheme.type === "apiKey") {
  346. // V2 only supports in: header and in: query
  347. return {
  348. authType: "api-key",
  349. addTo: scheme.in === "header" ? "Headers" : "Query params",
  350. authActive: true,
  351. key: scheme.name,
  352. value: "",
  353. }
  354. } else if (scheme.type === "oauth2") {
  355. // NOTE: We select flow on a first come basis on this order, accessCode > implicit > password > application
  356. if (scheme.flow === "accessCode") {
  357. return {
  358. authType: "oauth-2",
  359. authActive: true,
  360. accessTokenURL: scheme.tokenUrl ?? "",
  361. authURL: scheme.authorizationUrl ?? "",
  362. clientID: "",
  363. oidcDiscoveryURL: "",
  364. scope: _schemeData.join(" "),
  365. token: "",
  366. }
  367. } else if (scheme.flow === "implicit") {
  368. return {
  369. authType: "oauth-2",
  370. authActive: true,
  371. accessTokenURL: "",
  372. authURL: scheme.authorizationUrl ?? "",
  373. clientID: "",
  374. oidcDiscoveryURL: "",
  375. scope: _schemeData.join(" "),
  376. token: "",
  377. }
  378. } else if (scheme.flow === "application") {
  379. return {
  380. authType: "oauth-2",
  381. authActive: true,
  382. accessTokenURL: scheme.tokenUrl ?? "",
  383. authURL: "",
  384. clientID: "",
  385. oidcDiscoveryURL: "",
  386. scope: _schemeData.join(" "),
  387. token: "",
  388. }
  389. } else if (scheme.flow === "password") {
  390. return {
  391. authType: "oauth-2",
  392. authActive: true,
  393. accessTokenURL: scheme.tokenUrl ?? "",
  394. authURL: "",
  395. clientID: "",
  396. oidcDiscoveryURL: "",
  397. scope: _schemeData.join(" "),
  398. token: "",
  399. }
  400. } else {
  401. return {
  402. authType: "oauth-2",
  403. authActive: true,
  404. accessTokenURL: "",
  405. authURL: "",
  406. clientID: "",
  407. oidcDiscoveryURL: "",
  408. scope: _schemeData.join(" "),
  409. token: "",
  410. }
  411. }
  412. }
  413. return { authType: "none", authActive: true }
  414. }
  415. const resolveOpenAPIV2SecurityDef = (
  416. doc: OpenAPIV2.Document,
  417. schemeName: string,
  418. schemeData: string[]
  419. ): HoppRESTAuth => {
  420. const scheme = Object.entries(doc.securityDefinitions ?? {}).find(
  421. ([name]) => schemeName === name
  422. )
  423. if (!scheme) return { authType: "none", authActive: true }
  424. const schemeObj = scheme[1]
  425. return resolveOpenAPIV2SecurityScheme(schemeObj, schemeData)
  426. }
  427. const resolveOpenAPIV2Security = (
  428. doc: OpenAPIV2.Document,
  429. security: OpenAPIV2.SecurityRequirementObject[]
  430. ): HoppRESTAuth => {
  431. // NOTE: Hoppscotch only considers the first security requirement
  432. const sec = security[0] as OpenAPIV2.SecurityRequirementObject | undefined
  433. if (!sec) return { authType: "none", authActive: true }
  434. // NOTE: We only consider the first security condition within the first condition
  435. const [schemeName, schemeData] = (Object.entries(sec)[0] ?? [
  436. undefined,
  437. undefined,
  438. ]) as [string | undefined, string[] | undefined]
  439. if (!schemeName || !schemeData) return { authType: "none", authActive: true }
  440. return resolveOpenAPIV2SecurityDef(doc, schemeName, schemeData)
  441. }
  442. const parseOpenAPIV2Auth = (
  443. doc: OpenAPIV2.Document,
  444. op: OpenAPIV2.OperationObject
  445. ): HoppRESTAuth => {
  446. const rootAuth = doc.security
  447. ? resolveOpenAPIV2Security(doc, doc.security)
  448. : undefined
  449. const opAuth = op.security
  450. ? resolveOpenAPIV2Security(doc, op.security)
  451. : undefined
  452. return opAuth ?? rootAuth ?? { authType: "none", authActive: true }
  453. }
  454. const parseOpenAPIAuth = (
  455. doc: OpenAPI.Document,
  456. op: OpenAPIOperationType
  457. ): HoppRESTAuth =>
  458. isOpenAPIV3Operation(doc, op)
  459. ? parseOpenAPIV3Auth(doc as OpenAPIV3.Document | OpenAPIV31.Document, op)
  460. : parseOpenAPIV2Auth(doc as OpenAPIV2.Document, op)
  461. const convertPathToHoppReqs = (
  462. doc: OpenAPI.Document,
  463. pathName: string,
  464. pathObj: OpenAPIPathInfoType
  465. ) =>
  466. pipe(
  467. ["get", "head", "post", "put", "delete", "options", "patch"] as const,
  468. // Filter and map out path info
  469. RA.filterMap(
  470. flow(
  471. O.fromPredicate((method) => !!pathObj[method]),
  472. O.map((method) => ({ method, info: pathObj[method]! }))
  473. )
  474. ),
  475. // Construct request object
  476. RA.map(({ method, info }) =>
  477. makeRESTRequest({
  478. name: info.operationId ?? info.summary ?? "Untitled Request",
  479. method: method.toUpperCase(),
  480. endpoint: `<<baseUrl>>${replaceOpenApiPathTemplating(pathName)}`, // TODO: Make this proper
  481. // We don't need to worry about reference types as the Dereferencing pass should remove them
  482. params: parseOpenAPIParams(
  483. (info.parameters as OpenAPIParamsType[] | undefined) ?? []
  484. ),
  485. headers: parseOpenAPIHeaders(
  486. (info.parameters as OpenAPIParamsType[] | undefined) ?? []
  487. ),
  488. auth: parseOpenAPIAuth(doc, info),
  489. body: parseOpenAPIBody(doc, info),
  490. preRequestScript: "",
  491. testScript: "",
  492. })
  493. ),
  494. // Disable Readonly
  495. RA.toArray
  496. )
  497. const convertOpenApiDocToHopp = (
  498. doc: OpenAPI.Document
  499. ): TE.TaskEither<never, HoppCollection<HoppRESTRequest>[]> => {
  500. const name = doc.info.title
  501. const paths = Object.entries(doc.paths ?? {})
  502. .map(([pathName, pathObj]) => convertPathToHoppReqs(doc, pathName, pathObj))
  503. .flat()
  504. return TE.of([
  505. makeCollection<HoppRESTRequest>({
  506. name,
  507. folders: [],
  508. requests: paths,
  509. }),
  510. ])
  511. }
  512. const parseOpenAPIDocContent = (str: string) =>
  513. pipe(
  514. str,
  515. safeParseJSON,
  516. O.match(
  517. () => safeParseYAML(str),
  518. (data) => O.of(data)
  519. )
  520. )
  521. export default defineImporter({
  522. id: "openapi",
  523. name: "import.from_openapi",
  524. applicableTo: ["my-collections", "team-collections", "url-import"],
  525. icon: "file",
  526. steps: [
  527. step({
  528. stepName: "FILE_IMPORT",
  529. metadata: {
  530. caption: "import.from_openapi_description",
  531. acceptedFileTypes: ".json, .yaml, .yml",
  532. },
  533. }),
  534. ] as const,
  535. importer: ([fileContent]) =>
  536. pipe(
  537. // See if we can parse JSON properly
  538. fileContent,
  539. parseOpenAPIDocContent,
  540. TE.fromOption(() => IMPORTER_INVALID_FILE_FORMAT),
  541. // Try validating, else the importer is invalid file format
  542. TE.chainW((obj) =>
  543. pipe(
  544. TE.tryCatch(
  545. () => SwaggerParser.validate(obj),
  546. () => IMPORTER_INVALID_FILE_FORMAT
  547. )
  548. )
  549. ),
  550. // Deference the references
  551. TE.chainW((obj) =>
  552. pipe(
  553. TE.tryCatch(
  554. () => SwaggerParser.dereference(obj),
  555. () => OPENAPI_DEREF_ERROR
  556. )
  557. )
  558. ),
  559. TE.chainW(convertOpenApiDocToHopp)
  560. ),
  561. })