-
Notifications
You must be signed in to change notification settings - Fork 146
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: Add support for exporting APIs as .http files (#1076)
- Loading branch information
Showing
11 changed files
with
2,240 additions
and
17 deletions.
There are no files selected for viewing
91 changes: 91 additions & 0 deletions
91
idea-plugin/src/main/kotlin/com/itangcent/idea/plugin/api/export/http/HttpClientExporter.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,91 @@ | ||
package com.itangcent.idea.plugin.api.export.http | ||
|
||
import com.google.inject.Inject | ||
import com.google.inject.Singleton | ||
import com.itangcent.cache.HttpContextCacheHelper | ||
import com.itangcent.common.logger.traceError | ||
import com.itangcent.common.model.Request | ||
import com.itangcent.idea.plugin.api.export.core.FormatFolderHelper | ||
import com.itangcent.idea.utils.ModuleHelper | ||
import com.itangcent.intellij.logger.Logger | ||
|
||
/** | ||
* export requests as httpClient command | ||
* | ||
* @author tangcent | ||
*/ | ||
@Singleton | ||
class HttpClientExporter { | ||
|
||
@Inject | ||
private lateinit var httpClientFormatter: HttpClientFormatter | ||
|
||
@Inject | ||
private lateinit var httpClientFileSaver: HttpClientFileSaver | ||
|
||
@Inject | ||
private lateinit var logger: Logger | ||
|
||
@Inject | ||
private lateinit var moduleHelper: ModuleHelper | ||
|
||
@Inject | ||
private lateinit var formatFolderHelper: FormatFolderHelper | ||
|
||
@Inject | ||
private lateinit var httpContextCacheHelper: HttpContextCacheHelper | ||
|
||
/** | ||
* Exports a list of HTTP requests to a file. | ||
* | ||
* @param requests The list of HTTP requests to be exported. | ||
*/ | ||
fun export(requests: List<Request>) { | ||
try { | ||
if (requests.isEmpty()) { | ||
logger.info("No api be found to export!") | ||
return | ||
} | ||
exportToFile(requests) | ||
} catch (e: Exception) { | ||
logger.traceError("Apis save failed", e) | ||
} | ||
} | ||
|
||
/** | ||
* Performs exporting of HTTP requests to a file. | ||
* | ||
* @param requests The list of HTTP requests to be exported. | ||
*/ | ||
private fun exportToFile(requests: List<Request>) { | ||
// 1. Group requests by module and folder | ||
val moduleFolderRequestMap = mutableMapOf<Pair<String, String>, MutableList<Request>>() | ||
|
||
for (request in requests) { | ||
val module = moduleHelper.findModule(request.resource!!) ?: "easy-yapi" | ||
val folder = formatFolderHelper.resolveFolder(request.resource!!).name ?: "apis" | ||
val key = Pair(module, folder) | ||
val requestList = moduleFolderRequestMap.getOrPut(key) { mutableListOf() } | ||
requestList.add(request) | ||
} | ||
|
||
// 2. Process each grouped requests | ||
moduleFolderRequestMap.forEach { (key, folderRequests) -> | ||
val (module, folder) = key | ||
val host = httpContextCacheHelper.selectHost("Select Host For $module") | ||
httpClientFileSaver.saveAndOpenHttpFile(module, "$folder.http") { existedContent -> | ||
if (existedContent == null) { | ||
httpClientFormatter.parseRequests( | ||
host = host, requests = folderRequests | ||
) | ||
} else { | ||
httpClientFormatter.parseRequests( | ||
existedDoc = existedContent, | ||
host = host, | ||
requests = folderRequests | ||
) | ||
} | ||
} | ||
} | ||
} | ||
} |
86 changes: 86 additions & 0 deletions
86
idea-plugin/src/main/kotlin/com/itangcent/idea/plugin/api/export/http/HttpClientFileSaver.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,86 @@ | ||
package com.itangcent.idea.plugin.api.export.http | ||
|
||
import com.google.inject.Inject | ||
import com.google.inject.Singleton | ||
import com.intellij.openapi.application.PathManager | ||
import com.intellij.openapi.fileEditor.FileEditorManager | ||
import com.intellij.openapi.project.Project | ||
import com.intellij.openapi.vfs.LocalFileSystem | ||
import com.intellij.openapi.vfs.VirtualFile | ||
import com.intellij.util.io.createDirectories | ||
import com.intellij.util.io.readText | ||
import com.itangcent.intellij.context.ActionContext | ||
import java.io.IOException | ||
import java.nio.file.Files | ||
import java.nio.file.Path | ||
import java.nio.file.Paths | ||
import kotlin.io.path.writeText | ||
|
||
/** | ||
* Handling HTTP file saving and opening within a project. | ||
* | ||
* @author tangcent | ||
*/ | ||
@Singleton | ||
class HttpClientFileSaver { | ||
|
||
@Inject | ||
private lateinit var project: Project | ||
|
||
@Inject | ||
private lateinit var actionContext: ActionContext | ||
|
||
private val scratchesPath: Path by lazy { Paths.get(PathManager.getConfigPath(), "scratches") } | ||
|
||
private val localFileSystem by lazy { LocalFileSystem.getInstance() } | ||
|
||
/** | ||
* Saves the HTTP file with the specified content, derived from the provided lambda, | ||
* and opens the file in the editor. | ||
* | ||
* @param module The name of the module under which the file should be saved. | ||
* @param fileName The name of the file to be saved. | ||
* @param content A lambda that generates the content of the file, optionally based on the existing content. | ||
*/ | ||
fun saveAndOpenHttpFile( | ||
module: String, | ||
fileName: String, | ||
content: (String?) -> String, | ||
) { | ||
val file = saveHttpFile(module, fileName, content) | ||
openInEditor(file) | ||
} | ||
|
||
/** | ||
* Saves the HTTP file with the specified content, derived from the provided lambda. | ||
* | ||
* @param module The name of the module under which the file should be saved. | ||
* @param fileName The name of the file to be saved. | ||
* @param content A lambda that generates the content of the file, optionally based on the existing content. | ||
* @return The VirtualFile object representing the saved file. | ||
*/ | ||
private fun saveHttpFile( | ||
module: String, | ||
fileName: String, | ||
content: (String?) -> String, | ||
): VirtualFile { | ||
val file = scratchesPath.resolve(module).resolve(fileName).apply { | ||
parent.createDirectories() | ||
} | ||
file.writeText(content(file.takeIf { Files.exists(it) }?.readText())) | ||
|
||
return (localFileSystem.refreshAndFindFileByPath(file.toString()) | ||
?: throw IOException("Unable to find file: $file")) | ||
} | ||
|
||
/** | ||
* Opens the specified file in the editor. | ||
* | ||
* @param file The VirtualFile object representing the file to be opened. | ||
*/ | ||
private fun openInEditor(file: VirtualFile) { | ||
actionContext.runInWriteUI { | ||
FileEditorManager.getInstance(project).openFile(file, true) | ||
} | ||
} | ||
} |
211 changes: 211 additions & 0 deletions
211
idea-plugin/src/main/kotlin/com/itangcent/idea/plugin/api/export/http/HttpClientFormatter.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,211 @@ | ||
package com.itangcent.idea.plugin.api.export.http | ||
|
||
import com.google.inject.Inject | ||
import com.google.inject.Singleton | ||
import com.itangcent.common.model.Request | ||
import com.itangcent.common.model.getContentType | ||
import com.itangcent.common.utils.IDUtils | ||
import com.itangcent.http.RequestUtils | ||
import com.itangcent.idea.psi.resource | ||
import com.itangcent.intellij.context.ActionContext | ||
import com.itangcent.intellij.psi.PsiClassUtils | ||
|
||
/** | ||
* `HttpClientFormatter` is a utility class responsible for formatting and parsing HTTP client requests. | ||
* This class also provides functionality to update existing documentation with new or altered requests. | ||
* | ||
* @author tangcent | ||
*/ | ||
@Singleton | ||
class HttpClientFormatter { | ||
|
||
@Inject | ||
private lateinit var actionContext: ActionContext | ||
|
||
companion object { | ||
// Reference prefix used in documentation | ||
const val REF = "### ref: " | ||
} | ||
|
||
/** | ||
* Parses and formats a list of `Request` objects into a string representation. | ||
* | ||
* @param host The host URL. | ||
* @param requests The list of requests to be processed. | ||
* @return A string representation of the formatted requests. | ||
*/ | ||
fun parseRequests( | ||
host: String, | ||
requests: List<Request> | ||
): String { | ||
val sb = StringBuilder() | ||
for (request in requests) { | ||
parseRequest(host, request, sb) | ||
} | ||
return sb.toString() | ||
} | ||
|
||
/** | ||
* Parses and formats a list of `Request` objects, updating an existing documentation string. | ||
* | ||
* @param existedDoc The existing documentation string. | ||
* @param host The host URL. | ||
* @param requests The list of requests to be processed. | ||
* @return A string representation of the updated and formatted requests. | ||
*/ | ||
fun parseRequests( | ||
existedDoc: String, | ||
host: String, | ||
requests: List<Request> | ||
): String { | ||
val docs = splitDoc(existedDoc) | ||
val requestMap = requests.associateBy { | ||
it.ref() | ||
} | ||
val sb = StringBuilder() | ||
|
||
// Process and update existing entries | ||
for (doc in docs) { | ||
val request = requestMap[doc.first] | ||
if (request != null) { | ||
parseRequest(host, request, sb) | ||
} else { | ||
sb.append(REF).append(doc.first).append("\n") | ||
.append(doc.second) | ||
} | ||
} | ||
|
||
// Process new requests | ||
val processedRefs = docs.map { it.first }.toSet() | ||
requests.filter { request -> | ||
request.ref() !in processedRefs | ||
}.forEach { request -> | ||
parseRequest(host, request, sb) | ||
} | ||
|
||
return sb.toString().trimEnd('\n', '#', ' ') | ||
} | ||
|
||
/** | ||
* Splits an existing documentation string into a list of `RefDoc` objects for easy processing. | ||
* | ||
* @param doc The existing documentation string. | ||
* @return A list of `RefDoc` objects representing the split documentation. | ||
*/ | ||
private fun splitDoc(doc: String): List<RefDoc> { | ||
val refDocs = mutableListOf<RefDoc>() | ||
val lines = doc.lines() | ||
var ref: String? = null | ||
val sb = StringBuilder() | ||
for (line in lines) { | ||
if (line.startsWith(REF)) { | ||
if (ref != null) { | ||
refDocs.add(RefDoc(ref, sb.toString())) | ||
sb.clear() | ||
} | ||
ref = line.substring(REF.length) | ||
} else { | ||
sb.append(line).append("\n") | ||
} | ||
} | ||
if (ref != null) { | ||
refDocs.add(RefDoc(ref, sb.toString())) | ||
} | ||
return refDocs | ||
} | ||
|
||
/** | ||
* Parses and formats a single `Request` object, appending the formatted string to a `StringBuilder`. | ||
* | ||
* @param host The host URL. | ||
* @param request The request to be processed. | ||
* @param sb The `StringBuilder` to which the formatted string is appended. | ||
*/ | ||
private fun parseRequest( | ||
host: String, | ||
request: Request, | ||
sb: StringBuilder | ||
) { | ||
sb.appendRef(request) | ||
val apiName = request.name ?: (request.method + ":" + request.path?.url()) | ||
sb.append("### $apiName\n\n") | ||
if (!request.desc.isNullOrEmpty()) { | ||
request.desc!!.lines().forEach { | ||
sb.append("// ").append(it).append("\n") | ||
} | ||
} | ||
|
||
sb.append(request.method).append(" ") | ||
.append(RequestUtils.concatPath(host, request.path?.url() ?: "")) | ||
if (!request.querys.isNullOrEmpty()) { | ||
val query = request.querys!!.joinToString("&") { "${it.name}=${it.value ?: ""}" } | ||
if (query.isNotEmpty()) { | ||
sb.append("?").append(query) | ||
} | ||
} | ||
|
||
sb.append("\n") | ||
|
||
request.headers?.forEach { header -> | ||
sb.appendHeader(header.name ?: "", header.value) | ||
} | ||
|
||
val contentType = request.getContentType() | ||
when { | ||
contentType?.contains("application/json") == true -> { | ||
request.body?.let { body -> | ||
sb.append("\n") | ||
sb.append(RequestUtils.parseRawBody(body)) | ||
} | ||
} | ||
|
||
contentType?.contains("application/x-www-form-urlencoded") == true -> { | ||
request.formParams?.let { formParams -> | ||
val formData = formParams.joinToString("&") { "${it.name}=${it.value ?: ""}" } | ||
sb.append("\n") | ||
sb.append(formData) | ||
} | ||
} | ||
|
||
contentType?.contains("multipart/form-data") == true -> { | ||
request.formParams?.let { formParams -> | ||
sb.append("\n") | ||
sb.append("Content-Type: multipart/form-data; boundary=WebAppBoundary\n") | ||
for (param in formParams) { | ||
sb.append("\n--WebAppBoundary\n") | ||
if (param.type == "file") { | ||
sb.append("Content-Disposition: form-data; name=\"${param.name}\"; filename=\"${param.value ?: "file"}\"\n") | ||
sb.append("\n< ./relative/path/to/${param.value ?: "file"}\n") | ||
} else { | ||
sb.append("Content-Disposition: form-data; name=\"${param.name}\"\n") | ||
sb.append("\n${param.value ?: "[${param.name}]"}\n") | ||
} | ||
sb.append("--WebAppBoundary--\n") | ||
} | ||
} | ||
} | ||
} | ||
|
||
sb.appendEnd() | ||
} | ||
|
||
private fun StringBuilder.appendEnd() { | ||
append("\n\n###\n\n") | ||
} | ||
|
||
private fun StringBuilder.appendHeader(name: String, value: String?) = | ||
append(name).append(": ").append(value ?: "").append("\n") | ||
|
||
private fun StringBuilder.appendRef(request: Request) = | ||
append(REF).append(request.ref()) | ||
.append("\n") | ||
|
||
private fun Request.ref(): String = resource()?.let { | ||
actionContext.callInReadUI { PsiClassUtils.fullNameOfMember(it) } | ||
} ?: IDUtils.shortUUID() | ||
} | ||
|
||
/** | ||
* Type alias for a pair representing a reference and its documentation | ||
*/ | ||
typealias RefDoc = Pair<String, String> |
Oops, something went wrong.