diff --git a/README.md b/README.md index f32572e..ad7f093 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,11 @@ This project has two sides: Here you can see a [3 min quick demo](https://youtu.be/t4aOSJibuzs). +## 💻 Install +1. Download the latest binary from the [releases page](/~https://github.com/hexagonkt/codecv/releases) +2. Copy or link the binary to a directory in the PATH +3. Type `codecv --help` to check how to use the reference tool + ## 🤔 Motivation The format was developed and evolved with the simple requirement to store and maintain my own CV. @@ -52,7 +57,18 @@ editors to attach a schema to a file). Some formats (YAML and TOML) allow the use of a ['shebang'][shebang] to make them "executable" and launch the CV server automatically upon execution. Check the examples for more information. +The schema is also published in the [JSON Schema Repository](https://www.schemastore.org/json). This +means that CV documents will be supported out of the box at some code editors (most notably +[VS Code] and [JetBrains IDEs]). + +It implies that you will get autocomplete, documentation and validation for the following file +patterns: +- `cv.{json,yaml,yml,toml}`, +- `*.cv.{json,yaml,yml,toml}`, + [shebang]: https://en.wikipedia.org/wiki/Shebang_(Unix) +[VS Code]: https://code.visualstudio.com +[JetBrains IDEs]: https://www.jetbrains.com ## 🧰 Examples You can check some CV examples (in different formats) on the [/examples](/examples) directory. diff --git a/build.gradle.kts b/build.gradle.kts index 8a51869..131399f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -10,8 +10,8 @@ plugins { val os = getProperty("os.name").lowercase() -val hexagonVersion = "2.8.3" -val hexagonExtraVersion = "2.8.3" +val hexagonVersion = "2.8.4" +val hexagonExtraVersion = "2.8.4" val vertxVersion = "4.4.1" val gradleScripts = "https://raw.githubusercontent.com/hexagonkt/hexagon/$hexagonVersion/gradle" diff --git a/examples/full.cv.toml b/examples/full.cv.toml index 37e65b9..3ad1c2c 100755 --- a/examples/full.cv.toml +++ b/examples/full.cv.toml @@ -1,4 +1,4 @@ -#!/usr/bin/env cv +#!/usr/bin/env codecv "$schema" = "https://raw.githubusercontent.com/hexagonkt/codecv/master/cv.schema.json" diff --git a/examples/full.cv.yml b/examples/full.cv.yml index 19ba645..c07de07 100755 --- a/examples/full.cv.yml +++ b/examples/full.cv.yml @@ -1,4 +1,4 @@ -#!/usr/bin/env cv +#!/usr/bin/env codecv # yaml-language-server: $schema=https://raw.githubusercontent.com/hexagonkt/codecv/master/cv.schema.json $schema: https://raw.githubusercontent.com/hexagonkt/codecv/master/cv.schema.json diff --git a/examples/minimum.cv.toml b/examples/minimum.cv.toml index 9a82c07..85079b0 100755 --- a/examples/minimum.cv.toml +++ b/examples/minimum.cv.toml @@ -1,4 +1,4 @@ -#!/usr/bin/env cv +#!/usr/bin/env codecv "$schema" = "https://raw.githubusercontent.com/hexagonkt/codecv/master/cv.schema.json" diff --git a/examples/minimum.cv.yml b/examples/minimum.cv.yml index d08ee60..6d5fa11 100755 --- a/examples/minimum.cv.yml +++ b/examples/minimum.cv.yml @@ -1,4 +1,4 @@ -#!/usr/bin/env cv +#!/usr/bin/env codecv # yaml-language-server: $schema=https://raw.githubusercontent.com/hexagonkt/codecv/master/cv.schema.json $schema: https://raw.githubusercontent.com/hexagonkt/codecv/master/cv.schema.json diff --git a/examples/modular/brief.cv.yml b/examples/modular/brief.cv.yml index 5369baa..9c907be 100755 --- a/examples/modular/brief.cv.yml +++ b/examples/modular/brief.cv.yml @@ -1,4 +1,4 @@ -#!/usr/bin/env cv +#!/usr/bin/env codecv # yaml-language-server: $schema=https://raw.githubusercontent.com/hexagonkt/codecv/master/cv.schema.json $schema: https://raw.githubusercontent.com/hexagonkt/codecv/master/cv.schema.json diff --git a/examples/modular/full.cv.yml b/examples/modular/full.cv.yml index bb03b4e..0a66577 100755 --- a/examples/modular/full.cv.yml +++ b/examples/modular/full.cv.yml @@ -1,4 +1,4 @@ -#!/usr/bin/env cv +#!/usr/bin/env codecv # yaml-language-server: $schema=https://raw.githubusercontent.com/hexagonkt/codecv/master/cv.schema.json $schema: https://raw.githubusercontent.com/hexagonkt/codecv/master/cv.schema.json diff --git a/examples/regular.cv.toml b/examples/regular.cv.toml index 6b1fd7c..5674b1b 100755 --- a/examples/regular.cv.toml +++ b/examples/regular.cv.toml @@ -1,4 +1,4 @@ -#!/usr/bin/env cv +#!/usr/bin/env codecv "$schema" = "https://raw.githubusercontent.com/hexagonkt/codecv/master/cv.schema.json" diff --git a/examples/regular.cv.yml b/examples/regular.cv.yml index 5445471..02dcb90 100755 --- a/examples/regular.cv.yml +++ b/examples/regular.cv.yml @@ -1,4 +1,4 @@ -#!/usr/bin/env cv +#!/usr/bin/env codecv # yaml-language-server: $schema=https://raw.githubusercontent.com/hexagonkt/codecv/master/cv.schema.json $schema: https://raw.githubusercontent.com/hexagonkt/codecv/master/cv.schema.json diff --git a/src/main/kotlin/Cv.kt b/src/main/kotlin/Cv.kt index b93b376..4cb540e 100644 --- a/src/main/kotlin/Cv.kt +++ b/src/main/kotlin/Cv.kt @@ -1,18 +1,13 @@ package co.codecv -import com.hexagonkt.args.Command -import com.hexagonkt.args.Option -import com.hexagonkt.args.Parameter -import com.hexagonkt.args.Program +import com.hexagonkt.args.* import com.hexagonkt.args.Property.Companion.HELP import com.hexagonkt.args.Property.Companion.VERSION -import com.hexagonkt.core.exists -import com.hexagonkt.core.getPath +import com.hexagonkt.core.* import com.hexagonkt.core.logging.LoggingManager import com.hexagonkt.core.logging.logger import com.hexagonkt.core.media.mediaTypeOfOrNull -import com.hexagonkt.core.require -import com.hexagonkt.core.merge +import com.hexagonkt.helpers.CodedException import com.hexagonkt.helpers.properties import com.hexagonkt.helpers.wordsToCamel import com.hexagonkt.logging.jul.JulLoggingAdapter @@ -37,49 +32,160 @@ import io.vertx.json.schema.Draft.DRAFT7 import io.vertx.json.schema.JsonSchema import io.vertx.json.schema.JsonSchemaOptions import io.vertx.json.schema.Validator +import java.io.File import java.net.URI import java.net.URL import java.nio.file.Path import kotlin.io.path.exists import kotlin.system.exitProcess +const val preventExitFlag: String = "PREVENT_EXIT" +const val exitCodeProperty: String = "EXIT_CODE" + const val spec: String = "classpath:spec.yml" const val schema: String = "classpath:cv.schema.json" const val defaultTemplate: String = "classpath:templates/cv.html" const val buildProperties: String = "classpath:META-INF/build.properties" +const val mainPage: String = "classpath:ui.html" + +const val serveCommandName: String = "serve" +const val createCommandName: String = "create" +const val validateCommandName: String = "validate" + +const val urlParamName: String = "url" +const val fileParamName: String = "file" +const val templateOptShortName: Char = 't' +const val formatOptShortName: Char = 'f' lateinit var server: HttpServer fun main(vararg args: String) { - val buildProperties = properties(URL(buildProperties)) - val project = buildProperties.require("project") - - LoggingManager.adapter = JulLoggingAdapter(messageOnly = true, stream = System.err) - LoggingManager.defaultLoggerName = project - SerializationManager.formats = linkedSetOf(Yaml, Json, Toml) + try { + val buildProperties = properties(URL(buildProperties)) + val project = buildProperties.require("project") - val program = createProgram(buildProperties) - val command = program.parse(args) + LoggingManager.adapter = JulLoggingAdapter(messageOnly = true, stream = System.err) + LoggingManager.defaultLoggerName = project + SerializationManager.formats = linkedSetOf(Yaml, Json, Toml) - when (command.name) { - project -> serve(command) + val program = createProgram(buildProperties) + val command = program.parse(args) - else -> error("") + when (command.name) { + serveCommandName, project -> serve(command) + createCommandName -> create(command) + validateCommandName -> validate(command) + } + } + catch (e: Exception) { + exit(e) } } -private fun serve(command: Command) { - val url = command.parametersMap.require("url").values.first().let { - val urlValue = it as String - if (URI(urlValue).scheme != null) urlValue else "file:$urlValue" - } +private fun exit(exception: Exception) { + logger.error(exception) { exception.message } + val code = (exception as? CodedException)?.code ?: 500 - if(!URL(url).exists()) { - logger.error { "CV url not found: $url" } - exitProcess(1) - } + if (Jvm.systemFlag(preventExitFlag)) + System.setProperty(exitCodeProperty, code.toString()) + else + exitProcess(code) +} + +private fun createProgram(buildProperties: Map): Program { + val urlParamDescription = "URL for the CV file to use. If no schema, 'file' is assumed" + val urlParam = Parameter(urlParamName, urlParamDescription, optional = false) + + val serveCommand = Command( + name = serveCommandName, + title = "Serve a CV document", + description = "Serve the CV document supplied, allowing it to be rendered on a browser", + properties = setOf(HELP, urlParam), + ) + + val createCommand = Command( + name = createCommandName, + title = "Create a CV document", + description = "Creates a new CV document based on a template", + properties = setOf( + HELP, + Option( + shortName = templateOptShortName, + name = "template", + description = "Template used to create the new CV", + regex = Regex("(regular|full|minimum)"), + value = "regular", + ), + Option( + shortName = formatOptShortName, + name = "format", + description = "Data format used to store the generated document", + regex = Regex("(yaml|toml|json)"), + value = "yaml", + ), + Parameter( + name = fileParamName, + description = "File to store the CV document. Document printed on stdout if missed", + ) + ), + ) + + val validateCommand = Command( + name = validateCommandName, + title = "Validate an existing CV", + description = "Returns a list of errors and a 400 code if the CV document is not valid", + properties = setOf(HELP, urlParam), + ) + + return Program( + name = buildProperties.require("project"), + version = buildProperties.require("version"), + description = buildProperties.require("description"), + properties = setOf(VERSION) + serveCommand.properties, + commands = setOf(serveCommand, createCommand, validateCommand), + ) +} + +private fun create(command: Command) { + val template = command.propertyValueOrNull(templateOptShortName.toString()) + val format = command.propertyValueOrNull(formatOptShortName.toString()) + val extension = if (format == "yaml") "yml" else format + val file = command.propertyValueOrNull(fileParamName) + val url = URL("classpath:examples/$template.cv.$extension") + val content = url.readText() + + if (file != null) + File(file).writeText(content) + else + logger.info { content } +} + +private fun urlParameter(command: Command): URL { + val urlParameter = command.propertyValue(urlParamName) + .let { if (URI(it).scheme != null) it else "file:$it" } + + val url = URL(urlParameter) + return if (!url.exists()) throw CodedException(404, "CV url not found: $url") else url +} + +private fun validate(command: Command) { + val url = urlParameter(command) + + if(!url.exists()) + throw CodedException(404, "CV url not found: $url") + + val valid = validate(url.parseMap()) + + if (!valid) + throw CodedException(400, "Document in '$url' don't comply with CV schema") +} + +private fun serve(command: Command) { TemplateManager.defaultAdapter = PebbleAdapter(false, 1 * 1024 * 1024) + + val url = urlParameter(command) + val urlString = url.toString() val serverSettings = HttpServerSettings(zip = true) val protocol = serverSettings.protocol.toString().lowercase() val hostName = serverSettings.bindAddress.hostName @@ -93,43 +199,12 @@ private fun serve(command: Command) { get("/openapi.{format}") { getReformattedData(spec) } get("/schema.{format}") { getReformattedData(schema) } - get("/cv.{format}") { getReformattedData(url) } - get("/cv") { renderCv(url, base) } - get(callback = UrlCallback(URL("classpath:ui.html"))) + get("/cv.{format}") { getReformattedData(urlString) } + get("/cv") { renderCv(urlString, base) } + get(callback = UrlCallback(URL(mainPage))) } } -private fun createProgram(buildProperties: Map): Program { - val urlParameterDescription = "URL to the CV file to use. If no schema, 'file' is assumed" - val urlParameter = Parameter(String::class, "url", urlParameterDescription, optional = false) - val kindOption = Option(String::class, 'k', "kind", "desc", Regex("(regular|full|minimum)"), value = "regular") - val formatOption = Option(String::class, 'f', "format", "desc", Regex("(yaml|json|toml)"), value = "yaml") - return Program( - name = buildProperties.require("project"), - version = buildProperties.require("version"), - description = buildProperties.require("description"), - properties = setOf( - VERSION, - HELP, - urlParameter - ), - commands = setOf( - Command( - name = "create", - title = "title", - description = "description", - properties = setOf(HELP, kindOption, formatOption, urlParameter), - ), - Command( - name = "validate", - title = "title", - description = "description", - properties = setOf(HELP, urlParameter), - ), - ), - ) -} - private fun HttpContext.addHeaders(scriptSources: String): HttpContext { val contentSecurityValues = listOf("script-src $scriptSources", "object-src none") val contentSecurityPolicy = Header("content-security-policy", contentSecurityValues) @@ -142,7 +217,7 @@ private fun HttpContext.getReformattedData(url: String): HttpContext { val data = URL(url).parseMap() val format = pathParameters.require("format") val mediaType = mediaTypeOfOrNull(format) - ?: return badRequest("Invalid extension (only 'yaml', 'yml' and 'json' allowed): $format") + ?: return badRequest("Invalid extension (only 'yaml', 'yml', 'toml' and 'json'): $format") return ok(data.serialize(mediaType), contentType = ContentType(mediaType)) } @@ -150,12 +225,9 @@ private fun HttpContext.getReformattedData(url: String): HttpContext { private fun HttpContext.renderCv(cvUrl: String, base: String): HttpContext { val url = URL(cvUrl) val cvData = url.parseMap() - val errors = validate(cvData) - if (errors.isNotEmpty()) { - val errorSeparator = "\n - " - val errorsText = errors.joinToString(errorSeparator, errorSeparator) - return badRequest("CV does not complain with schema:$errorsText") - } + val valid = validate(cvData) + if (!valid) + return badRequest("CV does not complain with schema") val cv = decode(cvData, url) val template = cv.getPath>("templates")?.firstOrNull() ?: defaultTemplate @@ -203,19 +275,11 @@ private fun toCamelCase(data: Any?): Any? = data } -private fun validate(data: Map<*, *>): List { +private fun validate(data: Map<*, *>): Boolean { val schemaMap = URL(schema).parseMap() val jsonSchema = JsonSchema.of(JsonObject.mapFrom(schemaMap)) val options = JsonSchemaOptions().setDraft(DRAFT7).apply { baseUri = "file:./" } val validator = Validator.create(jsonSchema, options) - return validator.validate(JsonObject.mapFrom(data)) - .errors - ?.map { - val error = it.error - val location = it.instanceLocation - val keywordLocation = it.keywordLocation - "$error at $location. Cause at: $keywordLocation" - } - ?: emptyList() + return validator.validate(JsonObject.mapFrom(data)).valid } diff --git a/src/test/kotlin/CvTest.kt b/src/test/kotlin/CvTest.kt index 12b1321..866ae75 100644 --- a/src/test/kotlin/CvTest.kt +++ b/src/test/kotlin/CvTest.kt @@ -3,17 +3,38 @@ package co.codecv import com.hexagonkt.http.client.HttpClient import com.hexagonkt.http.client.HttpClientSettings import com.hexagonkt.http.client.jetty.JettyClientAdapter +import com.hexagonkt.http.model.BAD_REQUEST_400 import com.hexagonkt.http.model.HttpResponsePort +import com.hexagonkt.http.model.HttpStatus import com.hexagonkt.http.model.OK_200 +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.api.TestInstance.Lifecycle.PER_CLASS +import java.io.File +import java.lang.System.getProperty +import java.lang.System.setProperty import java.net.URL import kotlin.test.assertEquals +@TestInstance(PER_CLASS) internal class CvTest { + @BeforeEach fun setUp() { + setProperty(exitCodeProperty, "") + } + + @AfterEach fun shutDown() { + setProperty(exitCodeProperty, "") + setProperty(preventExitFlag, false.toString()) + } + @Test fun `Check modular examples`() { testCv("file:examples/modular/full.cv", setOf("yml")) testCv("file:examples/modular/brief.cv", setOf("yml")) + testCv("examples/modular/full.cv", setOf("yml")) + testCv("examples/modular/brief.cv", setOf("yml")) } @Test fun `Check examples`() { @@ -22,30 +43,107 @@ internal class CvTest { testCv("file:examples/regular.cv") } + @Test fun `Check create command`() { + setProperty(preventExitFlag, true.toString()) + + main("create") + checkExitCode() + + main("create", "-f", "json") + checkExitCode() + main("create", "-t", "minimum") + checkExitCode() + main("create", "-f", "json", "-t", "minimum") + checkExitCode() + + listOf("regular", "full", "minimum").forEach { t -> + listOf("yaml", "toml", "json").forEach { f -> + main("create", "-t", t, "-f", f, "build/$t.cv.$f") + checkExitCode() + assertEquals( + File("build/$t.cv.$f").readText(), + File("examples/$t.cv.${if (f == "yaml") "yml" else f}").readText() + ) + } + } + } + + @Test fun `Check validate command`() { + setProperty(preventExitFlag, true.toString()) + + main("validate", "file:build/x.cv.yml") + checkExitCode(404) + main("validate", "file:examples/full.cv.yml") + checkExitCode() + main("validate", "examples/full.cv.yml") + checkExitCode() + main("validate", "file:src/test/resources/incorrect.cv.yml") + checkExitCode(400) + } + + @Test fun `Check serve command`() { + setProperty(preventExitFlag, true.toString()) + + main("serve", "file:examples/full.cv.yml") + testHttp(server.runtimePort) + server.stop() + checkExitCode() + + main("file:build/invalid.cv.yml") + checkExitCode(404) + main("serve", "file:build/invalid.cv.yml") + checkExitCode(404) + main("build/invalid.cv.yml") + checkExitCode(404) + + main("serve", "file:src/test/resources/incorrect.cv.yml") + val baseUrl = URL("http://localhost:${server.runtimePort}") + val settings = HttpClientSettings(baseUrl = baseUrl) + val http = HttpClient(JettyClientAdapter(), settings) + http.start() + assertEquals(BAD_REQUEST_400, http.get("/cv").status) + server.stop() + } + private fun testCv(url: String, extensions: Set = setOf("json", "toml", "yml")) { extensions.forEach { main("${url}.${it}") - - val baseUrl = URL("http://localhost:${server.runtimePort}") - val settings = HttpClientSettings(baseUrl = baseUrl) - val http = HttpClient(JettyClientAdapter(), settings) - - http.start() - http.get("/openapi.json").checkResponse() - http.get("/openapi.yaml").checkResponse() - http.get("/openapi.yml").checkResponse() - http.get("/cv.json").checkResponse() - http.get("/cv.yaml").checkResponse() - http.get("/cv.yml").checkResponse() - http.get("/cv").checkResponse() - http.get() - http.stop() - + testHttp(server.runtimePort) server.stop() } } - private fun HttpResponsePort.checkResponse() { - assertEquals(OK_200, status) + private fun testHttp(port: Int) { + val baseUrl = URL("http://localhost:$port") + val settings = HttpClientSettings(baseUrl = baseUrl) + val http = HttpClient(JettyClientAdapter(), settings) + + http.start() + http.get("/openapi.json").checkResponse() + http.get("/openapi.yaml").checkResponse() + http.get("/openapi.yml").checkResponse() + http.get("/cv.json").checkResponse() + http.get("/cv.toml").checkResponse() + http.get("/cv.yaml").checkResponse() + http.get("/cv.yml").checkResponse() + http.get("/cv").checkResponse() + http.get("/cv.x").checkResponse(BAD_REQUEST_400) + http.get() + http.stop() + } + + private fun checkExitCode() { + val code = getProperty(exitCodeProperty) + assert(code == null || code == "") + setProperty(exitCodeProperty, "") + } + + private fun checkExitCode(code: Int) { + assertEquals(code.toString(), getProperty(exitCodeProperty)) + setProperty(exitCodeProperty, "") + } + + private fun HttpResponsePort.checkResponse(expectedStatus: HttpStatus = OK_200) { + assertEquals(expectedStatus, status) } } diff --git a/src/test/resources/incorrect.cv.yml b/src/test/resources/incorrect.cv.yml new file mode 100755 index 0000000..6dbd26a --- /dev/null +++ b/src/test/resources/incorrect.cv.yml @@ -0,0 +1,26 @@ +#!/usr/bin/env codecv +# yaml-language-server: $schema=https://raw.githubusercontent.com/hexagonkt/codecv/master/cv.schema.json + +$schema: https://raw.githubusercontent.com/hexagonkt/codecv/master/cv.schema.json + +Locale: en_US +Templates: [ file:templates/cv.html ] + +Personal: + GivenName: Richard + Family Name: Hendricks + Native Language: en + Gender: man + Birth: 1988-08-16 + Birth Country: US + Alias: Bitchard + Photo: https://static.wikia.nocookie.net/silicon-valley/images/3/33/Richard_Hendricks.jpg + +Residence: + Country: false + Region: California + City: San Francisco + +Contact: + Mobile: +1 654 12 34 56 + Email: richard@piedpiper.com