Skip to content

Commit 5b1c002

Browse files
committed
Add dynamic Google Sheets and Forms API code generation
1 parent cba3dc7 commit 5b1c002

5 files changed

Lines changed: 385 additions & 0 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package io.github.theapache64.retrosheet
2+
3+
import io.github.flaxoos.ktor.server.plugins.ratelimiter.*
4+
import io.github.flaxoos.ktor.server.plugins.ratelimiter.implementations.*
5+
import io.ktor.server.application.*
6+
import io.ktor.server.routing.*
7+
import kotlin.time.Duration.Companion.seconds
8+
9+
fun Application.configureAdministration() {
10+
routing {
11+
route("/") {
12+
install(RateLimiting) {
13+
rateLimiter {
14+
type = TokenBucket::class
15+
capacity = 100
16+
rate = 10.seconds
17+
}
18+
}
19+
}
20+
}
21+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package io.github.theapache64.retrosheet
2+
3+
import io.ktor.server.application.*
4+
import io.ktor.server.netty.EngineMain
5+
6+
fun main(args: Array<String>) {
7+
EngineMain.main(args)
8+
}
9+
10+
fun Application.module() {
11+
configureHTTP()
12+
configureSerialization()
13+
configureAdministration()
14+
configureRouting()
15+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package io.github.theapache64.retrosheet
2+
3+
import io.ktor.http.*
4+
import io.ktor.server.application.*
5+
import io.ktor.server.plugins.cors.routing.*
6+
7+
fun Application.configureHTTP() {
8+
install(CORS) {
9+
allowMethod(HttpMethod.Options)
10+
allowMethod(HttpMethod.Put)
11+
allowMethod(HttpMethod.Delete)
12+
allowMethod(HttpMethod.Patch)
13+
allowHeader(HttpHeaders.Authorization)
14+
allowHeader("MyCustomHeader")
15+
anyHost() // @TODO: Don't do this in production if possible. Try to limit it.
16+
}
17+
}
Lines changed: 314 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,314 @@
1+
package io.github.theapache64.retrosheet
2+
3+
import io.ktor.http.*
4+
import io.ktor.server.application.*
5+
import io.ktor.server.request.*
6+
import io.ktor.server.response.*
7+
import io.ktor.server.routing.*
8+
import io.ktor.util.*
9+
import kotlinx.serialization.SerialName
10+
import kotlinx.serialization.Serializable
11+
import org.json.JSONArray
12+
import java.net.URL
13+
import java.net.URLEncoder
14+
15+
@Serializable
16+
class CodeResponse(
17+
@SerialName("gradle") val gradle: String,
18+
@SerialName("api") val api: String,
19+
@SerialName("main") val main: String,
20+
)
21+
22+
private val CSV_PARSER_REGEX = "\"([^\"]*)\"(?:,|\$)".toRegex()
23+
24+
fun Application.configureRouting() {
25+
routing {
26+
post("/code") {
27+
// Params: googleSheetUrl, sheetName, googleFormUrl
28+
val params = call.receiveParameters()
29+
30+
// Either googleSheetUrl or googleFormUrl is required
31+
val googleSheetUrl = params["googleSheetUrl"]
32+
val googleFormUrl = params["googleFormUrl"]
33+
val sheetName = params["sheetName"]
34+
35+
if (googleSheetUrl == null && googleFormUrl == null) {
36+
call.respond(HttpStatusCode.BadRequest, "Either googleSheetUrl or googleFormUrl is required")
37+
return@post
38+
}
39+
40+
// If googleSheetUrl is there, sheetName is required
41+
if (googleSheetUrl != null && sheetName == null) {
42+
call.respond(HttpStatusCode.BadRequest, "sheetName is required")
43+
return@post
44+
}
45+
46+
// csvUrl format: https://docs.google.com/spreadsheets/d/12vMK4tdtpEbplmeg3Q3-qc3_yPKO92jp_o41wk4PYHg/gviz/tq?tqx=out:csv&sheet=dc
47+
val sheetId = googleSheetUrl?.substringAfter("/d/")?.substringBefore("/")
48+
var sheetHeaders: List<String>? = null
49+
var formTitles: List<String>? = null
50+
if (!sheetId.isNullOrEmpty()) {
51+
val csvUrl = buildString {
52+
append("https://docs.google.com/spreadsheets/d/$sheetId/gviz/tq?tqx=out:csv")
53+
append("&tq=")
54+
append(URLEncoder.encode("SELECT * LIMIT 1", "UTF-8"))
55+
append("&sheet=")
56+
append(URLEncoder.encode(sheetName, "UTF-8"))
57+
}
58+
59+
// GET request
60+
println("QuickTag: :configureRouting: csvUrl: $csvUrl")
61+
val csvData = URL(csvUrl).readText().split("\n").getOrNull(0) ?: return@post call.respond(
62+
status = HttpStatusCode.InternalServerError, message = "Failed to fetch CSV data from $csvUrl"
63+
)
64+
65+
sheetHeaders = CSV_PARSER_REGEX.findAll(csvData)
66+
.map {
67+
val columnName = it.groupValues[1]
68+
if (columnName.equals("Timestamp", ignoreCase = true)) {
69+
"\"_$columnName\""
70+
} else {
71+
"\"$columnName\""
72+
}
73+
}
74+
.toList()
75+
}
76+
77+
78+
79+
if (!googleFormUrl.isNullOrBlank()) {
80+
val googleFormHtmlData = URL(googleFormUrl).readText()
81+
val formData =
82+
googleFormHtmlData.split("FB_PUBLIC_LOAD_DATA_ = ").getOrNull(1)?.split("</script>")?.getOrNull(0)
83+
?.split(";")?.getOrNull(0)
84+
85+
if (formData != null) {
86+
formTitles = JSONArray(formData).getJSONArray(1).getJSONArray(1).map {
87+
it as JSONArray
88+
"\"${it.get(1) as String}\""
89+
}
90+
}
91+
}
92+
93+
94+
val isSeparateModelsNeeded = sheetHeaders != null && formTitles != null && sheetHeaders != formTitles
95+
96+
val addRowRequestModelName = if (isSeparateModelsNeeded) {
97+
"AddRowRequest"
98+
} else {
99+
"Row"
100+
}
101+
102+
val addRowRequestVariableName = if (isSeparateModelsNeeded) {
103+
"addRowRequest"
104+
} else {
105+
"row"
106+
}
107+
108+
val addRowRequestModel = if (isSeparateModelsNeeded) {
109+
"""@Serializable
110+
data class $addRowRequestModelName(
111+
${
112+
formTitles.joinToString("\n") { fieldName ->
113+
""" @SerialName($fieldName)
114+
val ${fieldName.toCamelcaseVariableName()}: String, """.trimMargin()
115+
}
116+
}
117+
)""".trimIndent()
118+
} else {
119+
""
120+
}
121+
122+
123+
val rowModel = """@Serializable
124+
data class Row(
125+
${
126+
sheetHeaders?.joinToString("\n") { fieldName ->
127+
""" @SerialName(${readVarFilter(fieldName)})
128+
val ${fieldName.toCamelcaseVariableName()}: String, """.trimMargin()
129+
}
130+
}
131+
)""".trimIndent()
132+
133+
val writeSample = if (!formTitles.isNullOrEmpty()) {
134+
"""// Adding sample order
135+
val newRow = myApi.addRow(
136+
$addRowRequestModelName(
137+
${
138+
formTitles.joinToString("\n") { fieldName ->
139+
" ${fieldName.toCamelcaseVariableName()} = \"sample ${
140+
fieldName.replace(
141+
"\"",
142+
"'"
143+
)
144+
} input\","
145+
}
146+
}
147+
)
148+
)
149+
150+
println(newRow)
151+
""".trimIndent()
152+
} else {
153+
""
154+
}
155+
156+
val readConfig = if (sheetHeaders != null) {
157+
"""// To Read
158+
.addSheet(
159+
"$sheetName", // sheet name
160+
${sheetHeaders.joinToString(", ") { fieldName -> "$fieldName" }} // columns in same order
161+
)""".trimIndent()
162+
} else {
163+
""
164+
}
165+
166+
val addRowKey = "add_row"
167+
val writeConfig = if (formTitles != null) {
168+
"""// To write
169+
.addForm(
170+
"$addRowKey",
171+
"$googleFormUrl"
172+
)""".trimIndent()
173+
} else {
174+
""
175+
}
176+
177+
val readSample = if (sheetHeaders != null) {
178+
"""// Reading sample
179+
val rows = myApi.getRows()
180+
println(rows)""".trimIndent()
181+
} else {
182+
""
183+
}
184+
185+
186+
val readApiFunctions = if (sheetHeaders != null) {
187+
"""@Read("SELECT *")
188+
@GET("$sheetName")
189+
suspend fun getRows(): List<Row>""".trimIndent()
190+
} else {
191+
""
192+
}
193+
194+
val writeApiFunction = if (formTitles != null) {
195+
"""@Write
196+
@POST("$addRowKey")
197+
suspend fun addRow(@Body $addRowRequestVariableName: $addRowRequestModelName): $addRowRequestModelName""".trimIndent()
198+
} else {
199+
""
200+
}
201+
202+
203+
call.respond(
204+
status = HttpStatusCode.OK, message = CodeResponse(
205+
gradle = """
206+
plugins {
207+
kotlin("jvm") version "2.1.10"
208+
id("org.jetbrains.kotlin.plugin.serialization") version "2.1.10"
209+
id("com.google.devtools.ksp") version "2.1.10-1.0.31"
210+
id("de.jensklingenberg.ktorfit") version "2.5.1"
211+
...
212+
}
213+
...
214+
dependencies {
215+
// JSON Serialization
216+
implementation("io.ktor:ktor-client-content-negotiation:3.1.3")
217+
implementation("io.ktor:ktor-serialization-kotlinx-json:3.1.3")
218+
219+
// Ktorfit
220+
implementation("de.jensklingenberg.ktorfit:ktorfit-lib:2.5.1")
221+
222+
// Retrosheet
223+
implementation("io.github.theapache64:retrosheet:3.0.0-alpha02")
224+
225+
...
226+
}
227+
...
228+
""".trimIndent(),
229+
api = """import kotlinx.serialization.SerialName
230+
import kotlinx.serialization.Serializable
231+
232+
interface MyApi {
233+
$readApiFunctions
234+
235+
$writeApiFunction
236+
237+
// Add more API functions here
238+
}
239+
240+
// Models
241+
$addRowRequestModel
242+
243+
$rowModel""".trimIndent(),
244+
main = """suspend fun main() {
245+
val myApi = createMyApi()
246+
247+
$readSample
248+
249+
$writeSample
250+
}
251+
252+
253+
fun createMyApi(
254+
configBuilder: RetrosheetConfig.Builder.() -> Unit = {}
255+
): MyApi {
256+
val config = RetrosheetConfig.Builder()
257+
.apply { this.configBuilder() }
258+
.setLogging(true)
259+
$readConfig
260+
$writeConfig
261+
.build()
262+
263+
val ktorClient = HttpClient {
264+
install(createRetrosheetPlugin(config)) {}
265+
install(ContentNegotiation) {
266+
json()
267+
}
268+
}
269+
270+
val ktorfit = Ktorfit.Builder()
271+
// GoogleSheet Public URL
272+
.baseUrl("https://docs.google.com/spreadsheets/d/$sheetId/")
273+
.httpClient(ktorClient)
274+
.converterFactories(RetrosheetConverter(config))
275+
.build()
276+
277+
return ktorfit.createMyApi()
278+
} """.trimIndent()
279+
)
280+
)
281+
}
282+
}
283+
}
284+
285+
private fun readVarFilter(column: String): String {
286+
return if (column.equals("\"_Timestamp\"", ignoreCase = true)) {
287+
"\"Timestamp\""
288+
} else {
289+
column
290+
}
291+
}
292+
293+
private val variableRegex = Regex("[^a-zA-Z0-9\\s]")
294+
295+
/**
296+
* string sample: "First Name:" -> firstName
297+
*/
298+
private fun String.toCamelcaseVariableName(): String {
299+
return replace(variableRegex, " ") // Remove special characters
300+
.split(Regex("\\s+")) // Split by whitespace
301+
.filter { it.isNotEmpty() } // Remove empty strings
302+
.mapIndexed { index, word ->
303+
if (index == 0) {
304+
if (!word[0].isLowerCase()) {
305+
word.lowercase()
306+
} else {
307+
word
308+
}
309+
} else {
310+
word.lowercase().replaceFirstChar { it.uppercase() }
311+
}
312+
}
313+
.joinToString("")
314+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package io.github.theapache64.retrosheet
2+
3+
import io.ktor.serialization.kotlinx.json.*
4+
import io.ktor.server.application.*
5+
import io.ktor.server.plugins.contentnegotiation.*
6+
import io.ktor.server.response.*
7+
import io.ktor.server.routing.*
8+
9+
fun Application.configureSerialization() {
10+
install(ContentNegotiation) {
11+
json()
12+
}
13+
routing {
14+
get("/json/kotlinx-serialization") {
15+
call.respond(mapOf("hello" to "world"))
16+
}
17+
}
18+
}

0 commit comments

Comments
 (0)