diff --git a/.github/workflows/dokka.yml b/.github/workflows/dokka.yml new file mode 100644 index 0000000..7516747 --- /dev/null +++ b/.github/workflows/dokka.yml @@ -0,0 +1,55 @@ +# Simple workflow for deploying static content to GitHub Pages +name: Deploy multi-module dokka to GitHub Pages + +on: + # Runs on pushes targeting the default branch + # Pattern matched against refs/tags + push: + tags: + - '[0-9]+.[0-9]+.[0-9]+' + + # Allows you to run this workflow manually from the Actions tab + workflow_dispatch: + +# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages +permissions: + contents: read + pages: write + id-token: write + +# Allow one concurrent deployment +concurrency: + group: "pages" + cancel-in-progress: true + +jobs: + # Single deploy job since we're just deploying + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v3.1.0 + with: + cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} + cache-read-only: true + - name: Build multi-module GH pages + run: ./gradlew dokkaHtmlMultiModule + - name: Setup Pages + uses: actions/configure-pages@v3 + - name: Upload artifact + uses: actions/upload-pages-artifact@v1 + with: + path: 'dokka/documentation' + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v1 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ab6a586 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.gradle/ +.idea/ +.kotlin/ + +build +detekt/reports/** +dokka/documentation/** +local.properties \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..4012df7 --- /dev/null +++ b/README.md @@ -0,0 +1,626 @@ +[example]: https://github.com/manriif/supabase-edge-functions-kt-example + +# Supabase Edge Functions Kotlin + +Build, serve and deploy Supabase Edge Functions with Kotlin and Gradle. + +The project aims to bring the ability of writing and deploying Supabase Edge Functions using Kotlin +as primary programming language. + +[![](https://img.shields.io/badge/Stability-experimental-orange)](#project-stability) +[![Kotlin](https://img.shields.io/badge/kotlin-2.0.0-blue.svg?logo=kotlin)](http://kotlinlang.org) +[![IR](https://img.shields.io/badge/Kotlin%2FJS-IR_only-yellow)](https://kotl.in/jsirsupported) +[![API](https://img.shields.io/badge/API-dokka-green)]() +[![Maven Central](https://img.shields.io/maven-central/v/io.github.manriif.supabase-functions/github-plugin?label=MavenCentral&logo=apache-maven)](https://search.maven.org/artifact/org.jetbrains.dokka/io.github.manriif.supabase-functions) +[![Gradle Plugin](https://img.shields.io/gradle-plugin-portal/v/io.github.manriif.supabase-functions?label=Gradle&logo=gradle)](https://plugins.gradle.org/plugin/io.github.manriif.supabase-functions) +[![MIT License](https://img.shields.io/badge/License-MIT-green.svg)](https://choosealicense.com/licenses/mit/) +[![slack](https://img.shields.io/badge/slack-%23supabase--kt-purple.svg?logo=slack)](https://kotlinlang.slack.com/archives/C06QXPC7064) + +## Get started + +It is recommended to use your favorite IntelliJ based IDE such as IntelliJ IDEA or Android Studio. + +Also, it is recommended to have one gradle subproject per function. +Inspiration on how to structure your gradle project can be found in the [example][example]. + +### Gradle setup + +If you plan to write multiple functions, declare the plugin in the root build script: + +```kotlin +// /build.gradle.kts + +plugins { + id("io.github.manriif.supabase-functions") version "0.0.1" apply false +} +``` + +Apply the Gradle plugin in the build script of your project: + +```kotlin +// /build.gradle.kts + +plugins { + id("io.github.manriif.supabase-functions") +} + +supabaseFunction { + packageName = "org.example.function" // Required, package of the main function + functionName = "my-function" // Optional, default to the project name + supabaseDir = file("supabase") // Optional, default to /supabase + envFile = file(".env.local") // Optional, default to /.env.local + projectRef = "supabase-project-ref" // Optional, no default value + importMap = false // Optional, default to true + verifyJwt = false // Optional, default to true +} +``` + +### Kotlin/JS setup + +Apply the Kotlin Multiplatform plugin in the build script of your project: + +```kotlin +// /build.gradle.kts + +plugins { + id("org.jetbrains.kotlin.multiplatform") +} + +kotlin { + js(IR) { + binaries.library() // Required + useEsModules() // Required + nodejs() // Required + } +} +``` + +### Main function + +An [example][example] repository is available +to get you started faster. + +The only requirement for the magic to work is to write an entry function that accepts a +single `org.w3c.fetch.Request` parameter and returns a `org.w3c.fetch.Response`. + +The function can be marked with suspend modifier. + +In any kotlin source file of your project (function): + +```kotlin +// src/jsMain/kotlin/org/example/function/serve.kt + +package org.example.function + +suspend fun serve(request: Request): Response { + return Response(body = "Hello, world !") +} +``` + +### Run + +After a successful gradle sync and if you are using an IntelliJ based IDE, you will see new run configurations for your function. + + + + + Run configurations + + +Run: + +- ` deploy` for deploying the function to the remote project. +- ` inspect` for inspecting the javascript code with Chrome DevTools. +- ` request` for verifying the function (send preconfigured request(s)). +- ` serve` for serving the function locally. + +## Features + +Belows the features offered by the plugin. + +| Name | ☑️ | +|---------------------------------------|-----| +| Write Kotlin code | ✅️ | +| Write Javascript code | ✅️ | +| NPM support | ✅️ | +| Multi-module support | ✅️ | +| Serve function | ✅️ | +| [Verify function](#automatic-request) | ✅️ | +| [Deploy function](#deployment) | ✅️ | +| [Import map](#import-map) | ✅️ | +| [Debugging](#debugging) | 🚧️ | + +## Modules + +The project provides convenient modules which covers the needs related to the development of supabase +functions. + +Available modules: + +- [binding-deno](modules/binding-deno/MODULE.md) +- [fetch-json](modules/fetch-json/MODULE.md) + +## Advanced usage + +### Continuous build + +The plugin provides first class support for Gradle continuous build and configuration cache. +This results in faster builds and an uninterrupted development workflow. + +Serve related tasks (serve, request, inspect) will automatically reload after file changes +are detected by gradle. + +### Main function + +The plugin will, by default, generate a kotlin function that acts as a bridge between your main +function and the Deno serve function. This also results in the generation of the function's `index.ts` +file. + +
+ Disable the task
+ +If, for some reasons you do not want this behaviour, you can simply disable the related task: + +```kotlin +// /build.gradle.kts + +tasks { + supabaseFunctionGenerateKotlinBridge { + enabled = false + } +} +``` + +It is then your responsibility to connect the two worlds. + +
+ +
+ Change the main function name
+ +By default the main function name is `serve`. +If this name struggles seducing you, you can change it by editing your function level build script. +Let's say you want your main function to be named `handleRequest`: + +```kotlin +// /build.gradle.kts + +tasks { + supabaseFunctionGenerateKotlinBridge { + mainFunctionName = "handleRequest" + } +} +``` + +After that, your main function should looks like: + +```kotlin +// src/jsMain/kotlin/org/example/function/serve.kt + +package org.example.function + +suspend fun handleRequest(request: Request): Response { + return Response(body = "Hello, world !") +} +``` +
+ +### JavaScript + +You can embed local JavaScript sources from a subproject, other subproject or even through a +composite build project. + +
+ Rules
+ +Working with JavaScript must be done according to a few rules: + +- The JavaScript source code must be placed in the `src//js` of the target project. +There is no restriction regarding the kotlin source-set. It can be `commonMain`, `jsMain`, both, +or any other source-set that the `jsMain` source-set depends on. +This gives you complete flexibility on how you structure your modules. + +- There cannot be the same file (same name and same path relative to the `js` directory) within two +different source-sets of the same project (module). + +- There is a magical keyword `module` which must be used to refer to the local project when importing. +This keyword ensures proper resolution of js files among all included projects and depending on +the call site. + +Note that this functionality relies on `import_map.json` and it is your responsibility to hand-write +these rules in case you have disabled the import map task. + +
+ +
+Import an exported Kotlin function into a JavaScript file
+ +```javascript +// src/jsMain/js/bonjour.js + +import { howAreYou } from 'module'; // howAreYou is an exported Kotlin function + +export function bonjour(name) { + return "Bonjour " + name + ", " + howAreYou(); +} +``` + +More explanation on how to consume Kotlin code from JavaScript [here](https://kotlinlang.org/docs/js-to-kotlin-interop.html). +
+ +
+Import an exported JavaScript function into a Kotlin file
+ +```kotlin +// src/jsMain/kotlin/org/example/function/Bonjour.kt +@file:JsModule("module/bonjour.js") // full path to the js file relative to the js directory after module/ + +package org.example.function + +external fun bonjour(name: String): String +``` + +More explanation on how to consume Javascript code in Kotlin [here](https://kotlinlang.org/docs/js-interop.html). +
+ +### Import map + +The plugin automatically configures a single import_map.json file which take cares of NPM dependencies +and local js sources files. The file is generated under the `supabase/functions` directory and aggregates +all the single `import_map.json` files of each individual function. + +You can specify this import_map.json file in your favorite JavaScript IDE and it's Deno configuration. + +
+ Generate the import_map.json
+ +The task responsible for generating the file is triggered after a successful project sync but you can manually +trigger it by running: + +`./gradlew :supabaseFunctionAggregateImportMap` + +
+ +
+Modify the generated file
+ +You can add entries to the generated `import_map.json` by writing your own +`import_map_template.json` file under the `supabase/functions` directory. +This file will take precedence over any other `import_map.json`, +meaning that your entries will not be overwritten. This allows you to force a specific version +for an NPM package. + +Do not directly modify the generated `import_map.json` as it will be overwritten. + +
+ +
+ Disable the feature
+ +If, for some reasons you want to manually manage the import map, you can disable the related task(s): + +
+ For a single function
+ +```kotlin +// /build.gradle.kts + +supabaseFunction { + importMap = false +} + +tasks { + supabaseFunctionGenerateImportMap { + enabled = false + } +} +``` + +
+ +
+ For all functions
+ +```kotlin +// /build.gradle.kts + +tasks.withType { + enabled = false +} +``` +
+ +Keep in mind that you should manually create and populate necessary import_map.json file(s). + +
+ +### Automatic request + +With the aim of limiting tools and speeding up function development time, the plugin provides the +ability to automatically send preconfigured requests to the function endpoint. + +
+ Configuration
+ +Under the project (function) directory, create a `request-config.json` file: + +```json5 +{ + "headers": { // Optional, defaults headers for all requests + "authorization": "Bearer ${ANON_KEY}" // ${ANON_KEY} will be resolved at runtime. You can use + // any variable printed by the `supabase status` command + }, + "requests": [ // Required, list of requests that should be performed + { + "name": "Response body should be 'Hello, world !!'", // Required, the name of the request + "method": "get", // Required, the http method: get, post, put, patch, option, delete, etc + "headers": { // Optional, request headers + "authorization": "Bearer ${SERVICE_ROLE_KEY}" // Override default + }, + "parameters": { // Optional, URI parameters + "name": "Paul" + }, + "type": "plain", // Optional, the type of the request: `plain`, `json` or `file` + "body": "", // Conditional, body of the request, required if a type is specified + "body": "John", // Body of the request for `plain` type, must be a valid string + "body": { // Body of the request for `json` type, must be a valid json object + "from": 0, + "to": 10 + }, + "body": "./file-to-upload.png", // Body of the request for `file` type. File path must be + // relative to the project directory + "validation": { // Optional, used for assertions + "status": 400, // Optional, the expected response status code, default to 200 + "type": "plain",// Optional, the expected response type: `plain`, `json` or `file` + "body": "", // Conditional, expected response body, required if a type is specified + "body": "Hello, world !", // Expected body for `plain` type + "body": { // Expected body for `json` type + "cities": [ + { + name: "Bordeaux", + country: "France" + } + ] + }, + "body": "./expected-body.txt" // Body of the request for `file` type. File path must + // be relative to the project directory + } + } + ] +} +``` + +You can further customize the behaviour of the serve task for auto request: + +```kotlin +// /build.gradle.kts + +tasks { + supabaseFunctionServe { + autoRequest { + logResponse = true // Print request and response details + logStatus = true // Print available supabase variables + } + } +} +``` + +It is also possible to pass gradle parameters for altering the behaviour and avoid modifying +gradle script: + +- pass `-PsupFunLogResponse" for printing request and response details +- pass `-PsupFunLogStatus" for printing available supabase variables + +And: + +`./gradlew :path:to:function:supabaseFunctionServe -PsupFunLogResponse -PsubFunLogStatus` + +
+ +
+ Continuous build
+ +When using continuous build, requests are sent after files changes are detected by gradle. +However, depending on your function size, the requests may be sent too quickly and not allow enough +time for the supabase hot loader to process the changes. This can lead to race condition issues and +results in edge function invocation error. + +To solve the problem, it is possible to delay the requests sending: + +```kotlin +// /build.gradle.kts + +tasks { + supabaseFunctionServe { + autoRequest { + sendRequestOnCodeChangeDelay = 1000 // milliseconds, default to 500. + } + } +} +``` + +Note that changes to the `request-config.json` file will also trigger live reload, which let you edit +it while the task is running. + +
+ +
+Request + +You can auto request a function by running the ` request` run configuration or by running +the gradle command: + +`./gradlew :path:to:function:supabaseFunctionServe -PsupFunAutoRequest` + +
+ +### Debugging + +
+Logging + +Log events that are printed to the terminal window are explained [here](https://supabase.com/docs/guides/functions/logging#events-that-get-logged). +Thus, you can print your own custom log events. + +For uncaught exception logs, stacktrace files are resolved relatively to your local file system. + +Regarding Kotlin code, the plugin offers the possibility to map the generated javascript file to the +Kotlin source file to facilitate debugging. On the other hand, this may not be as accurate, especially +because of inlining and suspension. That's why this feature is marked as experimental. + +To apply source mapping: + +```kotlin +// /build.gradle.kts + +tasks { + supabaseFunctionServe { + @OptIn(ExperimentalSupabaseFunctionApi::class) + stackTraceSourceMapStrategy = StackTraceSourceMapStrategy.KotlinPreferred + // or if you don't want to hear about js + stackTraceSourceMapStrategy = StackTraceSourceMapStrategy.KotlinOnly + } +} +``` + +
+ +
+JavaScript code
+ +It is possible to use Chrome DevTools for JavaScript debugging as specified [here](https://supabase.com/docs/guides/functions/debugging-tools). +By default, the inspect mode is `brk`, if you want to change it: + +```kotlin +// /build.gradle.kts + +tasks { + supabaseFunctionServe { + inspect { + mode = ServeInspect.Mode.Wait // default to ServeInspect.Mode.Brk + main = true // create an inspector session for the main worker, default to false + debug = true // pass --debug flag to the serve command, default to false + } + } +} +``` + +
+ +
+Kotlin code 🚧️
+ +Currently it is not possible to debug Kotlin code. +This is the project's next major feature. + +As this is not a trivial task and due to lack of time, it may take some time before such a feature +is released. The feature would likely take the form of an IDEA plugin because this goes beyond the +scope of a gradle plugin. + +
+ +
+Inspect
+ +You can inspect a function by running the ` inspect` run configuration or by running +the gradle command: + +`./gradlew :path:to:function:supabaseFunctionServe -PsupFunInspect` + +
+ +### Run configurations + +Run configurations, for each function, are automatically created for IntelliJ based IDEs. + +
+Configure
+ +You can choose which run configuration to generate: + +```kotlin +// /build.gradle.kts + +supabaseFunction { + runConfiguration { + deploy = false // Enable the deploy run configuration, true by default + + serve { // Serve run configuration + enabled = false // Enable the configuration, true by default + continuous = false // continuous build enabled by default, true by default + } + + inspect { // Inspect run configuration + enabled = false // Enable the configuration, true by default + continuous = false // continuous build enabled by default, true by default + } + + request { // Request run configuration + enabled = false // Enable the configuration, true by default + continuous = false // continuous build enabled by default, true by default + } + } +} +``` + +
+ +### Deployment + +Function can be deployed to the remote project from the plugin. + +
+Deploy + +You can deploy a function by running the ` deploy` run configuration or by running +the gradle command: + +`./gradlew :path:to:function:supabaseFunctionDeploy` + +Before deploying the function, make sure you have correctly [linked](https://supabase.com/docs/reference/cli/supabase-link) +the remote project. + +
+ +### Gitignore + +It is generally a good practice not to import files that are generated to VCS. Thus, and by its nature, +the plugin provides a task for creating or updating necessary `.gitignore` files. Existing `.gitignore ` +files will not be overwritten but completed with missing entries. + +
+Disable or edit the task
+ +You can disable the task or change its behaviour at the project level: + +```kotlin +// /build.gradle.kts + +tasks { + supabaseFunctionServe { + enabled = false // Disable the task + importMapEntry = false // Prevent the task from adding the import_map.json to .gitignore + // This could be necessary if you manually configured the import map + indexEntry = false // Prevent the task from adding the index.ts to .gitignore + // This could be necessary if you manually created the index.ts file + } +} +``` + +
+ +## Project stability + +The project is currently in an experimental phase due to its freshness and reliance on +experimental features such as [Kotlin JsExport](https://kotlinlang.org/docs/js-to-kotlin-interop.html#jsexport-annotation). + +It should therefore be consumed in moderation. + +## Limitations + +Following limitations applies: + +- Kotlin versions before 2.0 are not supported +- browser JS subtarged is not supported +- `per-file` and `whole-program` JS IR [output granularity](https://kotlinlang.org/docs/js-ir-compiler.html#output-mode) are not supported. +- Depending on a Kotlin library that uses require() may result in runtime error \ No newline at end of file diff --git a/build-logic/build.gradle.kts b/build-logic/build.gradle.kts new file mode 100644 index 0000000..65e3a18 --- /dev/null +++ b/build-logic/build.gradle.kts @@ -0,0 +1,28 @@ +/** + * Copyright (c) 2024 Maanrifa Bacar Ali. + * Use of this source code is governed by the MIT license. + */ + +plugins { + `kotlin-dsl` +} + +gradlePlugin { + plugins { + listOf("common", "detekt", "dokka", "kmp", "publish").forEach { scriptName -> + named("conventions-$scriptName") { + version = libs.versions.supabase.functions.get() + } + } + } +} + +dependencies { + implementation(libs.detekt.gradle.plugin) + implementation(libs.dokka.gradle.plugin) + implementation(libs.kotlin.gradle.plugin) + implementation(libs.vanniktech.maven.publish) + + // https://github.com/gradle/gradle/issues/15383#issuecomment-779893192 + implementation(files(libs.javaClass.superclass.protectionDomain.codeSource.location)) +} \ No newline at end of file diff --git a/build-logic/settings.gradle.kts b/build-logic/settings.gradle.kts new file mode 100644 index 0000000..8d3659b --- /dev/null +++ b/build-logic/settings.gradle.kts @@ -0,0 +1,20 @@ +/** + * Copyright (c) 2024 Maanrifa Bacar Ali. + * Use of this source code is governed by the MIT license. + */ + +rootProject.name = "build-logic" + +@Suppress("UnstableApiUsage") +dependencyResolutionManagement { + repositories { + mavenCentral() + gradlePluginPortal() + } + + versionCatalogs { + create("libs") { + from(files("../gradle/libs.versions.toml")) + } + } +} \ No newline at end of file diff --git a/build-logic/src/main/kotlin/Project.kt b/build-logic/src/main/kotlin/Project.kt new file mode 100644 index 0000000..7d1fae5 --- /dev/null +++ b/build-logic/src/main/kotlin/Project.kt @@ -0,0 +1,60 @@ +/** + * Copyright (c) 2024 Maanrifa Bacar Ali. + * Use of this source code is governed by the MIT license. + */ + +import org.gradle.api.Project +import org.gradle.kotlin.dsl.extra +import kotlin.properties.ReadOnlyProperty + +private const val ROOT_PROJECT_PROPERTY_PREFIX = "project" +private const val LOCAL_PROJECT_PROPERTY_PREFIX = "local" + +private const val IS_MODULE_EXTRA_NAME = "isModule" + +private fun Project.getProperty(prefix: String, name: String): String { + val propertyName = "$prefix.$name" + + if (!hasProperty(propertyName)) { + error("property $propertyName not found in project `${path}`") + } + + return property(propertyName).toString() +} + +/////////////////////////////////////////////////////////////////////////// +// Root project +/////////////////////////////////////////////////////////////////////////// + +private fun rootProjectProperty(name: String): ReadOnlyProperty { + return ReadOnlyProperty { thisRef, _ -> + thisRef.rootProject.getProperty(ROOT_PROJECT_PROPERTY_PREFIX, name) + } +} + +val Project.projectGroup by rootProjectProperty("group") +val Project.projectWebsite by rootProjectProperty("website") +val Project.projectLicenseName by rootProjectProperty("license.name") +val Project.projectLicenseUrl by rootProjectProperty("license.url") +val Project.projectGitBase by rootProjectProperty("git.base") +val Project.projectGitUrl by rootProjectProperty("git.url") + +val Project.projectDevId by rootProjectProperty("dev.id") +val Project.projectDevName by rootProjectProperty("dev.name") +val Project.projectDevUrl by rootProjectProperty("dev.url") + +/////////////////////////////////////////////////////////////////////////// +// Local project +/////////////////////////////////////////////////////////////////////////// + +val Project.isModule: Boolean + get() = extra[IS_MODULE_EXTRA_NAME] == true + +private fun localProjectProperty(name: String): ReadOnlyProperty { + return ReadOnlyProperty { thisRef, _ -> + thisRef.getProperty(LOCAL_PROJECT_PROPERTY_PREFIX, name) + } +} + +val Project.localName: String by localProjectProperty("name") +val Project.localDescription: String by localProjectProperty("description") \ No newline at end of file diff --git a/build-logic/src/main/kotlin/VersionCatolog.kt b/build-logic/src/main/kotlin/VersionCatolog.kt new file mode 100644 index 0000000..a590b8d --- /dev/null +++ b/build-logic/src/main/kotlin/VersionCatolog.kt @@ -0,0 +1,11 @@ +/** + * Copyright (c) 2024 Maanrifa Bacar Ali. + * Use of this source code is governed by the MIT license. + */ + +import org.gradle.accessors.dm.LibrariesForLibs +import org.gradle.api.Project +import org.gradle.kotlin.dsl.the + +internal val Project.libs: LibrariesForLibs + get() = the() \ No newline at end of file diff --git a/build-logic/src/main/kotlin/conventions-common.gradle.kts b/build-logic/src/main/kotlin/conventions-common.gradle.kts new file mode 100644 index 0000000..63685ea --- /dev/null +++ b/build-logic/src/main/kotlin/conventions-common.gradle.kts @@ -0,0 +1,10 @@ +/** + * Copyright (c) 2024 Maanrifa Bacar Ali. + * Use of this source code is governed by the MIT license. + */ + +plugins { + id("conventions-detekt") + id("conventions-dokka") + id("conventions-publish") +} \ No newline at end of file diff --git a/build-logic/src/main/kotlin/conventions-detekt.gradle.kts b/build-logic/src/main/kotlin/conventions-detekt.gradle.kts new file mode 100644 index 0000000..18827eb --- /dev/null +++ b/build-logic/src/main/kotlin/conventions-detekt.gradle.kts @@ -0,0 +1,54 @@ +/** + * Copyright (c) 2024 Maanrifa Bacar Ali. + * Use of this source code is governed by the MIT license. + */ + +import io.gitlab.arturbosch.detekt.Detekt +import io.gitlab.arturbosch.detekt.DetektCreateBaselineTask +import org.gradle.api.tasks.TaskProvider +import org.gradle.kotlin.dsl.invoke +import org.gradle.kotlin.dsl.withType + +plugins { + io.gitlab.arturbosch.detekt +} + +val detektDir = rootProject.layout.projectDirectory.dir("detekt") +val detektReportsDir = detektDir.dir("reports") + +detekt { + buildUponDefaultConfig = true + ignoreFailures = true + baseline = detektDir.file("baseline.xml").asFile + + config.setFrom(detektDir.file("config.yml")) +} + +tasks { + withType().configureEach { + jvmTarget = libs.versions.jvm.target.get() + basePath = rootDir.absolutePath + + reports { + listOf(html, xml, sarif, md).forEach { report -> + report.required = true + + report.outputLocation = detektReportsDir + .file("${report.type.reportId}/${project.name}.${report.type.extension}") + } + } + } + + withType().configureEach { + jvmTarget = libs.versions.jvm.target.get() + } +} + +// https://detekt.dev/docs/gettingstarted/gradle#disabling-detekt-from-the-check-task +afterEvaluate { + tasks.named("check") { + setDependsOn(dependsOn.filterNot { + it is TaskProvider<*> && it.name.contains("detekt") + }) + } +} \ No newline at end of file diff --git a/build-logic/src/main/kotlin/conventions-dokka.gradle.kts b/build-logic/src/main/kotlin/conventions-dokka.gradle.kts new file mode 100644 index 0000000..2d5daaa --- /dev/null +++ b/build-logic/src/main/kotlin/conventions-dokka.gradle.kts @@ -0,0 +1,34 @@ +/** + * Copyright (c) 2024 Maanrifa Bacar Ali. + * Use of this source code is governed by the MIT license. + */ + +import org.jetbrains.dokka.DokkaConfiguration +import org.jetbrains.dokka.gradle.DokkaTaskPartial +import java.net.URI + +plugins { + org.jetbrains.dokka +} + +tasks.withType().configureEach { + suppressInheritedMembers = true + + dokkaSourceSets.configureEach { + val path = if (!project.isModule) project.name else { + "modules/${project.name.removePrefix("module-")}" + } + + val url = "https://github.com/manriif/supabase-functions-kt/tree/main/$path/src" + + includes = project.layout.projectDirectory.files("MODULE.md") + documentedVisibilities = setOf(DokkaConfiguration.Visibility.PUBLIC) + noStdlibLink = true + + sourceLink { + localDirectory = projectDir.resolve("src") + remoteUrl = URI(url).toURL() + remoteLineSuffix = "#L" + } + } +} \ No newline at end of file diff --git a/build-logic/src/main/kotlin/conventions-kmp.gradle.kts b/build-logic/src/main/kotlin/conventions-kmp.gradle.kts new file mode 100644 index 0000000..61e2d5e --- /dev/null +++ b/build-logic/src/main/kotlin/conventions-kmp.gradle.kts @@ -0,0 +1,26 @@ +/** + * Copyright (c) 2024 Maanrifa Bacar Ali. + * Use of this source code is governed by the MIT license. + */ + +plugins { + org.jetbrains.kotlin.multiplatform + id("conventions-common") + id("conventions-publish") +} + +kotlin { + applyDefaultHierarchyTemplate() + + js(IR) { + useEsModules() + + nodejs { + testTask { + enabled = false + } + } + } +} + + diff --git a/build-logic/src/main/kotlin/conventions-publish.gradle.kts b/build-logic/src/main/kotlin/conventions-publish.gradle.kts new file mode 100644 index 0000000..3953864 --- /dev/null +++ b/build-logic/src/main/kotlin/conventions-publish.gradle.kts @@ -0,0 +1,39 @@ +/** + * Copyright (c) 2024 Maanrifa Bacar Ali. + * Use of this source code is governed by the MIT license. + */ + +plugins { + `maven-publish` + com.vanniktech.maven.publish +} + +mavenPublishing { + pom { + name = localName + description = localDescription + url = projectWebsite + inceptionYear = "2024" + + licenses { + license { + name = projectLicenseName + url = projectLicenseUrl + } + } + + developers { + developer { + id = projectDevId + name = projectDevName + url = projectDevUrl + } + } + + scm { + url = projectGitUrl + connection = "scm:git:git://${projectGitBase}.git" + developerConnection = "scm:git:ssh://git@${projectGitBase}.git" + } + } +} \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..9999a51 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,34 @@ +/** + * Copyright (c) 2024 Maanrifa Bacar Ali. + * Use of this source code is governed by the MIT license. + */ + +import org.jetbrains.dokka.gradle.DokkaMultiModuleTask + +plugins { + alias(libs.plugins.kotlin.multiplatform) apply false + alias(libs.plugins.vanniktech.maven.publish) apply false + alias(libs.plugins.gradle.plugin.publish) apply false + alias(libs.plugins.detekt) apply false + alias(libs.plugins.dokka) +} + +allprojects { + group = property("project.group").toString() + version = rootProject.libs.versions.supabase.functions.get() + extra["isModule"] = path.startsWith(":modules") +} + +tasks.withType { + val dokkaDir = rootProject.layout.projectDirectory.dir("dokka") + + includes = dokkaDir.files("README.md") + moduleName = rootProject.property("project.name").toString() + outputDirectory = dokkaDir.dir("documentation") + + pluginsMapConfiguration = mapOf( + "org.jetbrains.dokka.base.DokkaBase" to """{ + "footerMessage": "© 2024 Maanrifa Bacar Ali." + }""" + ) +} \ No newline at end of file diff --git a/detekt/baseline.xml b/detekt/baseline.xml new file mode 100644 index 0000000..9d2589f --- /dev/null +++ b/detekt/baseline.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/detekt/config.yml b/detekt/config.yml new file mode 100644 index 0000000..71f38d6 --- /dev/null +++ b/detekt/config.yml @@ -0,0 +1,795 @@ +build: + maxIssues: 0 + excludeCorrectable: false + weights: + # complexity: 2 + # LongParameterList: 1 + # style: 1 + # comments: 1 + +config: + validation: true + warningsAsErrors: false + checkExhaustiveness: false + # when writing own rules with new properties, exclude the property path e.g.: 'my_rule_set,.*>.*>[my_property]' + excludes: '' + +processors: + active: true + exclude: + - 'DetektProgressListener' + # - 'KtFileCountProcessor' + # - 'PackageCountProcessor' + # - 'ClassCountProcessor' + # - 'FunctionCountProcessor' + # - 'PropertyCountProcessor' + # - 'ProjectComplexityProcessor' + # - 'ProjectCognitiveComplexityProcessor' + # - 'ProjectLLOCProcessor' + # - 'ProjectCLOCProcessor' + # - 'ProjectLOCProcessor' + # - 'ProjectSLOCProcessor' + # - 'LicenseHeaderLoaderExtension' + +console-reports: + active: true + exclude: + - 'ProjectStatisticsReport' + - 'ComplexityReport' + - 'NotificationReport' + - 'FindingsReport' + - 'FileBasedFindingsReport' + # - 'LiteFindingsReport' + +output-reports: + active: true + exclude: + # - 'TxtOutputReport' + # - 'XmlOutputReport' + # - 'HtmlOutputReport' + # - 'MdOutputReport' + # - 'SarifOutputReport' + +comments: + active: true + AbsentOrWrongFileLicense: + active: false + licenseTemplateFile: 'license.template' + licenseTemplateIsRegex: false + CommentOverPrivateFunction: + active: false + CommentOverPrivateProperty: + active: false + DeprecatedBlockTag: + active: false + EndOfSentenceFormat: + active: false + endOfSentenceFormat: '([.?!][ \t\n\r\f<])|([.?!:]$)' + KDocReferencesNonPublicProperty: + active: false + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**', '**/mingwX64Test/**', '**/macosTest/**', '**/appleTest/**', '**/linuxTest/**'] + OutdatedDocumentation: + active: false + matchTypeParameters: true + matchDeclarationsOrder: true + allowParamOnConstructorProperties: false + UndocumentedPublicClass: + active: true + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**', '**/mingwX64Test/**', '**/macosTest/**', '**/appleTest/**', '**/linuxTest/**'] + searchInNestedClass: true + searchInInnerClass: true + searchInInnerObject: true + searchInInnerInterface: true + searchInProtectedClass: false + ignoreAnnotated: + - 'SupabaseInternal' + - 'SupabaseExperimental' + UndocumentedPublicFunction: + active: true + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**', '**/mingwX64Test/**', '**/macosTest/**', '**/appleTest/**', '**/linuxTest/**'] + searchProtectedFunction: false + ignoreAnnotated: + - 'SupabaseInternal' + - 'SupabaseExperimental' + UndocumentedPublicProperty: + active: true + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**', '**/mingwX64Test/**', '**/macosTest/**', '**/appleTest/**', '**/linuxTest/**'] + searchProtectedProperty: false + ignoreAnnotated: + - 'SupabaseInternal' + - 'SupabaseExperimental' + +complexity: + active: true + CognitiveComplexMethod: + active: false + threshold: 15 + ComplexCondition: + active: true + threshold: 4 + ComplexInterface: + active: false + threshold: 10 + includeStaticDeclarations: false + includePrivateDeclarations: false + ignoreOverloaded: false + CyclomaticComplexMethod: + active: false + threshold: 15 + ignoreSingleWhenExpression: false + ignoreSimpleWhenEntries: false + ignoreNestingFunctions: false + nestingFunctions: + - 'also' + - 'apply' + - 'forEach' + - 'isNotNull' + - 'ifNull' + - 'let' + - 'run' + - 'use' + - 'with' + LabeledExpression: + active: false + ignoredLabels: [] + LargeClass: + active: true + threshold: 600 + LongMethod: + active: true + threshold: 70 + LongParameterList: + active: false + functionThreshold: 6 + constructorThreshold: 7 + ignoreDefaultParameters: false + ignoreDataClasses: true + ignoreAnnotatedParameter: [] + MethodOverloading: + active: false + threshold: 6 + NamedArguments: + active: false + threshold: 3 + ignoreArgumentsMatchingNames: false + NestedBlockDepth: + active: true + threshold: 4 + NestedScopeFunctions: + active: false + threshold: 1 + functions: + - 'kotlin.apply' + - 'kotlin.run' + - 'kotlin.with' + - 'kotlin.let' + - 'kotlin.also' + ReplaceSafeCallChainWithRun: + active: false + StringLiteralDuplication: + active: false + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**', '**/mingwX64Test/**', '**/macosTest/**', '**/appleTest/**', '**/linuxTest/**'] + threshold: 3 + ignoreAnnotation: true + excludeStringsWithLessThan5Characters: true + ignoreStringsRegex: '$^' + TooManyFunctions: + active: false + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**', '**/mingwX64Test/**', '**/macosTest/**', '**/appleTest/**', '**/linuxTest/**'] + thresholdInFiles: 11 + thresholdInClasses: 11 + thresholdInInterfaces: 11 + thresholdInObjects: 11 + thresholdInEnums: 11 + ignoreDeprecated: false + ignorePrivate: false + ignoreOverridden: false + +coroutines: + active: true + GlobalCoroutineUsage: + active: false + InjectDispatcher: + active: false + dispatcherNames: + - 'IO' + - 'Default' + - 'Unconfined' + RedundantSuspendModifier: + active: true + SleepInsteadOfDelay: + active: true + SuspendFunSwallowedCancellation: + active: false + SuspendFunWithCoroutineScopeReceiver: + active: false + SuspendFunWithFlowReturnType: + active: true + +empty-blocks: + active: true + EmptyCatchBlock: + active: true + allowedExceptionNameRegex: '_|(ignore|expected).*' + EmptyClassBlock: + active: true + EmptyDefaultConstructor: + active: true + EmptyDoWhileBlock: + active: true + EmptyElseBlock: + active: true + EmptyFinallyBlock: + active: true + EmptyForBlock: + active: true + EmptyFunctionBlock: + active: true + ignoreOverridden: false + EmptyIfBlock: + active: true + EmptyInitBlock: + active: true + EmptyKtFile: + active: true + EmptySecondaryConstructor: + active: true + EmptyTryBlock: + active: true + EmptyWhenBlock: + active: true + EmptyWhileBlock: + active: true + +exceptions: + active: true + ExceptionRaisedInUnexpectedLocation: + active: true + methodNames: + - 'equals' + - 'finalize' + - 'hashCode' + - 'toString' + InstanceOfCheckForException: + active: false + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**', '**/mingwX64Test/**', '**/macosTest/**', '**/appleTest/**', '**/linuxTest/**'] + NotImplementedDeclaration: + active: false + ObjectExtendsThrowable: + active: false + PrintStackTrace: + active: true + RethrowCaughtException: + active: true + ReturnFromFinally: + active: true + ignoreLabeled: false + SwallowedException: + active: false + ignoredExceptionTypes: + - 'InterruptedException' + - 'MalformedURLException' + - 'NumberFormatException' + - 'ParseException' + allowedExceptionNameRegex: '_|(ignore|expected).*' + ThrowingExceptionFromFinally: + active: true + ThrowingExceptionInMain: + active: false + ThrowingExceptionsWithoutMessageOrCause: + active: true + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**', '**/mingwX64Test/**', '**/macosTest/**', '**/appleTest/**', '**/linuxTest/**'] + exceptions: + - 'ArrayIndexOutOfBoundsException' + - 'Exception' + - 'IllegalArgumentException' + - 'IllegalMonitorStateException' + - 'IllegalStateException' + - 'IndexOutOfBoundsException' + - 'NullPointerException' + - 'RuntimeException' + - 'Throwable' + ThrowingNewInstanceOfSameException: + active: true + TooGenericExceptionCaught: + active: false + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**', '**/mingwX64Test/**', '**/macosTest/**', '**/appleTest/**', '**/linuxTest/**'] + exceptionNames: + - 'ArrayIndexOutOfBoundsException' + - 'Error' + - 'Exception' + - 'IllegalMonitorStateException' + - 'IndexOutOfBoundsException' + - 'NullPointerException' + - 'RuntimeException' + - 'Throwable' + allowedExceptionNameRegex: '_|(ignore|expected).*' + TooGenericExceptionThrown: + active: true + exceptionNames: + - 'Error' + - 'Exception' + - 'RuntimeException' + - 'Throwable' + +naming: + active: true + BooleanPropertyNaming: + active: false + allowedPattern: '^(is|has|are)' + ClassNaming: + active: true + classPattern: '[A-Z][a-zA-Z0-9]*' + ConstructorParameterNaming: + active: true + parameterPattern: '[a-z][A-Za-z0-9]*' + privateParameterPattern: '[a-z][A-Za-z0-9]*' + excludeClassPattern: '$^' + EnumNaming: + active: true + enumEntryPattern: '[A-Z][_a-zA-Z0-9]*' + ForbiddenClassName: + active: false + forbiddenName: [] + FunctionMaxLength: + active: false + maximumFunctionNameLength: 30 + FunctionMinLength: + active: false + minimumFunctionNameLength: 3 + FunctionNaming: + active: true + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**', '**/mingwX64Test/**', '**/macosTest/**', '**/appleTest/**', '**/linuxTest/**'] + functionPattern: '[a-z][a-zA-Z0-9]*' + excludeClassPattern: '$^' + ignoreAnnotated: + - Composable + FunctionParameterNaming: + active: true + parameterPattern: '[a-z][A-Za-z0-9]*' + excludeClassPattern: '$^' + InvalidPackageDeclaration: + active: true + rootPackage: '' + requireRootInDeclaration: false + LambdaParameterNaming: + active: false + parameterPattern: '[a-z][A-Za-z0-9]*|_' + MatchingDeclarationName: + active: true + mustBeFirst: true + MemberNameEqualsClassName: + active: true + ignoreOverridden: true + NoNameShadowing: + active: true + NonBooleanPropertyPrefixedWithIs: + active: false + ObjectPropertyNaming: + active: true + constantPattern: '[A-Za-z][_A-Za-z0-9]*' + propertyPattern: '[A-Za-z][_A-Za-z0-9]*' + privatePropertyPattern: '(_)?[A-Za-z][_A-Za-z0-9]*' + PackageNaming: + active: true + packagePattern: '[a-z]+(\.[a-z][A-Za-z0-9]*)*' + TopLevelPropertyNaming: + active: true + constantPattern: '[A-Z][_A-Z0-9]*' + propertyPattern: '[A-Za-z][_A-Za-z0-9]*' + privatePropertyPattern: '_?[A-Za-z][_A-Za-z0-9]*' + VariableMaxLength: + active: false + maximumVariableNameLength: 64 + VariableMinLength: + active: false + minimumVariableNameLength: 1 + VariableNaming: + active: true + variablePattern: '(_)?[a-z][A-Za-z0-9]*' + privateVariablePattern: '(_)?[a-z][A-Za-z0-9]*' + excludeClassPattern: '$^' + +performance: + active: true + ArrayPrimitive: + active: true + CouldBeSequence: + active: false + threshold: 3 + ForEachOnRange: + active: true + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**', '**/mingwX64Test/**', '**/macosTest/**', '**/appleTest/**', '**/linuxTest/**'] + SpreadOperator: + active: false + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**', '**/mingwX64Test/**', '**/macosTest/**', '**/appleTest/**', '**/linuxTest/**'] + UnnecessaryPartOfBinaryExpression: + active: false + UnnecessaryTemporaryInstantiation: + active: true + +potential-bugs: + active: true + AvoidReferentialEquality: + active: true + forbiddenTypePatterns: + - 'kotlin.String' + CastNullableToNonNullableType: + active: false + CastToNullableType: + active: false + Deprecation: + active: false + DontDowncastCollectionTypes: + active: false + DoubleMutabilityForCollection: + active: true + mutableTypes: + - 'kotlin.collections.MutableList' + - 'kotlin.collections.MutableMap' + - 'kotlin.collections.MutableSet' + - 'java.util.ArrayList' + - 'java.util.LinkedHashSet' + - 'java.util.HashSet' + - 'java.util.LinkedHashMap' + - 'java.util.HashMap' + ElseCaseInsteadOfExhaustiveWhen: + active: false + ignoredSubjectTypes: [] + EqualsAlwaysReturnsTrueOrFalse: + active: true + EqualsWithHashCodeExist: + active: true + ExitOutsideMain: + active: false + ExplicitGarbageCollectionCall: + active: true + HasPlatformType: + active: true + IgnoredReturnValue: + active: true + restrictToConfig: true + returnValueAnnotations: + - 'CheckResult' + - '*.CheckResult' + - 'CheckReturnValue' + - '*.CheckReturnValue' + ignoreReturnValueAnnotations: + - 'CanIgnoreReturnValue' + - '*.CanIgnoreReturnValue' + returnValueTypes: + - 'kotlin.sequences.Sequence' + - 'kotlinx.coroutines.flow.*Flow' + - 'java.util.stream.*Stream' + ignoreFunctionCall: [] + ImplicitDefaultLocale: + active: true + ImplicitUnitReturnType: + active: false + allowExplicitReturnType: true + InvalidRange: + active: true + IteratorHasNextCallsNextMethod: + active: true + IteratorNotThrowingNoSuchElementException: + active: true + LateinitUsage: + active: false + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**', '**/mingwX64Test/**', '**/macosTest/**', '**/appleTest/**', '**/linuxTest/**'] + ignoreOnClassesPattern: '' + MapGetWithNotNullAssertionOperator: + active: true + MissingPackageDeclaration: + active: false + excludes: ['**/*.kts'] + NullCheckOnMutableProperty: + active: false + NullableToStringCall: + active: false + PropertyUsedBeforeDeclaration: + active: false + UnconditionalJumpStatementInLoop: + active: false + UnnecessaryNotNullCheck: + active: false + UnnecessaryNotNullOperator: + active: true + UnnecessarySafeCall: + active: true + UnreachableCatchBlock: + active: true + UnreachableCode: + active: true + UnsafeCallOnNullableType: + active: true + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**', '**/mingwX64Test/**', '**/macosTest/**', '**/appleTest/**', '**/linuxTest/**'] + UnsafeCast: + active: true + UnusedUnaryOperator: + active: true + UselessPostfixExpression: + active: true + WrongEqualsTypeParameter: + active: true + +style: + active: true + AlsoCouldBeApply: + active: false + BracesOnIfStatements: + active: false + singleLine: 'never' + multiLine: 'always' + BracesOnWhenStatements: + active: false + singleLine: 'necessary' + multiLine: 'consistent' + CanBeNonNullable: + active: false + CascadingCallWrapping: + active: false + includeElvis: true + ClassOrdering: + active: false + CollapsibleIfStatements: + active: false + DataClassContainsFunctions: + active: false + conversionFunctionPrefix: + - 'to' + allowOperators: false + DataClassShouldBeImmutable: + active: false + DestructuringDeclarationWithTooManyEntries: + active: true + maxDestructuringEntries: 3 + DoubleNegativeLambda: + active: false + negativeFunctions: + - reason: 'Use `takeIf` instead.' + value: 'takeUnless' + - reason: 'Use `all` instead.' + value: 'none' + negativeFunctionNameParts: + - 'not' + - 'non' + EqualsNullCall: + active: true + EqualsOnSignatureLine: + active: false + ExplicitCollectionElementAccessMethod: + active: false + ExplicitItLambdaParameter: + active: true + ExpressionBodySyntax: + active: false + includeLineWrapping: false + ForbiddenAnnotation: + active: false + annotations: + - reason: 'it is a java annotation. Use `Suppress` instead.' + value: 'java.lang.SuppressWarnings' + - reason: 'it is a java annotation. Use `kotlin.Deprecated` instead.' + value: 'java.lang.Deprecated' + - reason: 'it is a java annotation. Use `kotlin.annotation.MustBeDocumented` instead.' + value: 'java.lang.annotation.Documented' + - reason: 'it is a java annotation. Use `kotlin.annotation.Target` instead.' + value: 'java.lang.annotation.Target' + - reason: 'it is a java annotation. Use `kotlin.annotation.Retention` instead.' + value: 'java.lang.annotation.Retention' + - reason: 'it is a java annotation. Use `kotlin.annotation.Repeatable` instead.' + value: 'java.lang.annotation.Repeatable' + - reason: 'Kotlin does not support @Inherited annotation, see https://youtrack.jetbrains.com/issue/KT-22265' + value: 'java.lang.annotation.Inherited' + ForbiddenComment: + active: false + comments: + - reason: 'Forbidden FIXME todo marker in comment, please fix the problem.' + value: 'FIXME:' + - reason: 'Forbidden STOPSHIP todo marker in comment, please address the problem before shipping the code.' + value: 'STOPSHIP:' + - reason: 'Forbidden TODO todo marker in comment, please do the changes.' + value: 'TODO:' + allowedPatterns: '' + ForbiddenImport: + active: false + imports: [] + forbiddenPatterns: '' + ForbiddenMethodCall: + active: false + methods: + - reason: 'print does not allow you to configure the output stream. Use a logger instead.' + value: 'kotlin.io.print' + - reason: 'println does not allow you to configure the output stream. Use a logger instead.' + value: 'kotlin.io.println' + ForbiddenSuppress: + active: false + rules: [] + ForbiddenVoid: + active: true + ignoreOverridden: false + ignoreUsageInGenerics: false + FunctionOnlyReturningConstant: + active: true + ignoreOverridableFunction: true + ignoreActualFunction: true + excludedFunctions: [] + LoopWithTooManyJumpStatements: + active: false + maxJumpCount: 1 + MagicNumber: + active: true + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**', '**/mingwX64Test/**', '**/macosTest/**', '**/appleTest/**', '**/linuxTest/**', '**/*.kts'] + ignoreNumbers: + - '-1' + - '0' + - '1' + - '2' + ignoreHashCodeFunction: true + ignorePropertyDeclaration: false + ignoreLocalVariableDeclaration: false + ignoreConstantDeclaration: true + ignoreCompanionObjectPropertyDeclaration: true + ignoreAnnotation: false + ignoreNamedArgument: true + ignoreEnums: false + ignoreRanges: false + ignoreExtensionFunctions: true + MandatoryBracesLoops: + active: false + MaxChainedCallsOnSameLine: + active: false + maxChainedCalls: 5 + MaxLineLength: + active: false + maxLineLength: 300 + excludePackageStatements: true + excludeImportStatements: true + excludeCommentStatements: false + excludeRawStrings: true + MayBeConst: + active: true + ModifierOrder: + active: true + MultilineLambdaItParameter: + active: false + MultilineRawStringIndentation: + active: false + indentSize: 4 + trimmingMethods: + - 'trimIndent' + - 'trimMargin' + NestedClassesVisibility: + active: true + NewLineAtEndOfFile: + active: false + NoTabs: + active: false + NullableBooleanCheck: + active: false + ObjectLiteralToLambda: + active: true + OptionalAbstractKeyword: + active: true + OptionalUnit: + active: false + PreferToOverPairSyntax: + active: false + ProtectedMemberInFinalClass: + active: true + RedundantExplicitType: + active: false + RedundantHigherOrderMapUsage: + active: true + RedundantVisibilityModifierRule: + active: false + ReturnCount: + active: false + max: 2 + excludedFunctions: + - 'equals' + excludeLabeled: false + excludeReturnFromLambda: true + excludeGuardClauses: false + SafeCast: + active: true + SerialVersionUIDInSerializableClass: + active: true + SpacingBetweenPackageAndImports: + active: false + StringShouldBeRawString: + active: false + maxEscapedCharacterCount: 2 + ignoredCharacters: [] + ThrowsCount: + active: true + max: 2 + excludeGuardClauses: false + TrailingWhitespace: + active: false + TrimMultilineRawString: + active: false + trimmingMethods: + - 'trimIndent' + - 'trimMargin' + UnderscoresInNumericLiterals: + active: false + acceptableLength: 4 + allowNonStandardGrouping: false + UnnecessaryAbstractClass: + active: true + UnnecessaryAnnotationUseSiteTarget: + active: false + UnnecessaryApply: + active: true + UnnecessaryBackticks: + active: false + UnnecessaryBracesAroundTrailingLambda: + active: false + UnnecessaryFilter: + active: true + UnnecessaryInheritance: + active: true + UnnecessaryInnerClass: + active: false + UnnecessaryLet: + active: false + UnnecessaryParentheses: + active: false + allowForUnclearPrecedence: false + UntilInsteadOfRangeTo: + active: false + UnusedImports: + active: false + UnusedParameter: + active: true + allowedNames: 'ignored|expected' + UnusedPrivateClass: + active: true + UnusedPrivateMember: + active: true + allowedNames: '' + UnusedPrivateProperty: + active: true + allowedNames: '_|ignored|expected|serialVersionUID' + UseAnyOrNoneInsteadOfFind: + active: true + UseArrayLiteralsInAnnotations: + active: true + UseCheckNotNull: + active: true + UseCheckOrError: + active: true + UseDataClass: + active: false + allowVars: false + UseEmptyCounterpart: + active: false + UseIfEmptyOrIfBlank: + active: false + UseIfInsteadOfWhen: + active: false + ignoreWhenContainingVariableDeclaration: false + UseIsNullOrEmpty: + active: true + UseLet: + active: false + UseOrEmpty: + active: true + UseRequire: + active: true + UseRequireNotNull: + active: true + UseSumOfInsteadOfFlatMapSize: + active: false + UselessCallOnNotNull: + active: true + UtilityClassWithPublicConstructor: + active: true + VarCouldBeVal: + active: true + ignoreLateinitVar: false + WildcardImport: + active: true + excludeImports: + - 'java.util.*' \ No newline at end of file diff --git a/dokka/README.md b/dokka/README.md new file mode 100644 index 0000000..39c35ef --- /dev/null +++ b/dokka/README.md @@ -0,0 +1,3 @@ +# Supabase Edge Functions Kotlin + +Build, serve and deploy Supabase Edge Functions with Kotlin and Gradle. \ No newline at end of file diff --git a/gradle-plugin/MODULE.md b/gradle-plugin/MODULE.md new file mode 100644 index 0000000..0c3a36d --- /dev/null +++ b/gradle-plugin/MODULE.md @@ -0,0 +1,3 @@ +# Module gradle-plugin + +Gradle plugin for building, serving and deploying functions. \ No newline at end of file diff --git a/gradle-plugin/build.gradle.kts b/gradle-plugin/build.gradle.kts new file mode 100644 index 0000000..a7c6923 --- /dev/null +++ b/gradle-plugin/build.gradle.kts @@ -0,0 +1,33 @@ +/** + * Copyright (c) 2024 Maanrifa Bacar Ali. + * Use of this source code is governed by the MIT license. + */ + +plugins { + `kotlin-dsl` + alias(libs.plugins.conventions.common) + alias(libs.plugins.gradle.plugin.publish) +} + +dependencies { + implementation(libs.kotlin.gradle.plugin) + implementation(libs.gradle.idea.ext) + implementation(libs.squareup.kotlinpoet) + implementation(libs.atlassian.sourcemap) +} + +@Suppress("UnstableApiUsage") +gradlePlugin { + website = projectWebsite + vcsUrl = projectGitUrl + + plugins { + create("supabase-function") { + id = projectGroup + implementationClass = "io.github.manriif.supabase.functions.SupabaseFunctionPlugin" + tags = setOf("kotlin", "supabase", "js", "edge-functions") + displayName = localName + description = localDescription + } + } +} \ No newline at end of file diff --git a/gradle-plugin/gradle.properties b/gradle-plugin/gradle.properties new file mode 100644 index 0000000..426529f --- /dev/null +++ b/gradle-plugin/gradle.properties @@ -0,0 +1,10 @@ +# +# Copyright 2024 Maanrifa Bacar Ali. +# Use of this source code is governed by the MIT license. +# +local.name=Supabase Edge Functions Kotlin - Gradle Plugin +local.description=The plugin helps in building Supabase Edge Functions using Kotlin as primary \ + programming language.\n\ + It offers support for multi-module project and Javascript sources.\n\n\ + Additionnaly, it provides gradle tasks for serving, inspecting and testing your functions \ + locally and later deploying them to a remote project. \ No newline at end of file diff --git a/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/Constants.kt b/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/Constants.kt new file mode 100644 index 0000000..4bf1718 --- /dev/null +++ b/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/Constants.kt @@ -0,0 +1,52 @@ +/** + * Copyright (c) 2024 Maanrifa Bacar Ali + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package io.github.manriif.supabase.functions + +internal const val SUPABASE_FUNCTION_PLUGIN_NAME = "supabase-function" +internal const val SUPABASE_FUNCTION_TASK_GROUP = "supabase function" +internal const val SUPABASE_FUNCTION_OUTPUT_DIR = "supabaseFunction" + +internal const val COROUTINES_VERSION = "1.8.1" +internal const val COROUTINES_VERSION_GRADLE_PROPERTY = "supabase.functions.coroutines.version" + +internal const val DENO_KOTLIN_BRIDGE_FUNCTION_NAME = "denoKotlinBridge" +internal const val KOTLIN_MAIN_FUNCTION_NAME = "serve" + +internal const val GITIGNORE_FILE_NAME = ".gitignore" +internal const val IMPORT_MAP_TEMPLATE_FILE_NAME = "import_map_template.json" +internal const val IMPORT_MAP_FILE_NAME = "import_map.json" +internal const val INDEX_FILE_NAME = "index.ts" +internal const val FUNCTION_KOTLIN_TARGET_DIR = "kotlin" +internal const val FUNCTION_JS_TARGET_DIR = "js" +internal const val LOCAL_ENV_FILE_NAME = ".env.local" +internal const val REQUEST_CONFIG_FILE_NAME = "request-config.json" +internal const val JS_SOURCES_INPUT_DIR = "js" + +internal const val PARAMETER_INSPECT = "supFunInspect" +internal const val PARAMETER_AUTO_REQUEST = "supFunAutoRequest" +internal const val PARAMETER_REQUEST_DELAY = "supFunReqDelay" +internal const val PARAMETER_LOG_RESPONSE = "supFunLogResponse" +internal const val PARAMETER_LOG_STATUS = "supFunLogStatus" +internal const val PARAMETER_SERVE_DEBUG = "supFunServeDebug" + +internal const val IMPORT_MAP_JSON_IMPORTS = "imports" +internal const val IMPORT_MAP_JSON_SCOPES = "scopes" \ No newline at end of file diff --git a/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/ExperimentalSupabaseFunctionApi.kt b/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/ExperimentalSupabaseFunctionApi.kt new file mode 100644 index 0000000..2a311aa --- /dev/null +++ b/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/ExperimentalSupabaseFunctionApi.kt @@ -0,0 +1,27 @@ +/** + * Copyright (c) 2024 Maanrifa Bacar Ali + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package io.github.manriif.supabase.functions + +@RequiresOptIn("This API is experimental and may be dropped at any time.") +@Retention(AnnotationRetention.BINARY) +@Target(AnnotationTarget.ANNOTATION_CLASS, AnnotationTarget.FIELD) +annotation class ExperimentalSupabaseFunctionApi \ No newline at end of file diff --git a/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/SupabaseFunctionExtension.kt b/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/SupabaseFunctionExtension.kt new file mode 100644 index 0000000..6796380 --- /dev/null +++ b/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/SupabaseFunctionExtension.kt @@ -0,0 +1,104 @@ +/** + * Copyright (c) 2024 Maanrifa Bacar Ali + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package io.github.manriif.supabase.functions + +import io.github.manriif.supabase.functions.idea.RunConfigurationOptions +import org.gradle.api.Action +import org.gradle.api.Project +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.Property + +/** + * Extensions for configuring supabase function plugin. + */ +abstract class SupabaseFunctionExtension { + + /** + * Name of the package in which the kotlin's main function resides. + */ + abstract val packageName: Property + + /** + * Supabase directory location. + * By default, the plugin expects this directory to be under the root project directory. + */ + abstract val supabaseDir: DirectoryProperty + + /** + * The name of the supabase function. + * Default to the project name. + * + * See [Supabase Docs](https://supabase.com/docs/guides/functions/quickstart) for function + * naming recommendations. + */ + abstract val functionName: Property + + /** + * Optional remote project reference for function deployment task. + * No default value. + */ + abstract val projectRef: Property + + /** + * Whether a valid JWT is required. + * Default to true. + * + * See [Supabase Docs](https://supabase.com/docs/guides/cli/config#functions.function_name.verify_jwt) + * for more explanation. + */ + abstract val verifyJwt: Property + + /** + * Whether to include import_map.json in function serve and deploy task. + * Default to true. + */ + abstract val importMap: Property + + /** + * Env file for function serving. + * By default, the plugin will use a `.env.local` that is expected to be under the supabase + * directory. The file will only be used if it exists. + */ + abstract val envFile: RegularFileProperty + + /** + * Configure the run configuration to generate for IntelliJ based IDEs. + */ + val runConfiguration = RunConfigurationOptions() + + /** + * Allows to configure [runConfiguration] in a DSL manner. + */ + @Suppress("unused", "MemberVisibilityCanBePrivate") + fun runConfiguration(action: Action) { + action.execute(runConfiguration) + } +} + +internal fun SupabaseFunctionExtension.setupConvention(project: Project) { + supabaseDir.convention(project.rootProject.layout.projectDirectory.dir("supabase")) + envFile.convention(supabaseDir.file(LOCAL_ENV_FILE_NAME)) + functionName.convention(project.name) + verifyJwt.convention(true) + importMap.convention(true) +} \ No newline at end of file diff --git a/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/SupabaseFunctionPlugin.kt b/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/SupabaseFunctionPlugin.kt new file mode 100644 index 0000000..8093b77 --- /dev/null +++ b/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/SupabaseFunctionPlugin.kt @@ -0,0 +1,81 @@ +/** + * Copyright (c) 2024 Maanrifa Bacar Ali + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package io.github.manriif.supabase.functions + +import io.github.manriif.supabase.functions.idea.configureIdeaGradleConfigurations +import io.github.manriif.supabase.functions.kmp.kotlinVersion +import io.github.manriif.supabase.functions.kmp.setupKotlinMultiplatform +import io.github.manriif.supabase.functions.task.PREPARE_KOTLIN_BUILD_SCRIPT_MODEL_TASK +import io.github.manriif.supabase.functions.task.configurePluginTasks +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.create +import org.gradle.kotlin.dsl.getByType +import org.gradle.kotlin.dsl.withType +import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension +import org.jetbrains.kotlin.gradle.plugin.KotlinMultiplatformPluginWrapper + +class SupabaseFunctionPlugin : Plugin { + + override fun apply(target: Project) { + target.checkKotlinVersionCompatibility() + + val extension = target.extensions.create("supabaseFunction") + extension.setupConvention(target) + + target.plugins.withType { + target.configurePlugin( + extension = extension, + kmpExtension = target.extensions.getByType() + ) + } + + target.afterEvaluate { + configureIdeaGradleConfigurations(extension) + } + } + + private fun Project.checkKotlinVersionCompatibility() { + val kotlinVersion = kotlinVersion() + + if (!kotlinVersion.isAtLeast(2, 0, 0)) { + error( + "You are applying `$SUPABASE_FUNCTION_PLUGIN_NAME` plugin on a project " + + "targeting Kotlin version $kotlinVersion. However, the plugin " + + "requires Kotlin version 2.0.0 or newer." + ) + } + } + + private fun Project.configurePlugin( + extension: SupabaseFunctionExtension, + kmpExtension: KotlinMultiplatformExtension + ) { + // Prevent plugin application on non-multiplatform project + if (rootProject.tasks.named { it == PREPARE_KOTLIN_BUILD_SCRIPT_MODEL_TASK }.isEmpty()) { + return + } + + setupKotlinMultiplatform(kmpExtension) + configurePluginTasks(extension, kmpExtension) + } +} \ No newline at end of file diff --git a/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/error/SupabaseFunctionException.kt b/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/error/SupabaseFunctionException.kt new file mode 100644 index 0000000..d38c964 --- /dev/null +++ b/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/error/SupabaseFunctionException.kt @@ -0,0 +1,35 @@ +/** + * Copyright (c) 2024 Maanrifa Bacar Ali + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package io.github.manriif.supabase.functions.error + +internal abstract class SupabaseFunctionException(message: String, cause: Throwable? = null) : + RuntimeException(message, cause) + +internal class SupabaseFunctionDeployException(message: String) : SupabaseFunctionException(message) + +internal class SupabaseFunctionServeException(message: String) : SupabaseFunctionException(message) + +internal class SupabaseFunctionImportMapTemplateException(message: String, cause: Throwable?) : + SupabaseFunctionException(message, cause) + +internal class SupabaseFunctionRequestConfigException(message: String, cause: Throwable?) : + SupabaseFunctionException(message, cause) \ No newline at end of file diff --git a/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/idea/RunConfiguration.kt b/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/idea/RunConfiguration.kt new file mode 100644 index 0000000..8b9ecf7 --- /dev/null +++ b/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/idea/RunConfiguration.kt @@ -0,0 +1,197 @@ +/** + * Copyright (c) 2024 Maanrifa Bacar Ali + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package io.github.manriif.supabase.functions.idea + +import io.github.manriif.supabase.functions.PARAMETER_AUTO_REQUEST +import io.github.manriif.supabase.functions.PARAMETER_INSPECT +import io.github.manriif.supabase.functions.SupabaseFunctionExtension +import io.github.manriif.supabase.functions.task.TASK_FUNCTION_DEPLOY +import io.github.manriif.supabase.functions.task.TASK_FUNCTION_SERVE +import org.gradle.api.Project +import org.gradle.kotlin.dsl.apply +import org.gradle.kotlin.dsl.findByType +import org.gradle.kotlin.dsl.register +import org.gradle.plugins.ide.idea.model.IdeaModel +import org.jetbrains.gradle.ext.Gradle +import org.jetbrains.gradle.ext.IdeaExtPlugin +import org.jetbrains.gradle.ext.runConfigurations +import org.jetbrains.gradle.ext.settings + +/** + * A run configuration that can be generated. + */ +data class ServeRunConfiguration( + + /** + * Whether a run configuration should be generated. + */ + var enabled: Boolean = true, + + /** + * Whether continuous build is enabled for the generated run configuration. + */ + var continuous: Boolean = true +) + +/** + * IDEA run configurations that must be generated. + */ +data class RunConfigurationOptions( + + /** + * Indicates if a run configuration should be generated for deploying the function. + */ + var deploy: Boolean = true, + + /** + * [ServeRunConfiguration] for serving the function. + */ + var serve: ServeRunConfiguration = ServeRunConfiguration(), + + /** + * [ServeRunConfiguration] for serving the function and inspecting (debugging) the function via + * Chrome DevTools. + */ + var inspect: ServeRunConfiguration = ServeRunConfiguration(), + + /** + * [ServeRunConfiguration] for serving the function and automatically send requests. + */ + var request: ServeRunConfiguration = ServeRunConfiguration() +) { + + /** + * Configure serve run configuration in a DSL manner. + */ + fun serve(action: ServeRunConfiguration.() -> Unit) { + action(serve) + } + + /** + * Configure inspect run configuration in a DSL manner. + */ + fun inspect(action: ServeRunConfiguration.() -> Unit) { + action(inspect) + } + + /** + * Configure request run configuration in a DSL manner. + */ + fun request(action: ServeRunConfiguration.() -> Unit) { + action(request) + } +} + +/////////////////////////////////////////////////////////////////////////// +// IDEA Configurations +/////////////////////////////////////////////////////////////////////////// + +internal fun Project.configureIdeaGradleConfigurations( + extension: SupabaseFunctionExtension +) = with(extension.runConfiguration) { + if (deploy) { + createIdeaDeployConfiguration(extension) + } + + if (serve.enabled) { + createIdeaServeConfiguration(extension, serve) + } + + if (inspect.enabled) { + createIdeaInspectConfiguration(extension, inspect) + } + + if (request.enabled) { + createAutoRequestConfiguration(extension, request) + } +} + +private fun Project.registerIdeaGradleConfiguration( + extension: SupabaseFunctionExtension, + name: String, + taskName: String, + configure: (Gradle.() -> Unit)? = null +) { + rootProject.pluginManager.apply(IdeaExtPlugin::class) + + rootProject.extensions.findByType()?.let { idea -> + idea.project.settings.runConfigurations.register( + name = "${extension.functionName.get()} $name" + ) { + projectPath = projectDir.absolutePath + taskNames = listOf(taskName) + configure?.invoke(this) + } + } +} + +private fun Gradle.configureServe( + configuration: ServeRunConfiguration, + params: String? = null +) { + when { + configuration.continuous && params != null -> scriptParameters = "$params --continuous" + configuration.continuous && params == null -> scriptParameters = "--continuous" + params != null -> scriptParameters = params + } +} + +private fun Project.createIdeaDeployConfiguration( + extension: SupabaseFunctionExtension +) = registerIdeaGradleConfiguration( + extension = extension, + name = "deploy", + taskName = TASK_FUNCTION_DEPLOY +) + +private fun Project.createIdeaServeConfiguration( + extension: SupabaseFunctionExtension, + configuration: ServeRunConfiguration +) = registerIdeaGradleConfiguration( + extension = extension, + name = "serve", + taskName = TASK_FUNCTION_SERVE, +) { + configureServe(configuration) +} + +private fun Project.createIdeaInspectConfiguration( + extension: SupabaseFunctionExtension, + configuration: ServeRunConfiguration, +) = registerIdeaGradleConfiguration( + extension = extension, + name = "inspect", + taskName = TASK_FUNCTION_SERVE +) { + configureServe(configuration, "-P$PARAMETER_INSPECT") +} + +private fun Project.createAutoRequestConfiguration( + extension: SupabaseFunctionExtension, + configuration: ServeRunConfiguration, +) = registerIdeaGradleConfiguration( + extension = extension, + name = "request", + taskName = TASK_FUNCTION_SERVE +) { + configureServe(configuration, "-P$PARAMETER_AUTO_REQUEST") +} \ No newline at end of file diff --git a/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/kmp/JsDependency.kt b/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/kmp/JsDependency.kt new file mode 100644 index 0000000..aab9114 --- /dev/null +++ b/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/kmp/JsDependency.kt @@ -0,0 +1,108 @@ +/** + * Copyright (c) 2024 Maanrifa Bacar Ali + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package io.github.manriif.supabase.functions.kmp + +import io.github.manriif.supabase.functions.JS_SOURCES_INPUT_DIR +import org.gradle.api.Project +import org.gradle.api.artifacts.ProjectDependency +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.file.FileCollection +import org.gradle.api.provider.Property +import org.gradle.api.provider.Provider +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputFiles +import org.gradle.api.tasks.Internal +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.kotlin.dsl.findByType +import org.gradle.kotlin.dsl.newInstance +import org.gradle.kotlin.dsl.withType +import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension +import org.jetbrains.kotlin.gradle.plugin.KotlinMultiplatformPluginWrapper +import javax.inject.Inject + +internal abstract class JsDependency @Inject constructor() { + + @get:InputFiles + @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val sourceDirectories: Property + + @get:Internal + abstract val projectDirectory: DirectoryProperty + + @get:Input + abstract val projectName: Property + + @get:Input + abstract val jsOutputName: Property +} + +internal fun Project.jsDependencies(): Provider> { + val projectDependencies = mutableMapOf() + findProjectDependencies(projectDependencies) + return provider(projectDependencies::values) +} + +private fun Project.configureJsDependency(collector: MutableMap) { + if (collector.containsKey(path)) { + return + } + + check(collector.values.none { it.projectName.orNull == name }) { + "Duplicate project name `$name`. Ensure that all projects " + + "(including included build project's) have a distinct name." + } + + val dependency = objects.newInstance().apply { + projectDirectory.convention(project.layout.projectDirectory) + projectName.convention(name) + } + + collector[path] = dependency + + plugins.withType { + extensions.findByType()?.run { + val directories = project.objects.fileCollection().apply { + disallowUnsafeRead() + } + + dependency.sourceDirectories.convention(provider { directories }) + dependency.jsOutputName.convention(jsOutputName(this)) + + sourceSets.configureEach { + if (!name.endsWith("test", ignoreCase = true)) { + directories.from(project.file("src/$name/$JS_SOURCES_INPUT_DIR")) + } + } + } + } +} + +private fun Project.findProjectDependencies(collector: MutableMap) { + configureJsDependency(collector) + + configurations.configureEach { + allDependencies.withType { + dependencyProject.findProjectDependencies(collector) + } + } +} \ No newline at end of file diff --git a/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/kmp/Kmp.kt b/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/kmp/Kmp.kt new file mode 100644 index 0000000..54985c0 --- /dev/null +++ b/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/kmp/Kmp.kt @@ -0,0 +1,104 @@ +/** + * Copyright (c) 2024 Maanrifa Bacar Ali + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package io.github.manriif.supabase.functions.kmp + +import io.github.manriif.supabase.functions.COROUTINES_VERSION +import io.github.manriif.supabase.functions.COROUTINES_VERSION_GRADLE_PROPERTY +import io.github.manriif.supabase.functions.SUPABASE_FUNCTION_PLUGIN_NAME +import io.github.manriif.supabase.functions.task.TASK_GENERATE_BRIDGE +import org.gradle.api.Project +import org.gradle.kotlin.dsl.withType +import org.jetbrains.kotlin.gradle.dsl.JsModuleKind +import org.jetbrains.kotlin.gradle.dsl.JsSourceMapEmbedMode +import org.jetbrains.kotlin.gradle.dsl.JsSourceMapNamesPolicy +import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension +import org.jetbrains.kotlin.gradle.plugin.KotlinCompilation +import org.jetbrains.kotlin.gradle.targets.js.ir.KotlinJsIrTarget + +internal fun Project.setupKotlinMultiplatform(kmpExtension: KotlinMultiplatformExtension) { + kmpExtension.targets.withType().configureEach { + ensureMeetRequirements() + configureCompilation() + } + + kmpExtension.sourceSets.named { it == "jsMain" }.configureEach { + val coroutinesVersion = findProperty(COROUTINES_VERSION_GRADLE_PROPERTY)?.toString() + ?: COROUTINES_VERSION + + dependencies { + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:${coroutinesVersion}") + } + } +} + +private fun KotlinJsIrTarget.ensureMeetRequirements() { + val granularity = project.findProperty("kotlin.js.ir.output.granularity")?.toString() + + if (!(granularity.isNullOrBlank() || granularity == "per-module")) { + error( + "Only `per-module` JS IR output granularity is supported " + + "by `$SUPABASE_FUNCTION_PLUGIN_NAME` plugin. " + + "Current granularity is `$granularity`." + ) + } + + if (isBrowserConfigured) { + error( + "Browser execution environment is not supported by " + + "`$SUPABASE_FUNCTION_PLUGIN_NAME` plugin." + ) + } + + if (!isNodejsConfigured) { + error( + "Node.js execution environment is a requirement " + + "for `$SUPABASE_FUNCTION_PLUGIN_NAME` plugin." + ) + } +} + +private fun KotlinJsIrTarget.configureCompilation() { + compilations.named(KotlinCompilation.MAIN_COMPILATION_NAME) { + // Module kind is not set when using new compiler option DSL, fallback to deprecated one + @Suppress("DEPRECATION") + compilerOptions.configure { + val kind = moduleKind.orNull + + if (kind != JsModuleKind.MODULE_ES) { + error( + "Plugin `supabase-function` only supports ES module kind. " + + "Current module kind is $kind." + ) + } + + // [KT-47968](https://youtrack.jetbrains.com/issue/KT-47968/KJS-IR-Debug-in-external-tool-cant-step-into-library-function-with-available-sources) + // [KT-49757](https://youtrack.jetbrains.com/issue/KT-49757/Kotlin-JS-support-sourceMapEmbedSources-setting-by-IR-backend) + sourceMap.set(true) + sourceMapNamesPolicy.set(JsSourceMapNamesPolicy.SOURCE_MAP_NAMES_POLICY_FQ_NAMES) + sourceMapEmbedSources.set(JsSourceMapEmbedMode.SOURCE_MAP_SOURCE_CONTENT_ALWAYS) + } + + compileTaskProvider.configure { + dependsOn(TASK_GENERATE_BRIDGE) + } + } +} \ No newline at end of file diff --git a/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/kmp/KotlinVersion.kt b/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/kmp/KotlinVersion.kt new file mode 100644 index 0000000..b7cf8d5 --- /dev/null +++ b/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/kmp/KotlinVersion.kt @@ -0,0 +1,41 @@ +/** + * Copyright (c) 2024 Maanrifa Bacar Ali + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package io.github.manriif.supabase.functions.kmp + +import org.gradle.api.Project +import org.jetbrains.kotlin.gradle.plugin.getKotlinPluginVersion + +internal fun Project.kotlinVersion(): KotlinVersion { + val kotlinPluginVersion = getKotlinPluginVersion() + + val (major, minor) = kotlinPluginVersion + .split('.') + .take(2) + .map { it.toInt() } + + val patch = kotlinPluginVersion + .substringAfterLast('.') + .substringBefore('-') + .toInt() + + return KotlinVersion(major, minor, patch) +} \ No newline at end of file diff --git a/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/kmp/jsOutputName.kt b/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/kmp/jsOutputName.kt new file mode 100644 index 0000000..2344c5c --- /dev/null +++ b/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/kmp/jsOutputName.kt @@ -0,0 +1,49 @@ +/** + * Copyright (c) 2024 Maanrifa Bacar Ali + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package io.github.manriif.supabase.functions.kmp + +import org.gradle.api.Project +import org.gradle.api.provider.Provider +import org.gradle.kotlin.dsl.withType +import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension +import org.jetbrains.kotlin.gradle.targets.js.ir.KotlinJsIrTarget + +internal fun Project.jsOutputName(kmpExtension: KotlinMultiplatformExtension): Provider { + val defaultModuleName = defaultOutputName(this) + + return provider { + kmpExtension.targets.withType().firstOrNull()?.moduleName + ?: defaultModuleName + } +} + +private fun defaultOutputName(project: Project): String { + var upperProject: Project? = project.parent + var moduleName = project.name + + while (upperProject != null) { + moduleName = upperProject.name + "-" + moduleName + upperProject = upperProject.parent + } + + return moduleName +} \ No newline at end of file diff --git a/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/request/Request.kt b/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/request/Request.kt new file mode 100644 index 0000000..993ec5f --- /dev/null +++ b/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/request/Request.kt @@ -0,0 +1,69 @@ +/** + * Copyright (c) 2024 Maanrifa Bacar Ali + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package io.github.manriif.supabase.functions.request + +import org.jetbrains.kotlin.com.google.gson.JsonElement +import org.jetbrains.kotlin.com.google.gson.annotations.SerializedName +import java.io.File + +// Gson set nulls reflectively no matter on default values and non-null types +internal class Request : RequestOptions() { + + val name: String? = null + val method: String? = null + val body: JsonElement? = null + val type: Type? = null + val validation: RequestValidation? = null + + @Suppress("USELESS_ELVIS") + val parameters: Map = emptyMap() + get() = field ?: emptyMap() + + @Transient + var resolvedFile: File? = null + + override fun toString(): String { + return "Request(" + + "name=$name, " + + "method=$method, " + + "http=$http, " + + "timeout=$timeout, " + + "type=$type, " + + "headers=$headers, " + + "parameters=$parameters, " + + "body=$body, " + + "validation=$validation" + + ")" + } + + enum class Type { + + @SerializedName("plain") + Plain, + + @SerializedName("json") + Json, + + @SerializedName("file") + File + } +} \ No newline at end of file diff --git a/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/request/RequestClient.kt b/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/request/RequestClient.kt new file mode 100644 index 0000000..be70742 --- /dev/null +++ b/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/request/RequestClient.kt @@ -0,0 +1,513 @@ +/** + * Copyright (c) 2024 Maanrifa Bacar Ali + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package io.github.manriif.supabase.functions.request + +import io.github.manriif.supabase.functions.error.SupabaseFunctionRequestConfigException +import io.github.manriif.supabase.functions.supabase.SUPABASE_ANON_KEY +import io.github.manriif.supabase.functions.supabase.SUPABASE_API_URL +import io.github.manriif.supabase.functions.supabase.SUPABASE_SERVICE_ROLE_KEY +import io.github.manriif.supabase.functions.supabase.supabaseCommand +import io.github.manriif.supabase.functions.util.Color +import io.github.manriif.supabase.functions.util.colored +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.logging.Logger +import org.gradle.api.provider.Provider +import org.gradle.process.internal.ExecActionFactory +import org.jetbrains.kotlin.com.google.gson.Gson +import org.jetbrains.kotlin.com.google.gson.GsonBuilder +import org.jetbrains.kotlin.com.google.gson.JsonParser +import org.jetbrains.kotlin.com.google.gson.JsonPrimitive +import org.jetbrains.kotlin.gradle.internal.ensureParentDirsCreated +import java.io.ByteArrayOutputStream +import java.net.URI +import java.net.http.HttpClient +import java.net.http.HttpHeaders +import java.net.http.HttpRequest +import java.net.http.HttpResponse +import java.time.Duration +import java.util.Date +import java.util.Timer +import java.util.TimerTask +import kotlin.concurrent.timerTask +import kotlin.math.max + +private val PlaceholderRegex = """\$\{(.*)\}""".toRegex() + +internal class RequestClient( + private val rootProjectDir: DirectoryProperty, + private val projectDir: DirectoryProperty, + private val requestOutputDir: DirectoryProperty, + private val requestConfigFile: RegularFileProperty, + private val functionName: Provider, + private val logger: Logger, + private val options: RequestClientOptions +) { + + private val properties = mutableMapOf() + private var initialized = false + + private val httpClient by lazy { HttpClient.newHttpClient() } + private val timer by lazy { Timer("Supabase Function") } + private val gson by lazy { createGson() } + + private var requestConfig: RequestConfig? = null + private var functionBaseUrl: String? = null + private var currentTask: TimerTask? = null + + /////////////////////////////////////////////////////////////////////////// + // Initialization + /////////////////////////////////////////////////////////////////////////// + + /** + * Initializes the request client. + */ + fun initialize(execActionFactory: ExecActionFactory): Boolean { + val result = doInitialization(execActionFactory) + + if (!result) { + logger.error( + "Automatic request sending is disabled, " + + "please ensure that Supabase CLI is up-to-date and running." + ) + } + + return result + } + + private fun doInitialization(execActionFactory: ExecActionFactory): Boolean { + check(!initialized) { "Request processor already initialized" } + + val mainSteam = ByteArrayOutputStream() + val errorStream = ByteArrayOutputStream() + + val action = execActionFactory.newExecAction().apply { + isIgnoreExitValue = true + workingDir = rootProjectDir.get().asFile + standardOutput = mainSteam + errorOutput = errorStream + + commandLine(supabaseCommand(), "status", "--output", "json") + } + + val result = action.execute() + val content = mainSteam.toString(Charsets.UTF_8) + + if (result.exitValue != 0) { + logger.error("Failed to resolve supabase local development settings: $errorStream") + return false + } + + if (!parseStatusCommandResult(content)) { + return false + } + + if (options.logStatus) { + printStatus(content) + } + + configureFunctionUrl() + reloadConfigFile() + + initialized = true + return true + } + + private fun parseStatusCommandResult(content: String): Boolean { + val json = kotlin.runCatching { + val jsonString = content.substring( + startIndex = content.indexOf('{'), + endIndex = content.lastIndexOf('}') + 1 + ) + + JsonParser.parseString(jsonString).asJsonObject + }.getOrElse { error -> + logger.error("Failed to parse supabase status response: ${error.stackTraceToString()}") + return false + } + + json.keySet().forEach { key -> + properties[key] = json.get(key).asString + } + + val required = listOf(SUPABASE_API_URL, SUPABASE_ANON_KEY, SUPABASE_SERVICE_ROLE_KEY) + + return properties.keys.containsAll(required).also { value -> + if (!value) { + logger.error( + "Some required variables were not returned by " + + "`supabase status` command. Restarting supabase local development " + + "stack may solve the issue." + ) + } + } + } + + private fun configureFunctionUrl() { + functionBaseUrl = properties[SUPABASE_API_URL]?.removeSuffix("/") ?: return + functionBaseUrl += "/functions/v1/${functionName.get()}" + } + + private fun printStatus(content: String) { + val disabledServices = content.substringBefore('\n') + + val allProperties = properties.entries.joinToString("\n") { (key, value) -> + "$key = $value" + } + + val message = """ + |$disabledServices + |$allProperties + """.trimMargin() + + logger.lifecycle(message) + } + + /////////////////////////////////////////////////////////////////////////// + // Config + /////////////////////////////////////////////////////////////////////////// + + private fun createGson(): Gson { + return GsonBuilder() + .setPrettyPrinting() + .create() + } + + fun reloadConfigFile() { + if (!requestConfigFile.isPresent) { + return + } + + val configFile = requestConfigFile.get().asFile + + if (!configFile.exists()) { + return + } + + requestConfig = try { + gson.fromJson(configFile.reader(), RequestConfig::class.java) + } catch (exception: Throwable) { + return logger.error("Failed to load request-config.json: ${exception.message}") + } + + try { + requestConfig?.checkValidity(projectDir) + } catch (throwable: Throwable) { + throw SupabaseFunctionRequestConfigException( + "Invalid request-config.json configuration", + throwable + ) + } + } + + private fun String.fillPlaceholders(): String { + return replace(PlaceholderRegex) { match -> + val value = match.groupValues.component2() + + try { + requireNotNull(properties[value.uppercase()]) + } catch (throwable: Throwable) { + throw SupabaseFunctionRequestConfigException( + message = "Property `${value}` does not exists.", + cause = throwable + ) + } + } + } + + /////////////////////////////////////////////////////////////////////////// + // Request + /////////////////////////////////////////////////////////////////////////// + + /** + * Sends all requests asynchronously. + */ + fun sendRequestsAsync(onFinish: (() -> Unit)? = null) { + currentTask?.cancel() + + currentTask = timerTask { + sendRequests() + onFinish?.invoke() + } + + timer.schedule(currentTask, options.requestDelay) + } + + /** + * Sends all requests and returns true if no request failed. + */ + private fun sendRequests(): Boolean { + val baseUrl = functionBaseUrl ?: return true + val config = requestConfig ?: return true + + if (config.requests.isEmpty()) { + return true + } + + val entries = config.requests.map { request -> + request to sendRequest(config, request, baseUrl) + } + + return entries.map valid@{ (request, response) -> + if (options.logResponse && response != null) { + logResponse(request, response) + } + + validateResponse(request, response) + }.all { it } + } + + /** + * Sends request and returns true it succeeded. + */ + private fun sendRequest( + config: RequestConfig, + request: Request, + baseUrl: String + ): HttpResponse? { + val queryString = if (request.parameters.isEmpty()) "" else { + "?" + request.parameters.entries.joinToString("&") { (key, value) -> + "$key=$value" + } + } + + val uri = URI(baseUrl + queryString) + + val httpRequestBuilder = HttpRequest + .newBuilder(uri) + .version( + when (request.http ?: config.http) { + RequestOptions.HttpVersion.HTTP_2 -> HttpClient.Version.HTTP_2 + RequestOptions.HttpVersion.HTTP_1_1, null -> HttpClient.Version.HTTP_1_1 + } + + ) + .method(request.method!!.uppercase(), request.bodyPublisher()) + + val headers = mutableMapOf().apply { + putAll(config.headers) + putAll(request.headers) + } + + if (request.type == Request.Type.Json) { + if (headers.keys.any { it.lowercase() == "content-type" }) { + headers["Content-Type"] = "application/json" + } + } + + (request.timeout ?: config.timeout)?.let { timeout -> + httpRequestBuilder.timeout(Duration.ofMillis(timeout)) + } + + headers.forEach { (name, value) -> + httpRequestBuilder.setHeader(name, value.fillPlaceholders()) + } + + return try { + httpClient.send( + httpRequestBuilder.build(), + request.bodyHandler() + ) + } catch (throwable: Throwable) { + logger.error( + """ + |An error occurred while sending request `${request.name}`: + |${throwable.stackTraceToString()} + """.trimMargin() + ) + + null + } + } + + private fun Request.bodyPublisher(): HttpRequest.BodyPublisher? { + return when (type) { + Request.Type.Json -> body?.asJsonObject?.toString()?.let { content -> + HttpRequest.BodyPublishers.ofString(content) + } + + Request.Type.File -> resolvedFile?.let { file -> + HttpRequest.BodyPublishers.ofFile(file.toPath()) + } + + Request.Type.Plain -> body?.asString?.let { content -> + HttpRequest.BodyPublishers.ofString(content) + } + + null -> HttpRequest.BodyPublishers.noBody() + } + } + + /////////////////////////////////////////////////////////////////////////// + // Response + /////////////////////////////////////////////////////////////////////////// + + private fun HttpHeaders.joinToString() = map().entries.joinToString("\n\t") { (key, values) -> + "$key: ${values.joinToString()}" + } + + private fun logResponse(request: Request, response: HttpResponse) { + val message = """ + | + |[${request.name}] + |Type: ${request.type ?: ""} + |[Request] + |Date: ${Date()} + |Endpoint: ${response.uri()} + |Method: ${request.method?.uppercase()} + |Headers: [ + | ${response.request().headers().joinToString()} + |] + |[Response] + |Status: ${response.statusCode()} + |Headers: [ + | ${response.headers().joinToString()} + |] + |Body: ${response.body().toString(gson)} + """.trimMargin() + + logger.lifecycle(message.colored(Color.Yellow)) + } + + private fun Request.bodyHandler(): HttpResponse.BodyHandler { + return when (validation?.type) { + RequestValidation.Type.Plain -> validationResultBodyHandler( + subscribe = { HttpResponse.BodySubscribers.ofString(Charsets.UTF_8) }, + transform = ValidationResult::Plain + ) + + RequestValidation.Type.Json -> validationResultBodyHandler( + subscribe = { HttpResponse.BodySubscribers.ofString(Charsets.UTF_8) }, + transform = { content -> + val json = kotlin.runCatching { + JsonParser.parseString(content).asJsonObject + }.getOrElse { + JsonPrimitive(content) + } + + ValidationResult.Json(json) + } + ) + + RequestValidation.Type.File -> validationResultBodyHandler( + subscribe = { + val outputFile = requestOutputDir + .file("${functionName.get()}/${name}_result") + .get().asFile + outputFile.ensureParentDirsCreated() + HttpResponse.BodySubscribers.ofFile(outputFile.toPath()) + }, + transform = { ValidationResult.File(it.toFile()) } + ) + + null -> validationResultBodyHandler( + subscribe = { HttpResponse.BodySubscribers.discarding() }, + transform = { ValidationResult.None } + ) + } + } + + /////////////////////////////////////////////////////////////////////////// + // Validation + /////////////////////////////////////////////////////////////////////////// + + private fun validationContent( + request: Request, + state: String, + stateColor: Color + ): String = """ + | + |[${"${request.name}".colored(Color.Magenta, bright = true)}] + |State: ${state.colored(stateColor, bright = true)} + """.trimMargin() + + private fun errorValidationContent( + request: Request, + reason: String, + expected: Any?, + actual: Any? + ): String { + return """ + |${validationContent(request, "Failed", Color.Red)} + |Reason: ${reason.colored(Color.Red, bright = true)} + |Expected: $expected + |Actual: $actual + """.trimMargin() + } + + private fun validateResponse( + request: Request, + response: HttpResponse? + ): Boolean { + if (response == null) { + logger.lifecycle(validationContent(request, "Failed", Color.Red)) + return false + } + + if (request.validation == null) { + logger.lifecycle(validationContent(request, "No validation", Color.Blue)) + return true + } + + val expectedStatus = request.validation.status ?: 200 + + if (expectedStatus != response.statusCode()) { + logger.lifecycle( + errorValidationContent( + request = request, + reason = "Actual status code differs from expected", + expected = request.validation.status, + actual = response.statusCode() + ) + ) + + return false + } + + val result = response.body() + + if (result.isValid(request.validation)) { + logger.lifecycle(validationContent(request, "Success", Color.Green)) + return true + } + + logger.lifecycle( + errorValidationContent( + request = request, + reason = "Actual content differs from expected", + expected = result.expected(request.validation, gson), + actual = result.toString(gson) + ) + ) + + return false + } + + private fun validationResultBodyHandler( + subscribe: (HttpResponse.ResponseInfo) -> HttpResponse.BodySubscriber, + transform: (T) -> ValidationResult, + ): HttpResponse.BodyHandler { + return HttpResponse.BodyHandler { responseInfo -> + HttpResponse.BodySubscribers.mapping(subscribe(responseInfo), transform) + } + } +} \ No newline at end of file diff --git a/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/request/RequestClientOptions.kt b/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/request/RequestClientOptions.kt new file mode 100644 index 0000000..7ec49f0 --- /dev/null +++ b/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/request/RequestClientOptions.kt @@ -0,0 +1,28 @@ +/** + * Copyright (c) 2024 Maanrifa Bacar Ali + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package io.github.manriif.supabase.functions.request + +internal data class RequestClientOptions( + val logStatus: Boolean, + val logResponse: Boolean, + val requestDelay: Long = 0 +) diff --git a/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/request/RequestConfig.kt b/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/request/RequestConfig.kt new file mode 100644 index 0000000..9e46d2e --- /dev/null +++ b/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/request/RequestConfig.kt @@ -0,0 +1,117 @@ +/** + * Copyright (c) 2024 Maanrifa Bacar Ali + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package io.github.manriif.supabase.functions.request + +import org.gradle.api.file.DirectoryProperty +import org.jetbrains.kotlin.com.google.gson.JsonElement +import java.io.File + +// Gson set nulls reflectively no matter on default values and non-null types +internal class RequestConfig : RequestOptions() { + + @Suppress("USELESS_ELVIS") + val requests: List = emptyList() + get() = field ?: emptyList() + + override fun toString(): String { + return "RequestConfig(" + + "http=$http, " + + "timeout=$timeout, " + + "headers=$headers, " + + "requests=$requests" + + ")" + } +} + +internal fun RequestConfig.checkValidity(projectDir: DirectoryProperty) { + requests.forEachIndexed { index, request -> + request.checkValidity(projectDir, index) + } +} + +private fun Request.checkValidity(projectDir: DirectoryProperty, index: Int) { + fun message(message: String): () -> String = { + "requests[$index]: $message" + } + + requireNotNull(name, message("required field `name` is missing")) + requireNotNull(method, message("required field `method` is missing")) + + if (type != null) { + requireNotNull(body, message("required field `body`is missing")) + + when (type) { + Request.Type.Plain -> { + require(body.isJsonPrimitive, message("plain type `body` must be a valid string")) + } + Request.Type.Json -> { + require(body.isJsonObject, message("json type `body` must be a valid json object")) + } + Request.Type.File -> { + resolvedFile = projectDir.checkBodyFile(body, ::message) + } + } + } + + validation?.checkValidity(projectDir, index) +} + +private fun RequestValidation.checkValidity(projectDir: DirectoryProperty, index: Int) { + fun message(message: String): () -> String = { + "requests[$index].validation: $message" + } + + if (type != null) { + requireNotNull(body, message("required field `body`is missing")) + + when (type) { + RequestValidation.Type.Plain -> { + require(body.isJsonPrimitive, message("plain type `body` must be a valid string")) + } + RequestValidation.Type.Json -> { + require(body.isJsonObject, message("json type `body` must be a valid json object")) + } + RequestValidation.Type.File -> { + resolvedFile = projectDir.checkBodyFile(body, ::message) + } + } + } +} + +private fun DirectoryProperty.checkBodyFile( + element: JsonElement?, + message: (String) -> () -> String +): File { + require( + element?.isJsonPrimitive == true, + message("file type `body` must be a valid file path") + ) + + val resolvedFile = asFile.get().resolve(File(element!!.asString)) + + require( + resolvedFile.isFile, + message("`body` must be a valid path relative to the project directory") + ) + + return resolvedFile +} \ No newline at end of file diff --git a/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/request/RequestOptions.kt b/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/request/RequestOptions.kt new file mode 100644 index 0000000..072e9dc --- /dev/null +++ b/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/request/RequestOptions.kt @@ -0,0 +1,44 @@ +/** + * Copyright (c) 2024 Maanrifa Bacar Ali + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package io.github.manriif.supabase.functions.request + +import org.jetbrains.kotlin.com.google.gson.annotations.SerializedName + +// Gson set nulls reflectively no matter on default values and non-null types +internal abstract class RequestOptions { + + val http: HttpVersion? = null + val timeout: Long? = null + + @Suppress("USELESS_ELVIS") + val headers: Map = emptyMap() + get() = field ?: emptyMap() + + enum class HttpVersion { + + @SerializedName("1.1") + HTTP_1_1, + + @SerializedName("2") + HTTP_2 + } +} \ No newline at end of file diff --git a/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/request/RequestValidation.kt b/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/request/RequestValidation.kt new file mode 100644 index 0000000..2f3e9de --- /dev/null +++ b/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/request/RequestValidation.kt @@ -0,0 +1,57 @@ +/** + * Copyright (c) 2024 Maanrifa Bacar Ali + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package io.github.manriif.supabase.functions.request + +import org.jetbrains.kotlin.com.google.gson.JsonElement +import org.jetbrains.kotlin.com.google.gson.annotations.SerializedName +import java.io.File + +// Gson set nulls reflectively no matter on default values and non-null types +internal class RequestValidation { + + val status: Int? = null + val type: Type? = null + val body: JsonElement? = null + + @Transient + var resolvedFile: File? = null + + override fun toString(): String { + return "RequestValidation(" + + "status=$status, " + + "type=$type, " + + "body=$body" + + ")" + } + + enum class Type { + + @SerializedName("plain") + Plain, + + @SerializedName("json") + Json, + + @SerializedName("file") + File + } +} \ No newline at end of file diff --git a/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/request/ValidationResult.kt b/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/request/ValidationResult.kt new file mode 100644 index 0000000..89c6570 --- /dev/null +++ b/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/request/ValidationResult.kt @@ -0,0 +1,86 @@ +/** + * Copyright (c) 2024 Maanrifa Bacar Ali + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package io.github.manriif.supabase.functions.request + +import io.github.manriif.supabase.functions.util.link +import org.jetbrains.kotlin.com.google.gson.Gson +import org.jetbrains.kotlin.com.google.gson.JsonElement + +internal sealed interface ValidationResult { + + fun isValid(validation: RequestValidation): Boolean + + fun expected(validation: RequestValidation, gson: Gson): String? + + fun toString(gson: Gson): String + + object None : ValidationResult { + + override fun isValid(validation: RequestValidation): Boolean = true + + override fun expected(validation: RequestValidation, gson: Gson): String? = null + + override fun toString(gson: Gson): String = "null" + } + + @JvmInline + value class Plain(private val content: String) : ValidationResult { + + override fun isValid(validation: RequestValidation): Boolean { + return validation.body?.asString == content + } + + override fun expected(validation: RequestValidation, gson: Gson): String? = + validation.body?.asString + + override fun toString(gson: Gson): String = content + } + + @JvmInline + value class Json(private val json: JsonElement) : ValidationResult { + + override fun isValid(validation: RequestValidation): Boolean { + return json == validation.body + } + + override fun expected(validation: RequestValidation, gson: Gson): String? = + gson.toJson(validation.body) + + override fun toString(gson: Gson): String = gson.toJson(json) + } + + @JvmInline + value class File(private val file: java.io.File) : ValidationResult { + + override fun isValid(validation: RequestValidation): Boolean { + return file.readBytes().contentEquals(validation.resolvedFile?.readBytes()) + } + + override fun expected(validation: RequestValidation, gson: Gson): String? { + return validation.resolvedFile?.canonicalPath + } + + override fun toString(gson: Gson): String { + return file.takeIf { it.exists() }?.link("output") ?: "" + } + } +} \ No newline at end of file diff --git a/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/serve/ServeAutoRequest.kt b/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/serve/ServeAutoRequest.kt new file mode 100644 index 0000000..56d1d33 --- /dev/null +++ b/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/serve/ServeAutoRequest.kt @@ -0,0 +1,48 @@ +/** + * Copyright (c) 2024 Maanrifa Bacar Ali + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package io.github.manriif.supabase.functions.serve + +import org.gradle.api.tasks.Input + +/** + * Configuration for auto request feature. + */ +data class ServeAutoRequest( + + /** + * Duration in milliseconds to wait before sending the request after the code has changed. + */ + @Input + var sendRequestOnCodeChangeDelay: Long = 500, + + /** + * Whether to print the supabase output obtained from `supabase status` command. + */ + @Input + var logStatus: Boolean = false, + + /** + * Whether to print the request response. + */ + @Input + var logResponse: Boolean = false +) \ No newline at end of file diff --git a/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/serve/ServeDeploymentHandle.kt b/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/serve/ServeDeploymentHandle.kt new file mode 100644 index 0000000..9779cdd --- /dev/null +++ b/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/serve/ServeDeploymentHandle.kt @@ -0,0 +1,79 @@ +/** + * Copyright (c) 2024 Maanrifa Bacar Ali + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package io.github.manriif.supabase.functions.serve + +import org.gradle.api.logging.Logger +import org.gradle.deployment.internal.Deployment +import org.gradle.deployment.internal.DeploymentHandle +import org.gradle.initialization.BuildCancellationToken +import org.gradle.process.ExecResult +import org.gradle.process.internal.ExecHandle +import org.gradle.process.internal.ExecHandleListener +import javax.inject.Inject + +internal open class ServeDeploymentHandle @Inject constructor( + private val runner: ServeRunner, + private val logger: Logger, + private val buildCancellationToken: BuildCancellationToken +) : DeploymentHandle { + + private var runContext: ServeRunner.RunContext? = null + + override fun isRunning() = runContext != null + + override fun start(deployment: Deployment) { + runContext = runner.start().also { context -> + context.target.addListener(Listener()) + } + } + + override fun stop() { + runContext?.target?.abort()?.also { + runContext = null + } + } + + fun notifyBuildReloaded() { + runContext?.onBuildRefreshed() + } + + private inner class Listener : ExecHandleListener { + + override fun beforeExecutionStarted(execHandle: ExecHandle?) = Unit + + override fun executionStarted(execHandle: ExecHandle?) = Unit + + override fun executionFinished(execHandle: ExecHandle, execResult: ExecResult) { + val reason = getServeCommandFailedReason( + result = execResult.exitValue, + message = runContext?.lastMessage + ) + + reason?.let { + logger.error("\n${reason.removeSuffix("\n")}") + } + + buildCancellationToken.cancel() + } + } + +} \ No newline at end of file diff --git a/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/serve/ServeInspect.kt b/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/serve/ServeInspect.kt new file mode 100644 index 0000000..8f1cce9 --- /dev/null +++ b/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/serve/ServeInspect.kt @@ -0,0 +1,75 @@ +/** + * Copyright (c) 2024 Maanrifa Bacar Ali + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package io.github.manriif.supabase.functions.serve + +import org.gradle.api.tasks.Input + +/** + * Behaviour of inspection during function serving. + * More explanations on [Supabase Docs](https://supabase.com/docs/reference/cli/supabase-functions-serve). + */ +data class ServeInspect( + + /** + * Activates the inspector capability. + */ + @Input + var mode: Mode = Mode.Brk, + + /** + * Allows the creation of an inspector session for the main worker which is not allowed by + * default. + */ + @Input + var main: Boolean = false, + + /** + * Adds the debug flag to the serve command. + */ + @Input + var debug: Boolean = false +) { + + /** + * Available inspection modes. + */ + enum class Mode(internal val value: String) { + /** + * Simply allows a connection without additional behavior. It is not ideal for short + * scripts, but it can be useful for long-running scripts where you might occasionally want + * to set breakpoints. + */ + Run("run"), + + /** + * Same as [Run] mode, but additionally sets a breakpoint at the first line to pause script + * execution before any code runs. + */ + Brk("brk"), + + /** + * Similar to [Brk] mode, but instead of setting a breakpoint at the first line, it pauses + * script execution until an inspector session is connected. + */ + Wait("wait") + } +} \ No newline at end of file diff --git a/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/serve/ServeRunner.kt b/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/serve/ServeRunner.kt new file mode 100644 index 0000000..2733c31 --- /dev/null +++ b/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/serve/ServeRunner.kt @@ -0,0 +1,210 @@ +/** + * Copyright (c) 2024 Maanrifa Bacar Ali + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package io.github.manriif.supabase.functions.serve + +import io.github.manriif.supabase.functions.PARAMETER_AUTO_REQUEST +import io.github.manriif.supabase.functions.PARAMETER_INSPECT +import io.github.manriif.supabase.functions.PARAMETER_LOG_RESPONSE +import io.github.manriif.supabase.functions.PARAMETER_LOG_STATUS +import io.github.manriif.supabase.functions.PARAMETER_REQUEST_DELAY +import io.github.manriif.supabase.functions.PARAMETER_SERVE_DEBUG +import io.github.manriif.supabase.functions.request.RequestClient +import io.github.manriif.supabase.functions.request.RequestClientOptions +import io.github.manriif.supabase.functions.serve.stacktrace.StackTraceProcessor +import io.github.manriif.supabase.functions.serve.stacktrace.StackTraceSourceMapStrategy +import io.github.manriif.supabase.functions.serve.stream.ServeInitOutputStream +import io.github.manriif.supabase.functions.serve.stream.ServeMainOutputStream +import io.github.manriif.supabase.functions.supabase.envFile +import io.github.manriif.supabase.functions.supabase.importMap +import io.github.manriif.supabase.functions.supabase.inspect +import io.github.manriif.supabase.functions.supabase.noVerifyJwt +import io.github.manriif.supabase.functions.supabase.supabaseCommand +import io.github.manriif.supabase.functions.util.getBooleanOrDefault +import io.github.manriif.supabase.functions.util.getLongOrDefault +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.logging.Logger +import org.gradle.api.provider.Property +import org.gradle.internal.service.ServiceRegistry +import org.gradle.process.ExecResult +import org.gradle.process.ExecSpec +import org.gradle.process.internal.ExecActionFactory +import org.gradle.process.internal.ExecHandle +import org.gradle.process.internal.ExecHandleFactory +import org.jetbrains.kotlin.gradle.internal.operation + +internal class ServeRunner( + private val execHandleFactory: ExecHandleFactory, + private val execActionFactory: ExecActionFactory, + private val properties: Map, + private val logger: Logger, + private val compiledSourceDir: DirectoryProperty, + private val rootProjectDir: DirectoryProperty, + private val projectDir: DirectoryProperty, + private val supabaseDir: DirectoryProperty, + private val functionName: Property, + private val verifyJwt: Property, + private val deployImportMap: Property, + private val envFile: RegularFileProperty, + private val sourceMapStrategy: Property, + private val inspect: ServeInspect, + private val autoRequest: ServeAutoRequest, + private val requestOutputDir: DirectoryProperty, + private val requestConfigFile: RegularFileProperty +) { + + /////////////////////////////////////////////////////////////////////////// + // Command + /////////////////////////////////////////////////////////////////////////// + + fun execute(serviceRegistry: ServiceRegistry): RunContext { + val description = "supabase functions serve" + val exec = execHandleFactory.newExec() + + return serviceRegistry.operation(description) { + progress(description) + + createRunContext { initOutputStream, mainOutputStream -> + exec.configure(initOutputStream, mainOutputStream) + val handle = exec.build() + initOutputStream.setHandle(handle) + handle.start().waitForFinish() + } + } + } + + fun start(): RunContext { + return createRunContext { initOutputStream, mainOutputStream -> + val builder = execHandleFactory.newExec() + builder.configure(initOutputStream, mainOutputStream) + builder.build().start() + } + } + + private fun ExecSpec.configure( + initOutputStream: ServeInitOutputStream, + mainOutputStream: ServeMainOutputStream + ) { + workingDir = rootProjectDir.get().asFile + standardOutput = initOutputStream + errorOutput = mainOutputStream + isIgnoreExitValue = true + executable = supabaseCommand() + + args("functions", "serve") + + if (properties.getBooleanOrDefault(PARAMETER_INSPECT, false)) { + inspect(inspect) + } else if (properties.getBooleanOrDefault(PARAMETER_SERVE_DEBUG, false)) { + args("--debug") + } + + envFile(envFile) + noVerifyJwt(verifyJwt) + importMap(supabaseDir, deployImportMap) + } + + /////////////////////////////////////////////////////////////////////////// + // Context + /////////////////////////////////////////////////////////////////////////// + + private fun createRequestClient(): RequestClient { + val options = RequestClientOptions( + logStatus = properties.getBooleanOrDefault( + key = PARAMETER_LOG_STATUS, + default = autoRequest.logStatus + ), + logResponse = properties.getBooleanOrDefault( + key = PARAMETER_LOG_RESPONSE, + default = autoRequest.logResponse + ), + requestDelay = properties.getLongOrDefault( + key = PARAMETER_REQUEST_DELAY, + default = autoRequest.sendRequestOnCodeChangeDelay + ) + ) + + val requestClient = RequestClient( + rootProjectDir = rootProjectDir, + projectDir = projectDir, + requestOutputDir = requestOutputDir, + requestConfigFile = requestConfigFile, + functionName = functionName, + logger = logger, + options = options + ) + + requestClient.initialize(execActionFactory) + return requestClient + } + + private fun createRunContext( + target: (ServeInitOutputStream, ServeMainOutputStream) -> T + ): RunContext { + val requestEnabled = properties.getBooleanOrDefault(PARAMETER_AUTO_REQUEST, false) + val requestClient = if (requestEnabled) createRequestClient() else null + + val stackTraceProcessor = StackTraceProcessor( + supabaseDirPath = supabaseDir.get().asFile.canonicalPath, + compiledSourceDir = compiledSourceDir, + sourceMapStrategy = sourceMapStrategy.get(), + logger = logger + ) + + val initOutputStream = ServeInitOutputStream( + requestSender = requestClient, + logger = logger + ) + + val mainOutputStream = ServeMainOutputStream( + stackTraceProcessor = stackTraceProcessor, + logger = logger + ) + + return RunContext( + target = target(initOutputStream, mainOutputStream), + stackTraceProcessor = stackTraceProcessor, + requestClient = requestClient, + mainOutputStream = mainOutputStream + ) + } + + class RunContext( + val target: T, + private val stackTraceProcessor: StackTraceProcessor, + private val mainOutputStream: ServeMainOutputStream, + private val requestClient: RequestClient? + ) { + + val lastMessage: String? + get() = mainOutputStream.lastMessage + + fun onBuildRefreshed() { + stackTraceProcessor.reloadSourceMaps() + + requestClient?.run { + reloadConfigFile() + sendRequestsAsync() + } + } + } +} \ No newline at end of file diff --git a/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/serve/getServeCommandFailedReason.kt b/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/serve/getServeCommandFailedReason.kt new file mode 100644 index 0000000..ff78ffb --- /dev/null +++ b/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/serve/getServeCommandFailedReason.kt @@ -0,0 +1,36 @@ +/** + * Copyright (c) 2024 Maanrifa Bacar Ali + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package io.github.manriif.supabase.functions.serve + +private const val PROCESS_KILLED = 143 +private const val DOCKER_KILLED = 137 + +internal fun getServeCommandFailedReason(result: Int, message: String?): String? { + if (result == 0 || result == PROCESS_KILLED || result == DOCKER_KILLED) { + return null + } + + val reason = message.takeIf { !it.isNullOrBlank() } + ?: "supabase functions serve` finished with exit code $result" + + return "Failed to serve function: $reason" +} \ No newline at end of file diff --git a/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/serve/stacktrace/SourceMapEntry.kt b/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/serve/stacktrace/SourceMapEntry.kt new file mode 100644 index 0000000..7897c04 --- /dev/null +++ b/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/serve/stacktrace/SourceMapEntry.kt @@ -0,0 +1,30 @@ +/** + * Copyright (c) 2024 Maanrifa Bacar Ali + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package io.github.manriif.supabase.functions.serve.stacktrace + +import com.atlassian.sourcemap.ReadableSourceMap +import java.io.File + +internal data class SourceMapEntry( + val sourceMap: ReadableSourceMap, + val mapFile: File +) \ No newline at end of file diff --git a/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/serve/stacktrace/StackTraceProcessor.kt b/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/serve/stacktrace/StackTraceProcessor.kt new file mode 100644 index 0000000..bdefa06 --- /dev/null +++ b/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/serve/stacktrace/StackTraceProcessor.kt @@ -0,0 +1,184 @@ +/** + * Copyright (c) 2024 Maanrifa Bacar Ali + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package io.github.manriif.supabase.functions.serve.stacktrace + +import com.atlassian.sourcemap.Mapping +import com.atlassian.sourcemap.ReadableSourceMap +import com.atlassian.sourcemap.ReadableSourceMapImpl +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.logging.Logger +import java.io.File + +internal class StackTraceProcessor( + private val supabaseDirPath: String, + private val compiledSourceDir: DirectoryProperty, + private val sourceMapStrategy: StackTraceSourceMapStrategy, + @Suppress("unused") private val logger: Logger +) { + + private var sourceMaps: Map = createSourceMapEntryMap() + + /////////////////////////////////////////////////////////////////////////// + // Source Map + /////////////////////////////////////////////////////////////////////////// + + private fun createSourceMapEntryMap(): Map { + if (!sourceMapStrategy.kotlin) { + return emptyMap() + } + + return compiledSourceDir.get().asFileTree.matching { + include { element -> + !element.isDirectory && element.name.endsWith(".mjs.map") + } + }.associate { sourceFile -> + sourceFile.nameWithoutExtension to SourceMapEntry( + sourceMap = ReadableSourceMapImpl.fromSource(sourceFile.reader()), + mapFile = sourceFile + ) + } + } + + private fun ReadableSourceMap.resolve(lineNumber: Int, column: Int): Pair? { + val mapping = getMapping(lineNumber, column) + + if (mapping != null) { + return mapping to 0 + } + + // Most of the time lineNumber -1 and column -1 is resolved. Why : ? + return getMapping(lineNumber - 1, column - 1)?.let { it to 1 } + } + + fun reloadSourceMaps() { + sourceMaps = createSourceMapEntryMap() + } + + /////////////////////////////////////////////////////////////////////////// + // Stack trace + /////////////////////////////////////////////////////////////////////////// + + private fun String.replaceFilePath(path: String): String { + return kotlin.runCatching { + replaceRange( + startIndex = indexOf('(') + 1, + endIndex = indexOf(')'), + replacement = path + ) + }.getOrElse { + this + } + } + + private fun unknownSource(trace: String): String { + return trace.replaceFilePath("Unknown Source") + } + + private fun resolveJsSourceFile(trace: String): String { + if (!sourceMapStrategy.js) { + return unknownSource(trace) + } + + val remoteFilePath = kotlin.runCatching { + trace.substring( + startIndex = trace.indexOf('(') + 1, + endIndex = trace.indexOf(')') + ) + }.getOrElse { + return trace + } + + val localFilePath = if (remoteFilePath.startsWith("file:///home/deno")) { + // old supabase behaviour + remoteFilePath.replace("file:///home/deno", supabaseDirPath) + } else { + // new supabase behaviour + remoteFilePath.replace("file:///", "/") + } + + val localFile = File(localFilePath.substringBefore(":")) + + if (!localFile.exists()) { + return unknownSource(trace) + } + + return trace.replaceFilePath(localFilePath) + } + + fun resolveStackTrace(trace: String): String { + if (!sourceMapStrategy.kotlin) { + return resolveJsSourceFile(trace) + } + + if (trace.trim().startsWith("at ") && !trace.endsWith(')')) { + val formattedTrace = trace.replace("at ", "at (") + ')' + return doResolveStackTrace(formattedTrace) + } + + return doResolveStackTrace(trace) + } + + private fun doResolveStackTrace(trace: String): String { + val fileNameWithCursorLocation = kotlin.runCatching { + trace.substring( + startIndex = trace.lastIndexOf('/') + 1, + endIndex = trace.lastIndexOf(')') + ) + }.getOrElse { + return resolveJsSourceFile(trace) + } + + val components = fileNameWithCursorLocation.split(':') + + if (components.size < 3) { + return resolveJsSourceFile(trace) + } + + val (fileName, lineNumber, column) = components + val entry = sourceMaps[fileName] ?: return resolveJsSourceFile(trace) + + val (mapping, offset) = kotlin.runCatching { + entry.sourceMap.resolve(lineNumber.toInt(), column.toInt()) + }.getOrNull() ?: return resolveJsSourceFile(trace) + + val mapFileDirectory = entry.mapFile.parentFile + val sourceFile = File(mapping.sourceFileName) + val resolvedFile = mapFileDirectory.resolve(sourceFile) + val sourceLine = mapping.sourceLine + offset + + if (!resolvedFile.exists()) { + return when { + sourceMapStrategy.js -> resolveJsSourceFile(trace) + resolvedFile.name.isBlank() -> unknownSource(trace) + else -> trace.replaceFilePath("${resolvedFile.name}:${sourceLine}") + } + } + + val location = "${resolvedFile.canonicalPath}:${sourceLine}" + + return if (mapping.sourceSymbolName != null && mapping.sourceSymbolName != "null") { + "at ${mapping.sourceSymbolName} ($location)" + } else { + trace.replaceFilePath(location) + } + } +} \ No newline at end of file diff --git a/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/serve/stacktrace/StackTraceSourceMapStrategy.kt b/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/serve/stacktrace/StackTraceSourceMapStrategy.kt new file mode 100644 index 0000000..f09de91 --- /dev/null +++ b/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/serve/stacktrace/StackTraceSourceMapStrategy.kt @@ -0,0 +1,56 @@ +/** + * Copyright (c) 2024 Maanrifa Bacar Ali + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package io.github.manriif.supabase.functions.serve.stacktrace + +import io.github.manriif.supabase.functions.ExperimentalSupabaseFunctionApi + +/** + * Strategy for resolving source file on uncaught exception. + */ +enum class StackTraceSourceMapStrategy( + internal val kotlin: Boolean, + internal val js: Boolean +) { + + /** + * Do not apply source mapping. + */ + None(kotlin = false, js = false), + + /** + * Resolve project js files only. + */ + JsOnly(kotlin = false, js = true), + + /** + * Resolve kotlin source files only. + * However, if the source file is not mapped, the js file will be resolved instead. + */ + @ExperimentalSupabaseFunctionApi + KotlinOnly(kotlin = true, js = false), + + /** + * Resolve kotlin source file whenever possible. Otherwise, fallback to the js file. + */ + @ExperimentalSupabaseFunctionApi + KotlinPreferred(kotlin = true, js = true), +} \ No newline at end of file diff --git a/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/serve/stream/ServeBaseOutputStream.kt b/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/serve/stream/ServeBaseOutputStream.kt new file mode 100644 index 0000000..a1dfbb6 --- /dev/null +++ b/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/serve/stream/ServeBaseOutputStream.kt @@ -0,0 +1,80 @@ +/** + * Copyright (c) 2024 Maanrifa Bacar Ali + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package io.github.manriif.supabase.functions.serve.stream + +import java.io.ByteArrayOutputStream +import java.io.IOException +import java.io.OutputStream + +internal abstract class ServeBaseOutputStream() : OutputStream() { + + private val buffer = ByteArrayOutputStream() + private var closed: Boolean = false + + override fun close() { + closed = true + flushLine() + } + + override fun write(b: ByteArray, off: Int, len: Int) { + if (closed) { + throw IOException("The stream has been closed.") + } + + var i = off + var last = off + + fun bytesToAppend() = i - last + + val end = off + len + + fun append(len: Int = bytesToAppend()) { + buffer.write(b, last, i - last) + last += len + } + + while (i < end) { + val c = b[i++] + + if (c == '\n'.code.toByte()) { + append() + flushLine() + } + } + + append() + } + + override fun write(b: Int) { + write(byteArrayOf(b.toByte()), 0, 1) + } + + private fun flushLine() { + if (buffer.size() > 0) { + val text = buffer.toString(Charsets.UTF_8) + process(text) + buffer.reset() + } + } + + protected abstract fun process(rawLine: String) +} \ No newline at end of file diff --git a/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/serve/stream/ServeInitOutputStream.kt b/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/serve/stream/ServeInitOutputStream.kt new file mode 100644 index 0000000..776aac5 --- /dev/null +++ b/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/serve/stream/ServeInitOutputStream.kt @@ -0,0 +1,53 @@ +/** + * Copyright (c) 2024 Maanrifa Bacar Ali + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package io.github.manriif.supabase.functions.serve.stream + +import io.github.manriif.supabase.functions.request.RequestClient +import org.gradle.api.logging.Logger +import org.gradle.process.internal.ExecHandle + +internal class ServeInitOutputStream( + private val requestSender: RequestClient?, + private val logger: Logger, +) : ServeBaseOutputStream() { + + private var execHandle: ExecHandle? = null + private var messageCount = 0 + + /** + * Sets the handle to abort after requests are sent. + */ + fun setHandle(handle: ExecHandle) { + execHandle = handle + } + + override fun process(rawLine: String) { + if (++messageCount > 1) { + // Send initial request after supabase-edge-runtime is ready + requestSender?.sendRequestsAsync { + execHandle?.abort() + } + } + + logger.lifecycle(rawLine.removeSuffix("\n")) + } +} \ No newline at end of file diff --git a/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/serve/stream/ServeMainOutputStream.kt b/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/serve/stream/ServeMainOutputStream.kt new file mode 100644 index 0000000..14b2a08 --- /dev/null +++ b/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/serve/stream/ServeMainOutputStream.kt @@ -0,0 +1,83 @@ +/** + * Copyright (c) 2024 Maanrifa Bacar Ali + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package io.github.manriif.supabase.functions.serve.stream + +import io.github.manriif.supabase.functions.serve.stacktrace.StackTraceProcessor +import org.gradle.api.logging.LogLevel +import org.gradle.api.logging.Logger + +internal class ServeMainOutputStream( + private val stackTraceProcessor: StackTraceProcessor, + private val logger: Logger, +) : ServeBaseOutputStream() { + + private var logLevel = LogLevel.LIFECYCLE + + var lastMessage: String? = null + private set + + override fun process(rawLine: String) { + val line = detectLogLevel(rawLine) + + if (line.trim().startsWith("at ")) { + logger.log(logLevel, stackTraceProcessor.resolveStackTrace(line)) + } else { + val fixedLine = preventGradleErrorInterpretation(line) + logger.log(logLevel, fixedLine) + } + + lastMessage = rawLine + } + + private fun detectLogLevel(text: String): String { + if (!text.startsWith('[')) { + return text.substringBefore('\n') + } + + val levelEnd = text.indexOf(']') + + if (levelEnd <= 1) { + return text.substringBefore('\n') + } + + logLevel = when (text.substring(1, levelEnd)) { + "Info" -> LogLevel.LIFECYCLE + "Error" -> LogLevel.ERROR + else -> LogLevel.LIFECYCLE + } + + return try { + text.substring(startIndex = levelEnd + 2, endIndex = text.indexOf('\n')) + } catch (throwable: Throwable) { + text.substringBefore('\n') + } + } + + private fun preventGradleErrorInterpretation(line: String): String { + if (line.startsWith("Error")) { + logLevel = LogLevel.ERROR + return line.replaceRange(0, 5, "\nServeRequestError") + } + + return line + } +} \ No newline at end of file diff --git a/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/supabase/SupabaseCli.kt b/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/supabase/SupabaseCli.kt new file mode 100644 index 0000000..0196851 --- /dev/null +++ b/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/supabase/SupabaseCli.kt @@ -0,0 +1,103 @@ +/** + * Copyright (c) 2024 Maanrifa Bacar Ali + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package io.github.manriif.supabase.functions.supabase + +import io.github.manriif.supabase.functions.serve.ServeInspect +import io.github.manriif.supabase.functions.IMPORT_MAP_FILE_NAME +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.Property +import org.gradle.internal.os.OperatingSystem +import org.gradle.process.ExecSpec + +/** + * Gets the supabase CLI command. + * This function exists because of an [issue](https://github.com/gradle/gradle/issues/10483) + * between macOS and Java 21. + */ +internal fun supabaseCommand(): String { + return when { + OperatingSystem.current().isMacOsX -> "/usr/local/bin/supabase" + else -> "supabase" + } +} + +/** + * Appends import-map argument to [this] if [deployImportMap] is set to true. + */ +internal fun ExecSpec.importMap( + supabaseDir: DirectoryProperty, + deployImportMap: Property +) { + if (deployImportMap.isPresent && deployImportMap.get()) { + val importMapFile = supabaseAllFunctionsDirFile(supabaseDir, IMPORT_MAP_FILE_NAME) + args("--import-map", importMapFile.canonicalPath) + } +} + +/** + * Appends no-verify-jwt argument to [this] if [verifyJwt] is set to false. + */ +internal fun ExecSpec.noVerifyJwt(verifyJwt: Property) { + if (verifyJwt.isPresent && !verifyJwt.get()) { + args("--no-verify-jwt") + } +} + +/** + * Appends project-ref argument to [this] if [projectRef] is not empty. + */ +internal fun ExecSpec.projectRef(projectRef: Property) { + if (projectRef.isPresent && projectRef.get().isNotBlank()) { + args("--project-ref", projectRef.get()) + } +} + +/** + * Appends env-file argument to [this] if [envFile] points to a valid file. + */ +internal fun ExecSpec.envFile(envFile: RegularFileProperty) { + if (!envFile.isPresent) { + return + } + + val file = envFile.get().asFile + + if (file.exists()) { + args("--env-file", file.canonicalPath) + } +} + +/** + * Appends inspection related arguments to [this] based on [inspect] properties. + */ +internal fun ExecSpec.inspect(inspect: ServeInspect) { + if (inspect.debug) { + args("--debug") + } + + args("--inspect-mode", inspect.mode.value) + + if (inspect.main) { + args("--inspect-main") + } +} \ No newline at end of file diff --git a/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/supabase/SupabaseDirectory.kt b/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/supabase/SupabaseDirectory.kt new file mode 100644 index 0000000..29c8ece --- /dev/null +++ b/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/supabase/SupabaseDirectory.kt @@ -0,0 +1,49 @@ +/** + * Copyright (c) 2024 Maanrifa Bacar Ali + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package io.github.manriif.supabase.functions.supabase + +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.provider.Provider +import java.io.File + +/** + * Gets a [File] named [fileName] under the supabase/functions dir where supabase directory is resolved + * from [supabaseDir]. + */ +internal fun supabaseAllFunctionsDirFile( + supabaseDir: DirectoryProperty, + fileName: String +): File { + return supabaseDir.file("functions/$fileName").get().asFile +} + +/** + * Gets a [File] named [fileName] under the supabase/functions/[functionName] dir where supabase + * directory is resolved from [supabaseDir]. + */ +internal fun supabaseFunctionDirFile( + supabaseDir: DirectoryProperty, + functionName: Provider, + fileName: String +): File { + return supabaseDir.file("functions/${functionName.get()}/$fileName").get().asFile +} \ No newline at end of file diff --git a/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/supabase/SupabaseKey.kt b/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/supabase/SupabaseKey.kt new file mode 100644 index 0000000..2ce6b10 --- /dev/null +++ b/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/supabase/SupabaseKey.kt @@ -0,0 +1,26 @@ +/** + * Copyright (c) 2024 Maanrifa Bacar Ali + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package io.github.manriif.supabase.functions.supabase + +internal const val SUPABASE_API_URL = "API_URL" +internal const val SUPABASE_ANON_KEY = "ANON_KEY" +internal const val SUPABASE_SERVICE_ROLE_KEY = "SERVICE_ROLE_KEY" \ No newline at end of file diff --git a/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/task/SupabaseFunctionAggregateImportMapTask.kt b/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/task/SupabaseFunctionAggregateImportMapTask.kt new file mode 100644 index 0000000..b66d5a7 --- /dev/null +++ b/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/task/SupabaseFunctionAggregateImportMapTask.kt @@ -0,0 +1,129 @@ +/** + * Copyright (c) 2024 Maanrifa Bacar Ali + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package io.github.manriif.supabase.functions.task + +import io.github.manriif.supabase.functions.IMPORT_MAP_FILE_NAME +import io.github.manriif.supabase.functions.IMPORT_MAP_JSON_IMPORTS +import io.github.manriif.supabase.functions.IMPORT_MAP_JSON_SCOPES +import io.github.manriif.supabase.functions.IMPORT_MAP_TEMPLATE_FILE_NAME +import io.github.manriif.supabase.functions.error.SupabaseFunctionImportMapTemplateException +import io.github.manriif.supabase.functions.supabase.supabaseAllFunctionsDirFile +import org.gradle.api.DefaultTask +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.tasks.CacheableTask +import org.gradle.api.tasks.IgnoreEmptyDirectories +import org.gradle.api.tasks.InputDirectory +import org.gradle.api.tasks.InputFile +import org.gradle.api.tasks.Internal +import org.gradle.api.tasks.OutputFile +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.TaskAction +import org.jetbrains.kotlin.com.google.gson.GsonBuilder +import org.jetbrains.kotlin.com.google.gson.JsonObject +import org.jetbrains.kotlin.com.google.gson.JsonParser +import java.io.File + +/** + * Task responsible for aggregating import maps from all functions into one import_map.json. + */ +@CacheableTask +abstract class SupabaseFunctionAggregateImportMapTask : DefaultTask() { + + @get:InputDirectory + @get:IgnoreEmptyDirectories + @get:PathSensitive(PathSensitivity.RELATIVE) + internal abstract val importMapsDir: DirectoryProperty + + @get:Internal + internal abstract val supabaseDir: DirectoryProperty + + @get:InputFile + @get:PathSensitive(PathSensitivity.RELATIVE) + internal val importMapTemplateFile: File + get() = supabaseAllFunctionsDirFile(supabaseDir, IMPORT_MAP_TEMPLATE_FILE_NAME) + + @get:OutputFile + internal val aggregatedImportMapFile: File + get() = supabaseAllFunctionsDirFile(supabaseDir, IMPORT_MAP_FILE_NAME) + + @TaskAction + fun aggregate() { + val gson = GsonBuilder() + .setPrettyPrinting() + .create() + + val importMap = if (importMapTemplateFile.exists() && importMapTemplateFile.isFile) { + try { + JsonParser.parseReader(importMapTemplateFile.reader()).asJsonObject + } catch (throwable: Throwable) { + throw SupabaseFunctionImportMapTemplateException( + message = "Failed to load $IMPORT_MAP_TEMPLATE_FILE_NAME", + cause = throwable + ) + } + } else { + JsonObject() + } + + if (!importMap.has(IMPORT_MAP_JSON_IMPORTS)) { + importMap.add(IMPORT_MAP_JSON_IMPORTS, JsonObject()) + } + + if (!importMap.has(IMPORT_MAP_JSON_SCOPES)) { + importMap.add(IMPORT_MAP_JSON_SCOPES, JsonObject()) + } + + val imports = importMap.getAsJsonObject(IMPORT_MAP_JSON_IMPORTS) + val scopes = importMap.getAsJsonObject(IMPORT_MAP_JSON_SCOPES) + + importMapsDir.get().asFileTree + .matching { + include { file -> + !file.isDirectory && file.name.endsWith(".json") + } + } + .mapNotNull { file -> + kotlin.runCatching { + JsonParser.parseReader(file.reader()).asJsonObject + }.getOrNull() + }.forEach { json -> + json.get(IMPORT_MAP_JSON_IMPORTS).takeIf { it.isJsonObject }?.asJsonObject?.run { + keySet().forEach { key -> + if (!imports.has(key) && get(key).isJsonPrimitive) { + imports.addProperty(key, get(key).asString) + } + } + } + + json.get(IMPORT_MAP_JSON_SCOPES).takeIf { it.isJsonObject }?.asJsonObject?.run { + keySet().forEach { key -> + if (!scopes.has(key) && get(key).isJsonObject) { + scopes.add(key, get(key)) + } + } + } + } + + aggregatedImportMapFile.writeText(gson.toJson(importMap)) + } +} \ No newline at end of file diff --git a/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/task/SupabaseFunctionCopyJsTask.kt b/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/task/SupabaseFunctionCopyJsTask.kt new file mode 100644 index 0000000..592c422 --- /dev/null +++ b/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/task/SupabaseFunctionCopyJsTask.kt @@ -0,0 +1,91 @@ +/** + * Copyright (c) 2024 Maanrifa Bacar Ali + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package io.github.manriif.supabase.functions.task + +import io.github.manriif.supabase.functions.FUNCTION_JS_TARGET_DIR +import io.github.manriif.supabase.functions.kmp.JsDependency +import io.github.manriif.supabase.functions.supabase.supabaseFunctionDirFile +import org.gradle.api.DefaultTask +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.file.DuplicatesStrategy +import org.gradle.api.file.FileSystemOperations +import org.gradle.api.provider.ListProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.CacheableTask +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.Internal +import org.gradle.api.tasks.Nested +import org.gradle.api.tasks.OutputDirectory +import org.gradle.api.tasks.TaskAction +import java.io.File +import javax.inject.Inject + +/** + * Task responsible for copying generated js code into supabase function directory. + */ +@CacheableTask +abstract class SupabaseFunctionCopyJsTask : DefaultTask() { + + @get:Inject + internal abstract val fileSystemOperations: FileSystemOperations + + @get:Internal + internal abstract val supabaseDir: DirectoryProperty + + @get:Nested + internal abstract val jsDependencies: ListProperty + + @get:Input + internal abstract val functionName: Property + + @get:OutputDirectory + internal val jsTargetDir: File + get() = supabaseFunctionDirFile(supabaseDir, functionName, FUNCTION_JS_TARGET_DIR) + + @TaskAction + fun generate() { + if (jsTargetDir.exists()) { + jsTargetDir.deleteRecursively() + } + + jsDependencies.get().forEach { dependency -> + copyJsSources(dependency) + } + + if (jsTargetDir.list()?.isEmpty() == true) { + jsTargetDir.delete() + } + } + + private fun copyJsSources(dependency: JsDependency) { + val outputDirectory = File(jsTargetDir, dependency.jsOutputName.get()) + + val directories = dependency.sourceDirectories.get() + .filter { it.isDirectory } + + fileSystemOperations.copy { + duplicatesStrategy = DuplicatesStrategy.FAIL + from(directories) + into(outputDirectory) + } + } +} \ No newline at end of file diff --git a/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/task/SupabaseFunctionCopyKotlinTask.kt b/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/task/SupabaseFunctionCopyKotlinTask.kt new file mode 100644 index 0000000..5cd2be9 --- /dev/null +++ b/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/task/SupabaseFunctionCopyKotlinTask.kt @@ -0,0 +1,78 @@ +/** + * Copyright (c) 2024 Maanrifa Bacar Ali + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package io.github.manriif.supabase.functions.task + +import io.github.manriif.supabase.functions.FUNCTION_KOTLIN_TARGET_DIR +import io.github.manriif.supabase.functions.supabase.supabaseFunctionDirFile +import org.gradle.api.DefaultTask +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.file.DuplicatesStrategy +import org.gradle.api.file.FileSystemOperations +import org.gradle.api.provider.Property +import org.gradle.api.tasks.CacheableTask +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputDirectory +import org.gradle.api.tasks.Internal +import org.gradle.api.tasks.OutputDirectory +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.TaskAction +import java.io.File +import javax.inject.Inject + +/** + * Task responsible for copying generated kotlin code into supabase function directory. + */ +@CacheableTask +abstract class SupabaseFunctionCopyKotlinTask : DefaultTask() { + + @get:Inject + internal abstract val fileSystemOperations: FileSystemOperations + + @get:Internal + internal abstract val supabaseDir: DirectoryProperty + + @get:InputDirectory + @get:PathSensitive(PathSensitivity.RELATIVE) + internal abstract val compiledSourceDir: DirectoryProperty + + @get:Input + internal abstract val functionName: Property + + @get:OutputDirectory + internal val kotlinTargetDir: File + get() = supabaseFunctionDirFile(supabaseDir, functionName, FUNCTION_KOTLIN_TARGET_DIR) + + @TaskAction + fun generate() { + if (kotlinTargetDir.exists()) { + kotlinTargetDir.deleteRecursively() + } + + fileSystemOperations.copy { + duplicatesStrategy = DuplicatesStrategy.INCLUDE + from(compiledSourceDir) + into(kotlinTargetDir) + include { it.name.endsWith(".mjs") } + } + } +} \ No newline at end of file diff --git a/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/task/SupabaseFunctionDeployTask.kt b/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/task/SupabaseFunctionDeployTask.kt new file mode 100644 index 0000000..5945e0d --- /dev/null +++ b/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/task/SupabaseFunctionDeployTask.kt @@ -0,0 +1,90 @@ +/** + * Copyright (c) 2024 Maanrifa Bacar Ali + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package io.github.manriif.supabase.functions.task + +import io.github.manriif.supabase.functions.supabase.importMap +import io.github.manriif.supabase.functions.supabase.noVerifyJwt +import io.github.manriif.supabase.functions.supabase.projectRef +import io.github.manriif.supabase.functions.supabase.supabaseCommand +import io.github.manriif.supabase.functions.error.SupabaseFunctionDeployException +import org.gradle.api.DefaultTask +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.CacheableTask +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.Internal +import org.gradle.api.tasks.Optional +import org.gradle.api.tasks.TaskAction +import org.gradle.process.ExecOperations +import java.io.ByteArrayOutputStream +import javax.inject.Inject + +/** + * Task responsible for deploying function to remote project. + */ +@CacheableTask +abstract class SupabaseFunctionDeployTask : DefaultTask() { + + @get:Inject + internal abstract val execOperations: ExecOperations + + @get:Internal + internal abstract val supabaseDir: DirectoryProperty + + @get:Input + internal abstract val functionName: Property + + @get:Input + @get:Optional + abstract val projectRef: Property + + @get:Input + abstract val verifyJwt: Property + + @get:Input + abstract val importMap: Property + + init { + outputs.upToDateWhen { false } + } + + @TaskAction + fun deploy() { + val output = ByteArrayOutputStream() + + val result = execOperations.exec { + isIgnoreExitValue = true + executable = supabaseCommand() + workingDir = supabaseDir.get().asFile + errorOutput = output + + args("functions", "deploy", functionName.get()) + projectRef(projectRef) + importMap(supabaseDir, importMap) + noVerifyJwt(verifyJwt) + } + + if (result.exitValue != 0) { + throw SupabaseFunctionDeployException("Failed to deploy function: $output") + } + } +} \ No newline at end of file diff --git a/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/task/SupabaseFunctionGenerateImportMapTask.kt b/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/task/SupabaseFunctionGenerateImportMapTask.kt new file mode 100644 index 0000000..6d74ea9 --- /dev/null +++ b/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/task/SupabaseFunctionGenerateImportMapTask.kt @@ -0,0 +1,160 @@ +/** + * Copyright (c) 2024 Maanrifa Bacar Ali + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package io.github.manriif.supabase.functions.task + +import io.github.manriif.supabase.functions.FUNCTION_JS_TARGET_DIR +import io.github.manriif.supabase.functions.FUNCTION_KOTLIN_TARGET_DIR +import io.github.manriif.supabase.functions.IMPORT_MAP_JSON_IMPORTS +import io.github.manriif.supabase.functions.IMPORT_MAP_JSON_SCOPES +import io.github.manriif.supabase.functions.JS_SOURCES_INPUT_DIR +import io.github.manriif.supabase.functions.kmp.JsDependency +import org.gradle.api.DefaultTask +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.provider.ListProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.CacheableTask +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputDirectory +import org.gradle.api.tasks.Internal +import org.gradle.api.tasks.Nested +import org.gradle.api.tasks.OutputFile +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.TaskAction +import org.jetbrains.kotlin.com.google.gson.GsonBuilder +import org.jetbrains.kotlin.com.google.gson.JsonObject +import org.jetbrains.kotlin.gradle.targets.js.npm.fromSrcPackageJson +import java.io.File +import kotlin.io.path.Path +import kotlin.io.path.relativeTo + +private const val MODULE_TARGET = "module" + +/** + * Task responsible for generating import_map.json file for the function. + * + * If this task is disabled: + * + * - NPM dependencies for the project and its dependencies must be manually + * added. + * - JS imports must be manually added. + */ +@CacheableTask +abstract class SupabaseFunctionGenerateImportMapTask : DefaultTask() { + + @get:Internal + internal abstract val importMapsDir: DirectoryProperty + + @get:InputDirectory + @get:PathSensitive(PathSensitivity.RELATIVE) + internal abstract val packageJsonDir: DirectoryProperty + + @get:Input + internal abstract val functionName: Property + + @get:Nested + internal abstract val jsDependencies: ListProperty + + @get:OutputFile + internal val importMapFile: File + get() = importMapsDir.file("${functionName.get()}.json").get().asFile + + @TaskAction + fun generate() { + val gson = GsonBuilder() + .setPrettyPrinting() + .create() + + val importMap = JsonObject().apply { + add(IMPORT_MAP_JSON_IMPORTS, createFunctionImports()) + add(IMPORT_MAP_JSON_SCOPES, createFunctionScopes()) + } + + importMapFile.writeText(gson.toJson(importMap)) + } + + private fun createFunctionImports(): JsonObject { + val imports = JsonObject() + val packageJsonFile = packageJsonDir.file("package.json").get().asFile + val packageJson = fromSrcPackageJson(packageJsonFile) ?: return imports + + packageJson.dependencies.forEach { (packageName, version) -> + imports.addProperty(packageName, "npm:$packageName@$version") + } + + return imports + } + + private fun functionRelativePath(filePath: String): String { + return "./${functionName.get()}/$filePath" + } + + private fun kotlinSource(filePath: String): String { + return functionRelativePath("$FUNCTION_KOTLIN_TARGET_DIR/$filePath") + } + + private fun jsSource(filePath: String): String { + return functionRelativePath("$FUNCTION_JS_TARGET_DIR/$filePath") + } + + private fun createFunctionScopes(): JsonObject { + val scopes = JsonObject() + val jsSourcesPath = Path(JS_SOURCES_INPUT_DIR) + val jsGlobalDir = jsSource("") + val jsGlobalScope = JsonObject() + + scopes.add(jsGlobalDir, jsGlobalScope) + + jsDependencies.get().forEach { dependency -> + val kotlinSourceFile = kotlinSource("${dependency.jsOutputName.get()}.mjs") + val files = dependency.sourceDirectories.get().asFileTree + + if (!files.isEmpty) { + val projectPath = dependency.projectDirectory.get().asFile.toPath() + val kotlinSourceScope = JsonObject() + + scopes.add(kotlinSourceFile, kotlinSourceScope) + + files.forEach { file -> + val pathFromProject = file.toPath().relativeTo(projectPath) + val jsPathIndex = pathFromProject.indexOf(jsSourcesPath) + 1 + val modulePath = pathFromProject.subpath(jsPathIndex, pathFromProject.nameCount) + + kotlinSourceScope.addProperty( + "$MODULE_TARGET/${modulePath}", + jsSource("${dependency.jsOutputName.get()}/${modulePath}") + ) + } + } + + jsGlobalScope.addProperty(dependency.projectName.get(), kotlinSourceFile) + + val jsModuleDir = jsSource("${dependency.jsOutputName.get()}/") + val jsModuleScope = JsonObject() + + scopes.add(jsModuleDir, jsModuleScope) + jsModuleScope.addProperty(MODULE_TARGET, kotlinSourceFile) + } + + return scopes + } +} \ No newline at end of file diff --git a/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/task/SupabaseFunctionGenerateKotlinBridgeTask.kt b/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/task/SupabaseFunctionGenerateKotlinBridgeTask.kt new file mode 100644 index 0000000..b160776 --- /dev/null +++ b/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/task/SupabaseFunctionGenerateKotlinBridgeTask.kt @@ -0,0 +1,144 @@ +/** + * Copyright (c) 2024 Maanrifa Bacar Ali + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package io.github.manriif.supabase.functions.task + +import io.github.manriif.supabase.functions.DENO_KOTLIN_BRIDGE_FUNCTION_NAME +import io.github.manriif.supabase.functions.INDEX_FILE_NAME +import io.github.manriif.supabase.functions.supabase.supabaseFunctionDirFile +import com.squareup.kotlinpoet.AnnotationSpec +import com.squareup.kotlinpoet.ClassName +import com.squareup.kotlinpoet.FileSpec +import com.squareup.kotlinpoet.FunSpec +import com.squareup.kotlinpoet.KModifier +import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy +import org.gradle.api.DefaultTask +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.CacheableTask +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.Internal +import org.gradle.api.tasks.OutputDirectory +import org.gradle.api.tasks.OutputFile +import org.gradle.api.tasks.TaskAction +import java.io.File + +/** + * Task responsible for generating necessary files for calling kotlin main function from deno serve + * function. + */ +@CacheableTask +abstract class SupabaseFunctionGenerateKotlinBridgeTask : DefaultTask() { + + @get:Internal + internal abstract val supabaseDir: DirectoryProperty + + @get:OutputDirectory + internal abstract val generatedSourceOutputDir: DirectoryProperty + + @get:Input + internal abstract val packageName: Property + + @get:Input + internal abstract val jsOutputName: Property + + @get:Input + internal abstract val functionName: Property + + @get:Input + abstract val mainFunctionName: Property + + @get:OutputFile + internal val indexFile: File + get() = supabaseFunctionDirFile(supabaseDir, functionName, INDEX_FILE_NAME) + + @TaskAction + fun generate() { + createDenoIndexFile() + createKotlinBridgeFunction() + } + + /** + * Creates the Deno function index.ts file. + */ + private fun createDenoIndexFile() { + val content = """ + |import { $DENO_KOTLIN_BRIDGE_FUNCTION_NAME } from './kotlin/${jsOutputName.get()}.mjs'; + | + |Deno.serve(async (req) => { + | return await $DENO_KOTLIN_BRIDGE_FUNCTION_NAME(req) + |}) + """.trimMargin() + + indexFile.writeText(content) + } + + /** + * Creates the Kotlin function that will be called by the index.ts serve function + */ + private fun createKotlinBridgeFunction() { + val delicateCoroutineApi = ClassName("kotlinx.coroutines", "DelicateCoroutinesApi") + val experimentalJsExport = ClassName("kotlin.js", "ExperimentalJsExport") + val optIn = ClassName("kotlin", "OptIn") + val request = ClassName("org.w3c.fetch", "Request") + val response = ClassName("org.w3c.fetch", "Response") + val promise = ClassName("kotlin.js", "Promise") + val jsExport = ClassName("kotlin.js", "JsExport") + + val optInAnnotation = AnnotationSpec.builder(optIn) + .addMember("%L::class, %L::class", delicateCoroutineApi, experimentalJsExport) + .build() + + val serveFunction = FunSpec.builder(DENO_KOTLIN_BRIDGE_FUNCTION_NAME) + .addAnnotation(optInAnnotation) + .addAnnotation(jsExport) + .addModifiers(KModifier.PUBLIC) + .addParameter("request", request) + .returns(promise.parameterizedBy(response)) + .addCode( + """ + return GlobalScope + .async( + context = Dispatchers.Unconfined, + start = CoroutineStart.UNDISPATCHED, + block = { + ${mainFunctionName.get()}(request) + } + ) + .asPromise() + """.trimIndent() + ) + .build() + + FileSpec.builder(packageName.get(), "SupabaseServe") + .addImport( + "kotlinx.coroutines", + "CoroutineStart", + "Dispatchers", + "GlobalScope", + "asPromise", + "async" + ) + .addFunction(serveFunction) + .build() + .writeTo(generatedSourceOutputDir.get().asFile) + } +} \ No newline at end of file diff --git a/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/task/SupabaseFunctionServeTask.kt b/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/task/SupabaseFunctionServeTask.kt new file mode 100644 index 0000000..917d2c3 --- /dev/null +++ b/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/task/SupabaseFunctionServeTask.kt @@ -0,0 +1,175 @@ +/** + * Copyright (c) 2024 Maanrifa Bacar Ali + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package io.github.manriif.supabase.functions.task + +import io.github.manriif.supabase.functions.error.SupabaseFunctionServeException +import io.github.manriif.supabase.functions.serve.ServeAutoRequest +import io.github.manriif.supabase.functions.serve.ServeDeploymentHandle +import io.github.manriif.supabase.functions.serve.ServeInspect +import io.github.manriif.supabase.functions.serve.ServeRunner +import io.github.manriif.supabase.functions.serve.getServeCommandFailedReason +import io.github.manriif.supabase.functions.serve.stacktrace.StackTraceSourceMapStrategy +import org.gradle.StartParameter +import org.gradle.api.DefaultTask +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.CacheableTask +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputFile +import org.gradle.api.tasks.Internal +import org.gradle.api.tasks.Nested +import org.gradle.api.tasks.Optional +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.TaskAction +import org.gradle.configurationcache.extensions.get +import org.gradle.deployment.internal.DeploymentRegistry +import org.gradle.initialization.BuildCancellationToken +import org.gradle.process.internal.ExecHandleFactory +import javax.inject.Inject + +private const val HANDLE_NAME = "supabaseServe" + +/** + * Task responsible for serving supabase functions. + */ +@CacheableTask +abstract class SupabaseFunctionServeTask : DefaultTask() { + + @get:Inject + internal abstract val execHandleFactory: ExecHandleFactory + + @get:Internal + internal abstract val compiledSourceDir: DirectoryProperty + + @get:Internal + internal abstract val rootProjectDir: DirectoryProperty + + @get:Internal + internal abstract val projectDir: DirectoryProperty + + @get:Internal + internal abstract val supabaseDir: DirectoryProperty + + @get:Internal + internal abstract val requestOutputDir: DirectoryProperty + + @get:InputFile + @get:Optional + @get:PathSensitive(PathSensitivity.RELATIVE) + internal abstract val requestConfigFile: RegularFileProperty + + @get:Internal + internal abstract val envFile: RegularFileProperty + + @get:Input + internal abstract val functionName: Property + + @get:Input + abstract val verifyJwt: Property + + @get:Input + abstract val importMap: Property + + @get:Input + abstract val stackTraceSourceMapStrategy: Property + + @Nested + val autoRequest = ServeAutoRequest() + + @Nested + val inspect = ServeInspect() + + init { + outputs.upToDateWhen { false } + } + + /** + * Configure [autoRequest] in a DSL manner. + */ + @Suppress("MemberVisibilityCanBePrivate") + fun autoRequest(action: ServeAutoRequest.() -> Unit) { + autoRequest.action() + } + + /** + * Configure [inspect] in a DSL manner. + */ + @Suppress("MemberVisibilityCanBePrivate") + fun inspect(action: ServeInspect.() -> Unit) { + inspect.action() + } + + @TaskAction + fun serve() { + val startParameter = services.get() + val properties = startParameter.projectProperties + + val runner = ServeRunner( + execHandleFactory = execHandleFactory, + execActionFactory = services.get(), + properties = properties, + logger = logger, + compiledSourceDir = compiledSourceDir, + rootProjectDir = rootProjectDir, + projectDir = projectDir, + supabaseDir = supabaseDir, + functionName = functionName, + verifyJwt = verifyJwt, + deployImportMap = importMap, + envFile = envFile, + inspect = inspect, + sourceMapStrategy = stackTraceSourceMapStrategy, + autoRequest = autoRequest, + requestOutputDir = requestOutputDir, + requestConfigFile = requestConfigFile + ) + + if (startParameter.isContinuous) { + val registry = services.get(DeploymentRegistry::class.java) + val handle = registry.get(HANDLE_NAME, ServeDeploymentHandle::class.java) + + if (handle == null) { + registry.start( + HANDLE_NAME, + DeploymentRegistry.ChangeBehavior.NONE, + ServeDeploymentHandle::class.java, + runner, + logger, + services.get() + ) + } else { + handle.notifyBuildReloaded() + } + } else { + val context = runner.execute(services) + + getServeCommandFailedReason( + result = context.target.exitValue, + message = context.lastMessage + )?.let { reason -> + throw SupabaseFunctionServeException(reason) + } + } + } +} \ No newline at end of file diff --git a/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/task/SupabaseFunctionUpdateGitignoreTask.kt b/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/task/SupabaseFunctionUpdateGitignoreTask.kt new file mode 100644 index 0000000..5637309 --- /dev/null +++ b/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/task/SupabaseFunctionUpdateGitignoreTask.kt @@ -0,0 +1,107 @@ +/** + * Copyright (c) 2024 Maanrifa Bacar Ali + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package io.github.manriif.supabase.functions.task + +import io.github.manriif.supabase.functions.FUNCTION_JS_TARGET_DIR +import io.github.manriif.supabase.functions.FUNCTION_KOTLIN_TARGET_DIR +import io.github.manriif.supabase.functions.GITIGNORE_FILE_NAME +import io.github.manriif.supabase.functions.IMPORT_MAP_FILE_NAME +import io.github.manriif.supabase.functions.INDEX_FILE_NAME +import io.github.manriif.supabase.functions.supabase.supabaseAllFunctionsDirFile +import io.github.manriif.supabase.functions.supabase.supabaseFunctionDirFile +import org.gradle.api.DefaultTask +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.CacheableTask +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.Internal +import org.gradle.api.tasks.OutputFile +import org.gradle.api.tasks.TaskAction +import org.jetbrains.kotlin.gradle.internal.ensureParentDirsCreated +import java.io.File + +/** + * Task responsible for updating .gitignore files by filling them with plugin generated files. + */ +@CacheableTask +abstract class SupabaseFunctionUpdateGitignoreTask : DefaultTask() { + + @get:Internal + internal abstract val supabaseDir: DirectoryProperty + + @get:Input + internal abstract val functionName: Property + + @get:Input + abstract val importMapEntry: Property + + @get:Input + abstract val indexEntry: Property + + @get:OutputFile + internal val allFunctionsDirGitignore: File + get() = supabaseAllFunctionsDirFile(supabaseDir, GITIGNORE_FILE_NAME) + + @get:OutputFile + internal val functionDirGitignore: File + get() = supabaseFunctionDirFile(supabaseDir, functionName, GITIGNORE_FILE_NAME) + + @TaskAction + fun update() { + if (importMapEntry.get()) { + updateGitignoreFile(allFunctionsDirGitignore, IMPORT_MAP_FILE_NAME) + } + + if (indexEntry.get()) { + updateGitignoreFile( + functionDirGitignore, + INDEX_FILE_NAME, + FUNCTION_JS_TARGET_DIR, + FUNCTION_KOTLIN_TARGET_DIR + ) + } else { + updateGitignoreFile( + functionDirGitignore, + FUNCTION_JS_TARGET_DIR, + FUNCTION_KOTLIN_TARGET_DIR + ) + } + } + + /** + * Updates [gitignoreFile] appending each [filePaths] entry to it's content if not recorded. + */ + private fun updateGitignoreFile(gitignoreFile: File, vararg filePaths: String) { + if (!gitignoreFile.exists()) { + gitignoreFile.ensureParentDirsCreated() + return gitignoreFile.writeText(filePaths.joinToString("\n")) + } + + val gitignoreEntries = gitignoreFile.useLines { it.toList() } + + filePaths.forEach { path -> + if (!gitignoreEntries.contains(path)) { + gitignoreFile.appendText("\n$path") + } + } + } +} \ No newline at end of file diff --git a/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/task/Tasks.kt b/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/task/Tasks.kt new file mode 100644 index 0000000..6307971 --- /dev/null +++ b/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/task/Tasks.kt @@ -0,0 +1,272 @@ +/** + * Copyright (c) 2024 Maanrifa Bacar Ali + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package io.github.manriif.supabase.functions.task + +import io.github.manriif.supabase.functions.KOTLIN_MAIN_FUNCTION_NAME +import io.github.manriif.supabase.functions.REQUEST_CONFIG_FILE_NAME +import io.github.manriif.supabase.functions.SUPABASE_FUNCTION_OUTPUT_DIR +import io.github.manriif.supabase.functions.SUPABASE_FUNCTION_TASK_GROUP +import io.github.manriif.supabase.functions.SupabaseFunctionExtension +import io.github.manriif.supabase.functions.kmp.JsDependency +import io.github.manriif.supabase.functions.kmp.jsDependencies +import io.github.manriif.supabase.functions.kmp.jsOutputName +import io.github.manriif.supabase.functions.serve.stacktrace.StackTraceSourceMapStrategy +import io.github.manriif.supabase.functions.util.orNone +import org.gradle.api.Project +import org.gradle.api.provider.Provider +import org.gradle.api.tasks.TaskProvider +import org.gradle.kotlin.dsl.extra +import org.gradle.kotlin.dsl.named +import org.gradle.kotlin.dsl.register +import org.gradle.kotlin.dsl.support.uppercaseFirstChar +import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension + +internal const val PREPARE_KOTLIN_BUILD_SCRIPT_MODEL_TASK = "prepareKotlinBuildScriptModel" + +internal const val TASK_PREFIX = "supabaseFunction" +internal const val TASK_GENERATE_ENVIRONMENT_TEMPLATE = "${TASK_PREFIX}CopyKotlin%s" +internal const val TASK_GENERATE_DEVELOPMENT_ENVIRONMENT = "${TASK_PREFIX}CopyKotlinDevelopment" +internal const val TASK_GENERATE_PRODUCTION_ENVIRONMENT = "${TASK_PREFIX}CopyKotlinProduction" + +internal const val TASK_COPY_JS = "${TASK_PREFIX}CopyJs" + +internal const val TASK_GENERATE_BRIDGE = "${TASK_PREFIX}GenerateKotlinBridge" +internal const val TASK_GENERATE_IMPORT_MAP = "${TASK_PREFIX}GenerateImportMap" + +internal const val TASK_AGGREGATE_IMPORT_MAP = "${TASK_PREFIX}AggregateImportMap" + +internal const val TASK_UPDATE_GITIGNORE = "${TASK_PREFIX}UpdateGitignore" + +internal const val TASK_FUNCTION_DEPLOY = "${TASK_PREFIX}Deploy" +internal const val TASK_FUNCTION_SERVE = "${TASK_PREFIX}Serve" + +internal fun Project.configurePluginTasks( + extension: SupabaseFunctionExtension, + kmpExtension: KotlinMultiplatformExtension +) { + rootProject.registerAggregateImportMapTask(extension) + + val jsDependenciesProvider = jsDependencies() + + registerGenerateImportMapTask(extension, jsDependenciesProvider) + registerGenerateBridgeTask(extension, kmpExtension) + registerCopyJsTask(extension, jsDependenciesProvider) + registerCopyKotlinTask(extension, "development") + registerCopyKotlinTask(extension, "production") + registerServeTask(extension) + registerDeployTask(extension) + registerUpdateGitignoreTask(extension) +} + +/** + * Creates the task responsible for merging imports maps. The project [this] must be the root one. + * This is achieved this way in order to prevent the user from applying the plugin in the + * root build.gradle. + */ +private fun Project.registerAggregateImportMapTask(extension: SupabaseFunctionExtension) { + if (extra.has(TASK_AGGREGATE_IMPORT_MAP) && extra.get(TASK_AGGREGATE_IMPORT_MAP) == true) { + return + } + + val taskProvider = tasks.register( + name = TASK_AGGREGATE_IMPORT_MAP + ) { + group = SUPABASE_FUNCTION_TASK_GROUP + description = "Aggregate functions import maps." + + importMapsDir.convention(layout.buildDirectory.dir("${SUPABASE_FUNCTION_OUTPUT_DIR}/importMaps")) + supabaseDir.convention(extension.supabaseDir) + } + + tasks.named(PREPARE_KOTLIN_BUILD_SCRIPT_MODEL_TASK) { + dependsOn(taskProvider) + } + + extra[TASK_AGGREGATE_IMPORT_MAP] = true +} + +private val Project.aggregateTaskProvider: TaskProvider + get() = checkNotNull( + rootProject.tasks.named(TASK_AGGREGATE_IMPORT_MAP) + ) { + "Aggregate task not found" + } + +private fun Project.registerGenerateBridgeTask( + extension: SupabaseFunctionExtension, + kmpExtension: KotlinMultiplatformExtension +) { + val sourceSet = kmpExtension.sourceSets.findByName("jsMain") ?: return + + val outputDir = layout.buildDirectory + .dir("generated/$SUPABASE_FUNCTION_OUTPUT_DIR/${sourceSet.name}/src") + + sourceSet.kotlin.srcDir(outputDir) + + tasks.register(TASK_GENERATE_BRIDGE) { + group = SUPABASE_FUNCTION_TASK_GROUP + + description = "Generate a kotlin function that acts as a bridge between " + + "the `Deno.serve` and the kotlin main function." + + supabaseDir.convention(extension.supabaseDir) + generatedSourceOutputDir.convention(outputDir) + packageName.convention(extension.packageName) + jsOutputName.convention(jsOutputName(kmpExtension)) + functionName.convention(extension.functionName) + mainFunctionName.convention(KOTLIN_MAIN_FUNCTION_NAME) + } +} + +private fun Project.registerCopyJsTask( + extension: SupabaseFunctionExtension, + jsDependenciesProvider: Provider> +) { + tasks.register(TASK_COPY_JS) { + group = SUPABASE_FUNCTION_TASK_GROUP + description = "Copy JS sources into supabase function directory." + + jsDependencies.convention(jsDependenciesProvider) + supabaseDir.convention(extension.supabaseDir) + functionName.convention(extension.functionName) + } +} + +private fun Project.registerCopyKotlinTask( + extension: SupabaseFunctionExtension, + environment: String, +) { + val uppercaseEnvironment = environment.uppercaseFirstChar() + val compileSyncTaskName = "js${uppercaseEnvironment}LibraryCompileSync" + + if (tasks.names.none { it == compileSyncTaskName }) { + error( + "Could not locate task `$compileSyncTaskName`, " + + "be sure you add `binaries.library()` to the js node target." + ) + } + + val taskName = TASK_GENERATE_ENVIRONMENT_TEMPLATE.format(uppercaseEnvironment) + + tasks.register(taskName) { + group = SUPABASE_FUNCTION_TASK_GROUP + description = "Copy Kotlin generated sources into supabase function directory." + + compiledSourceDir.convention( + layout.buildDirectory.dir("compileSync/js/main/${environment}Library/kotlin") + ) + + supabaseDir.convention(extension.supabaseDir) + functionName.convention(extension.functionName) + + dependsOn(compileSyncTaskName) + dependsOn(TASK_COPY_JS) + } +} + +private fun Project.registerDeployTask(extension: SupabaseFunctionExtension) { + tasks.register(TASK_FUNCTION_DEPLOY) { + group = SUPABASE_FUNCTION_TASK_GROUP + description = "Deploy function to remote project." + + supabaseDir.convention(extension.supabaseDir) + functionName.convention(extension.functionName) + projectRef.convention(extension.projectRef) + verifyJwt.convention(extension.verifyJwt) + importMap.convention(extension.importMap) + + dependsOn(TASK_GENERATE_PRODUCTION_ENVIRONMENT) + dependsOn(aggregateTaskProvider) + } +} + +private fun Project.registerServeTask(extension: SupabaseFunctionExtension) { + tasks.register(TASK_FUNCTION_SERVE) { + group = SUPABASE_FUNCTION_TASK_GROUP + description = "Serve function locally." + + compiledSourceDir.convention( + layout.buildDirectory.dir("compileSync/js/main/developmentLibrary/kotlin") + ) + + requestConfigFile.convention( + layout.projectDirectory.file(REQUEST_CONFIG_FILE_NAME).orNone(project) + ) + + requestOutputDir.convention(layout.buildDirectory.dir("tmp/$SUPABASE_FUNCTION_OUTPUT_DIR")) + rootProjectDir.convention(rootProject.layout.projectDirectory) + projectDir.convention(layout.projectDirectory) + supabaseDir.convention(extension.supabaseDir) + functionName.convention(extension.functionName) + verifyJwt.convention(extension.verifyJwt) + importMap.convention(extension.importMap) + stackTraceSourceMapStrategy.convention(StackTraceSourceMapStrategy.JsOnly) + envFile.convention(extension.envFile.orNone()) + + dependsOn(TASK_GENERATE_DEVELOPMENT_ENVIRONMENT) + dependsOn(aggregateTaskProvider) + } +} + +private fun Project.registerUpdateGitignoreTask(extension: SupabaseFunctionExtension) { + val task = tasks.register(TASK_UPDATE_GITIGNORE) { + group = SUPABASE_FUNCTION_TASK_GROUP + description = "Update .gitignore files." + + supabaseDir.convention(extension.supabaseDir) + functionName.convention(extension.functionName) + importMapEntry.convention(true) + indexEntry.convention(true) + } + + rootProject.tasks.named(PREPARE_KOTLIN_BUILD_SCRIPT_MODEL_TASK) { + dependsOn(task) + } +} + +private fun Project.registerGenerateImportMapTask( + extension: SupabaseFunctionExtension, + jsDependenciesProvider: Provider> +) { + val generateTaskProvider = tasks.register( + name = TASK_GENERATE_IMPORT_MAP + ) { + group = SUPABASE_FUNCTION_TASK_GROUP + description = "Generate import map." + + packageJsonDir.convention(layout.buildDirectory.dir("tmp/jsPublicPackageJson")) + functionName.convention(extension.functionName) + jsDependencies.convention(jsDependenciesProvider) + + dependsOn("jsPublicPackageJson") + } + + aggregateTaskProvider.configure { + val aggregateTask = apply { + dependsOn(generateTaskProvider) + } + + generateTaskProvider.configure { + importMapsDir.convention(aggregateTask.importMapsDir) + } + } +} \ No newline at end of file diff --git a/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/util/Files.kt b/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/util/Files.kt new file mode 100644 index 0000000..567034f --- /dev/null +++ b/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/util/Files.kt @@ -0,0 +1,41 @@ +/** + * Copyright (c) 2024 Maanrifa Bacar Ali + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package io.github.manriif.supabase.functions.util + +import org.gradle.api.Project +import org.gradle.api.file.RegularFile +import org.gradle.api.provider.Provider + +/** + * Returns a [Provider] that will provides the file ony if it exists. + */ +internal fun Provider.orNone(): Provider { + @Suppress("UnstableApiUsage") + return filter { it.asFile.exists() } +} + +/** + * Returns a [Provider] that will provides the file ony if it exists. + */ +internal fun RegularFile.orNone(project: Project): Provider { + return project.provider { this }.orNone() +} \ No newline at end of file diff --git a/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/util/Parameters.kt b/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/util/Parameters.kt new file mode 100644 index 0000000..a7c656f --- /dev/null +++ b/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/util/Parameters.kt @@ -0,0 +1,52 @@ +/** + * Copyright (c) 2024 Maanrifa Bacar Ali + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package io.github.manriif.supabase.functions.util + +internal fun Map.getBooleanOrDefault(key: String, default: Boolean): Boolean { + if (!containsKey(key)) { + return default + } + + val value = get(key)?.lowercase() + ?: return default + + + if (value.isBlank()) { + return true + } + + return value.toBooleanStrictOrNull() ?: default +} + +internal fun Map.getLongOrDefault(key: String, default: Long = 0): Long { + if (!containsKey(key)) { + return default + } + + val value = get(key) ?: return default + + if (value.isBlank()) { + return default + } + + return value.toLongOrNull() ?: default +} \ No newline at end of file diff --git a/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/util/Terminal.kt b/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/util/Terminal.kt new file mode 100644 index 0000000..a5dc3fe --- /dev/null +++ b/gradle-plugin/src/main/kotlin/io/github/manriif/supabase/functions/util/Terminal.kt @@ -0,0 +1,85 @@ +/** + * Copyright (c) 2024 Maanrifa Bacar Ali + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package io.github.manriif.supabase.functions.util + +import java.io.File + +private const val ESC = "\u001B[" +/*private const val OSC = "\u001B]" +private const val BEL = "\u0007" +private const val SEP = ";"*/ + +/////////////////////////////////////////////////////////////////////////// +// Color +/////////////////////////////////////////////////////////////////////////// + +internal enum class Color(private val value: Int) { + Black(0), + Red(1), + Green(2), + Yellow(3), + Blue(4), + Magenta(5), + Cyan(6), + White(7), + Default(9); + + fun text(): Int = value + 30 + + fun textBright(): Int = value + 90 +} + +private fun ansiColor(value: Any): String { + return "${ESC}${value}m" +} + +internal fun String.colored(color: Color, bright: Boolean = false): String { + val value = if (bright) { + color.textBright() + } else { + color.text() + } + + return "${ansiColor(value)}${this}${ansiColor(0)}" +} + +/////////////////////////////////////////////////////////////////////////// +// Link +/////////////////////////////////////////////////////////////////////////// + +@Suppress("UNUSED_PARAMETER") +internal fun File.link(label: String): String { + return canonicalPath /*listOf( + OSC, + "8", + SEP, + SEP, + "file://$canonicalPath", + BEL, + label, + OSC, + "8", + SEP, + SEP, + BEL + ).joinToString("")*/ +} \ No newline at end of file diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..2185949 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,26 @@ +# +# Copyright 2024 Maanrifa Bacar Ali. +# Use of this source code is governed by the MIT license. +# + +# Gradle +org.gradle.jvmargs=-Xmx4096m +org.gradle.configuration-cache=true +org.gradle.parallel=true +org.gradle.caching=true + +# Kotlin +kotlin.code.style=official + +# Project +project.name=Supabase Edge Functions Kotlin +project.group=io.github.manriif.supabase-functions +project.website=https://github.com/manriif/supabase-edge-functions-kt +project.license.name=MIT License +project.license.url=https://github.com/manriif/supabase-edge-functions-kt/blob/main/LICENSE +project.git.base=github.com/manriif/supabase-edge-functions-kt +project.git.url=https://github.com/manriif/supabase-edge-functions-kt + +project.dev.id=manriif +project.dev.name=Maanrifa Bacar Ali +project.dev.url=https://github.com/manriif \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..5c10a13 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,40 @@ +[versions] + +# Projct version +supabase-functions = "0.0.1" + +jvm-target = "1.8" + +detekt = "1.23.6" +dokka = "1.9.20" +gradle-idea-ext = "1.1.8" +gradle-plugin-publish = "1.2.1" +kotlin = "2.0.0" +kotlinpoet = "1.17.0" +kotlinx-coroutines = "1.8.1" +kotlinx-serialization = "1.7.0" +sourcemap = "2.0.0" +vanniktech-maven-publish = "0.29.0" + +[plugins] + +conventions-common = { id = "conventions-common", version.ref = "supabase-functions" } +conventions-kmp = { id = "conventions-kmp", version.ref = "supabase-functions" } + +detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" } +dokka = { id = "org.jetbrains.dokka", version.ref = "dokka" } +gradle-plugin-publish = { id = "com.gradle.plugin-publish", version.ref = "gradle-plugin-publish" } +kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } +vanniktech-maven-publish = { id = "com.vanniktech.maven.publish", version.ref = "vanniktech-maven-publish" } + +[libraries] + +atlassian-sourcemap = { module = "com.atlassian.sourcemap:sourcemap", version.ref = "sourcemap" } +detekt-gradle-plugin = { module = "io.gitlab.arturbosch.detekt:detekt-gradle-plugin", version.ref = "detekt" } +dokka-gradle-plugin = { module = "org.jetbrains.dokka:dokka-gradle-plugin", version.ref = "dokka" } +gradle-idea-ext = { module = "gradle.plugin.org.jetbrains.gradle.plugin.idea-ext:gradle-idea-ext", version.ref = "gradle-idea-ext" } +kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } +kotlin-gradle-plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } +squareup-kotlinpoet = { module = "com.squareup:kotlinpoet", version.ref = "kotlinpoet" } +vanniktech-maven-publish = { module = "com.vanniktech:gradle-maven-publish-plugin", version.ref = "vanniktech-maven-publish" } \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..e708b1c Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..f4e2974 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Thu Sep 07 10:53:48 CEST 2023 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..4f906e0 --- /dev/null +++ b/gradlew @@ -0,0 +1,185 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# 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 +# +# https://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. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=`expr $i + 1` + done + case $i in + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=`save "$@"` + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..107acd3 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/modules/binding-deno/MODULE.md b/modules/binding-deno/MODULE.md new file mode 100644 index 0000000..308239f --- /dev/null +++ b/modules/binding-deno/MODULE.md @@ -0,0 +1,3 @@ +# Module module-binding-deno + +Kotlin/JS language bindings for Deno. \ No newline at end of file diff --git a/modules/binding-deno/build.gradle.kts b/modules/binding-deno/build.gradle.kts new file mode 100644 index 0000000..53dd476 --- /dev/null +++ b/modules/binding-deno/build.gradle.kts @@ -0,0 +1,8 @@ +/** + * Copyright (c) 2024 Maanrifa Bacar Ali. + * Use of this source code is governed by the MIT license. + */ + +plugins { + alias(libs.plugins.conventions.kmp) +} \ No newline at end of file diff --git a/modules/binding-deno/gradle.properties b/modules/binding-deno/gradle.properties new file mode 100644 index 0000000..23603d0 --- /dev/null +++ b/modules/binding-deno/gradle.properties @@ -0,0 +1,6 @@ +# +# Copyright 2024 Maanrifa Bacar Ali. +# Use of this source code is governed by the MIT license. +# +local.name="Deno bindings for Supabase Edge Functions Kotlin" +local.description=Kotlin/JS language bindings for Deno. \ No newline at end of file diff --git a/modules/binding-deno/src/jsMain/kotlin/io/github/manriif/supabase/functions/binding/deno/Deno.kt b/modules/binding-deno/src/jsMain/kotlin/io/github/manriif/supabase/functions/binding/deno/Deno.kt new file mode 100644 index 0000000..086f429 --- /dev/null +++ b/modules/binding-deno/src/jsMain/kotlin/io/github/manriif/supabase/functions/binding/deno/Deno.kt @@ -0,0 +1,33 @@ +/** + * Copyright (c) 2024 Maanrifa Bacar Ali + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package io.github.manriif.supabase.functions.binding.deno + +/** + * Global Deno object. + */ +external object Deno { + + /** + * Environment variables accessor. + */ + val env: Env +} \ No newline at end of file diff --git a/modules/binding-deno/src/jsMain/kotlin/io/github/manriif/supabase/functions/binding/deno/Env.kt b/modules/binding-deno/src/jsMain/kotlin/io/github/manriif/supabase/functions/binding/deno/Env.kt new file mode 100644 index 0000000..ed2c9db --- /dev/null +++ b/modules/binding-deno/src/jsMain/kotlin/io/github/manriif/supabase/functions/binding/deno/Env.kt @@ -0,0 +1,50 @@ +/** + * Copyright (c) 2024 Maanrifa Bacar Ali + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package io.github.manriif.supabase.functions.binding.deno + +/** + * Read-only environment API. + */ +external interface Env { + + /** + * Whether an environment variable is set for [key]. + */ + fun has(key: String): Boolean + + /** + * Gets the value of an environment variable or `null` if [key] doesn't exist. + */ + operator fun get(key: String): String? + + /** + * Returns a snapshot of the environment variables at invocation. + */ + fun toObject(): dynamic +} + +/** + * Gets the environment variables for [key] or throws an ISE if no value is present. + */ +fun Env.require(key: String): String { + return checkNotNull(get(key)) { "Environment value for key `$key` is not set" } +} \ No newline at end of file diff --git a/modules/binding-deno/src/jsMain/kotlin/io/github/manriif/supabase/functions/binding/deno/SupabaseSecrets.kt b/modules/binding-deno/src/jsMain/kotlin/io/github/manriif/supabase/functions/binding/deno/SupabaseSecrets.kt new file mode 100644 index 0000000..fc800c2 --- /dev/null +++ b/modules/binding-deno/src/jsMain/kotlin/io/github/manriif/supabase/functions/binding/deno/SupabaseSecrets.kt @@ -0,0 +1,49 @@ +/** + * Copyright (c) 2024 Maanrifa Bacar Ali + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package io.github.manriif.supabase.functions.binding.deno + +/** + * Convenience for built-in supabase environment keys. + */ +object SupabaseSecrets { + + /** + * Supabase project url. + */ + const val URL = "SUPABASE_URL" + + /** + * Supabase anonymous key. + */ + const val ANON_KEY = "SUPABASE_ANON_KEY" + + /** + * Supabase service_role_key. + * Use with caution. + */ + const val SERVICE_ROLE_KEY = "SUPABASE_SERVICE_ROLE_KEY" + + /** + * Supabase database url. + */ + const val DB_URL = "SUPABASE_DB_URL" +} \ No newline at end of file diff --git a/modules/fetch-json/MODULE.md b/modules/fetch-json/MODULE.md new file mode 100644 index 0000000..fc130bb --- /dev/null +++ b/modules/fetch-json/MODULE.md @@ -0,0 +1,3 @@ +# Module module-fetch-json + +API for working with Javascript Fetch API and JSON. \ No newline at end of file diff --git a/modules/fetch-json/build.gradle.kts b/modules/fetch-json/build.gradle.kts new file mode 100644 index 0000000..9b100a5 --- /dev/null +++ b/modules/fetch-json/build.gradle.kts @@ -0,0 +1,19 @@ +/** + * Copyright (c) 2024 Maanrifa Bacar Ali. + * Use of this source code is governed by the MIT license. + */ + +plugins { + alias(libs.plugins.conventions.kmp) +} + +kotlin { + sourceSets { + jsMain { + dependencies { + api(libs.kotlinx.serialization.json) + api(libs.kotlinx.coroutines.core) + } + } + } +} \ No newline at end of file diff --git a/modules/fetch-json/gradle.properties b/modules/fetch-json/gradle.properties new file mode 100644 index 0000000..8b1a423 --- /dev/null +++ b/modules/fetch-json/gradle.properties @@ -0,0 +1,6 @@ +# +# Copyright 2024 Maanrifa Bacar Ali. +# Use of this source code is governed by the MIT license. +# +local.name="Fetch Json for Supabase Edge Functions Kotlin" +local.description=API for working with Javascript Fetch API and JSON. \ No newline at end of file diff --git a/modules/fetch-json/src/jsMain/kotlin/io/github/manriif/supabase/functions/fetch/json/Header.kt b/modules/fetch-json/src/jsMain/kotlin/io/github/manriif/supabase/functions/fetch/json/Header.kt new file mode 100644 index 0000000..66b6b77 --- /dev/null +++ b/modules/fetch-json/src/jsMain/kotlin/io/github/manriif/supabase/functions/fetch/json/Header.kt @@ -0,0 +1,31 @@ +/** + * Copyright (c) 2024 Maanrifa Bacar Ali + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package io.github.manriif.supabase.functions.fetch.json + +import org.w3c.fetch.Headers + +/** + * Appends json content-type to [this] [Headers] instance. + */ +fun Headers.jsonContentType() { + set("Content-Type", "application/json") +} \ No newline at end of file diff --git a/modules/fetch-json/src/jsMain/kotlin/io/github/manriif/supabase/functions/fetch/json/Request.kt b/modules/fetch-json/src/jsMain/kotlin/io/github/manriif/supabase/functions/fetch/json/Request.kt new file mode 100644 index 0000000..c30d993 --- /dev/null +++ b/modules/fetch-json/src/jsMain/kotlin/io/github/manriif/supabase/functions/fetch/json/Request.kt @@ -0,0 +1,33 @@ +/** + * Copyright (c) 2024 Maanrifa Bacar Ali + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package io.github.manriif.supabase.functions.fetch.json + +import kotlinx.coroutines.await +import kotlinx.serialization.json.Json +import org.w3c.fetch.Request + +/** + * Deserializes and returns [this] [Request] body as an instance of type [T]. + */ +suspend inline fun Request.body(): T { + return Json.decodeFromString(text().await()) +} \ No newline at end of file diff --git a/modules/fetch-json/src/jsMain/kotlin/io/github/manriif/supabase/functions/fetch/json/Response.kt b/modules/fetch-json/src/jsMain/kotlin/io/github/manriif/supabase/functions/fetch/json/Response.kt new file mode 100644 index 0000000..a91d5e7 --- /dev/null +++ b/modules/fetch-json/src/jsMain/kotlin/io/github/manriif/supabase/functions/fetch/json/Response.kt @@ -0,0 +1,46 @@ +/** + * Copyright (c) 2024 Maanrifa Bacar Ali + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package io.github.manriif.supabase.functions.fetch.json + +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.w3c.fetch.Headers +import org.w3c.fetch.Response +import org.w3c.fetch.ResponseInit + +/** + * Creates a [Response] with json content-type header and [body] as body. + * The response status (200 by default) and headers can be changed by supplying [init]. + */ +inline fun jsonResponse( + body: T, + init: ResponseInit = ResponseInit( + status = 200, + statusText = "OK", + headers = Headers().apply { + jsonContentType() + } + ) +): Response = Response( + body = Json.encodeToString(body), + init = init +) \ No newline at end of file diff --git a/readme/run_config_dark.png b/readme/run_config_dark.png new file mode 100644 index 0000000..31f2d21 Binary files /dev/null and b/readme/run_config_dark.png differ diff --git a/readme/run_config_light.png b/readme/run_config_light.png new file mode 100644 index 0000000..1f94384 Binary files /dev/null and b/readme/run_config_light.png differ diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..07a1260 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2024 Maanrifa Bacar Ali. + * Use of this source code is governed by the MIT license. + */ + +rootProject.name = "supabase-edge-functions-kt" + +enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") + +pluginManagement { + includeBuild("build-logic") + + repositories { + mavenCentral() + gradlePluginPortal() + } +} + +@Suppress("UnstableApiUsage") +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.PREFER_PROJECT) + + repositories { + mavenCentral() + gradlePluginPortal() + } +} + +fun includeModule(name: String) { + val path = ":modules:$name" + include(path) + project(path).name = "module-$name" +} + +include("gradle-plugin") +includeModule("binding-deno") +includeModule("fetch-json") \ No newline at end of file