A framework scans PSI for ApiEndpoints declared with a specific
framework's annotations (Spring MVC, JAX-RS, Feign, gRPC, Micronaut, …).
Adding a new one is a one-package operation plus two plugin.xml lines.
Shared concerns (threading, logging, enablement, testing) are in the developer README. This page covers framework-specific concerns only.
Write a framework when you have a new source framework that declares API endpoints in source code. The 6 built-in frameworks:
| Framework | Recognizer | Exporter | enabledByDefault |
|---|---|---|---|
| Spring MVC | SpringControllerRecognizer |
SpringMvcClassExporter |
true |
| Actuator | ActuatorEndpointRecognizer |
ActuatorEndpointExporter |
false |
| JAX-RS | JaxRsResourceRecognizer |
JaxRsClassExporter |
true |
| Feign | FeignClientRecognizer |
FeignClassExporter |
false |
| gRPC | GrpcServiceRecognizer |
GrpcClassExporter |
true |
| Custom | CustomApiRecognizer |
CustomClassExporter |
false |
The Custom framework is rules-driven (no hard-coded annotation detection)
— see custom-framework.md for the rule surface
and the Spring-equivalent reference ruleset.
If you need to export ApiEndpoints (already extracted by a framework
exporter) to a new destination, that's a channel.
A framework registers on two IntelliJ extension points — this is the single most common contributor mistake (registering only the exporter and silently breaking line markers, index scanning, and AI discovery).
| EP name | Interface FQN | Purpose |
|---|---|---|
classExporter |
com.itangcent.easyapi.core.export.ClassExporter |
Extract ApiEndpoints from a PsiClass. Heavy; called per recognized class during a scan. |
apiClassRecognizer |
com.itangcent.easyapi.core.export.recognizer.ApiClassRecognizer |
Cheap "is this an API class?" check. Drives line markers, AnnotatedElementsSearch index scanning, and AI discovery. FrameworkRegistry keys enablement off this EP. |
Both EPs are area="IDEA_PROJECT" → IntelliJ injects Project via the
constructor (one instance per project). The frameworkName you set on the
recognizer is the join key that ties the exporter, the recognizer, and
FrameworkRegistry.isEnabled(frameworkName) together — and is what the user
sees in Settings → General → "Framework Support". A mismatch between the two
silently disables the framework.
The full contract lives in
core/export/ClassExporter.kt.
| Member | Type | Default | Purpose |
|---|---|---|---|
frameworkName |
String |
— | Join key — MUST match the recognizer's frameworkName. |
isEnabled() |
suspend fun → Boolean |
true |
One-liner: FrameworkRegistry.getInstance(project).isEnabled(frameworkName). |
export(psiClass) |
suspend fun → List<ApiEndpoint> |
— | Extract endpoints from a PsiClass. Called only when isEnabled is true. |
Constructor signature (required by IDEA_PROJECT area):
class XxxClassExporter(private val project: Project) : ClassExporter { … }The full contract lives in
core/export/recognizer/ApiClassRecognizer.kt.
| Member | Type | Default | Purpose |
|---|---|---|---|
frameworkName |
String |
— | Join key — MUST match the exporter's frameworkName. |
targetAnnotations |
Set<String> |
— | Annotation FQNs for AnnotatedElementsSearch (index scanning). |
isApiClass(psiClass) |
suspend fun → Boolean |
— | Slow / accurate check; may consult RuleEngine + MetaAnnotationResolver. |
isEnabled(project) |
fun → Boolean |
true |
Per-project state gate (e.g. annotation presence); AND-combined with FrameworkRegistry. |
enabledByDefault |
Boolean |
true |
Compile-time enablement default. |
matchesClass(psiClass) |
fun → Boolean |
false |
Per-class fast-path for line markers. MUST NOT consult the rule engine. |
ruleKeys() |
fun → List<RuleKey<*>> |
emptyList() |
Contributes framework-specific rule keys to RuleKeyRegistry (surfaced to AI tools / RuleProposalValidator). See CustomRuleKeys for the pattern. |
createSettingsPanel(project) |
fun → SettingsPanel<*>? |
null |
Contributes a settings tab to the EasyApi settings dialog (from the SettingsPanelProvider contract, shared with Channel and FieldFormatChannel). |
Constructor signature (EP instantiates with no-arg — recognizers receive their dependencies via constructor parameters with defaults):
class XxxResourceRecognizer(
private val ruleEngine: RuleEngine? = null,
private val enabled: Boolean = true
) : ApiClassRecognizer { … }The CompositeApiClassRecognizer discovers recognizers via the EP and filters
by FrameworkRegistry.isEnabled(it) && it.isEnabled(project). The filter is
re-evaluated whenever settings change (the composite subscribes to
settings-change events and rebuilds its cache).
matchesClass is a fast-path used by line-marker providers — it must
NOT consult the rule engine (which is consulted only in isApiClass).
Default false is correct for annotation-driven frameworks (Spring MVC,
JAX-RS, Feign, Actuator — isApiClass already returns false quickly for
non-matching classes). Two frameworks override it:
- gRPC — its
extends BindableServicewalk is a per-class fast-path the line marker needs. SeeGrpcServiceRecognizer.matchesClass. - Custom — gated by a settings toggle (
CustomSettings.enableLineMarker, defaultfalse). When the user opts in,matchesClassreturnstrueso the line-marker provider claims Custom API classes without consulting the rule engine. SeeCustomApiRecognizer.matchesClassand custom-framework.md → Line-marker toggle.
Getting this wrong regresses line-marker behavior — rule-engine overrides would otherwise cause non-gRPC classes to be marked as gRPC services by the line marker.
JAX-RS is the canonical mirror — its four-resolver split is the layout to copy.
Create framework/<id>/<Id>ResourceRecognizer.kt. Set frameworkName,
targetAnnotations (FQNs for AnnotatedElementsSearch), enabledByDefault.
Implement isApiClass (may consult RuleEngine +
MetaAnnotationResolver).
Override matchesClass only if your framework needs an expensive
non-annotation fast-path (gRPC does; annotation-driven frameworks don't).
package com.itangcent.easyapi.framework.micronaut
import com.intellij.psi.PsiClass
import com.itangcent.easyapi.core.export.recognizer.ApiClassRecognizer
import com.itangcent.easyapi.core.export.recognizer.MetaAnnotationResolver
import com.itangcent.easyapi.core.rule.RuleKeys
import com.itangcent.easyapi.core.rule.engine.RuleEngine
class MicronautControllerRecognizer(
private val ruleEngine: RuleEngine? = null,
private val enabled: Boolean = true
) : ApiClassRecognizer {
override val frameworkName: String = "Micronaut"
override val targetAnnotations: Set<String> = CONTROLLER_ANNOTATIONS
override val enabledByDefault: Boolean = false
override suspend fun isApiClass(psiClass: PsiClass): Boolean {
if (!enabled) return false
if (ruleEngine?.evaluate(RuleKeys.CLASS_IS_MICRONAUT_CTRL, psiClass) == true) return true
return MetaAnnotationResolver.hasMetaAnnotation(psiClass, CONTROLLER_ANNOTATIONS)
}
companion object {
val CONTROLLER_ANNOTATIONS = setOf(
"io.micronaut.http.annotation.Controller"
)
}
}Reference: JaxRsResourceRecognizer.
Create framework/<id>/<Id>ClassExporter.kt. Constructor takes Project;
frameworkName matches the recognizer; isEnabled is one line:
FrameworkRegistry.getInstance(project).isEnabled(frameworkName). In
export(psiClass) iterate ResolvedType.suitableMethods(), fire rule
lifecycle hooks (see below), and build ApiEndpoints via EndpointBuilder.
package com.itangcent.easyapi.framework.micronaut
import com.intellij.openapi.project.Project
import com.intellij.psi.PsiClass
import com.itangcent.easyapi.core.export.*
import com.itangcent.easyapi.core.logging.IdeaLog
import com.itangcent.easyapi.core.psi.helper.DocMetadataResolver
import com.itangcent.easyapi.core.psi.type.ResolvedType
import com.itangcent.easyapi.core.internal.threading.read
import com.itangcent.easyapi.core.rule.engine.RuleEngine
import com.itangcent.easyapi.framework.spi.FrameworkRegistry
class MicronautClassExporter(
private val project: Project
) : ClassExporter {
override val frameworkName: String = "Micronaut"
override suspend fun isEnabled(): Boolean =
FrameworkRegistry.getInstance(project).isEnabled("Micronaut")
private val engine = RuleEngine.getInstance(project)
private val recognizer = MicronautControllerRecognizer(engine)
private val pathResolver = MicronautPathResolver(/* annotationHelper */)
private val methodResolver = MicronautHttpMethodResolver(/* annotationHelper */)
private val parameterResolver = MicronautParameterResolver(/* annotationHelper */)
private val metadataResolver = DocMetadataResolver.getInstance(project)
private val endpointBuilder = EndpointBuilder.getInstance(project)
override suspend fun export(psiClass: PsiClass): List<ApiEndpoint> {
if (!recognizer.isApiClass(psiClass)) return emptyList()
// ... iterate suitableMethods(), fire rule hooks, build ApiEndpoint ...
}
}Reference: JaxRsClassExporter
(4-resolver split, full rule lifecycle, the canonical "annotation-driven
framework" template).
Split per-concern logic into plain Kotlin classes taking an
AnnotationHelper (or similar), wired into the exporter at construction:
| Resolver | Concern |
|---|---|
*PathResolver |
Combine class-level and method-level path annotations. |
*HttpMethodResolver |
Map framework annotations to HttpMethod. |
*ParameterResolver |
Map framework parameter annotations (@QueryParam, @PathParam, …) to ApiParameter + ParameterBinding. |
*ContentTypeResolver |
Extract @Consumes / @Produces or equivalent. |
These are not registered anywhere — they're construction-time
dependencies of the exporter. Reference the JAX-RS 4-resolver split:
JaxRsPathResolver,
JaxRsHttpMethodResolver,
JaxRsParameterResolver,
JaxRsContentTypeResolver.
Add two lines under <extensions defaultExtensionNs="..."> in
plugin.xml:
<classExporter implementation="com.itangcent.easyapi.framework.micronaut.MicronautClassExporter"/>
<apiClassRecognizer implementation="com.itangcent.easyapi.framework.micronaut.MicronautControllerRecognizer"/>Both EPs are area="IDEA_PROJECT" → Project is injected via the
exporter's constructor. (The recognizer's no-arg constructor builds the
default ruleEngine = null / enabled = true shape; the
CompositeApiClassRecognizer re-evaluates enablement on each access via
FrameworkRegistry.)
A framework implementation has substantial shared infrastructure to lean on:
EndpointBuilder— shared utility for building endpoint components:buildHeaders(contentType, paramHeaders, additionalHeaders, additionalResponseHeaders)— header dedup + mergebuildResponseBody(method, resolvedReturnType)— response model withmethod.return/method.return.mainrule overridesexpandBodyParam(resolvedParamType)— request body model from a@BodyparametermergePathParameters(...)— path-param enrichment (enum / default / description)applyReturnMain(...)— apply@returndoc to a specific field
DocMetadataResolver— rule-aware resolvers forapi.name,method.doc, folders, param defaults, additional headers, additional response headers, ignored params.TypeResolver/ResolvedType— type resolution with generics substitution; iterate methods viaResolvedType.ClassType.suitableMethods().UnifiedAnnotationHelper— language-agnostic annotation lookup (works for Java, Kotlin, Groovy, Scala).RuleEngine+RuleKeys— rule evaluation for per-class / per-method overrides (class.is.spring.controller,method.content.type,method.return, …).FrameworkRegistry— the single enablement chokepoint.isEnabled(frameworkId)returnstrueif the user has explicitly enabled it (its id is inGeneralSettings.enabledFrameworks), OR it isenabledByDefaultand not explicitly disabled.
CompositeApiClassRecognizer
discovers recognizers via the EP, filters by isEnabled, caches, and
rebuilds on settings change. It exposes recognizers(),
allTargetAnnotations, and isApiClass(psiClass) to AI tools and other
core consumers — so don't hard-import your recognizer elsewhere. The
composite is the only legitimate way for core.* to reach a framework
recognizer (the framework.spi.* carve-out in the DAG rule).
A framework exporter MUST fire the rule lifecycle hooks around its
parse loop so user rules (api_class.parse.before, method.doc,
api.after, …) can run. The exact engine.evaluate(...) calls (from
JaxRsClassExporter):
// before iterating methods:
withContext(IdeDispatchers.Background) {
engine.evaluate(RuleKeys.API_CLASS_PARSE_BEFORE, psiClass)
}
try {
for (resolvedMethod in resolvedType.suitableMethods()) {
// before each method:
withContext(IdeDispatchers.Background) {
engine.evaluate(RuleKeys.API_METHOD_PARSE_BEFORE, resolvedMethod)
}
try {
// ... build the ApiEndpoint ...
withContext(IdeDispatchers.Background) {
for (endpoint in endpoints) {
engine.evaluate(RuleKeys.EXPORT_AFTER, resolvedMethod) { ctx ->
ctx.setExt("api", endpoint)
}
}
}
} finally {
// after each method (success or failure):
withContext(IdeDispatchers.Background) {
engine.evaluate(RuleKeys.API_METHOD_PARSE_AFTER, resolvedMethod)
}
}
}
} finally {
// after the whole class:
withContext(IdeDispatchers.Background) {
engine.evaluate(RuleKeys.API_CLASS_PARSE_AFTER, psiClass)
}
}API_METHOD_PARSE_AFTER and API_CLASS_PARSE_AFTER go in finally blocks
so they fire even when a method throws. EXPORT_AFTER runs after the
endpoint is built and sets the api extension on the rule context so user
rules can mutate the endpoint before it leaves the exporter.
The full contract lives in
core/export/ApiModels.kt.
data class ApiEndpoint(
val name: String? = null,
val folder: String? = null,
var description: String? = null,
val sourceClass: PsiClass? = null,
val sourceMethod: PsiMethod? = null,
val className: String? = null,
val classDescription: String? = null,
val metadata: ApiMetadata, // HttpMetadata or GrpcMetadata
val extensions: Extension = Extension.EMPTY
)Build HTTP endpoints via the
httpMetadata(...)
factory:
val endpoint = ApiEndpoint(
name = name,
folder = folder,
description = description,
sourceClass = psiClass,
sourceMethod = method,
className = classQualifiedName,
classDescription = classFolder.description,
metadata = httpMetadata(
path = path,
method = httpMethod,
parameters = params + additionalParams,
headers = headers,
contentType = contentType,
body = body,
responseBody = responseBody,
responseType = responseTypeName
)
)HttpMetadata fields: path, method, parameters, headers,
contentType, bodyAttr, alternativePaths, body, responseBody,
responseType. The ParameterBinding sealed class is what binds each
parameter to a request location:
ParameterBinding |
HTTP location |
|---|---|
Query |
Query string (?name=value) |
Path |
URL path (/users/{id}) |
Header |
HTTP header |
Cookie |
Cookie value |
Body |
Request body |
Form |
Form data |
Ignored |
Framework-injected (skip in output) |
For gRPC, build a GrpcMetadata instead (path, serviceName,
methodName, packageName, streamingType, protoFile, body,
responseBody, responseType).
Recognizer + exporter + 2 resolvers + 2 plugin.xml lines:
MicronautControllerRecognizer.kt — see Step 1 above.
MicronautPathResolver.kt:
class MicronautPathResolver(private val annotationHelper: AnnotationHelper) {
suspend fun resolve(psiClass: PsiClass, psiMethod: PsiMethod): String {
val classPath = annotationHelper.findAttrAsString(psiClass, "io.micronaut.http.annotation.Controller", "value")
?.trim('/') ?: ""
val methodPath = annotationHelper.findAttrAsString(psiMethod, "io.micronaut.http.annotation.Get", "value")
?: annotationHelper.findAttrAsString(psiMethod, "io.micronaut.http.annotation.Post", "value")
?: ""
return "/" + listOf(classPath, methodPath.trim('/')).filter { it.isNotEmpty() }.joinToString("/")
}
}MicronautHttpMethodResolver.kt:
class MicronautHttpMethodResolver(private val annotationHelper: AnnotationHelper) {
suspend fun resolve(psiMethod: PsiMethod): HttpMethod? {
for ((ann, method) in ANNOTATION_TO_METHOD) {
if (annotationHelper.findAnnotation(psiMethod, ann) != null) return method
}
return null
}
companion object {
private val ANNOTATION_TO_METHOD = listOf(
"io.micronaut.http.annotation.Get" to HttpMethod.GET,
"io.micronaut.http.annotation.Post" to HttpMethod.POST,
"io.micronaut.http.annotation.Put" to HttpMethod.PUT,
"io.micronaut.http.annotation.Delete" to HttpMethod.DELETE,
"io.micronaut.http.annotation.Patch" to HttpMethod.PATCH,
)
}
}MicronautClassExporter.kt — see Step 2 above; follow
JaxRsClassExporter.exportMethod
for the per-method parse + endpoint-build pattern.
plugin.xml:
<classExporter implementation="com.itangcent.easyapi.framework.micronaut.MicronautClassExporter"/>
<apiClassRecognizer implementation="com.itangcent.easyapi.framework.micronaut.MicronautControllerRecognizer"/>That's the entire integration. The recognizer drives line markers, index scanning, AI discovery, and Settings → General → "Framework Support"; the exporter drives the actual endpoint extraction.
A framework may import from:
core.export.*(ClassExporter,ApiEndpoint,EndpointBuilder,ExportContext,Extension,ParameterBinding,HttpMethod,HttpMetadata, …)core.export.recognizer.*(ApiClassRecognizer,MetaAnnotationResolver)core.psi.*(PsiClassHelper,ResolvedType,TypeResolver,UnifiedAnnotationHelper,DocMetadataResolver,ObjectModel)core.config.*,core.rule.*,core.settings.*core.grpc.*(only forframework.grpc— descriptor reflection, proto)core.util.*,core.logging.*,core.internal.threading.*framework.spi.*(FrameworkRegistry— the onlyframework.*sibling import allowed forcore.*; frameworks may of course import it too)- IntelliJ SDK
- Your own
framework.<id>.*package
A framework MUST NOT import from:
channel.*(channels consume endpoints; frameworks produce them)format.*(formats consume field models; frameworks produce endpoints)- Sibling
framework.<other-id>.*packages
- Recognizer unit tests — plain JUnit; build a
PsiClassfrom a fixture, assertisApiClassreturns the expected value. Test meta-annotations (a custom annotation annotated with@Controller). - Exporter integration tests — extend
EasyApiLightCodeInsightFixtureTestCase, load a fixture.java/.kt, callexport(psiClass), snapshot the resultingApiEndpointlist viaResultLoader.load(...). Test rule-lifecycle hooks by configuring a rule that mutates the endpoint and asserting the mutation landed. - Enablement tests —
FrameworkRegistry.resolveEnabledis extracted as a pureinternal companion fun; unit-test the truth table without aProject.
Reference tests:
ApiEndpointTest,
FrameworkRegistryTest
(if present).
See shared testing concerns in the developer README,
and invoke the write-test-case skill before writing tests.