Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add support for exporting APIs as .http files #1076

Merged
merged 1 commit into from
Nov 19, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading