Skip to content

Commit

Permalink
feat: add cron description endpoint (#14463)
Browse files Browse the repository at this point in the history
Co-authored-by: Vladimir <[email protected]>
  • Loading branch information
josephkmh and dizel852 committed Nov 1, 2024
1 parent 896ed90 commit 7f9a3b3
Show file tree
Hide file tree
Showing 27 changed files with 502 additions and 596 deletions.
5 changes: 2 additions & 3 deletions airbyte-api/problems-api/src/main/openapi/api-problems.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -692,13 +692,12 @@ components:
ProblemCronExpressionData:
type: object
required:
- connectionId
- cronExpression
properties:
connectionId:
type: string
cronExpression:
type: string
validationErrorMessage:
type: string
ProblemCronTimezoneData:
type: object
required:
Expand Down
47 changes: 47 additions & 0 deletions airbyte-api/server-api/src/main/openapi/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3302,6 +3302,28 @@ paths:
$ref: "#/components/schemas/WebBackendConnectionRead"
"422":
$ref: "#/components/responses/InvalidInputResponse"
/v1/web_backend/describe_cron_expression:
post:
tags:
- web_backend
summary: Returns a human-readable description of a CronTrigger expression
description: Pass a Quartz CronTrigger expression to be validated and turned into a human-readable description.
operationId: webBackendDescribeCronExpression
requestBody:
content:
application/json:
schema:
$ref: "#/components/schemas/WebBackendDescribeCronExpressionRequestBody"
required: true
responses:
"200":
description: Successful operation
content:
application/json:
schema:
$ref: "#/components/schemas/WebBackendCronExpressionDescription"
"422":
$ref: "#/components/responses/InvalidInputResponse"
/v1/web_backend/state/get_type:
post:
tags:
Expand Down Expand Up @@ -13261,6 +13283,31 @@ components:
$ref: "#/components/schemas/ActorDefinitionVersionRead"
destinationActorDefinitionVersion:
$ref: "#/components/schemas/ActorDefinitionVersionRead"
WebBackendDescribeCronExpressionRequestBody:
type: object
required:
- cronExpression
properties:
cronExpression:
type: string
WebBackendCronExpressionDescription:
type: object
required:
- cronExpression
- description
- nextExecutions
properties:
cronExpression:
type: string
description:
type: string
nextExecutions:
type: array
description: Array of Unix timestamps for the next execution times
items:
type: integer
format: int64
description: Unix timestamp of a scheduled execution
NonBreakingChangesPreference:
enum:
- ignore # do nothing if we detect a schema change
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,6 @@ public void populateSyncFromScheduleTypeAndData(final StandardSync standardSync,
parsedCronExpression.setTimeZone(timeZone);
} catch (final ParseException e) {
throw new CronValidationInvalidExpressionProblem(new ProblemCronExpressionData()
.connectionId(connectionId)
.cronExpression(cronExpression));
} catch (final IllegalArgumentException e) {
throw new CronValidationInvalidTimezoneProblem(new ProblemCronTimezoneData()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,9 +106,8 @@ void testAvailableCronTimeZonesStayTheSame() {
/*
* NOTE: this test exists to make sure that the server stays in sync with the frontend. The list of
* supported timezones is copied from
* oss/airbyte-webapp/src/components/connection/ConnectionForm/ScheduleFormField/
* availableCronTimeZones.json. If this test fails, then THAT file must be updated with the new
* timezones.
* oss/airbyte-webapp/src/core/utils/cron/availableCronTimeZones.json If this test fails, then THAT
* file must be updated with the new timezones.
*/
final String[] timezoneStrings = {
"Africa/Abidjan",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
import io.airbyte.api.model.generated.WebBackendConnectionReadList;
import io.airbyte.api.model.generated.WebBackendConnectionRequestBody;
import io.airbyte.api.model.generated.WebBackendConnectionUpdate;
import io.airbyte.api.model.generated.WebBackendCronExpressionDescription;
import io.airbyte.api.model.generated.WebBackendDescribeCronExpressionRequestBody;
import io.airbyte.api.model.generated.WebBackendGeographiesListResult;
import io.airbyte.api.model.generated.WebBackendWorkspaceState;
import io.airbyte.api.model.generated.WebBackendWorkspaceStateResult;
Expand All @@ -32,6 +34,7 @@
import io.airbyte.commons.server.handlers.WebBackendGeographiesHandler;
import io.airbyte.commons.server.scheduling.AirbyteTaskExecutors;
import io.airbyte.metrics.lib.TracingHelper;
import io.airbyte.server.handlers.WebBackendCronExpressionHandler;
import io.micronaut.http.annotation.Body;
import io.micronaut.http.annotation.Controller;
import io.micronaut.http.annotation.Post;
Expand All @@ -47,15 +50,18 @@ public class WebBackendApiController implements WebBackendApi {
private final WebBackendConnectionsHandler webBackendConnectionsHandler;
private final WebBackendGeographiesHandler webBackendGeographiesHandler;
private final WebBackendCheckUpdatesHandler webBackendCheckUpdatesHandler;
private final WebBackendCronExpressionHandler webBackendCronExpressionHandler;
private final ApiAuthorizationHelper apiAuthorizationHelper;

public WebBackendApiController(final WebBackendConnectionsHandler webBackendConnectionsHandler,
final WebBackendGeographiesHandler webBackendGeographiesHandler,
final WebBackendCheckUpdatesHandler webBackendCheckUpdatesHandler,
final WebBackendCronExpressionHandler webBackendCronExpressionHandler,
final ApiAuthorizationHelper apiAuthorizationHelper) {
this.webBackendConnectionsHandler = webBackendConnectionsHandler;
this.webBackendGeographiesHandler = webBackendGeographiesHandler;
this.webBackendCheckUpdatesHandler = webBackendCheckUpdatesHandler;
this.webBackendCronExpressionHandler = webBackendCronExpressionHandler;
this.apiAuthorizationHelper = apiAuthorizationHelper;
}

Expand Down Expand Up @@ -150,4 +156,12 @@ public WebBackendConnectionRead webBackendUpdateConnection(@Body final WebBacken
});
}

@Post("/describe_cron_expression")
@Secured({AUTHENTICATED_USER})
@ExecuteOn(AirbyteTaskExecutors.IO)
@Override
public WebBackendCronExpressionDescription webBackendDescribeCronExpression(@Body final WebBackendDescribeCronExpressionRequestBody body) {
return ApiHelper.execute(() -> webBackendCronExpressionHandler.describeCronExpression(body));
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package io.airbyte.server.handlers

import com.cronutils.descriptor.CronDescriptor
import com.cronutils.model.CronType
import com.cronutils.model.definition.CronDefinitionBuilder
import com.cronutils.model.time.ExecutionTime
import com.cronutils.parser.CronParser
import io.airbyte.api.model.generated.WebBackendCronExpressionDescription
import io.airbyte.api.model.generated.WebBackendDescribeCronExpressionRequestBody
import io.airbyte.api.problems.model.generated.ProblemCronExpressionData
import io.airbyte.api.problems.throwable.generated.CronValidationInvalidExpressionProblem
import jakarta.inject.Singleton
import java.time.ZonedDateTime
import java.util.Locale

@Singleton
class WebBackendCronExpressionHandler {
fun describeCronExpression(body: WebBackendDescribeCronExpressionRequestBody): WebBackendCronExpressionDescription? {
val cronDefinition = CronDefinitionBuilder.instanceDefinitionFor(CronType.QUARTZ)

try {
val cron = CronParser(cronDefinition).parse(body.cronExpression)
cron.validate()

val description = CronDescriptor.instance(Locale.ENGLISH).describe(cron)

val executionTime = ExecutionTime.forCron(cron)
val nextExecutions = mutableListOf<Long>()
var nextExecution = ZonedDateTime.now()

for (i in 1..3) {
nextExecution = executionTime.nextExecution(nextExecution).orElse(null) ?: break
nextExecutions.add(nextExecution.toEpochSecond())
}

return WebBackendCronExpressionDescription()
.cronExpression(body.cronExpression)
.description(description)
.nextExecutions(nextExecutions)
} catch (e: IllegalArgumentException) {
throw CronValidationInvalidExpressionProblem(ProblemCronExpressionData().cronExpression(body.cronExpression).validationErrorMessage(e.message))
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package io.airbyte.server.handlers

import io.airbyte.api.model.generated.WebBackendDescribeCronExpressionRequestBody
import io.airbyte.api.problems.throwable.generated.CronValidationInvalidExpressionProblem
import junit.framework.TestCase.assertEquals
import junit.framework.TestCase.assertNotNull
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows

const val CRON_EVERY_HOUR = "0 0 * * * ?"
const val CRON_EVERY_MINUTE = "0 * * * * ?"
const val Y2K = "0 0 0 1 1 ? 2000"

class WebBackendCronExpressionHandlerTest {
private var webBackendCronExpressionHandler: WebBackendCronExpressionHandler = WebBackendCronExpressionHandler()

@Test
fun testDescribeEveryHourCronExpression() {
val body = WebBackendDescribeCronExpressionRequestBody().cronExpression(CRON_EVERY_HOUR)
val result = webBackendCronExpressionHandler.describeCronExpression(body)

assertNotNull(result)
if (result != null) {
assertEquals(CRON_EVERY_HOUR, result.cronExpression)
assertEquals(3, result.nextExecutions.size)
assertEquals(result.nextExecutions[1] - result.nextExecutions[0], 3600)
}
}

@Test
fun testDescribeEveryMinuteCronExpression() {
val body = WebBackendDescribeCronExpressionRequestBody().cronExpression(CRON_EVERY_MINUTE)
val result = webBackendCronExpressionHandler.describeCronExpression(body)

assertNotNull(result)
if (result != null) {
assertEquals(CRON_EVERY_MINUTE, result.cronExpression)
assertEquals(3, result.nextExecutions.size)
assertEquals(result.nextExecutions[1] - result.nextExecutions[0], 60)
}
}

@Test
fun testDescribeY2KCronExpression() {
val body = WebBackendDescribeCronExpressionRequestBody().cronExpression(Y2K)
val result = webBackendCronExpressionHandler.describeCronExpression(body)

assertNotNull(result)
if (result != null) {
assertEquals(Y2K, result.cronExpression)
// Y2K already passed, so there are no future executions
assertEquals(0, result.nextExecutions.size)
}
}

@Test
fun testThrowsInvalidCronExpression() {
val body = WebBackendDescribeCronExpressionRequestBody().cronExpression("invalid")
assertThrows<CronValidationInvalidExpressionProblem> {
webBackendCronExpressionHandler.describeCronExpression(body)
}
}
}

This file was deleted.

Loading

0 comments on commit 7f9a3b3

Please sign in to comment.