diff --git a/cli/src/main/resources/application.yml b/cli/src/main/resources/application.yml
index 477be73b69..54e02aa9b5 100644
--- a/cli/src/main/resources/application.yml
+++ b/cli/src/main/resources/application.yml
@@ -9,6 +9,9 @@ micronaut:
ui:
paths: classpath:ui
mapping: /ui/**
+ static:
+ paths: classpath:static
+ mapping: /static/**
server:
max-request-size: 10GB
multipart:
diff --git a/webserver/src/main/java/io/kestra/webserver/Application.java b/webserver/src/main/java/io/kestra/webserver/Application.java
index c14fe9dc5c..f90ce2ca4c 100644
--- a/webserver/src/main/java/io/kestra/webserver/Application.java
+++ b/webserver/src/main/java/io/kestra/webserver/Application.java
@@ -4,12 +4,22 @@
import io.swagger.v3.oas.annotations.OpenAPIDefinition;
import io.swagger.v3.oas.annotations.info.Info;
import io.swagger.v3.oas.annotations.info.License;
+import io.swagger.v3.oas.annotations.tags.Tag;
@OpenAPIDefinition(
info = @Info(
title = "Kestra",
license = @License(name = "Apache 2.0", url = "https://raw.githubusercontent.com/kestra-io/kestra/master/LICENSE")
- )
+ ),
+ tags = {
+ @Tag(name = "Flows", description = "Flows api"),
+ @Tag(name = "Templates", description = "Templates api"),
+ @Tag(name = "Executions", description = "Executions api"),
+ @Tag(name = "Logs", description = "Logs api"),
+ @Tag(name = "Plugins", description = "Plugins api"),
+ @Tag(name = "Stats", description = "Stats api"),
+ @Tag(name = "Misc", description = "Misc api"),
+ }
)
public class Application {
public static void main(String[] args) {
diff --git a/webserver/src/main/java/io/kestra/webserver/controllers/ApiController.java b/webserver/src/main/java/io/kestra/webserver/controllers/ApiController.java
new file mode 100644
index 0000000000..aaec3eacd0
--- /dev/null
+++ b/webserver/src/main/java/io/kestra/webserver/controllers/ApiController.java
@@ -0,0 +1,93 @@
+package io.kestra.webserver.controllers;
+
+import io.kestra.core.exceptions.IllegalVariableEvaluationException;
+import io.kestra.core.exceptions.InternalException;
+import io.kestra.core.models.SearchResult;
+import io.kestra.core.models.flows.Flow;
+import io.kestra.core.models.hierarchies.FlowGraph;
+import io.kestra.core.models.tasks.Task;
+import io.kestra.core.models.validations.ManualConstraintViolation;
+import io.kestra.core.repositories.FlowRepositoryInterface;
+import io.kestra.webserver.responses.PagedResults;
+import io.kestra.webserver.utils.PageableUtils;
+import io.micronaut.context.annotation.Value;
+import io.micronaut.core.annotation.Nullable;
+import io.micronaut.http.HttpResponse;
+import io.micronaut.http.HttpStatus;
+import io.micronaut.http.MediaType;
+import io.micronaut.http.annotation.*;
+import io.micronaut.http.exceptions.HttpStatusException;
+import io.micronaut.scheduling.TaskExecutors;
+import io.micronaut.scheduling.annotation.ExecuteOn;
+import io.micronaut.validation.Validated;
+import io.swagger.v3.oas.annotations.Hidden;
+import jakarta.inject.Inject;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+import java.util.stream.Collectors;
+import javax.validation.ConstraintViolationException;
+import javax.validation.Valid;
+
+import static io.kestra.core.utils.Rethrow.throwFunction;
+
+@Validated
+@Controller("/api")
+public class ApiController {
+ @Value("${micronaut.server.context-path:}")
+ protected String basePath;
+
+ protected String getBasePath() {
+ return basePath.replaceAll("/$","");
+ }
+
+ protected String getSwaggerFilename() {
+ return "kestra.yml";
+ }
+
+ @Get()
+ @Hidden
+ public HttpResponse> rapidoc() {
+ String doc = "\n" +
+ "\n" +
+ "
\n" +
+ " Api | Kestra\n" +
+ " \n" +
+ " \n" +
+ " \n" +
+ " \n" +
+ " \n" +
+ "\n" +
+ "\n" +
+ " \n" +
+ " \n" +
+ "\n" +
+ " \n" +
+ " \n" +
+ "\n" +
+ "\n";
+
+ return HttpResponse
+ .ok()
+ .contentType(MediaType.TEXT_HTML_TYPE)
+ .body(doc);
+ }
+
+}
diff --git a/webserver/src/main/java/io/kestra/webserver/controllers/ExecutionController.java b/webserver/src/main/java/io/kestra/webserver/controllers/ExecutionController.java
index 12050441dd..75f6bd991e 100644
--- a/webserver/src/main/java/io/kestra/webserver/controllers/ExecutionController.java
+++ b/webserver/src/main/java/io/kestra/webserver/controllers/ExecutionController.java
@@ -15,6 +15,11 @@
import io.micronaut.validation.Validated;
import io.reactivex.BackpressureStrategy;
import io.reactivex.Flowable;
+import io.swagger.v3.oas.annotations.Hidden;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import io.swagger.v3.oas.annotations.responses.ApiResponses;
import org.apache.commons.io.FilenameUtils;
import io.kestra.core.events.CrudEvent;
import io.kestra.core.events.CrudEventType;
@@ -93,12 +98,13 @@ public class ExecutionController {
@ExecuteOn(TaskExecutors.IO)
@Get(uri = "executions/search", produces = MediaType.TEXT_JSON)
+ @Operation(tags = {"Executions"}, summary = "Search for executions")
public PagedResults find(
- @QueryValue(value = "q") String query,
- @QueryValue(value = "page", defaultValue = "1") int page,
- @QueryValue(value = "size", defaultValue = "10") int size,
- @Nullable @QueryValue(value = "state") List state,
- @Nullable @QueryValue(value = "sort") List sort
+ @Parameter(description = "Lucene string filter") @QueryValue(value = "q") String query,
+ @Parameter(description = "The current page") @QueryValue(value = "page", defaultValue = "1") int page,
+ @Parameter(description = "The current page size") @QueryValue(value = "size", defaultValue = "10") int size,
+ @Parameter(description = "The sort of current page") @Nullable @QueryValue(value = "sort") List sort,
+ @Parameter(description = "A state filter") @Nullable @QueryValue(value = "state") List state
) {
return PagedResults.of(
executionRepository
@@ -108,12 +114,13 @@ public PagedResults find(
@ExecuteOn(TaskExecutors.IO)
@Get(uri = "taskruns/search", produces = MediaType.TEXT_JSON)
+ @Operation(tags = {"Executions"}, summary = "Search for taskruns")
public PagedResults findTaskRun(
- @QueryValue(value = "q") String query,
- @QueryValue(value = "page", defaultValue = "1") int page,
- @QueryValue(value = "size", defaultValue = "10") int size,
+ @Parameter(description = "Lucene string filter") @QueryValue(value = "q") String query,
+ @Parameter(description = "The current page") @QueryValue(value = "page", defaultValue = "1") int page,
+ @Parameter(description = "The current page size") @QueryValue(value = "size", defaultValue = "10") int size,
@Nullable @QueryValue(value = "state") List state,
- @Nullable @QueryValue(value = "sort") List sort
+ @Parameter(description = "The sort of current page") @Nullable @QueryValue(value = "sort") List sort
) {
return PagedResults.of(
executionRepository
@@ -123,19 +130,17 @@ public PagedResults findTaskRun(
@ExecuteOn(TaskExecutors.IO)
@Get(uri = "taskruns/maxTaskRunSetting")
+ @Hidden
public Integer maxTaskRunSetting() {
return executionRepository.maxTaskRunSetting();
}
- /**
- * Get an execution flow tree
- *
- * @param executionId The execution identifier
- * @return the flow tree with the provided identifier
- */
@ExecuteOn(TaskExecutors.IO)
@Get(uri = "executions/{executionId}/graph", produces = MediaType.TEXT_JSON)
- public FlowGraph flowGraph(String executionId) throws IllegalVariableEvaluationException {
+ @Operation(tags = {"Executions"}, summary = "Generate a graph for an execution")
+ public FlowGraph flowGraph(
+ @Parameter(description = "The execution id") String executionId
+ ) throws IllegalVariableEvaluationException {
return executionRepository
.findById(executionId)
.map(throwFunction(execution -> {
@@ -152,94 +157,63 @@ public FlowGraph flowGraph(String executionId) throws IllegalVariableEvaluationE
.orElse(null);
}
- /**
- * Get a execution
- *
- * @param executionId The execution identifier
- * @return the execution with the provided identifier
- */
@ExecuteOn(TaskExecutors.IO)
@Get(uri = "executions/{executionId}", produces = MediaType.TEXT_JSON)
- public Execution get(String executionId) {
+ @Operation(tags = {"Executions"}, summary = "Get an execution")
+ public Execution get(
+ @Parameter(description = "The execution id") String executionId
+ ) {
return executionRepository
.findById(executionId)
.orElse(null);
}
- /**
- * Find and returns all executions for a specific namespace and flow identifier
- *
- * @param namespace The flow namespace
- * @param flowId The flow identifier
- * @param page The number of result pages to return
- * @param size The number of result by page
- * @return a list of found executions
- */
@ExecuteOn(TaskExecutors.IO)
@Get(uri = "executions", produces = MediaType.TEXT_JSON)
+ @Operation(tags = {"Executions"}, summary = "Search for executions for a flow")
public PagedResults findByFlowId(
- @QueryValue(value = "namespace") String namespace,
- @QueryValue(value = "flowId") String flowId,
- @QueryValue(value = "page", defaultValue = "1") int page,
- @QueryValue(value = "size", defaultValue = "10") int size) {
+ @Parameter(description = "The flow namespace") @QueryValue(value = "namespace") String namespace,
+ @Parameter(description = "The flow id") @QueryValue(value = "flowId") String flowId,
+ @Parameter(description = "The current page") @QueryValue(value = "page", defaultValue = "1") int page,
+ @Parameter(description = "The current page size") @QueryValue(value = "size", defaultValue = "10") int size
+ ) {
return PagedResults.of(
executionRepository
.findByFlowId(namespace, flowId, Pageable.from(page, size))
);
}
- /**
- * Trigger a new execution for a webhook trigger
- *
- * @param namespace The flow namespace
- * @param id The flow id
- * @param key The webhook trigger uid
- * @return execution created
- */
@ExecuteOn(TaskExecutors.IO)
@Post(uri = "executions/webhook/{namespace}/{id}/{key}", produces = MediaType.TEXT_JSON)
+ @Operation(tags = {"Executions"}, summary = "Trigger a new execution by POST webhook trigger")
public Execution webhookTriggerPost(
- String namespace,
- String id,
- String key,
+ @Parameter(description = "The flow namespace") String namespace,
+ @Parameter(description = "The flow id") String id,
+ @Parameter(description = "The webhook trigger uid") String key,
HttpRequest request
) {
return this.webhook(namespace, id, key, request);
}
- /**
- * Trigger a new execution for a webhook trigger
- *
- * @param namespace The flow namespace
- * @param id The flow id
- * @param key The webhook trigger uid
- * @return execution created
- */
@ExecuteOn(TaskExecutors.IO)
@Get(uri = "executions/webhook/{namespace}/{id}/{key}", produces = MediaType.TEXT_JSON)
+ @Operation(tags = {"Executions"}, summary = "Trigger a new execution by GET webhook trigger")
public Execution webhookTriggerGet(
- String namespace,
- String id,
- String key,
+ @Parameter(description = "The flow namespace") String namespace,
+ @Parameter(description = "The flow id") String id,
+ @Parameter(description = "The webhook trigger uid") String key,
HttpRequest request
) {
return this.webhook(namespace, id, key, request);
}
- /**
- * Trigger a new execution for a webhook trigger
- *
- * @param namespace The flow namespace
- * @param id The flow id
- * @param key The webhook trigger uid
- * @return execution created
- */
@ExecuteOn(TaskExecutors.IO)
@Put(uri = "executions/webhook/{namespace}/{id}/{key}", produces = MediaType.TEXT_JSON)
+ @Operation(tags = {"Executions"}, summary = "Trigger a new execution by PUT webhook trigger")
public Execution webhookTriggerPut(
- String namespace,
- String id,
- String key,
+ @Parameter(description = "The flow namespace") String namespace,
+ @Parameter(description = "The flow id") String id,
+ @Parameter(description = "The webhook trigger uid") String key,
HttpRequest request
) {
return this.webhook(namespace, id, key, request);
@@ -284,19 +258,13 @@ private Execution webhook(
return execution.get();
}
- /**
- * Trigger a new execution for current flow
- *
- * @param namespace The flow namespace
- * @param id The flow id
- * @return execution created
- * @throws IllegalStateException if the flow is disabled
- */
@ExecuteOn(TaskExecutors.IO)
@Post(uri = "executions/trigger/{namespace}/{id}", produces = MediaType.TEXT_JSON, consumes = MediaType.MULTIPART_FORM_DATA)
+ @Operation(tags = {"Executions"}, summary = "Trigger a new execution for a flow")
+ @ApiResponse(responseCode = "409", description = "if the flow is disabled")
public Execution trigger(
- String namespace,
- String id,
+ @Parameter(description = "The flow namespace") String namespace,
+ @Parameter(description = "The flow id") String id,
@Nullable Map inputs,
@Nullable Publisher files
) {
@@ -353,17 +321,13 @@ protected HttpResponse validateFile(String executionId, URI path, String
throw new IllegalArgumentException("Invalid prefix path");
}
- /**
- * Download file binary from uri parameter
- *
- * @param path The file URI to return
- * @return data binary content
- */
+
@ExecuteOn(TaskExecutors.IO)
@Get(uri = "executions/{executionId}/file", produces = MediaType.APPLICATION_OCTET_STREAM)
+ @Operation(tags = {"Executions"}, summary = "Download file for an execution")
public HttpResponse file(
- String executionId,
- @QueryValue(value = "path") URI path
+ @Parameter(description = "The execution id") String executionId,
+ @Parameter(description = "The internal storage uri") @QueryValue(value = "path") URI path
) throws IOException, URISyntaxException {
HttpResponse httpResponse = this.validateFile(executionId, path, "/api/v1/executions/{executionId}/file?path=" + path);
if (httpResponse != null) {
@@ -376,17 +340,12 @@ public HttpResponse file(
);
}
- /**
- * Get file meta information from given path
- *
- * @param path The file URI to gather metas values
- * @return metadata about given file
- */
@ExecuteOn(TaskExecutors.IO)
@Get(uri = "executions/{executionId}/file/metas", produces = MediaType.TEXT_JSON)
+ @Operation(tags = {"Executions"}, summary = "Get file meta information for an execution")
public HttpResponse filesize(
- String executionId,
- @QueryValue(value = "path") URI path
+ @Parameter(description = "The execution id") String executionId,
+ @Parameter(description = "The internal storage uri") @QueryValue(value = "path") URI path
) throws IOException {
HttpResponse httpResponse =this.validateFile(executionId, path, "/api/v1/executions/{executionId}/file/metas?path=" + path);
if (httpResponse != null) {
@@ -399,18 +358,13 @@ public HttpResponse filesize(
);
}
- /**
- * Restart a new execution from an old one
- *
- * @param executionId the origin execution id to clone
- * @return the restarted execution
- */
@ExecuteOn(TaskExecutors.IO)
@Post(uri = "executions/{executionId}/restart", produces = MediaType.TEXT_JSON)
+ @Operation(tags = {"Executions"}, summary = "Restart a new execution from an old one")
public Execution restart(
- String executionId,
- @Nullable @QueryValue(value = "revision") Integer revision
- ) throws Exception {
+ @Parameter(description = "The execution id") String executionId,
+ @Parameter(description = "The flow revision to use for new execution") @Nullable @QueryValue(value = "revision") Integer revision
+ ) throws Exception {
Optional execution = executionRepository.findById(executionId);
if (execution.isEmpty()) {
return null;
@@ -425,19 +379,13 @@ public Execution restart(
return restart;
}
- /**
- * Create a new execution from an old one and start it from a specified task run id
- *
- * @param executionId the origin execution id to clone
- * @param taskRunId the reference taskRun id
- * @return the restarted execution
- */
@ExecuteOn(TaskExecutors.IO)
@Post(uri = "executions/{executionId}/replay", produces = MediaType.TEXT_JSON)
+ @Operation(tags = {"Executions"}, summary = "Create a new execution from an old one and start it from a specified task run id")
public Execution replay(
- String executionId,
- @Nullable @QueryValue(value = "taskRunId") String taskRunId,
- @Nullable @QueryValue(value = "revision") Integer revision
+ @Parameter(description = "the original execution id to clone") String executionId,
+ @Parameter(description = "The taskrun id") @Nullable @QueryValue(value = "taskRunId") String taskRunId,
+ @Parameter(description = "The flow revision to use for new execution") @Nullable @QueryValue(value = "revision") Integer revision
) throws Exception {
Optional execution = executionRepository.findById(executionId);
if (execution.isEmpty()) {
@@ -469,16 +417,13 @@ private void controlRevision(Execution execution, Integer revision) {
}
}
- /**
- * Create a new execution from an old one and start it from a specified task run id
- *
- * @param executionId the origin execution id to clone
- * @param stateRequest the taskRun id & state to apply
- * @return the restarted execution
- */
@ExecuteOn(TaskExecutors.IO)
@Post(uri = "executions/{executionId}/state", produces = MediaType.TEXT_JSON)
- public Execution changeState(String executionId, @Body StateRequest stateRequest) throws Exception {
+ @Operation(tags = {"Executions"}, summary = "Change state for a taskrun in an execution")
+ public Execution changeState(
+ @Parameter(description = "The execution id") String executionId,
+ @Parameter(description = "the taskRun id and state to apply") @Body StateRequest stateRequest
+ ) throws Exception {
Optional execution = executionRepository.findById(executionId);
if (execution.isEmpty()) {
return null;
@@ -497,15 +442,18 @@ public static class StateRequest {
State.Type state;
}
- /**
- * Kill an execution and stop all works
- *
- * @param executionId the execution id to kill
- * @throws IllegalStateException if the executions is already finished
- */
@ExecuteOn(TaskExecutors.IO)
@Delete(uri = "executions/{executionId}/kill", produces = MediaType.TEXT_JSON)
- public HttpResponse> kill(String executionId) {
+ @Operation(tags = {"Executions"}, summary = "Kill an execution")
+ @ApiResponses(
+ value = {
+ @ApiResponse(responseCode = "204", description = "On success"),
+ @ApiResponse(responseCode = "409", description = "if the executions is already finished")
+ }
+ )
+ public HttpResponse> kill(
+ @Parameter(description = "The execution id") String executionId
+ ) {
Optional execution = executionRepository.findById(executionId);
if (execution.isPresent() && execution.get().getState().isTerninated()) {
throw new IllegalStateException("Execution is already finished, can't kill it");
@@ -525,15 +473,12 @@ private boolean isStopFollow(Flow flow, Execution execution) {
execution.getState().getCurrent() != State.Type.PAUSED;
}
- /**
- * Trigger a new execution for current flow and follow execution
- *
- * @param executionId The execution id to follow
- * @return execution sse event
- */
@ExecuteOn(TaskExecutors.IO)
@Get(uri = "executions/{executionId}/follow", produces = MediaType.TEXT_EVENT_STREAM)
- public Flowable> follow(String executionId) {
+ @Operation(tags = {"Executions"}, summary = "Follow an execution")
+ public Flowable> follow(
+ @Parameter(description = "The execution id") String executionId
+ ) {
AtomicReference cancel = new AtomicReference<>();
return Flowable
diff --git a/webserver/src/main/java/io/kestra/webserver/controllers/FlowController.java b/webserver/src/main/java/io/kestra/webserver/controllers/FlowController.java
index f459499707..22cb2db628 100644
--- a/webserver/src/main/java/io/kestra/webserver/controllers/FlowController.java
+++ b/webserver/src/main/java/io/kestra/webserver/controllers/FlowController.java
@@ -25,6 +25,10 @@
import java.util.Set;
import java.util.stream.Collectors;
import io.micronaut.core.annotation.Nullable;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import io.swagger.v3.oas.annotations.responses.ApiResponses;
import jakarta.inject.Inject;
import javax.validation.ConstraintViolationException;
import javax.validation.Valid;
@@ -37,85 +41,72 @@ public class FlowController {
@Inject
private FlowRepositoryInterface flowRepository;
- /**
- * @param namespace The flow namespace
- * @param id The flow id
- * @return flow tree found
- */
@ExecuteOn(TaskExecutors.IO)
@Get(uri = "{namespace}/{id}/graph", produces = MediaType.TEXT_JSON)
- public FlowGraph flowGraph(String namespace, String id, Optional revision) throws IllegalVariableEvaluationException {
+ @Operation(tags = {"Flows"}, summary = "Generate a graph for a flow")
+ public FlowGraph flowGraph(
+ @Parameter(description = "The flow namespace") String namespace,
+ @Parameter(description = "The flow id") String id,
+ @Parameter(description = "The flow revision") Optional revision
+ ) throws IllegalVariableEvaluationException {
return flowRepository
.findById(namespace, id, revision)
.map(throwFunction(FlowGraph::of))
.orElse(null);
}
- /**
- * @param namespace The flow namespace
- * @param id The flow id
- * @return flow found
- */
@ExecuteOn(TaskExecutors.IO)
@Get(uri = "{namespace}/{id}", produces = MediaType.TEXT_JSON)
- public Flow index(String namespace, String id) {
+ @Operation(tags = {"Flows"}, summary = "Get a flow")
+ public Flow index(
+ @Parameter(description = "The flow namespace") String namespace,
+ @Parameter(description = "The flow id") String id
+ ) {
return flowRepository
.findById(namespace, id)
.orElse(null);
}
- /**
- * @param namespace The flow namespace
- * @param id The flow id
- * @return flow revisions found
- */
@ExecuteOn(TaskExecutors.IO)
@Get(uri = "{namespace}/{id}/revisions", produces = MediaType.TEXT_JSON)
- public List revisions(String namespace, String id) {
+ @Operation(tags = {"Flows"}, summary = "Get revisions for a flow")
+ public List revisions(
+ @Parameter(description = "The flow namespace") String namespace,
+ @Parameter(description = "The flow id") String id
+ ) {
return flowRepository.findRevisions(namespace, id);
}
- /**
- * @param query The flow query that is a lucene string
- * @param page Page in flow pagination
- * @param size Element count in pagination selection
- * @return flow list
- */
@ExecuteOn(TaskExecutors.IO)
@Get(uri = "/search", produces = MediaType.TEXT_JSON)
+ @Operation(tags = {"Flows"}, summary = "Search for flows")
public PagedResults find(
- @QueryValue(value = "q") String query, //Search by namespace using lucene
- @QueryValue(value = "page", defaultValue = "1") int page,
- @QueryValue(value = "size", defaultValue = "10") int size,
- @Nullable @QueryValue(value = "sort") List sort
+ @Parameter(description = "Lucene string filter") @QueryValue(value = "q") String query,
+ @Parameter(description = "The current page") @QueryValue(value = "page", defaultValue = "1") int page,
+ @Parameter(description = "The current page size") @QueryValue(value = "size", defaultValue = "10") int size,
+ @Parameter(description = "The sort of current page") @Nullable @QueryValue(value = "sort") List sort
) throws HttpStatusException {
return PagedResults.of(flowRepository.find(query, PageableUtils.from(page, size, sort)));
}
- /**
- * @param query The flow query that is a lucene string
- * @param page Page in flow pagination
- * @param size Element count in pagination selection
- * @return flow search list
- */
@ExecuteOn(TaskExecutors.IO)
@Get(uri = "/source", produces = MediaType.TEXT_JSON)
+ @Operation(tags = {"Flows"}, summary = "Search for flows source code")
public PagedResults> source(
- @QueryValue(value = "q") String query, //Search by namespace using lucene
- @QueryValue(value = "page", defaultValue = "1") int page,
- @QueryValue(value = "size", defaultValue = "10") int size,
- @Nullable @QueryValue(value = "sort") List sort
+ @Parameter(description = "Lucene string filter") @QueryValue(value = "q") String query,
+ @Parameter(description = "The current page") @QueryValue(value = "page", defaultValue = "1") int page,
+ @Parameter(description = "The current page size") @QueryValue(value = "size", defaultValue = "10") int size,
+ @Parameter(description = "The sort of current page") @Nullable @QueryValue(value = "sort") List sort
) throws HttpStatusException {
return PagedResults.of(flowRepository.findSourceCode(query, PageableUtils.from(page, size, sort)));
}
- /**
- * @param flow The flow content
- * @return flow created
- */
@ExecuteOn(TaskExecutors.IO)
@Post(produces = MediaType.TEXT_JSON)
- public HttpResponse create(@Body @Valid Flow flow) throws ConstraintViolationException {
+ @Operation(tags = {"Flows"}, summary = "Create a flow")
+ public HttpResponse create(
+ @Parameter(description = "The flow") @Body @Valid Flow flow
+ ) throws ConstraintViolationException {
if (flowRepository.findById(flow.getNamespace(), flow.getId()).isPresent()) {
throw new ConstraintViolationException(Collections.singleton(ManualConstraintViolation.of(
"Flow id already exists",
@@ -129,15 +120,18 @@ public HttpResponse create(@Body @Valid Flow flow) throws ConstraintViolat
return HttpResponse.ok(flowRepository.create(flow));
}
- /**
- * @param namespace The namespace to update
- * @param flows The flows content, all flow will be created / updated for this namespace.
- * Flow in repository but not in {@code flows} will also be deleted
- * @return flows created or updated
- */
@ExecuteOn(TaskExecutors.IO)
@Post(uri = "{namespace}", produces = MediaType.TEXT_JSON)
- public List updateNamespace(String namespace, @Body @Valid List flows) throws ConstraintViolationException {
+ @Operation(
+ tags = {"Flows"},
+ summary = "Update a complete namespace",
+ description = "All flow will be created / updated for this namespace.\n" +
+ "Flow that already created but not in `flows` will be deleted"
+ )
+ public List updateNamespace(
+ @Parameter(description = "The flow namespace") String namespace,
+ @Parameter(description = "A list of flows") @Body @Valid List flows
+ ) throws ConstraintViolationException {
// control namespace to update
Set> invalids = flows
.stream()
@@ -199,14 +193,14 @@ public List updateNamespace(String namespace, @Body @Valid List flo
.collect(Collectors.toList());
}
- /**
- * @param namespace flow namespace
- * @param id flow id to update
- * @return flow updated
- */
@Put(uri = "{namespace}/{id}", produces = MediaType.TEXT_JSON)
@ExecuteOn(TaskExecutors.IO)
- public HttpResponse update(String namespace, String id, @Body @Valid Flow flow) throws ConstraintViolationException {
+ @Operation(tags = {"Flows"}, summary = "Update a flow")
+ public HttpResponse update(
+ @Parameter(description = "The flow namespace") String namespace,
+ @Parameter(description = "The flow id") String id,
+ @Parameter(description = "The flow") @Body @Valid Flow flow
+ ) throws ConstraintViolationException {
Optional existingFlow = flowRepository.findById(namespace, id);
if (existingFlow.isEmpty()) {
@@ -216,15 +210,15 @@ public HttpResponse update(String namespace, String id, @Body @Valid Flow
return HttpResponse.ok(flowRepository.update(flow, existingFlow.get()));
}
- /**
- * @param namespace flow namespace
- * @param id flow id to update
- * @param taskId taskId id to update
- * @return flow updated
- */
@Patch(uri = "{namespace}/{id}/{taskId}", produces = MediaType.TEXT_JSON)
@ExecuteOn(TaskExecutors.IO)
- public HttpResponse updateTask(String namespace, String id, String taskId, @Valid @Body Task task) throws ConstraintViolationException {
+ @Operation(tags = {"Flows"}, summary = "Update a single task on a flow")
+ public HttpResponse updateTask(
+ @Parameter(description = "The flow namespace") String namespace,
+ @Parameter(description = "The flow id") String id,
+ @Parameter(description = "The task id") String taskId,
+ @Parameter(description = "The task") @Valid @Body Task task
+ ) throws ConstraintViolationException {
Optional existingFlow = flowRepository.findById(namespace, id);
if (existingFlow.isEmpty()) {
@@ -244,14 +238,16 @@ public HttpResponse updateTask(String namespace, String id, String taskId,
}
}
- /**
- * @param namespace flow namespace
- * @param id flow id to delete
- * @return Http 204 on delete or Http 404 when not found
- */
@Delete(uri = "{namespace}/{id}", produces = MediaType.TEXT_JSON)
@ExecuteOn(TaskExecutors.IO)
- public HttpResponse delete(String namespace, String id) {
+ @Operation(tags = {"Flows"}, summary = "Delete a flow")
+ @ApiResponses(
+ @ApiResponse(responseCode = "204", description = "On success")
+ )
+ public HttpResponse delete(
+ @Parameter(description = "The flow namespace") String namespace,
+ @Parameter(description = "The flow id") String id
+ ) {
Optional flow = flowRepository.findById(namespace, id);
if (flow.isPresent()) {
flowRepository.delete(flow.get());
@@ -261,11 +257,9 @@ public HttpResponse delete(String namespace, String id) {
}
}
- /**
- * @return The flow's namespaces set
- */
@ExecuteOn(TaskExecutors.IO)
@Get(uri = "distinct-namespaces", produces = MediaType.TEXT_JSON)
+ @Operation(tags = {"Flows"}, summary = "List all distinct namespaces")
public List listDistinctNamespace() {
return flowRepository.findDistinctNamespace();
}
diff --git a/webserver/src/main/java/io/kestra/webserver/controllers/LogController.java b/webserver/src/main/java/io/kestra/webserver/controllers/LogController.java
index 863e3cc5e3..fcd1e26e7b 100644
--- a/webserver/src/main/java/io/kestra/webserver/controllers/LogController.java
+++ b/webserver/src/main/java/io/kestra/webserver/controllers/LogController.java
@@ -18,6 +18,8 @@
import io.kestra.core.repositories.LogRepositoryInterface;
import io.kestra.webserver.responses.PagedResults;
import io.kestra.webserver.utils.PageableUtils;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
import org.slf4j.event.Level;
import java.util.List;
@@ -37,43 +39,29 @@ public class LogController {
@Named(QueueFactoryInterface.WORKERTASKLOG_NAMED)
protected QueueInterface logQueue;
- /**
- * Search for logs
- *
- * @param query The lucene query
- * @param page The current page
- * @param size The current page size
- * @param sort The sort of current page
- * @return Paged log result
- */
@ExecuteOn(TaskExecutors.IO)
@Get(uri = "logs/search", produces = MediaType.TEXT_JSON)
+ @Operation(tags = {"Logs"}, summary = "Search for logs")
public PagedResults find(
- @QueryValue(value = "q") String query,
- @QueryValue(value = "page", defaultValue = "1") int page,
- @QueryValue(value = "size", defaultValue = "10") int size,
- @Nullable @QueryValue(value = "minLevel") Level minLevel,
- @Nullable @QueryValue(value = "sort") List sort
- ) {
+ @Parameter(description = "Lucene string filter") @QueryValue(value = "q") String query,
+ @Parameter(description = "The current page") @QueryValue(value = "page", defaultValue = "1") int page,
+ @Parameter(description = "The current page size") @QueryValue(value = "size", defaultValue = "10") int size,
+ @Parameter(description = "The sort of current page") @Nullable @QueryValue(value = "sort") List sort,
+ @Parameter(description = "The min log level filter") @Nullable @QueryValue(value = "minLevel") Level minLevel
+ ) {
return PagedResults.of(
logRepository.find(query, PageableUtils.from(page, size, sort), minLevel)
);
}
- /**
- * Get execution log
- *
- * @param executionId The execution identifier
-
- * @return Paged log result
- */
@ExecuteOn(TaskExecutors.IO)
@Get(uri = "logs/{executionId}", produces = MediaType.TEXT_JSON)
+ @Operation(tags = {"Logs"}, summary = "Get logs for a specific execution")
public List findByExecution(
- String executionId,
- @Nullable @QueryValue(value = "minLevel") Level minLevel,
- @Nullable @QueryValue(value = "taskRunId") String taskRunId,
- @Nullable @QueryValue(value = "taskId") String taskId
+ @Parameter(description = "The execution id") String executionId,
+ @Parameter(description = "The min log level filter") @Nullable @QueryValue(value = "minLevel") Level minLevel,
+ @Parameter(description = "The taskrun id") @Nullable @QueryValue(value = "taskRunId") String taskRunId,
+ @Parameter(description = "The task id") @Nullable @QueryValue(value = "taskId") String taskId
) {
if (taskId != null) {
return logRepository.findByExecutionIdAndTaskId(executionId, taskId, minLevel);
@@ -84,15 +72,13 @@ public List findByExecution(
}
}
- /**
- * Follow log for a specific execution
- *
- * @param executionId The execution id to follow
- * @return execution log sse event
- */
@ExecuteOn(TaskExecutors.IO)
@Get(uri = "logs/{executionId}/follow", produces = MediaType.TEXT_EVENT_STREAM)
- public Flowable> follow(String executionId, @Nullable @QueryValue(value = "minLevel") Level minLevel) {
+ @Operation(tags = {"Logs"}, summary = "Follow log for a specific execution")
+ public Flowable> follow(
+ @Parameter(description = "The execution id") String executionId,
+ @Parameter(description = "The min log level filter") @Nullable @QueryValue(value = "minLevel") Level minLevel
+ ) {
AtomicReference cancel = new AtomicReference<>();
List levels = LogEntry.findLevelsByMin(minLevel);
diff --git a/webserver/src/main/java/io/kestra/webserver/controllers/MiscController.java b/webserver/src/main/java/io/kestra/webserver/controllers/MiscController.java
index f479e618d1..594a86951f 100644
--- a/webserver/src/main/java/io/kestra/webserver/controllers/MiscController.java
+++ b/webserver/src/main/java/io/kestra/webserver/controllers/MiscController.java
@@ -5,6 +5,8 @@
import io.micronaut.http.annotation.Get;
import io.micronaut.scheduling.TaskExecutors;
import io.micronaut.scheduling.annotation.ExecuteOn;
+import io.swagger.v3.oas.annotations.Hidden;
+import io.swagger.v3.oas.annotations.Operation;
import lombok.Value;
import lombok.extern.slf4j.Slf4j;
import io.kestra.core.utils.VersionProvider;
@@ -18,13 +20,14 @@ public class MiscController {
VersionProvider versionProvider;
@Get("/ping")
+ @Hidden
public HttpResponse> ping() {
return HttpResponse.ok("pong");
}
-
@Get("/api/v1/version")
@ExecuteOn(TaskExecutors.IO)
+ @Operation(tags = {"Misc"}, summary = "Get current version")
public Version version() {
return new Version(versionProvider.getVersion());
}
diff --git a/webserver/src/main/java/io/kestra/webserver/controllers/PluginController.java b/webserver/src/main/java/io/kestra/webserver/controllers/PluginController.java
index fd5dd3da42..a94dd1ebf4 100644
--- a/webserver/src/main/java/io/kestra/webserver/controllers/PluginController.java
+++ b/webserver/src/main/java/io/kestra/webserver/controllers/PluginController.java
@@ -12,6 +12,8 @@
import io.micronaut.scheduling.TaskExecutors;
import io.micronaut.scheduling.annotation.ExecuteOn;
import io.micronaut.validation.Validated;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@@ -33,7 +35,8 @@ public class PluginController {
@Get
@ExecuteOn(TaskExecutors.IO)
- public List search() throws HttpStatusException {
+ @Operation(tags = {"Plugins"}, summary = "Get list of plugins")
+ public List search() {
return pluginService
.allPlugins()
.stream()
@@ -42,7 +45,8 @@ public List search() throws HttpStatusException {
}
@Get(uri = "icons")
- public Map icons() throws HttpStatusException {
+ @Operation(tags = {"Plugins"}, summary = "Get plugins icons")
+ public Map icons() {
return pluginService
.allPlugins()
.stream()
@@ -69,7 +73,10 @@ public Map icons() throws HttpStatusException {
@SuppressWarnings({"rawtypes", "unchecked"})
@Get(uri = "{cls}")
@ExecuteOn(TaskExecutors.IO)
- public Doc pluginDocumentation(String cls) throws HttpStatusException, IOException {
+ @Operation(tags = {"Plugins"}, summary = "Get plugin documentation")
+ public Doc pluginDocumentation(
+ @Parameter(description = "The plugin full class name") String cls
+ ) throws IOException {
ClassPluginDocumentation classPluginDocumentation = pluginDocumentation(
pluginService.allPlugins(),
cls
diff --git a/webserver/src/main/java/io/kestra/webserver/controllers/RedirectController.java b/webserver/src/main/java/io/kestra/webserver/controllers/RedirectController.java
index 274b88893a..67041852fb 100644
--- a/webserver/src/main/java/io/kestra/webserver/controllers/RedirectController.java
+++ b/webserver/src/main/java/io/kestra/webserver/controllers/RedirectController.java
@@ -3,6 +3,7 @@
import io.micronaut.http.HttpResponse;
import io.micronaut.http.annotation.Controller;
import io.micronaut.http.annotation.Get;
+import io.swagger.v3.oas.annotations.Hidden;
import lombok.extern.slf4j.Slf4j;
import java.net.URI;
@@ -11,6 +12,7 @@
@Controller
public class RedirectController {
@Get
+ @Hidden
public HttpResponse> slash() {
return HttpResponse.temporaryRedirect(URI.create("/ui/"));
}
diff --git a/webserver/src/main/java/io/kestra/webserver/controllers/StatsController.java b/webserver/src/main/java/io/kestra/webserver/controllers/StatsController.java
index bb862a3b3b..e8349ea208 100644
--- a/webserver/src/main/java/io/kestra/webserver/controllers/StatsController.java
+++ b/webserver/src/main/java/io/kestra/webserver/controllers/StatsController.java
@@ -4,6 +4,7 @@
import io.micronaut.http.MediaType;
import io.micronaut.http.annotation.Controller;
import io.micronaut.http.annotation.Post;
+import io.micronaut.http.annotation.QueryValue;
import io.micronaut.scheduling.TaskExecutors;
import io.micronaut.scheduling.annotation.ExecuteOn;
import io.micronaut.validation.Validated;
@@ -15,6 +16,8 @@
import java.util.List;
import java.util.Map;
import io.micronaut.core.annotation.Nullable;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
import jakarta.inject.Inject;
@Validated
@@ -23,20 +26,14 @@ public class StatsController {
@Inject
protected ExecutionRepositoryInterface executionRepository;
- /**
- * Return daily statistics for all executions filter optionnaly by a lucene query
- *
- * @param q Lucene string to filter execution
- * @param startDate default to now - 30 days
- * @param endDate default to now
- * @return a list of DailyExecutionStatistics
- */
+
@ExecuteOn(TaskExecutors.IO)
@Post(uri = "executions/daily", produces = MediaType.TEXT_JSON)
+ @Operation(tags = {"Stats"}, summary = "Get daily statistics for executions")
public List dailyStatistics(
- @Nullable String q,
- @Nullable @Format("yyyy-MM-dd'T'HH:mm[:ss][.SSS][XXX]") ZonedDateTime startDate,
- @Nullable @Format("yyyy-MM-dd'T'HH:mm[:ss][.SSS][XXX]") ZonedDateTime endDate
+ @Parameter(description = "Lucene string filter") @QueryValue(value = "q") String q,
+ @Parameter(description = "The start datetime, default to now - 30 days") @Nullable @Format("yyyy-MM-dd'T'HH:mm[:ss][.SSS][XXX]") ZonedDateTime startDate,
+ @Parameter(description = "The end datetime, default to now") @Nullable @Format("yyyy-MM-dd'T'HH:mm[:ss][.SSS][XXX]") ZonedDateTime endDate
) {
// @TODO: seems to be converted back to utc by micronaut
return executionRepository.dailyStatistics(
@@ -47,20 +44,14 @@ public List dailyStatistics(
);
}
- /**
- * Return daily statistics for all taskRuns filter optionnaly by a lucene query
- *
- * @param q Lucene string to filter execution
- * @param startDate default to now - 30 days
- * @param endDate default to now
- * @return a list of DailyExecutionStatistics
- */
+
@ExecuteOn(TaskExecutors.IO)
@Post(uri = "taskruns/daily", produces = MediaType.TEXT_JSON)
+ @Operation(tags = {"Stats"}, summary = "Get daily statistics for taskRuns")
public List taskRunsDailyStatistics(
- @Nullable String q,
- @Nullable @Format("yyyy-MM-dd'T'HH:mm[:ss][.SSS][XXX]") ZonedDateTime startDate,
- @Nullable @Format("yyyy-MM-dd'T'HH:mm[:ss][.SSS][XXX]") ZonedDateTime endDate
+ @Parameter(description = "Lucene string filter") @QueryValue(value = "q") String q,
+ @Parameter(description = "The start datetime, default to now - 30 days") @Nullable @Format("yyyy-MM-dd'T'HH:mm[:ss][.SSS][XXX]") ZonedDateTime startDate,
+ @Parameter(description = "The end datetime, default to now") @Nullable @Format("yyyy-MM-dd'T'HH:mm[:ss][.SSS][XXX]") ZonedDateTime endDate
) {
return executionRepository.dailyStatistics(
q,
@@ -70,21 +61,14 @@ public List taskRunsDailyStatistics(
);
}
- /**
- * Return daily statistics for all executions filter optionnaly by a lucene query group by namespace & flow
- *
- * @param q Lucene string to filter execution
- * @param startDate default to now - 30 days
- * @param endDate default to now
- * @return map of namespace, containing a Map of flow, DailyExecutionStatistics
- */
@ExecuteOn(TaskExecutors.IO)
@Post(uri = "executions/daily/group-by-flow", produces = MediaType.TEXT_JSON)
+ @Operation(tags = {"Stats"}, summary = "Get daily statistics for executions group by namespaces and flows")
public Map>> dailyGroupByFlowStatistics(
- @Nullable String q,
- @Nullable @Format("yyyy-MM-dd'T'HH:mm[:ss][.SSS][XXX]") ZonedDateTime startDate,
- @Nullable @Format("yyyy-MM-dd'T'HH:mm[:ss][.SSS][XXX]") ZonedDateTime endDate,
- @Nullable Boolean namespaceOnly
+ @Parameter(description = "Lucene string filter") @QueryValue(value = "q") String q,
+ @Parameter(description = "The start datetime, default to now - 30 days") @Nullable @Format("yyyy-MM-dd'T'HH:mm[:ss][.SSS][XXX]") ZonedDateTime startDate,
+ @Parameter(description = "The end datetime, default to now") @Nullable @Format("yyyy-MM-dd'T'HH:mm[:ss][.SSS][XXX]") ZonedDateTime endDate,
+ @Parameter(description = "Return only namespace result and skip flows") @Nullable Boolean namespaceOnly
) {
return executionRepository.dailyGroupByFlowStatistics(
diff --git a/webserver/src/main/java/io/kestra/webserver/controllers/TemplateController.java b/webserver/src/main/java/io/kestra/webserver/controllers/TemplateController.java
index 53c8c1e032..8ca7b14963 100644
--- a/webserver/src/main/java/io/kestra/webserver/controllers/TemplateController.java
+++ b/webserver/src/main/java/io/kestra/webserver/controllers/TemplateController.java
@@ -18,6 +18,10 @@
import java.util.List;
import java.util.Optional;
import io.micronaut.core.annotation.Nullable;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import io.swagger.v3.oas.annotations.responses.ApiResponses;
import jakarta.inject.Inject;
import javax.validation.ConstraintViolationException;
import javax.validation.Valid;
@@ -28,42 +32,36 @@ public class TemplateController {
@Inject
private TemplateRepositoryInterface templateRepository;
- /**
- * @param id The template id
- * @return template found
- */
@ExecuteOn(TaskExecutors.IO)
@Get(uri = "{namespace}/{id}", produces = MediaType.TEXT_JSON)
- public Template index(String namespace, String id) {
+ @Operation(tags = {"Template"}, summary = "Get a template")
+ public Template index(
+ @Parameter(description = "The template namespace") String namespace,
+ @Parameter(description = "The template id") String id
+ ) {
return templateRepository
.findById(namespace, id)
.orElse(null);
}
- /**
- * @param query The template query that is a lucen string
- * @param page Page in template pagination
- * @param size Element count in pagination selection
- * @return template list
- */
@ExecuteOn(TaskExecutors.IO)
@Get(uri = "/search", produces = MediaType.TEXT_JSON)
+ @Operation(tags = {"Template"}, summary = "Search for templates")
public PagedResults find(
- @QueryValue(value = "q") String query, //Search by namespace using lucene
- @QueryValue(value = "page", defaultValue = "1") int page,
- @QueryValue(value = "size", defaultValue = "10") int size,
- @Nullable @QueryValue(value = "sort") List sort
+ @Parameter(description = "Lucene string filter") @QueryValue(value = "q") String query,
+ @Parameter(description = "The current page") @QueryValue(value = "page", defaultValue = "1") int page,
+ @Parameter(description = "The current page size") @QueryValue(value = "size", defaultValue = "10") int size,
+ @Parameter(description = "The sort of current page") @Nullable @QueryValue(value = "sort") List sort
) throws HttpStatusException {
return PagedResults.of(templateRepository.find(query, PageableUtils.from(page, size, sort)));
}
- /**
- * @param template The template content
- * @return template created
- */
@ExecuteOn(TaskExecutors.IO)
@Post(produces = MediaType.TEXT_JSON)
- public HttpResponse create(@Valid @Body Template template) throws ConstraintViolationException {
+ @Operation(tags = {"Template"}, summary = "Create a template")
+ public HttpResponse create(
+ @Parameter(description = "The template") @Valid @Body Template template
+ ) throws ConstraintViolationException {
if (templateRepository.findById(template.getNamespace(), template.getId()).isPresent()) {
throw new ConstraintViolationException(Collections.singleton(ManualConstraintViolation.of(
"Template id already exists",
@@ -77,13 +75,14 @@ public HttpResponse create(@Valid @Body Template template) throws Cons
return HttpResponse.ok(templateRepository.create(template));
}
- /**
- * @param id template id to update
- * @return template updated
- */
@ExecuteOn(TaskExecutors.IO)
@Put(uri = "{namespace}/{id}", produces = MediaType.TEXT_JSON)
- public HttpResponse update(String namespace, String id, @Valid @Body Template template) throws ConstraintViolationException {
+ @Operation(tags = {"Template"}, summary = "Update a template")
+ public HttpResponse update(
+ @Parameter(description = "The template namespace") String namespace,
+ @Parameter(description = "The template id") String id,
+ @Parameter(description = "The template") @Valid @Body Template template
+ ) throws ConstraintViolationException {
Optional existingTemplate = templateRepository.findById(namespace, id);
if (existingTemplate.isEmpty()) {
@@ -93,13 +92,16 @@ public HttpResponse update(String namespace, String id, @Valid @Body T
return HttpResponse.ok(templateRepository.update(template, existingTemplate.get()));
}
- /**
- * @param id template id to delete
- * @return Http 204 on delete or Http 404 when not found
- */
@ExecuteOn(TaskExecutors.IO)
@Delete(uri = "{namespace}/{id}", produces = MediaType.TEXT_JSON)
- public HttpResponse delete(String namespace, String id) {
+ @Operation(tags = {"Template"}, summary = "Delete a template")
+ @ApiResponses(
+ @ApiResponse(responseCode = "204", description = "On success")
+ )
+ public HttpResponse delete(
+ @Parameter(description = "The template namespace") String namespace,
+ @Parameter(description = "The template id") String id
+ ) {
Optional template = templateRepository.findById(namespace, id);
if (template.isPresent()) {
templateRepository.delete(template.get());
@@ -109,11 +111,9 @@ public HttpResponse delete(String namespace, String id) {
}
}
- /**
- * @return The template's namespaces set
- */
@ExecuteOn(TaskExecutors.IO)
@Get(uri = "distinct-namespaces", produces = MediaType.TEXT_JSON)
+ @Operation(tags = {"Template"}, summary = "List all distinct namespaces")
public List listDistinctNamespace() {
return templateRepository.findDistinctNamespace();
}
diff --git a/webserver/src/main/resources/static/logo.svg b/webserver/src/main/resources/static/logo.svg
new file mode 100644
index 0000000000..24d4ffdc75
--- /dev/null
+++ b/webserver/src/main/resources/static/logo.svg
@@ -0,0 +1,12 @@
+
+