Skip to content

Commit

Permalink
feat: Add support for exporting APIs as .http files (#1076)
Browse files Browse the repository at this point in the history
  • Loading branch information
tangcent authored Nov 19, 2023
1 parent d759761 commit a86f16c
Show file tree
Hide file tree
Showing 11 changed files with 2,240 additions and 17 deletions.
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
)
}
}
}
}
}
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)
}
}
}
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>
Loading

0 comments on commit a86f16c

Please sign in to comment.