Skip to content

Commit

Permalink
Merge pull request #22 from hexagonkt/develop
Browse files Browse the repository at this point in the history
Add CLI options
  • Loading branch information
jaguililla authored May 5, 2023
2 parents f6451ed + dbe7f16 commit 9e89666
Show file tree
Hide file tree
Showing 13 changed files with 311 additions and 107 deletions.
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion examples/full.cv.toml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
#!/usr/bin/env cv
#!/usr/bin/env codecv

"$schema" = "https://raw.githubusercontent.com/hexagonkt/codecv/master/cv.schema.json"

Expand Down
2 changes: 1 addition & 1 deletion examples/full.cv.yml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion examples/minimum.cv.toml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
#!/usr/bin/env cv
#!/usr/bin/env codecv

"$schema" = "https://raw.githubusercontent.com/hexagonkt/codecv/master/cv.schema.json"

Expand Down
2 changes: 1 addition & 1 deletion examples/minimum.cv.yml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion examples/modular/brief.cv.yml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion examples/modular/full.cv.yml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion examples/regular.cv.toml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
#!/usr/bin/env cv
#!/usr/bin/env codecv

"$schema" = "https://raw.githubusercontent.com/hexagonkt/codecv/master/cv.schema.json"

Expand Down
2 changes: 1 addition & 1 deletion examples/regular.cv.yml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
222 changes: 143 additions & 79 deletions src/main/kotlin/Cv.kt
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<String, String>): Program {
val urlParamDescription = "URL for the CV file to use. If no schema, 'file' is assumed"
val urlParam = Parameter<String>(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<String>(
shortName = templateOptShortName,
name = "template",
description = "Template used to create the new CV",
regex = Regex("(regular|full|minimum)"),
value = "regular",
),
Option<String>(
shortName = formatOptShortName,
name = "format",
description = "Data format used to store the generated document",
regex = Regex("(yaml|toml|json)"),
value = "yaml",
),
Parameter<String>(
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<String>(templateOptShortName.toString())
val format = command.propertyValueOrNull<String>(formatOptShortName.toString())
val extension = if (format == "yaml") "yml" else format
val file = command.propertyValueOrNull<String>(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<String>(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
Expand All @@ -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<String, String>): 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)
Expand All @@ -142,20 +217,17 @@ 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))
}

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<Collection<String>>("templates")?.firstOrNull() ?: defaultTemplate
Expand Down Expand Up @@ -203,19 +275,11 @@ private fun toCamelCase(data: Any?): Any? =
data
}

private fun validate(data: Map<*, *>): List<String> {
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
}
Loading

0 comments on commit 9e89666

Please sign in to comment.