From 2d45586ba9055d21e2490db2f40aefec8a9e532f Mon Sep 17 00:00:00 2001 From: "Taro L. Saito" Date: Wed, 30 Oct 2024 08:06:13 -0700 Subject: [PATCH] internal (#3703): Add core module --- .scalafmt.conf | 3 + .../wvlet/airframe/core/log/LogSource.scala | 19 ++ .../airframe/core/log/LoggerMacros.scala | 31 +++ .../scala/wvlet/airframe/core/log/Color.scala | 26 ++ .../airframe/core/log/LogFormatter.scala | 226 ++++++++++++++++++ .../wvlet/airframe/core/log/LogLevel.scala | 18 ++ .../wvlet/airframe/core/log/LogRecord.scala | 36 +++ .../wvlet/airframe/core/log/LogSupport.scala | 55 +++++ .../core/log/LogTimestampFormatter.scala | 52 ++++ .../wvlet/airframe/core/log/Logger.scala | 59 +++++ build.sbt | 27 +++ 11 files changed, 552 insertions(+) create mode 100644 airframe-core-macros/src/main/scala/wvlet/airframe/core/log/LogSource.scala create mode 100644 airframe-core-macros/src/main/scala/wvlet/airframe/core/log/LoggerMacros.scala create mode 100644 airframe-core/src/main/scala/wvlet/airframe/core/log/Color.scala create mode 100644 airframe-core/src/main/scala/wvlet/airframe/core/log/LogFormatter.scala create mode 100644 airframe-core/src/main/scala/wvlet/airframe/core/log/LogLevel.scala create mode 100644 airframe-core/src/main/scala/wvlet/airframe/core/log/LogRecord.scala create mode 100644 airframe-core/src/main/scala/wvlet/airframe/core/log/LogSupport.scala create mode 100644 airframe-core/src/main/scala/wvlet/airframe/core/log/LogTimestampFormatter.scala create mode 100644 airframe-core/src/main/scala/wvlet/airframe/core/log/Logger.scala diff --git a/.scalafmt.conf b/.scalafmt.conf index e2602612a..5b06dd1ff 100644 --- a/.scalafmt.conf +++ b/.scalafmt.conf @@ -20,6 +20,9 @@ runner.dialectOverride.allowSignificantIndentation = false runner.dialectOverride.allowQuestionMarkAsTypeWildcard = false fileOverride { + "glob:airframe-core*/**/scala/**" { + runner.dialect = scala3 + } "glob:**/scala/**" { runner.dialect = scala213source3 } diff --git a/airframe-core-macros/src/main/scala/wvlet/airframe/core/log/LogSource.scala b/airframe-core-macros/src/main/scala/wvlet/airframe/core/log/LogSource.scala new file mode 100644 index 000000000..37fad57dc --- /dev/null +++ b/airframe-core-macros/src/main/scala/wvlet/airframe/core/log/LogSource.scala @@ -0,0 +1,19 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package wvlet.airframe.core.log + + +case class LogSource(fileName: String, line: Int, col: Int): + // We do not include the full path of the source due toe overwhleming log message size + def fileLoc = s"${fileName}:${line}" diff --git a/airframe-core-macros/src/main/scala/wvlet/airframe/core/log/LoggerMacros.scala b/airframe-core-macros/src/main/scala/wvlet/airframe/core/log/LoggerMacros.scala new file mode 100644 index 000000000..cb1d585fc --- /dev/null +++ b/airframe-core-macros/src/main/scala/wvlet/airframe/core/log/LoggerMacros.scala @@ -0,0 +1,31 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package wvlet.airframe.core.log + +object LoggerMacros: + import scala.quoted.* + + inline def sourcePos(): LogSource = ${ sourcePos } + + private def sourcePos(using q: Quotes): Expr[wvlet.airframe.core.log.LogSource] = + import q.reflect.* + val pos = Position.ofMacroExpansion + val line = Expr(pos.startLine) + val column = Expr(pos.endColumn) + val src = pos.sourceFile + val srcPath: java.nio.file.Path = java.nio.file.Paths.get(src.path) + val fileName = Expr(srcPath.getFileName().toString) + '{ wvlet.airframe.core.log.LogSource(${ fileName }, ${ line } + 1, ${ column }) } + + diff --git a/airframe-core/src/main/scala/wvlet/airframe/core/log/Color.scala b/airframe-core/src/main/scala/wvlet/airframe/core/log/Color.scala new file mode 100644 index 000000000..3324ff73e --- /dev/null +++ b/airframe-core/src/main/scala/wvlet/airframe/core/log/Color.scala @@ -0,0 +1,26 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package wvlet.airframe.core.log + +import scala.io.AnsiColor + +object Color extends AnsiColor: + final val GRAY = "\u001b[90m" + final val BRIGHT_RED = "\u001b[91m" + final val BRIGHT_GREEN = "\u001b[92m" + final val BRIGHT_YELLOW = "\u001b[93m" + final val BRIGHT_BLUE = "\u001b[94m" + final val BRIGHT_MAGENTA = "\u001b[95m" + final val BRIGHT_CYAN = "\u001b[96m" + final val BRIGHT_WHITE = "\u001b[97m" diff --git a/airframe-core/src/main/scala/wvlet/airframe/core/log/LogFormatter.scala b/airframe-core/src/main/scala/wvlet/airframe/core/log/LogFormatter.scala new file mode 100644 index 000000000..dae51f088 --- /dev/null +++ b/airframe-core/src/main/scala/wvlet/airframe/core/log/LogFormatter.scala @@ -0,0 +1,226 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package wvlet.airframe.core.log + + +import java.io.{PrintWriter, StringWriter} +import java.util.logging.Formatter +import java.util.regex.Pattern +import java.util.logging as jl +import wvlet.airframe.core.log.LogLevel.{DEBUG, ERROR, INFO, TRACE, WARN} + +/** + * To implement your own log formatter, implement this formatLog(r: LogRecord) method + */ +trait LogFormatter { + def formatLog(r: LogRecord): String +} + +object LogFormatter { + import LogTimestampFormatter.* + import Color.* + + private def currentThreadName: String = Thread.currentThread().getName + + private val testFrameworkFilter = + Pattern.compile("""\s+at (sbt\.|org\.scalatest\.|wvlet\.airspec\.).*""") + val DEFAULT_STACKTRACE_FILTER: String => Boolean = { (line: String) => + !testFrameworkFilter.matcher(line).matches() + } + private var stackTraceFilter: String => Boolean = DEFAULT_STACKTRACE_FILTER + + /** + * Set stack trace line filter + * + * @param filter + */ + def setStackTraceFilter(filter: String => Boolean): Unit = { + stackTraceFilter = filter + } + + def formatStacktrace(e: Throwable): String = { + e match { + case null => + // Exception cause can be null + "" + case _ => + val trace = new StringWriter() + e.printStackTrace(new PrintWriter(trace)) + val stackTraceLines = trace.toString.split("\n") + val filtered = + stackTraceLines + .filter(stackTraceFilter) + .sliding(2) + .collect { case Array(a, b) if a != b => b } + + (stackTraceLines.headOption ++ filtered).mkString("\n") + } + } + + def withColor(prefix: String, s: String) = { + s"${prefix}${s}${Console.RESET}" + } + + def highlightLog(level: LogLevel, message: String): String = { + val color = level match { + case ERROR => Console.RED + case WARN => Console.YELLOW + case INFO => Console.CYAN + case DEBUG => Console.GREEN + case TRACE => Console.MAGENTA + case _ => Console.RESET + } + withColor(color, message) + } + + def appendStackTrace(m: String, r: LogRecord, coloring: Boolean = true): String = { + r.cause match { + case Some(ex) if coloring => + s"${m}\n${highlightLog(r.level, formatStacktrace(ex))}" + case Some(ex) => + s"${m}\n${formatStacktrace(ex)}" + case None => + m + } + } + + object TSVLogFormatter extends LogFormatter { + override def formatLog(record: LogRecord): String = { + val s = Seq.newBuilder[String] + s += formatTimestampWithNoSpaace(record.getMillis()) + s += record.level.toString + s += currentThreadName + s += record.leafLoggerName + s += record.message + + val log = s.result().mkString("\t") + record.cause match { + case Some(ex) => + // Print only the first line of the exception message + s"${log}\n${formatStacktrace(ex).split("\n").head}" + case None => + log + } + } + } + + /** + * Simple log formatter that shows only logger name and message + */ + object SimpleLogFormatter extends LogFormatter { + override def formatLog(r: LogRecord): String = { + val log = + s"[${highlightLog(r.level, r.leafLoggerName)}] ${highlightLog(r.level, r.message)}" + appendStackTrace(log, r) + } + } + + /** + * log format for command-line user client (without source code location) + */ + object AppLogFormatter extends LogFormatter { + override def formatLog(r: LogRecord): String = { + val logTag = highlightLog(r.level, r.level.name) + val log = + f"${withColor(Console.BLUE, formatTimestamp(r.getMillis()))} ${logTag}%14s [${withColor(Console.WHITE, r.leafLoggerName)}] ${highlightLog(r.level, r.message)}" + appendStackTrace(log, r) + } + } + + /** + * log format for debugging source code + */ + object SourceCodeLogFormatter extends LogFormatter { + override def formatLog(r: LogRecord): String = { + val loc = + r.source + .map(source => s" ${withColor(Console.BLUE, s"- (${source.fileLoc})")}") + .getOrElse("") + + val logTag = highlightLog(r.level, r.level.name) + val log = + f"${withColor(Console.BLUE, formatTimestamp(r.getMillis()))} ${logTag}%14s [${withColor( + Console.WHITE, + r.leafLoggerName + )}] ${highlightLog(r.level, r.message)} ${loc}" + appendStackTrace(log, r) + } + } + + object ThreadLogFormatter extends LogFormatter { + override def formatLog(r: LogRecord): String = { + val loc = + r.source + .map(source => s" ${withColor(Console.BLUE, s"- (${source.fileLoc})")}") + .getOrElse("") + + val logTag = highlightLog(r.level, r.level.name) + val log = + f"${withColor(Console.BLUE, formatTimestamp(r.getMillis()))} [${withColor(BRIGHT_BLUE, currentThreadName)}] ${logTag}%14s [${withColor( + Console.WHITE, + r.leafLoggerName + )}] ${highlightLog(r.level, r.message)} ${loc}" + appendStackTrace(log, r) + } + } + + /** + * log format for debugging source code without using ANSI colors + */ + object PlainSourceCodeLogFormatter extends LogFormatter { + override def formatLog(r: LogRecord): String = { + val loc = + r.source + .map(source => s" - (${source.fileLoc})") + .getOrElse("") + + val log = + f"${formatTimestamp(r.getMillis())} ${r.level.name}%5s [${r.leafLoggerName}] ${r.message} ${loc}" + appendStackTrace(log, r, coloring = false) + } + } + + /** + * Enable source code links in the run/debug console of IntelliJ + */ + object IntelliJLogFormatter extends LogFormatter { + override def formatLog(r: LogRecord): String = { + val loc = + r.source + .map(source => s" ${withColor(Console.BLUE, s"- ${r.getLoggerName()}(${source.fileLoc})")}") + .getOrElse("") + + val log = + s"[${highlightLog(r.level, r.level.name)}] ${highlightLog(r.level, r.message)}$loc" + appendStackTrace(log, r) + } + } + + /** + * For formatting log as is. + */ + object BareFormatter extends LogFormatter { + override def formatLog(r: LogRecord): String = { + val m = r.message + r.cause match { + case Some(ex) => + s"${m}\n${formatStacktrace(ex)}" + case None => + m + } + } + } +} + + diff --git a/airframe-core/src/main/scala/wvlet/airframe/core/log/LogLevel.scala b/airframe-core/src/main/scala/wvlet/airframe/core/log/LogLevel.scala new file mode 100644 index 000000000..094920f27 --- /dev/null +++ b/airframe-core/src/main/scala/wvlet/airframe/core/log/LogLevel.scala @@ -0,0 +1,18 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package wvlet.airframe.core.log + +enum LogLevel: + def name: String = this.toString + case OFF, ERROR, WARN, INFO, DEBUG, TRACE diff --git a/airframe-core/src/main/scala/wvlet/airframe/core/log/LogRecord.scala b/airframe-core/src/main/scala/wvlet/airframe/core/log/LogRecord.scala new file mode 100644 index 000000000..e264e0546 --- /dev/null +++ b/airframe-core/src/main/scala/wvlet/airframe/core/log/LogRecord.scala @@ -0,0 +1,36 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package wvlet.airframe.core.log + +case class LogRecord(level: LogLevel, source: Option[LogSource], message: String, cause: Option[Throwable]): + private val timestamp = System.currentTimeMillis() + def getMillis: Long = timestamp + def leafLoggerName: String = + val name = getLoggerName() + leafLoggerNameCache.getOrElseUpdate( + name, { + name match { + case null => "" + case name => + val pos = name.lastIndexOf('.') + if (pos == -1) { + name + } + else { + name.substring(pos + 1) + } + } + } + ) + diff --git a/airframe-core/src/main/scala/wvlet/airframe/core/log/LogSupport.scala b/airframe-core/src/main/scala/wvlet/airframe/core/log/LogSupport.scala new file mode 100644 index 000000000..848b758b0 --- /dev/null +++ b/airframe-core/src/main/scala/wvlet/airframe/core/log/LogSupport.scala @@ -0,0 +1,55 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package wvlet.airframe.core.log + +trait LogSupport { + protected[this] lazy val logger: Logger = Logger(Logger.getLoggerNameOf(this.getClass)) + + inline protected def error(inline message: Any): Unit = + if logger.isEnabled(LogLevel.ERROR) then logger.log(LogLevel.ERROR, LoggerMacros.sourcePos(), message) + + inline protected def warn(inline message: Any): Unit = + if logger.isEnabled(LogLevel.WARN) then logger.log(LogLevel.WARN, LoggerMacros.sourcePos(), message) + + inline protected def info(inline message: Any): Unit = + if logger.isEnabled(LogLevel.INFO) then logger.log(LogLevel.INFO, LoggerMacros.sourcePos(), message) + + inline protected def debug(inline message: Any): Unit = + if logger.isEnabled(LogLevel.DEBUG) then logger.log(LogLevel.DEBUG, LoggerMacros.sourcePos(), message) + + inline protected def trace(inline message: Any): Unit = + if logger.isEnabled(LogLevel.TRACE) then logger.log(LogLevel.TRACE, LoggerMacros.sourcePos(), message) + + inline protected def logAt(inline logLevel: LogLevel, inline message: Any): Unit = + if logger.isEnabled(logLevel) then logger.log(logLevel, LoggerMacros.sourcePos(), message) + + inline protected def error(inline message: Any, inline cause: Throwable): Unit = + if logger.isEnabled(LogLevel.ERROR) then + logger.logWithCause(LogLevel.ERROR, LoggerMacros.sourcePos(), message, cause) + + inline protected def warn(inline message: Any, inline cause: Throwable): Unit = + if logger.isEnabled(LogLevel.WARN) then logger.logWithCause(LogLevel.WARN, LoggerMacros.sourcePos(), message, cause) + + inline protected def info(inline message: Any, inline cause: Throwable): Unit = + if logger.isEnabled(LogLevel.INFO) then logger.logWithCause(LogLevel.INFO, LoggerMacros.sourcePos(), message, cause) + + inline protected def debug(inline message: Any, inline cause: Throwable): Unit = + if logger.isEnabled(LogLevel.DEBUG) then + logger.logWithCause(LogLevel.DEBUG, LoggerMacros.sourcePos(), message, cause) + + inline protected def trace(inline message: Any, inline cause: Throwable): Unit = + if logger.isEnabled(LogLevel.TRACE) then + logger.logWithCause(LogLevel.TRACE, LoggerMacros.sourcePos(), message, cause) + +} diff --git a/airframe-core/src/main/scala/wvlet/airframe/core/log/LogTimestampFormatter.scala b/airframe-core/src/main/scala/wvlet/airframe/core/log/LogTimestampFormatter.scala new file mode 100644 index 000000000..e40d72b52 --- /dev/null +++ b/airframe-core/src/main/scala/wvlet/airframe/core/log/LogTimestampFormatter.scala @@ -0,0 +1,52 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package wvlet.airframe.core.log + +import java.time.{Instant, ZonedDateTime} +import java.time.format.{DateTimeFormatterBuilder, SignStyle} +import java.time.temporal.ChronoField.{ + DAY_OF_MONTH, + HOUR_OF_DAY, + MILLI_OF_SECOND, + MINUTE_OF_HOUR, + MONTH_OF_YEAR, + SECOND_OF_MINUTE, + YEAR +} +import java.util.Locale + +object LogTimestampFormatter { + val humanReadableTimestampFormatter = new DateTimeFormatterBuilder() + .parseCaseInsensitive() + .appendValue(YEAR, 4, 10, SignStyle.EXCEEDS_PAD) + .appendLiteral('-') + .appendValue(MONTH_OF_YEAR, 2) + .appendLiteral('-') + .appendValue(DAY_OF_MONTH, 2) + .appendLiteral(' ') + .appendValue(HOUR_OF_DAY, 2) + .appendLiteral(':') + .appendValue(MINUTE_OF_HOUR, 2) + .appendLiteral(':') + .appendValue(SECOND_OF_MINUTE, 2) + .appendLiteral('.') + .appendValue(MILLI_OF_SECOND, 3) + .appendOffset("+HHMM", "Z") + .toFormatter(Locale.US) + + def formatTimestamp(timeMillis: Long): String = { + val timestamp = ZonedDateTime.ofInstant(Instant.ofEpochMilli(timeMillis), systemZone) + humanReadableTimestampFormatter.format(timestamp) + } +} diff --git a/airframe-core/src/main/scala/wvlet/airframe/core/log/Logger.scala b/airframe-core/src/main/scala/wvlet/airframe/core/log/Logger.scala new file mode 100644 index 000000000..c83cbc461 --- /dev/null +++ b/airframe-core/src/main/scala/wvlet/airframe/core/log/Logger.scala @@ -0,0 +1,59 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package wvlet.airframe.core.log + + +object Logger: + def apply(fullName: String): Logger = + val pos = fullName.lastIndexOf('.') + if pos == -1 then + new Logger(null, fullName) + else + new Logger(Logger(fullName.substring(0, pos)), fullName.substring(pos + 1)) + + private[log] def getLoggerNameOf(cl: Class[_]): String = { + var name = cl.getName + + if (name.endsWith("$")) { + // Remove trailing $ of Scala Object name + name = name.substring(0, name.length - 1) + } + + // When class is an anonymous trait + if (name.contains("$anon$")) { + val interfaces = cl.getInterfaces + if (interfaces != null && interfaces.length > 0) { + // Use the first interface name instead of the anonymous name + name = interfaces(0).getName + } + } + name + } + +class Logger(parent: Logger | Null, val name: String) extends Serializable: + private var _logLevel: LogLevel = LogLevel.INFO + + def logLevel: LogLevel = _logLevel + def setLogLevel(logLevel: LogLevel): Unit = + _logLevel = logLevel + + + def isEnabled(logLevel: LogLevel): Boolean = + logLevel.ordinal >= _logLevel.ordinal + + + def log(logLevel: LogLevel, source: LogSource, message: Any): Unit = ??? + + + def logWithCause(logLevel: LogLevel, source: LogSource, message: Any, cause: Throwable): Unit = ??? diff --git a/build.sbt b/build.sbt index 79b2a79ea..8a654e12d 100644 --- a/build.sbt +++ b/build.sbt @@ -426,6 +426,33 @@ def excludePomDependency(excludes: Seq[String]) = { node: XmlNode => }).transform(node).head } +lazy val coreMacros = + crossProject(JVMPlatform, JSPlatform, NativePlatform) + .crossType(CrossType.Pure) + .in(file("airframe-core-macros")) + .settings(buildSettings) + .settings(scala3Only) + .settings( + name := "airframe-core-macros", + description := "Macro module for airframe-core" + ) + .jsSettings(jsBuildSettings) + .nativeSettings(nativeBuildSettings) + +lazy val core = + crossProject(JVMPlatform, JSPlatform, NativePlatform) + .crossType(CrossType.Pure) + .in(file("airframe-core")) + .settings(buildSettings) + .settings(scala3Only) + .settings( + name := "airframe-core", + description := "A new core module of Airframe for Scala 3" + ) + .jsSettings(jsBuildSettings) + .nativeSettings(nativeBuildSettings) + .dependsOn(coreMacros) + def airframeDIDependencies = Seq( "javax.annotation" % "javax.annotation-api" % JAVAX_ANNOTATION_API_VERSION )