Initial commit.

This commit is contained in:
jay
2016-09-27 11:16:23 +01:00
parent 61ee23c0f2
commit 77941e2ce0
15 changed files with 6532 additions and 0 deletions
+25
View File
@@ -0,0 +1,25 @@
<assembly xmlns="http://maven.apache.org/plugins/maven-assembly-plugin/assembly/1.1.3"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/plugins/maven-assembly-plugin/assembly/1.1.3 http://maven.apache.org/xsd/assembly-1.1.3.xsd">
<id>zip</id>
<formats>
<format>zip</format>
</formats>
<includeBaseDirectory>false</includeBaseDirectory>
<dependencySets>
<dependencySet>
<outputDirectory>lib</outputDirectory>
<useProjectArtifact>true</useProjectArtifact>
<scope>runtime</scope>
</dependencySet>
</dependencySets>
<fileSets>
<fileSet>
<directory>src/main/bin</directory>
<outputDirectory>bin</outputDirectory>
<lineEnding>unix</lineEnding>
<fileMode>0755</fileMode>
</fileSet>
</fileSets>
</assembly>
+10
View File
@@ -0,0 +1,10 @@
#!/usr/bin/env bash
cd "$(dirname "$0")/.."
mkdir -p logs
LOG=logs/ticket.$(date "+%Y-%m-%d").$$.log
nohup java -cp .:lib/* org/ethtrader/ticker/EntryKt $* >> $LOG 2>&1 &
tail -F $LOG
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,65 @@
package org.ethtrader.ticker
import com.typesafe.config.Config
import com.typesafe.config.ConfigFactory
import java.awt.Color
import java.lang.Long
import java.net.URL
import kotlin.reflect.KFunction1
class TickerEthConfig(getResourceUrl: (String) -> URL) {
init {
System.setProperty("java.awt.headless", "true")
}
private val conf = ConfigFactory.parseURL(getResourceUrl("/ticker.conf")).getConfig("ticker")
private val providerConf = conf.getConfig("providers")
private val dataPointConf = conf.getConfig("dataPoints")
private val tickerConf = conf.getConfig("tickers")
val reddit = RedditConf(conf)
val providers = providerConf.keys().map { it to ProviderConf(providerConf, it) }.toMap()
val dataPoints = dataPointConf.keys().map { it to dataPointConf.getStringList(it) }.toMap()
val tickers = tickerConf.keys().map { it to TickerConf(tickerConf, it) }.toMap()
}
class TickerConf(config: Config, name: String) {
private val tickerConf = config.getConfig(name)
val font = tickerConf.getString("font")
val fontHeight = tickerConf.getInt("fontHeight")
val color = tickerConf.getString("color").toColor()
val background = tickerConf.getString("background").toColor()
val backgroundHighlight = tickerConf.getString("backgroundHighlight").toColor()
val positiveColor = tickerConf.getString("positiveColor").toColor()
val negativeColor = tickerConf.getString("negativeColor").toColor()
val segments = tickerConf.getStringList("segments")
val top = tickerConf.getString("top")
val cssSelector = tickerConf.getString("cssSelector")
}
fun String.toColor(): Color = if (startsWith("#") && length == 9) java.lang.Long.decode(this).let {
Color(((it shr 24) and 0xFF).toInt(), ((it shr 16) and 0xFF).toInt(), ((it shr 8) and 0xFF).toInt(), (it and 0xFF).toInt())
} else Color.decode(this)
fun Config.keys(): Set<String> =
entrySet().map { it.key.split(".", limit = 2).first() }.toSet()
class RedditConf(config: Config) {
private val redditConf = config.getConfig("reddit")
val frequencySeconds = redditConf.getLong("frequencySeconds")
val authUrl = redditConf.getString("authUrl")
val apiUrl = redditConf.getString("apiUrl")
}
class ProviderConf(config: Config, name: String) {
private val providerConf = config.getConfig(name)
private val fieldsConf = providerConf.getConfig("fields")
val url = providerConf.getString("url")
val fields: Map<String, String> = fieldsConf.keys().map { it to fieldsConf.getString(it) }.toMap()
}
fun Config.getStringOr(key: String, default: String?) = try {
getString(key).toString()
} catch(e: Throwable) {
default
}
@@ -0,0 +1,87 @@
package org.ethtrader.ticker
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.registerKotlinModule
import org.glassfish.jersey.client.ClientProperties
import java.lang.System.clearProperty
import java.lang.System.setProperty
import java.math.BigDecimal
import java.net.URL
import java.security.SecureRandom
import java.security.cert.X509Certificate
import javax.net.ssl.SSLContext
import javax.net.ssl.TrustManager
import javax.net.ssl.X509TrustManager
import javax.ws.rs.client.Client
import javax.ws.rs.client.ClientBuilder
class DataReader(providers: Map<String, ProviderConf>) {
private val client = ClientBuilder.newBuilder()
.property(ClientProperties.CONNECT_TIMEOUT, 10000)
.property(ClientProperties.READ_TIMEOUT, 10000)
.sslContext(sslContext())
private val objectMapper = ObjectMapper()
.registerKotlinModule()
private val providerFunctions = providers.map { provider ->
provider.key to { values: List<String> ->
try {
val response = readObject(provider.value.url.format(*values.toTypedArray()))
println("JSON Response form provider [${provider.key}], $values: $response")
println("Reading fields: ${provider.value.fields}")
response.parseFields(provider.value.fields)
} catch (e: Throwable) {
println("Unexpected exception: " + e.printStackTrace())
mapOf<String, BigDecimal>()
}
}
}.toMap()
fun read(dataPoints: Map<String, List<String>>): Map<String, Map<String, BigDecimal?>?> {
val data = dataPoints.map {
val function = providerFunctions.get(it.value.first())
it.key to if (function == null) null else function(it.value.drop(1))
}.toMap()
println("Data points retrieved:\n - ${data.map { it }.joinToString("\n - ")}")
return data
}
private fun JsonNode.parseFields(fields: Map<String, String>): Map<String, BigDecimal?> =
fields.map {
it.key to try {
val text = at(it.value).asText()
if (text.isNullOrEmpty()) BigDecimal.ZERO else BigDecimal(text)
} catch(t: Throwable) {
null
}
}.toMap()
private fun readObject(url: String, attempts: Int = 3): JsonNode {
println("Fetching from URL: $url")
return retry(times = 10) {
Thread.sleep(1000)
val json = client.build().target(url).request().get(String::class.java)
objectMapper.readTree(json)
}
}
private fun sslContext(): SSLContext? {
val sslContext = SSLContext.getInstance("SSL")
sslContext.init(null, arrayOf(object : X509TrustManager {
override fun checkClientTrusted(p0: Array<out X509Certificate>?, p1: String?) = Unit
override fun checkServerTrusted(p0: Array<out X509Certificate>?, p1: String?) = Unit
override fun getAcceptedIssuers(): Array<out X509Certificate>? = null
}), SecureRandom())
return sslContext
}
}
fun <T> retry(times: Int, total: Int = times, action: () -> T): T = try {
Thread.sleep(total.toLong() - times)
action()
} catch (e: Throwable) {
if (times > 0) retry(times - 1, total, action) else throw e
}
@@ -0,0 +1,67 @@
package org.ethtrader.ticker
import java.io.File
import java.net.CookieHandler
import java.net.CookieManager
import java.net.CookiePolicy
import java.net.URL
import java.util.concurrent.TimeUnit
fun main(args: Array<String>) {
try {
CookieHandler.setDefault(CookieManager(null, java.net.CookiePolicy.ACCEPT_ALL));
fun getResourceUrl(path: String): URL = URL(args[5] + path)
val outputDir = File("output")
outputDir.mkdirs()
val config = TickerEthConfig(::getResourceUrl)
val reader = DataReader(config.providers)
val tickerFactory = TickerFactory(outputDir, ::getResourceUrl)
val client = OAuthClient(config.reddit.apiUrl, AuthInfo(args[1], args[2], args[3], args[4]))
val reddit = Reddit(client, args[0])
val test = args.contains("--test")
while (true) {
val data = reader.read(config.dataPoints)
config.tickers.entries.forEach {
try {
val (name, tickerConfig) = it
val ticker = tickerFactory.produceTicker(name, data, tickerConfig)
if (!test) reddit.uploadImage(name, ticker)
} catch (e: Exception) {
println("ERROR: " + e.message)
e.printStackTrace()
}
}
try {
val oldCss = reddit.getStylesheet()
File(outputDir, "old.css").writeTextAndLog(oldCss, "Wrote old css to %s")
val newCss = tickerFactory.getAndResetCss(oldCss)
File(outputDir, "new.css").writeTextAndLog(newCss, "Wrote new css to %s")
if (!test) reddit.uploadStylesheet(newCss)
} catch (e: Throwable) {
println("Error trying to update tickers")
e.printStackTrace()
}
println("Waiting ${config.reddit.frequencySeconds} seconds.")
Thread.sleep(TimeUnit.SECONDS.toMillis(config.reddit.frequencySeconds))
if (test) break;
}
} catch (e: Throwable) {
System.err.println("Usage: <<subreddit>> <<username>> <<password>> <<clientId>> <<secret>> <<resourceUrl>>")
System.err.println("clientId and secret can be administered here: https://www.reddit.com/prefs/apps/")
System.err.println("resourceUrl points to the configuration/resources to build the ticker.")
System.err.println("\n Error: ${e.message}")
e.printStackTrace()
}
}
fun File.writeTextAndLog(text: String, message: String) {
writeText(text)
println(message.format(absolutePath))
}
@@ -0,0 +1,75 @@
package org.ethtrader.ticker
import com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.PropertyNamingStrategy.SNAKE_CASE
import com.fasterxml.jackson.jaxrs.json.JacksonJaxbJsonProvider
import com.fasterxml.jackson.module.kotlin.readValue
import com.fasterxml.jackson.module.kotlin.registerKotlinModule
import org.glassfish.jersey.client.authentication.HttpAuthenticationFeature.basicBuilder
import org.glassfish.jersey.media.multipart.MultiPartFeature
import java.io.Serializable
import java.time.Instant.now
import javax.ws.rs.client.ClientBuilder.newBuilder
import javax.ws.rs.client.Entity
import javax.ws.rs.client.Invocation
import javax.ws.rs.core.HttpHeaders.AUTHORIZATION
import javax.ws.rs.core.HttpHeaders.USER_AGENT
import javax.ws.rs.core.MediaType
data class AuthInfo(val username: String, val password: String, val clientId: String, val secret: String) : Serializable
data class AuthData(val accessToken: String?, val expiresIn: Int, val scope: String?, val tokenType: String?)
class OAuthClient(private val apiUrl: String, private val authInfo: AuthInfo) {
private var authData: AuthData? = null
private var timeTokenAcquired = -1L
private val objectMapper = ObjectMapper()
.registerKotlinModule()
.setPropertyNamingStrategy(SNAKE_CASE)
.disable(FAIL_ON_UNKNOWN_PROPERTIES)
private val client = newBuilder()
.register(basicBuilder().credentials(authInfo.clientId, authInfo.secret).build())
.register(JacksonJaxbJsonProvider(objectMapper, emptyArray()))
.register(MultiPartFeature())
.build()
fun renewAccessToken() {
if (!newTokenNeeded()) return
val post = client
.target("https://www.reddit.com/api/v1/")
.queryParam("grant_type", "password")
.queryParam("username", authInfo.username)
.queryParam("password", authInfo.password)
.path("access_token")
.request()
.post(Entity.text(""))
authData = objectMapper.readValue(post.readEntity(String::class.java))
timeTokenAcquired = now().epochSecond
}
private fun newTokenNeeded(): Boolean {
val authData = authData
return authData == null || now().epochSecond >= timeTokenAcquired + authData.expiresIn - 180
}
fun requestApi(path: String, vararg urlParameters: Pair<String, Any?>): Invocation.Builder {
return request(apiUrl, path, urlParameters, MediaType.APPLICATION_JSON_TYPE)
.header(AUTHORIZATION, "bearer " + authData?.accessToken)
}
fun requestPlain(path: String, vararg urlParameters: Pair<String, Any?>): Invocation.Builder {
return request("https://www.reddit.com/", path, urlParameters, MediaType.WILDCARD_TYPE)
}
private fun request(baseUrl: String, path: String, urlParameters: Array<out Pair<String, Any?>>, mediaType: MediaType?): Invocation.Builder {
println("Calling $path, parameters: ${if (urlParameters.isEmpty()) "" else "\n - "}${urlParameters.filter { it.second != null }.joinToString("\n - ")}")
renewAccessToken()
return urlParameters.filter { it.second != null }.fold(client.target(baseUrl).path(path)) { q, it -> q.queryParam(it.first, it.second) }
.request()
.accept(mediaType)
.header(USER_AGENT, "kotlin:org.ethtrader:0.1 (by /u/etherboard)")
}
}
@@ -0,0 +1,28 @@
package org.ethtrader.ticker
import org.jsoup.Jsoup
import java.io.InputStream
class Reddit(private val client: OAuthClient, val subRedditName: String) {
fun uploadImage(name: String, ticker: InputStream) {
val response = client.postRSubredditUpload_sr_img(subRedditName,
"file" to ticker,
"name" to name,
"upload_type" to "img")
println("Response: " + response)
}
fun uploadStylesheet(css: String) {
val response = client.postRSubredditSubreddit_stylesheet(subRedditName,
"stylesheet_contents" to css,
"op" to "save")
println("Response: " + response)
}
fun getStylesheet(): String = retry(3) {
val cssPage = client.requestPlain("/r/$subRedditName/about/stylesheet/").get(String::class.java)
val document = Jsoup.parse(cssPage)
document.select("pre.subreddit-stylesheet-source > code").text()
}
}
@@ -0,0 +1,172 @@
package org.ethtrader.ticker
import java.awt.*
import java.awt.RenderingHints.VALUE_ANTIALIAS_ON
import java.awt.image.BufferedImage
import java.awt.image.BufferedImage.TYPE_INT_ARGB
import java.io.*
import java.math.BigDecimal
import java.math.BigDecimal.ZERO
import java.math.RoundingMode.HALF_DOWN
import java.net.URL
import java.util.Locale.ENGLISH
import javax.imageio.ImageIO
class TickerFactory(private val outputDir: File, private val loadResource: (String) -> URL) {
val apiStart = "/** Generated by ticker bot [start of section] */"
val apiEnd = "/** Generated by ticker bot [end of section] */"
private val css = StringBuilder()
init {
System.setProperty("java.awt.headless", "true")
resetCss()
}
fun produceTicker(name: String, data: Map<String, Map<String, BigDecimal?>?>, config: TickerConf): InputStream {
val font = Font(config.font, Font.BOLD, config.fontHeight)
val graphics: Graphics = BufferedImage(1, 1, TYPE_INT_ARGB).graphics.fluently { it.font = font }
val images = config.segments.map {
try {
if (it.startsWith("[[") && it.endsWith("]]")) {
loadImage(config, it.substring(2, it.length - 2))
} else {
produceTickerSegment(data, config, it, graphics.fontMetrics)
}
} catch (e: Exception) {
System.err.println(e.message)
e.printStackTrace()
null
}
}.filterNotNull()
val image = combineImages(images, config)
val file = File(outputDir, "$name.png")
ImageIO.write(image, "png", file)
println("Ticker [$name] saved to [${file.absolutePath}]")
addCss(name, config, image)
return getInputStream(image)
}
private fun loadImage(config: TickerConf, location: String): BufferedImage {
val image = ImageIO.read(loadResource(location.trimStart('!')))
val imageWithBackground = BufferedImage(image.width, image.height, TYPE_INT_ARGB)
val bold = location.startsWith("!")
(imageWithBackground.graphics as Graphics2D)
.fluently { it.color = if (bold) config.backgroundHighlight else config.background }
.fluently { it.fillRect(0, 0, image.width, image.height) }
.fluently { it.drawImage(image, 0, 0, null) }
.fluently { it.drawImage(image, 0, 0, null) }
.fluently { it.drawImage(image, 0, 0, null) }
.fluently { it.drawImage(image, 0, 0, null) }
return imageWithBackground
}
private fun addCss(name: String, conf: TickerConf, image: BufferedImage) {
val template = loadResource("/per-ticker.css").readText(Charsets.UTF_8)
.replace("\${ticker.name}", name)
.replace("\${ticker.width}", image.width.toString())
.replace("\${ticker.top}", conf.top)
.replace("\${css.selector}", conf.cssSelector)
css.append("$template\n\n")
}
private fun combineImages(images: List<BufferedImage>, tickerConf: TickerConf): BufferedImage {
var cumulative = 0
val image = BufferedImage(images.sumBy { it.width }, images.first().height, TYPE_INT_ARGB)
val graphics = image.graphics
images.forEach { image ->
graphics.drawImage(image, cumulative, 0, null)
cumulative += image.width
}
return image
}
private fun produceTickerSegment(data: Map<String, Map<String, BigDecimal?>?>, config: TickerConf, it: String, fontMetrics: FontMetrics): BufferedImage {
val segments = parseSegments(data, config, it, fontMetrics)
return render(config, segments, fontMetrics)
}
private fun parseSegments(data: Map<String, Map<String, BigDecimal?>?>, config: TickerConf, it: String, fontMetrics: FontMetrics): List<Triple<String, Color, Int>> {
return it.split(Regex("[\\{\\}]")).map { match ->
val tokens = match.trim('{', '}').split('|', '.')
if (tokens.size == 8) format(data, tokens, config) else match to config.color
}.map { Triple(it.first, it.second, fontMetrics.stringWidth(it.first)) }
}
private fun render(config: TickerConf, segments: List<Triple<String, Color, Int>>, fontMetrics: FontMetrics): BufferedImage {
val image = BufferedImage(segments.sumBy { it.third }, fontMetrics.height + 4, TYPE_INT_ARGB)
val graphics = image.graphics as Graphics2D
val bold = segments.first().first.startsWith("!")
graphics.fluently { it.setRenderingHint(RenderingHints.KEY_ANTIALIASING, VALUE_ANTIALIAS_ON) }
.fluently { it.color = if (bold) config.backgroundHighlight else config.background }
var x = 0
val weight = if (true) Font.BOLD else Font.PLAIN
segments.forEach { segment ->
graphics.fluently { it.color = if (bold) config.backgroundHighlight else config.background }
.fluently { it.fillRect(x, 0, segment.third, image.height) }
.fluently { it.color = segment.second }
.fluently { it.font = Font(config.font, weight, config.fontHeight) }
.fluently { it.drawString(segment.first.trimStart('!'), x, fontMetrics.ascent + 2) }
x += segment.third
}
return image
}
private fun format(data: Map<String, Map<String, BigDecimal?>?>, tokens: List<String>, config: TickerConf): Pair<String, Color> {
val (dataPointNames, valueName, prefix, suffix, precision, useColor, shorten, divisor) = tokens
return dataPointNames.split('^').map { dataPointName ->
val value = data[dataPointName]?.get(valueName)?.divide(BigDecimal(divisor.toString()))
if (value == null) {
null
} else {
val color = if (useColor == "Y") if (value.compareTo(ZERO) >= 0) config.positiveColor else config.negativeColor else config.color
val adjustedPrecision = value.setScale(precision.toInt(), HALF_DOWN)
val shortenedValue = if (shorten == "Y") shorten(adjustedPrecision, "", "k", "M", "B", "T") else String.format(ENGLISH, "%,.${precision}f", adjustedPrecision)
("$prefix$shortenedValue$suffix") to color
}
}.filterNotNull().firstOrNull() ?: throw RuntimeException("ERROR - No value [$valueName] or no data point [$dataPointNames] in [$data]")
}
private val OneThousand = BigDecimal("1000")
private val TenThousand = BigDecimal("10000")
private fun shorten(value: BigDecimal, vararg denomiators: String): String {
return if (value < TenThousand) value.toPlainString() + denomiators.first()
else shorten(value / OneThousand, *denomiators.drop(1).toTypedArray())
}
private fun getInputStream(upper: BufferedImage): InputStream {
val byteArrayOutputStream = ByteArrayOutputStream()
ImageIO.write(upper, "png", byteArrayOutputStream)
return ByteArrayInputStream(byteArrayOutputStream.toByteArray())
}
fun <T> T.fluently(action: (T) -> Unit): T {
action(this)
return this
}
fun getAndResetCss(existing: String): String {
val existingLines = existing.lines()
val newLines = css.lines()
resetCss()
return (existingLines.takeWhile { !it.equals(apiStart) } +
"$apiStart\n\n" +
newLines +
if (existingLines.contains(apiEnd)) existingLines.dropWhile { !it.equals(apiEnd) } else listOf(apiEnd)).joinToString(System.lineSeparator())
}
private fun resetCss() {
css.setLength(0)
css.append(loadResource("/ticker.css").readText(Charsets.UTF_8)).append("\n\n")
}
}
operator fun <E> List<E>.component6(): Any = get(5)!!
operator fun <E> List<E>.component7(): Any = get(6)!!
operator fun <E> List<E>.component8(): Any = get(7)!!
+44
View File
@@ -0,0 +1,44 @@
{
"symbol": "eth",
"position": "2",
"name": "Ethereum",
"market_cap": {
"usd": 984329687.973,
"eur": 882254699.3301998,
"cny": 6576649192.079028,
"gbp": 740383261.4026514,
"cad": 1278612766.1269119,
"rub": 64165323174.881805,
"hkd": 7633972832.393353,
"jpy": 102314377622.91515,
"aud": 1299426417.379101,
"btc": 1614129.70563
},
"price": {
"usd": 11.7671,
"eur": 10.546851729999998,
"cny": 78.6200900508,
"gbp": 8.850859607,
"cad": 15.285086352799999,
"rub": 767.0598413890999,
"hkd": 91.25979111839999,
"jpy": 1223.11002872,
"aud": 15.5339016823,
"btc": 0.019296
},
"supply": "83651000",
"volume": {
"usd": 9764720,
"eur": 8752118.536,
"cny": 65241492.44256,
"gbp": 7344729.4424,
"cad": 12684058.80896,
"rub": 636531054.75512,
"hkd": 75730325.01888,
"jpy": 1014976243.904,
"aud": 12890533.81336,
"btc": 16012.4
},
"change": "1.63",
"timestamp": "1473015486.242"
}
@@ -0,0 +1,147 @@
package com.ethtrader.ticker
import org.jsoup.Jsoup
import org.jsoup.examples.HtmlToPlainText
import org.jsoup.nodes.Element
import org.jsoup.nodes.Node
import org.jsoup.nodes.TextNode
import org.junit.Test
import java.io.BufferedWriter
import java.io.File
import java.util.*
import kotlin.text.RegexOption.MULTILINE
class RedditApiGeneration {
private val url = "https://www.reddit.com/dev/api"
private val apiStart = "// Generated API start."
private val apiEnd = "// Generated API end."
private val outputFile = File("src/main/java/${javaClass.`package`.name.replace(".", "/")}/Api.kt")
@Test fun generateApi() {
// val document = Jsoup.parse(URL(url), TimeUnit.MINUTES.toMillis(1).toInt())
val document = Jsoup.parse(javaClass.getResourceAsStream("/api.html"), Charsets.UTF_8.name(), url)
val lines = outputFile.readLines()
val writer = outputFile.bufferedWriter()
try {
lines.takeWhile { !it.equals(apiStart) }.forEach { writer.write("$it\n") }
writer.write("$apiStart\n\n")
document.select("div.section.methods > div").forEach { parseEndpoint(writer, it) }
writer.write("\n$apiEnd\n\n")
} catch(throwable: Throwable) {
lines.dropWhile { !it.equals(apiEnd) }.forEach { writer.write("$it\n") }
} finally {
writer.close()
}
}
private val types = mapOf(
"comma-separated list" to "List<String>",
"comma-delimited list" to "List<String>",
"a positive integer" to "Int",
"number of" to "Int",
"expand subreddits" to "Boolean",
"true, false" to "Boolean"
).withDefault { "String" }
private fun parseEndpoint(writer: BufferedWriter, endpointElement: Element) {
val queryParameters = parseQueryParameters(endpointElement)
val method = parseEndpointAndGetMethod(endpointElement, writer, "get", endpointElement.selectFirst("h3")?.childNodes(), queryParameters)
endpointElement.selectFirst("ul.uri-variants")?.childNodes()?.forEach {
parseEndpointAndGetMethod(endpointElement, writer, method, it.childNodes(), queryParameters)
}
}
private fun parseEndpointAndGetMethod(endpointElement: Element, writer: BufferedWriter, previousMetho: String, urlElements: List<Node>?, urlParams: Map<String, Param>): String {
val (method, pathParams, url) = parseUrl(previousMetho, urlElements)
val get = method == "get"
val urlParamsAndType = urlParams.filterNot { pathParams.contains(it.value.name) }.map { it.value.parameterName to it.value.type + "?" }
val pathParamsAndType = pathParams.map { it to (urlParams.get(it)?.type ?: "String") }
val urlParamsPart = if (get) urlParamsAndType.map { "${it.first}: ${it.second}" } else listOf("vararg urlParams: Pair<String, Any?>")
val methodParams = (pathParamsAndType.map { "${it.first}: ${it.second}" } + urlParamsPart).joinToString(", ")
val paramsString = if (!get || urlParams.isEmpty()) "" else urlParams.map { "\n \"${it.key}\" to ${format(it.value)}" }.joinToString(", ")
val text = HtmlToPlainText().getPlainText(endpointElement).replace("\n\n", "\n - ").replace(Regex("\n - $", MULTILINE), "")
val requestMethod = when (method) {
"get" -> "get()"
"post" -> "post(urlParams.multipart())"
else -> "method(\"$method\")"
}
val name = method + url.replaceFirst("/api", "").replace("$", "").split("/", "[", "]").map { it.capitalize() }.joinToString ("")
val readResponse = if (url.startsWith("/api")) "getResponse()" else "readEntity(String::class.java)"
if (name.isNotEmpty()) {
if (get) {
writer.write("""
/*
* ${text.lines().joinToString("\n * ")}
**/
fun OAuthClient.$name($methodParams) =
retry(3) { requestApi("$url"${if (paramsString.isEmpty()) "" else ", " + paramsString}).$requestMethod.$readResponse }
""")
} else {
writer.write("""
/*
* ${text.lines().joinToString("\n * ")}
**/
fun OAuthClient.$name($methodParams) =
retry(3) { requestApi("$url").$requestMethod.$readResponse }
""")
}
}
return method
}
private fun parseUrl(originalMethod: String, urlNodes: List<Node>?): Triple<String, List<String>, String> {
var method = originalMethod
var queryParameters = listOf<String>()
var url = ""
urlNodes?.forEach {
when (it) {
is TextNode -> url += it.text().replace("[", "").replace("]", "")
is Element -> {
it.useText("span.method") { method = it }
it.useText("em.placeholder") { url += "$$it" }
it.useText("em.placeholder") { queryParameters += it }
}
}
}
return Triple(method.toLowerCase(Locale.ENGLISH), queryParameters, url.replace("→ ", ""))
}
private fun parseQueryParameters(endpointElement: Element): Map<String, Param> {
val select = endpointElement.selectFirst("div.info")?.children()?.select("table.parameters > tbody > tr")
return select?.map { row -> row.selectFirst("td > p")?.parseParameter(row) }?.filterNotNull()?.toMap() ?: mapOf()
}
private fun Element.parseParameter(row: Element): Pair<String, Param> {
val text = text().split("(", ")").map { it.trim() }
val name = row.selectFirst("th")!!.text().split(" ").first()
val type = types.entries.firstOrNull { text.any { t -> t.contains(it.key) } }?.value ?: "String"
val parameterName = name.split('|', '_', '-').map { it.capitalize() }.joinToString("").decapitalize()
val optional = text.contains("default:") || text.contains("optional")
return name to Param(name, optional, type, parameterName)
}
private fun format(it: Param) = when (it.type) {
"List<String>" -> it.parameterName + ".csv()"
else -> it.parameterName
}
private fun Element.useText(selector: String, action: (String) -> Unit) {
val text = select(selector)?.text()?.trim()
if (text != null && text != "") action(text)
}
data class Param(val name: String, val optional: Boolean, val type: String, val parameterName: String)
fun Element.selectFirst(selector: String): Element? =
select(selector).first()
}
File diff suppressed because one or more lines are too long