diff --git a/ui/src/components/flows/blueprints/BlueprintDetail.vue b/ui/src/components/flows/blueprints/BlueprintDetail.vue index f359b41fc52..86cba1a030a 100644 --- a/ui/src/components/flows/blueprints/BlueprintDetail.vue +++ b/ui/src/components/flows/blueprints/BlueprintDetail.vue @@ -1,7 +1,7 @@ @@ -36,6 +43,12 @@ emits: [ "loaded" ], + props: { + kind: { + type: String, + required: true + }, + }, data() { return { selectedBlueprintId: undefined, diff --git a/ui/src/override/components/flows/blueprints/BlueprintsBrowser.vue b/ui/src/override/components/flows/blueprints/BlueprintsBrowser.vue index e9823850d1a..8ed94233858 100644 --- a/ui/src/override/components/flows/blueprints/BlueprintsBrowser.vue +++ b/ui/src/override/components/flows/blueprints/BlueprintsBrowser.vue @@ -48,7 +48,7 @@
@@ -85,7 +85,7 @@ {{ $t('copy') }} - + {{ $t('use') }}
@@ -126,6 +126,10 @@ type: String, default: undefined, }, + blueprintKind: { + type: String, + default: "flow", + }, embed: { type: Boolean, default: false @@ -153,12 +157,15 @@ } }, methods: { + kind() { + return this.blueprintType === "community" ? this.blueprintKind : undefined; + }, initSelectedTag() { return this.$route?.query?.selectedTag ?? 0 }, - async copy(blueprintId) { + async copy(id) { await Utils.copy( - (await this.$http.get(`${this.embedFriendlyBlueprintBaseUri}/${blueprintId}/flow`)).data + (await this.$store.dispatch("blueprints/getBlueprintSource", {type: this.blueprintType, kind: this.kind(), id: id})) ); }, async blueprintToEditor(blueprintId) { @@ -181,17 +188,13 @@ if (this.$route.query.q || this.q) { query.q = this.$route.query.q || this.q; } - - return this.$http - .get(beforeLoadBlueprintBaseUri + "/tags", { - params: query - }) - .then(response => { + return this.$store.dispatch("blueprints/getBlueprintTagsForQuery", {type: this.blueprintType, kind: this.kind(), ...query}) + .then(data => { // Handle switch tab while fetching data if (this.embedFriendlyBlueprintBaseUri === beforeLoadBlueprintBaseUri) { - this.tags = this.tagsResponseMapper(response.data); + this.tags = this.tagsResponseMapper(data); } - }) + }); }, loadBlueprints(beforeLoadBlueprintBaseUri) { const query = {} @@ -200,7 +203,6 @@ query.page = parseInt(this.$route.query.page || this.internalPageNumber); } - if (this.$route.query.size || this.internalPageSize) { query.size = parseInt(this.$route.query.size || this.internalPageSize); } @@ -215,16 +217,13 @@ query.tags = this.$route.query.selectedTag || this.selectedTag; } - return this.$http - .get(beforeLoadBlueprintBaseUri, { - params: query - }) - .then(response => { + return this.$store + .dispatch("blueprints/getBlueprintsForQuery", {type: this.blueprintType, kind: this.kind(), ...query}) + .then(data => { // Handle switch tab while fetching data if (this.embedFriendlyBlueprintBaseUri === beforeLoadBlueprintBaseUri) { - const blueprintsResponse = response.data; - this.total = blueprintsResponse.total; - this.blueprints = blueprintsResponse.results; + this.total = data.total; + this.blueprints = data.results; } }); }, @@ -258,6 +257,7 @@ computed: { ...mapState("auth", ["user"]), ...mapState("plugin", ["icons"]), + ...mapState("blueprint", ["blueprints"]), userCanCreateFlow() { return this.user.hasAnyAction(permission.FLOW, action.CREATE); }, @@ -268,6 +268,9 @@ return base ? (base.endsWith("/undefined") ? base.replace("/undefined", `/${tab}`) : base) : `${apiUrl(this.$store)}/blueprints/${tab}`; + }, + blueprintType() { + return this.tab ?? this?.$route?.params?.tab ?? "community"; } }, watch: { @@ -308,7 +311,10 @@ this.loadData(); }, tab() { - this.loadData() + this.loadData(); + }, + blueprintKind() { + this.loadData(); } } }; diff --git a/ui/src/override/components/useLeftMenu.ts b/ui/src/override/components/useLeftMenu.ts index 85a53ea324f..c48962fcc4e 100644 --- a/ui/src/override/components/useLeftMenu.ts +++ b/ui/src/override/components/useLeftMenu.ts @@ -103,13 +103,23 @@ export function useLeftMenu() { } }, { - href: {name: "blueprints"}, + href: {name: "blueprints", params: {kind: "flow"}}, routes: routeStartWith("blueprints"), title: t("blueprints.title"), icon: { element: shallowRef(BallotOutline), class: "menu-icon" }, + child: [ + { + title: t("homeDashboard.title"), + icon: { + element: shallowRef(ViewDashboardVariantOutline), + class: "menu-icon" + }, + href: {name: "blueprints", params: {kind: "dashboard"}}, + }, + ] }, { href: {name: "plugins/list"}, diff --git a/ui/src/routes/routes.js b/ui/src/routes/routes.js index 6dcf79d602e..b3a90b9cf70 100644 --- a/ui/src/routes/routes.js +++ b/ui/src/routes/routes.js @@ -25,8 +25,8 @@ export default [ {name: "taskruns/list", path: "/:tenant?/taskruns", component: () => import("../components/taskruns/TaskRuns.vue")}, //Blueprints - {name: "blueprints", path: "/:tenant?/blueprints", component: () => import("override/components/flows/blueprints/Blueprints.vue"), props: {topNavbar: false}}, - {name: "blueprints/view", path: "/:tenant?/blueprints/:blueprintId", component: () => import("../components/flows/blueprints/BlueprintDetail.vue"), props: true}, + {name: "blueprints", path: "/:tenant?/blueprints/:kind", component: () => import("override/components/flows/blueprints/Blueprints.vue"), props: {topNavbar: false}}, + {name: "blueprints/view", path: "/:tenant?/blueprints/:kind/:blueprintId", component: () => import("../components/flows/blueprints/BlueprintDetail.vue"), props: true}, //Documentation {name: "plugins/list", path: "/:tenant?/plugins", component: () => import("../components/plugins/Plugin.vue")}, diff --git a/ui/src/stores/blueprints.js b/ui/src/stores/blueprints.js new file mode 100644 index 00000000000..d2f31a98b91 --- /dev/null +++ b/ui/src/stores/blueprints.js @@ -0,0 +1,80 @@ +import {apiUrl} from "override/utils/route"; +export default { + namespaced: true, + state: { + blueprint: undefined, + blueprints: [], + source: undefined, + graph: undefined + }, + + actions: { + getBlueprint({commit}, options) { + const kind = options.kind ? `/${options.kind}` : ""; + return this.$http.get( + `${apiUrl(this)}/blueprints/${options.type}${kind}/${options.id}` + ) + .then(response => { + commit("setBlueprint", response.data) + return response.data; + }); + }, + getBlueprintSource({commit}, options) { + const kind = options.kind ? `/${options.kind}` : ""; + return this.$http.get( + `${apiUrl(this)}/blueprints/${options.type}${kind}/${options.id}/source` + ) + .then(response => { + commit("setSource", response.data) + return response.data; + }); + }, + getBlueprintGraph({commit}, options) { + const kind = options.kind ? `/${options.kind}` : ""; + return this.$http.get( + `${apiUrl(this)}/blueprints/${options.type}${kind}/${options.id}/graph` + ) + .then(response => { + commit("setGraph", response.data) + return response.data; + }); + }, + getBlueprintsForQuery({commit}, options) { + const kind = options.kind ? `/${options.kind}` : ""; + return this.$http.get( + `${apiUrl(this)}/blueprints/${options.type}${kind}`, + {params: options.params} + ) + .then(response => { + commit("setBlueprints", response.data) + return response.data; + }); + }, + getBlueprintTagsForQuery(_, options) { + const kind = options.kind ? `/${options.kind}` : ""; + return this.$http.get( + `${apiUrl(this)}/blueprints/${options.type}${kind}/tags`, + {params: options.params} + ) + .then(response => { + return response.data; + }); + }, + }, + mutations: { + setBlueprints(state, blueprints) { + state.blueprints = blueprints + }, + setBlueprint(state, blueprint) { + state.blueprint = blueprint + }, + setSource(state, source) { + state.source = source + }, + setGraph(state, graph) { + state.graph = graph + }, + }, + getters: { + } +} diff --git a/ui/src/stores/store.js b/ui/src/stores/store.js index 3c9556967a6..b7edc28f32b 100644 --- a/ui/src/stores/store.js +++ b/ui/src/stores/store.js @@ -18,6 +18,7 @@ import doc from "./doc"; import bookmarks from "./bookmarks"; import dashboard from "./dashboard"; import code from "./code"; +import blueprints from "./blueprints"; export default { modules: { @@ -40,6 +41,7 @@ export default { doc, bookmarks, dashboard, - code + code, + blueprints, } } diff --git a/ui/src/translations/de.json b/ui/src/translations/de.json index a74bac83503..cfd24301324 100644 --- a/ui/src/translations/de.json +++ b/ui/src/translations/de.json @@ -262,7 +262,7 @@ "header": { "catch phrase": { "1": "Der erste Schritt ist immer der schwerste.", - "2": "Erkunden Sie Blueprints, um Ihren nächsten Flow zu starten." + "2": "Erkunden Sie Blueprints, um Ihr nächstes {kind} zu starten." }, "alt": "Blueprints-Symbol" }, diff --git a/ui/src/translations/en.json b/ui/src/translations/en.json index d3d3bb56c05..20a3498dffe 100644 --- a/ui/src/translations/en.json +++ b/ui/src/translations/en.json @@ -308,7 +308,7 @@ "header": { "catch phrase": { "1": "The first step is always the hardest.", - "2": "Explore blueprints to kick-start your next flow." + "2": "Explore blueprints to kick-start your next {kind}." }, "alt": "Blueprints Icon" } diff --git a/ui/src/translations/es.json b/ui/src/translations/es.json index a59a2fa88f0..c4b6cae37cf 100644 --- a/ui/src/translations/es.json +++ b/ui/src/translations/es.json @@ -262,7 +262,7 @@ "header": { "catch phrase": { "1": "El primer paso siempre es el más difícil.", - "2": "Explora blueprints para iniciar tu próximo flujo." + "2": "Explora blueprints para iniciar tu próximo {kind}." }, "alt": "Icono de Blueprints" }, diff --git a/ui/src/translations/fr.json b/ui/src/translations/fr.json index 2352fb627cd..328aca57975 100644 --- a/ui/src/translations/fr.json +++ b/ui/src/translations/fr.json @@ -262,7 +262,7 @@ "header": { "catch phrase": { "1": "La première étape est toujours la plus difficile.", - "2": "Explorez les blueprints pour créer rapidement vos flows." + "2": "Explorez les blueprints pour démarrer votre prochain {kind}." }, "alt": "Icône des Blueprints" }, diff --git a/ui/src/translations/hi.json b/ui/src/translations/hi.json index d37698f24ef..2a00d89247d 100644 --- a/ui/src/translations/hi.json +++ b/ui/src/translations/hi.json @@ -262,7 +262,7 @@ "header": { "catch phrase": { "1": "पहला कदम हमेशा सबसे कठिन होता है।", - "2": "अपने अगले flow को किक-स्टार्ट करने के लिए Blueprints का अन्वेषण करें।" + "2": "अपने अगले {kind} को शुरू करने के लिए ब्लूप्रिंट्स का अन्वेषण करें।" }, "alt": "ब्लूप्रिंट्स आइकन" }, diff --git a/ui/src/translations/it.json b/ui/src/translations/it.json index 26b2f6fc965..70d108b5d11 100644 --- a/ui/src/translations/it.json +++ b/ui/src/translations/it.json @@ -262,7 +262,7 @@ "header": { "catch phrase": { "1": "Il primo passo è sempre il più difficile.", - "2": "Esplora i blueprints per avviare il tuo prossimo flow." + "2": "Esplora i blueprint per avviare il tuo prossimo {kind}." }, "alt": "Icona dei Blueprint" }, diff --git a/ui/src/translations/ja.json b/ui/src/translations/ja.json index 94472da36b3..8bb6bacd7f9 100644 --- a/ui/src/translations/ja.json +++ b/ui/src/translations/ja.json @@ -262,7 +262,7 @@ "header": { "catch phrase": { "1": "最初の一歩が一番難しい。", - "2": "次のFlowを始めるためにBlueprintsを探索してください。" + "2": "次の{kind}を始めるために、blueprintを探索してください。" }, "alt": "ブループリントアイコン" }, diff --git a/ui/src/translations/ko.json b/ui/src/translations/ko.json index 6dac718e783..d99983ae2a8 100644 --- a/ui/src/translations/ko.json +++ b/ui/src/translations/ko.json @@ -262,7 +262,7 @@ "header": { "catch phrase": { "1": "첫 번째 단계가 항상 가장 어렵습니다.", - "2": "다음 flow를 시작하기 위해 blueprints를 탐색하세요." + "2": "다음 {kind}을 시작하기 위해 blueprint를 탐색하세요." }, "alt": "블루프린트 아이콘" }, diff --git a/ui/src/translations/pl.json b/ui/src/translations/pl.json index 39cf44b9ab1..7336e8122dd 100644 --- a/ui/src/translations/pl.json +++ b/ui/src/translations/pl.json @@ -262,7 +262,7 @@ "header": { "catch phrase": { "1": "Pierwszy krok jest zawsze najtrudniejszy.", - "2": "Odkryj blueprints, aby rozpocząć swój następny flow." + "2": "Eksploruj blueprints, aby rozpocząć swój następny {kind}." }, "alt": "Ikona Blueprintów" }, diff --git a/ui/src/translations/pt.json b/ui/src/translations/pt.json index 966dabea795..ee024daeb7e 100644 --- a/ui/src/translations/pt.json +++ b/ui/src/translations/pt.json @@ -262,7 +262,7 @@ "header": { "catch phrase": { "1": "O primeiro passo é sempre o mais difícil.", - "2": "Explore blueprints para iniciar seu próximo flow." + "2": "Explore blueprints para iniciar seu próximo {kind}." }, "alt": "Ícone de Blueprints" }, diff --git a/ui/src/translations/ru.json b/ui/src/translations/ru.json index 7561a423016..361eedd8a69 100644 --- a/ui/src/translations/ru.json +++ b/ui/src/translations/ru.json @@ -262,7 +262,7 @@ "header": { "catch phrase": { "1": "Первый шаг всегда самый трудный.", - "2": "Исследуйте blueprints, чтобы начать ваш следующий flow." + "2": "Исследуйте blueprints, чтобы начать ваш следующий {kind}." }, "alt": "Иконка Blueprints" }, diff --git a/ui/src/translations/zh_CN.json b/ui/src/translations/zh_CN.json index 607beb42acb..0421e37a8d6 100644 --- a/ui/src/translations/zh_CN.json +++ b/ui/src/translations/zh_CN.json @@ -262,7 +262,7 @@ "header": { "catch phrase": { "1": "第一步总是最难的。", - "2": "探索蓝图以启动你的下一个流程。" + "2": "探索blueprints以启动您的下一个{kind}。" }, "alt": "蓝图图标" }, diff --git a/webserver/src/main/java/io/kestra/webserver/controllers/api/BlueprintController.java b/webserver/src/main/java/io/kestra/webserver/controllers/api/BlueprintController.java index d3658b1d976..c9ca4d884e0 100644 --- a/webserver/src/main/java/io/kestra/webserver/controllers/api/BlueprintController.java +++ b/webserver/src/main/java/io/kestra/webserver/controllers/api/BlueprintController.java @@ -1,6 +1,8 @@ package io.kestra.webserver.controllers.api; +import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonInclude; +import io.kestra.core.utils.Enums; import io.kestra.core.utils.VersionProvider; import io.kestra.webserver.responses.PagedResults; import io.micronaut.core.annotation.Introspected; @@ -37,66 +39,79 @@ public class BlueprintController { @Inject @Client("api") private HttpClient httpClient; - @Inject protected VersionProvider versionProvider; @SuppressWarnings("unchecked") @ExecuteOn(TaskExecutors.IO) - @Get + @Get("/{kind}") @Operation(tags = {"Blueprints"}, summary = "List all blueprints") - public PagedResults blueprints( + public PagedResults blueprints( @Parameter(description = "A string filter") @Nullable @QueryValue(value = "q") Optional q, @Parameter(description = "The sort of current page") @Nullable @QueryValue(value = "sort") Optional sort, @Parameter(description = "A tags filter") @Nullable @QueryValue(value = "tags") Optional> tags, @Parameter(description = "The current page") @QueryValue(defaultValue = "1") @Min(1) Integer page, @Parameter(description = "The current page size") @QueryValue(defaultValue = "1") @Min(1) Integer size, + @Parameter(description = "The blueprint kind") Kind kind, HttpRequest httpRequest ) throws URISyntaxException { - return fastForwardToKestraApi(httpRequest, "/v1/blueprints/versions/" + versionProvider.getVersion(), Map.of("ee", false), Argument.of(PagedResults.class, BlueprintItem.class)); + return fastForwardToKestraApi(httpRequest, getApiBasePath(kind), Map.of("ee", false), Argument.of(PagedResults.class, ApiBlueprintItem.class)); } @ExecuteOn(TaskExecutors.IO) - @Get(value = "{id}/flow", produces = "application/yaml") + @Get(value = "/{kind}/{id}/source", produces = "application/yaml") @Operation(tags = {"Blueprints"}, summary = "Get a blueprint flow") - public String blueprintFlow( + public String blueprintSource( @Parameter(description = "The blueprint id") String id, + @Parameter(description = "The blueprint kind") Kind kind, HttpRequest httpRequest ) throws URISyntaxException { - return fastForwardToKestraApi(httpRequest, "/v1/blueprints/" + id + "/versions/" + versionProvider.getVersion() + "/flow", Argument.of(String.class)); + return fastForwardToKestraApi(httpRequest, getApiBasePath(id, kind) + "/source", Argument.of(String.class)); } @ExecuteOn(TaskExecutors.IO) - @Get(value = "{id}/graph") + @Get(value = "/{kind}/{id}/graph") @Operation(tags = {"Blueprints"}, summary = "Get a blueprint graph") public Map blueprintGraph( @Parameter(description = "The blueprint id") String id, + @Parameter(description = "The blueprint kind") Kind kind, HttpRequest httpRequest ) throws URISyntaxException { - return fastForwardToKestraApi(httpRequest, "/v1/blueprints/" + id + "/versions/" + versionProvider.getVersion() + "/graph", Argument.mapOf(String.class, Object.class)); + return fastForwardToKestraApi(httpRequest, getApiBasePath(id, kind) + "/graph", Argument.mapOf(String.class, Object.class)); } @ExecuteOn(TaskExecutors.IO) - @Get(value = "{id}") + @Get(value = "/{kind}/{id}") @Operation(tags = {"Blueprints"}, summary = "Get a blueprint") - public BlueprintItemWithFlow blueprint( + public ApiBlueprintItemWithSource blueprint( @Parameter(description = "The blueprint id") String id, + @Parameter(description = "The blueprint kind") Kind kind, HttpRequest httpRequest ) throws URISyntaxException { - return fastForwardToKestraApi(httpRequest, "/v1/blueprints/" + id + "/versions/" + versionProvider.getVersion(), Argument.of(BlueprintItemWithFlow.class)); + return fastForwardToKestraApi(httpRequest, getApiBasePath(id, kind), Argument.of(ApiBlueprintItemWithSource.class)); } @SuppressWarnings("unchecked") @ExecuteOn(TaskExecutors.IO) - @Get("tags") + @Get("/{kind}/tags") @Operation(tags = {"Blueprint Tags"}, summary = "List blueprint tags matching the filter") - public List blueprintTags( + public List blueprintTags( + @Parameter(description = "The blueprint kind") Kind kind, @Parameter(description = "A string filter to get tags with matching blueprints only") @Nullable @QueryValue(value = "q") Optional q, HttpRequest httpRequest ) throws URISyntaxException { - return fastForwardToKestraApi(httpRequest, "/v1/blueprints/versions/" + versionProvider.getVersion() + "/tags", Argument.of(List.class, BlueprintTagItem.class)); + return fastForwardToKestraApi(httpRequest, getApiBasePath(kind) + "/tags", Argument.of(List.class, ApiBlueprintTagItem.class)); + } + + private String getApiBasePath(final Kind kind) { + return "/v1/blueprints/kinds/" + kind.val() + "/versions/" + versionProvider.getVersion(); } + private String getApiBasePath(final String id, final Kind kind) { + return "/v1/blueprints/kinds/" + kind.val() + "/" + id + "/versions/" + versionProvider.getVersion(); + } + + protected T fastForwardToKestraApi(HttpRequest originalRequest, String newPath, Argument returnType) throws URISyntaxException { return this.fastForwardToKestraApi(originalRequest, newPath, null, returnType); } @@ -124,11 +139,12 @@ private T fastForwardToKestraApi(HttpRequest originalRequest, String newP } @Value - @SuperBuilder + @SuperBuilder(toBuilder = true) @Jacksonized @Introspected - public static class BlueprintItemWithFlow extends BlueprintItem { - String flow; + public static class ApiBlueprintItemWithSource extends ApiBlueprintItem { + String source; + Kind kind; } @ToString @@ -136,10 +152,10 @@ public static class BlueprintItemWithFlow extends BlueprintItem { @AllArgsConstructor @FieldDefaults(makeFinal = true, level = AccessLevel.PRIVATE) @Getter - @SuperBuilder + @SuperBuilder(toBuilder = true) @Jacksonized @Introspected - public static class BlueprintItem { + public static class ApiBlueprintItem { String id; String title; String description; @@ -154,10 +170,23 @@ public static class BlueprintItem { @Builder @Jacksonized @Introspected - public static class BlueprintTagItem { + public static class ApiBlueprintTagItem { String id; String name; @Builder.Default Instant publishedAt = Instant.now(); } + + public enum Kind { + APP, DASHBOARD, FLOW; + + public String val() { + return name().toLowerCase(); + } + + @JsonCreator + public Kind from(String s) { + return Enums.getForNameIgnoreCase(s, Kind.class); + } + } } diff --git a/webserver/src/main/java/io/kestra/webserver/services/FlowAutoLoaderService.java b/webserver/src/main/java/io/kestra/webserver/services/FlowAutoLoaderService.java index 452327c4077..cd2ab924b0f 100644 --- a/webserver/src/main/java/io/kestra/webserver/services/FlowAutoLoaderService.java +++ b/webserver/src/main/java/io/kestra/webserver/services/FlowAutoLoaderService.java @@ -7,7 +7,7 @@ import io.kestra.core.utils.NamespaceUtils; import io.kestra.core.utils.VersionProvider; import io.kestra.webserver.annotation.WebServerEnabled; -import io.kestra.webserver.controllers.api.BlueprintController.BlueprintItem; +import io.kestra.webserver.controllers.api.BlueprintController.ApiBlueprintItem; import io.kestra.webserver.responses.PagedResults; import io.micronaut.context.annotation.Requires; import io.micronaut.core.type.Argument; @@ -60,14 +60,14 @@ public void load() { // Loads all flows. Integer count = Mono.from(httpClient .exchange( - HttpRequest.create(HttpMethod.GET, "/v1/blueprints/versions/" + versionProvider.getVersion() + "?tags=getting-started"), - Argument.of(PagedResults.class, BlueprintItem.class) + HttpRequest.create(HttpMethod.GET, "/v1/blueprints/kinds/flow/versions/" + versionProvider.getVersion() + "?tags=getting-started"), + Argument.of(PagedResults.class, ApiBlueprintItem.class) )) - .map(response -> ((PagedResults)response.body()).getResults()) + .map(response -> ((PagedResults)response.body()).getResults()) .flatMapIterable(Function.identity()) .flatMap(it -> Mono.from(httpClient .exchange( - HttpRequest.create(HttpMethod.GET, "/v1/blueprints/" + it.getId() + "/versions/" + versionProvider.getVersion() + "/flow"), + HttpRequest.create(HttpMethod.GET, "/v1/blueprints/kinds/flow/" + it.getId() + "/versions/" + versionProvider.getVersion() + "/source"), Argument.STRING )).mapNotNull(response -> { String body = response.body(); diff --git a/webserver/src/test/java/io/kestra/webserver/controllers/api/BlueprintControllerTest.java b/webserver/src/test/java/io/kestra/webserver/controllers/api/BlueprintControllerTest.java index c9743964068..55f8d1293c7 100644 --- a/webserver/src/test/java/io/kestra/webserver/controllers/api/BlueprintControllerTest.java +++ b/webserver/src/test/java/io/kestra/webserver/controllers/api/BlueprintControllerTest.java @@ -3,9 +3,7 @@ import com.github.tomakehurst.wiremock.client.WireMock; import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo; import com.github.tomakehurst.wiremock.junit5.WireMockTest; -import io.kestra.core.repositories.ArrayListTotal; import io.kestra.core.utils.VersionProvider; -import io.kestra.webserver.controllers.api.BlueprintController; import io.kestra.webserver.responses.PagedResults; import io.micronaut.core.type.Argument; import io.micronaut.http.HttpRequest; @@ -26,6 +24,20 @@ @KestraTest @WireMockTest(httpPort = 28181) class BlueprintControllerTest { + + // GET "/v1/blueprints/kinds/{kind}/versions/{version}" + private static final String API_BLUEPRINT_SEARCH_KIND_FLOW = "/v1/blueprints/kinds/%s/versions/%s"; + // GET "/v1/blueprints/kinds/{kind}/{id}/versions/{version}" + private static final String API_BLUEPRINT_GET = "/v1/blueprints/kinds/%s/%s/versions/%s"; + // GET "/v1/blueprints/kinds/{kind}/{id}/versions/{version}/source" + private static final String API_BLUEPRINT_GET_SOURCE = API_BLUEPRINT_GET + "/source"; + // GET "/v1/blueprints/kinds/{kind}/{id}/versions/{version}/graph" + private static final String API_BLUEPRINT_GET_GRAPH = API_BLUEPRINT_GET + "/graph"; + // GET "/v1/blueprints/kinds/{kind}/{id}/versions/{version}/graph" + private static final String API_BLUEPRINT_GET_TAGS = "/v1/blueprints/kinds/%s/versions/%s/tags?q=%s"; + private static final String KIND_FLOW = BlueprintController.Kind.FLOW.val(); + public static final String API_V1_BLUEPRINT_COMMUNITY_FLOW_PATH = "/api/v1/blueprints/community/flow"; + @Inject @Client("/") HttpClient client; @@ -35,20 +47,20 @@ class BlueprintControllerTest { @SuppressWarnings("unchecked") @Test - void blueprints(WireMockRuntimeInfo wmRuntimeInfo) { + void shouldFindBlueprints(WireMockRuntimeInfo wmRuntimeInfo) { stubFor(get(urlMatching("/v1/blueprints.*")) .willReturn(aResponse() .withHeader("Content-Type", "application/json") .withBodyFile("blueprints.json")) ); - PagedResults blueprintsWithTotal = client.toBlocking().retrieve( - HttpRequest.GET("/api/v1/blueprints/community?page=1&size=5&q=someTitle&sort=title:asc&tags=3"), - Argument.of(PagedResults.class, BlueprintController.BlueprintItem.class) + PagedResults blueprintsWithTotal = client.toBlocking().retrieve( + HttpRequest.GET(API_V1_BLUEPRINT_COMMUNITY_FLOW_PATH + "?page=1&size=5&q=someTitle&sort=title:asc&tags=3"), + Argument.of(PagedResults.class, BlueprintController.ApiBlueprintItem.class) ); assertThat(blueprintsWithTotal.getTotal(), is(2L)); - List blueprints = blueprintsWithTotal.getResults(); + List blueprints = blueprintsWithTotal.getResults(); assertThat(blueprints.size(), is(2)); assertThat(blueprints.getFirst().getId(), is("1")); assertThat(blueprints.getFirst().getTitle(), is("GCS Trigger")); @@ -59,39 +71,39 @@ void blueprints(WireMockRuntimeInfo wmRuntimeInfo) { assertThat(blueprints.get(1).getId(), is("2")); WireMock wireMock = wmRuntimeInfo.getWireMock(); - wireMock.verifyThat(getRequestedFor(urlEqualTo("/v1/blueprints/versions/" + versionProvider.getVersion() + "?page=1&size=5&q=someTitle&sort=title%3Aasc&tags=3&ee=false"))); + wireMock.verifyThat(getRequestedFor(urlEqualTo(String.format(API_BLUEPRINT_SEARCH_KIND_FLOW , KIND_FLOW, versionProvider.getVersion()) + "?page=1&size=5&q=someTitle&sort=title%3Aasc&tags=3&ee=false"))); } @Test - void blueprintFlow(WireMockRuntimeInfo wmRuntimeInfo) { - stubFor(get(urlMatching("/v1/blueprints/id_1/.*/flow.*")) + void shouldGetSourceForExistingBlueprint(WireMockRuntimeInfo wmRuntimeInfo) { + stubFor(get(urlMatching("/v1/blueprints/kinds/.*/id_1/.*/source.*")) .willReturn(aResponse() .withHeader("Content-Type", "application/json") .withBodyFile("blueprint-flow.yaml")) ); String blueprintFlow = client.toBlocking().retrieve( - HttpRequest.GET("/api/v1/blueprints/community/id_1/flow"), + HttpRequest.GET(API_V1_BLUEPRINT_COMMUNITY_FLOW_PATH + "/id_1/source"), String.class ); assertThat(blueprintFlow, not(emptyOrNullString())); WireMock wireMock = wmRuntimeInfo.getWireMock(); - wireMock.verifyThat(getRequestedFor(urlEqualTo("/v1/blueprints/id_1/versions/" + versionProvider.getVersion() + "/flow"))); + wireMock.verifyThat(getRequestedFor(urlEqualTo(String.format(API_BLUEPRINT_GET_SOURCE, KIND_FLOW, "id_1", versionProvider.getVersion())))); } @SuppressWarnings("unchecked") @Test - void blueprintGraph(WireMockRuntimeInfo wmRuntimeInfo) { - stubFor(get(urlMatching("/v1/blueprints/id_1/.*/graph.*")) + void shouldGetGraphForExistingBlueprint(WireMockRuntimeInfo wmRuntimeInfo) { + stubFor(get(urlMatching("/v1/blueprints/kinds/.*/id_1/.*/graph.*")) .willReturn(aResponse() .withHeader("Content-Type", "application/json") .withBodyFile("blueprint-graph.json")) ); Map graph = client.toBlocking().retrieve( - HttpRequest.GET("/api/v1/blueprints/community/id_1/graph"), + HttpRequest.GET(API_V1_BLUEPRINT_COMMUNITY_FLOW_PATH + "/id_1/graph"), Argument.mapOf(String.class, Object.class) ); @@ -104,46 +116,46 @@ void blueprintGraph(WireMockRuntimeInfo wmRuntimeInfo) { assertThat(clusters.size(), is(1)); WireMock wireMock = wmRuntimeInfo.getWireMock(); - wireMock.verifyThat(getRequestedFor(urlEqualTo("/v1/blueprints/id_1/versions/" + versionProvider.getVersion() + "/graph"))); + wireMock.verifyThat(getRequestedFor(urlEqualTo(String.format(API_BLUEPRINT_GET_GRAPH, KIND_FLOW, "id_1", versionProvider.getVersion())))); } @Test - void blueprint(WireMockRuntimeInfo wmRuntimeInfo) { - stubFor(get(urlMatching("/v1/blueprints/id_1.*")) + void shouldGetDetailsForExistingBlueprint(WireMockRuntimeInfo wmRuntimeInfo) { + stubFor(get(urlMatching("/v1/blueprints/kinds/.*/id_1.*")) .willReturn(aResponse() .withHeader("Content-Type", "application/json") .withBodyFile("blueprint.json")) ); - BlueprintController.BlueprintItemWithFlow blueprint = client.toBlocking().retrieve( - HttpRequest.GET("/api/v1/blueprints/community/id_1"), - BlueprintController.BlueprintItemWithFlow.class + BlueprintController.ApiBlueprintItemWithSource blueprint = client.toBlocking().retrieve( + HttpRequest.GET(API_V1_BLUEPRINT_COMMUNITY_FLOW_PATH + "/id_1"), + BlueprintController.ApiBlueprintItemWithSource.class ); assertThat(blueprint.getId(), is("1")); assertThat(blueprint.getTitle(), is("GCS Trigger")); assertThat(blueprint.getDescription(), is("GCS trigger flow")); - assertThat(blueprint.getFlow(), not(emptyOrNullString())); + assertThat(blueprint.getSource(), not(emptyOrNullString())); assertThat(blueprint.getPublishedAt(), is(Instant.parse("2023-06-01T08:37:34.661Z"))); assertThat(blueprint.getTags().size(), is(2)); assertThat(blueprint.getTags(), contains("3", "2")); WireMock wireMock = wmRuntimeInfo.getWireMock(); - wireMock.verifyThat(getRequestedFor(urlEqualTo("/v1/blueprints/id_1/versions/" + versionProvider.getVersion()))); + wireMock.verifyThat(getRequestedFor(urlEqualTo(String.format(API_BLUEPRINT_GET, KIND_FLOW, "id_1", versionProvider.getVersion())))); } @SuppressWarnings("unchecked") @Test - void blueprintTags(WireMockRuntimeInfo wmRuntimeInfo) { + void shouldGetTags(WireMockRuntimeInfo wmRuntimeInfo) { stubFor(get(urlMatching("/v1/blueprints/.*/tags.*")) .willReturn(aResponse() .withHeader("Content-Type", "application/json") .withBodyFile("blueprint-tags.json")) ); - List blueprintTags = client.toBlocking().retrieve( - HttpRequest.GET("/api/v1/blueprints/community/tags?q=someQuery"), - Argument.of(List.class, BlueprintController.BlueprintTagItem.class) + List blueprintTags = client.toBlocking().retrieve( + HttpRequest.GET(API_V1_BLUEPRINT_COMMUNITY_FLOW_PATH + "/tags?q=someQuery"), + Argument.of(List.class, BlueprintController.ApiBlueprintTagItem.class) ); assertThat(blueprintTags.size(), is(3)); @@ -152,6 +164,6 @@ void blueprintTags(WireMockRuntimeInfo wmRuntimeInfo) { assertThat(blueprintTags.getFirst().getPublishedAt(), is(Instant.parse("2023-06-01T08:37:10.171Z"))); WireMock wireMock = wmRuntimeInfo.getWireMock(); - wireMock.verifyThat(getRequestedFor(urlEqualTo("/v1/blueprints/versions/" + versionProvider.getVersion() + "/tags?q=someQuery"))); + wireMock.verifyThat(getRequestedFor(urlEqualTo(String.format(API_BLUEPRINT_GET_TAGS, KIND_FLOW, versionProvider.getVersion(), "someQuery")))); } } diff --git a/webserver/src/test/resources/__files/blueprint.json b/webserver/src/test/resources/__files/blueprint.json index 5fab3e2118c..2e09e020e3b 100644 --- a/webserver/src/test/resources/__files/blueprint.json +++ b/webserver/src/test/resources/__files/blueprint.json @@ -2,7 +2,8 @@ "id": "1", "title": "GCS Trigger", "description": "GCS trigger flow", - "flow": "id: gcs-wait-for-file-trigger-subflow\nnamespace: io.kestra.demo.google\n\ndescription: |\n This flow will wait for a file on a gcs bucket.\n\n We check for new file every minute and once detected, we move it to a subfolder.\n\n For each file found, we trigger a subflow that will handle process the downloaded files.\n\n You can generate a file on the bucket using the flow `extract-bigquery-table-to-gcs`\ntasks:\n - id: each\n type: io.kestra.plugin.core.flow.EachParallel\n tasks:\n - id: subflow-etl-flow\n type: io.kestra.plugin.core.flow.Flow\n namespace: io.kestra.demo.google\n inputs:\n file: \"{{ taskrun.value | jq ('.uri') | first }}\"\n filename: \"{{ taskrun.value | jq ('.name') | first }}\"\n flowId: subflow-etl-flow\n wait: true\n concurrent: 8\n value: \"{{ trigger.blobs }}\"\ntriggers:\n - id: watch\n type: io.kestra.plugin.gcp.gcs.Trigger\n action: MOVE\n from: gs://demo-kestra-prd/demo-extract/\n moveDirectory: gs://demo-kestra-prd/archive/demo-extract/", + "source": "id: gcs-wait-for-file-trigger-subflow\nnamespace: io.kestra.demo.google\n\ndescription: |\n This flow will wait for a file on a gcs bucket.\n\n We check for new file every minute and once detected, we move it to a subfolder.\n\n For each file found, we trigger a subflow that will handle process the downloaded files.\n\n You can generate a file on the bucket using the flow `extract-bigquery-table-to-gcs`\ntasks:\n - id: each\n type: io.kestra.plugin.core.flow.EachParallel\n tasks:\n - id: subflow-etl-flow\n type: io.kestra.plugin.core.flow.Flow\n namespace: io.kestra.demo.google\n inputs:\n file: \"{{ taskrun.value | jq ('.uri') | first }}\"\n filename: \"{{ taskrun.value | jq ('.name') | first }}\"\n flowId: subflow-etl-flow\n wait: true\n concurrent: 8\n value: \"{{ trigger.blobs }}\"\ntriggers:\n - id: watch\n type: io.kestra.plugin.gcp.gcs.Trigger\n action: MOVE\n from: gs://demo-kestra-prd/demo-extract/\n moveDirectory: gs://demo-kestra-prd/archive/demo-extract/", + "kind": "FLOW", "includedTasks": [ "io.kestra.plugin.core.flow.EachParallel", "io.kestra.plugin.core.flow.Flow",