diff --git a/application/pom.xml b/application/pom.xml
index 819af507..1f5dac81 100644
--- a/application/pom.xml
+++ b/application/pom.xml
@@ -205,7 +205,7 @@
it.smartcommunitylabdhub
dh-runtime-base
${revision}
-
+
it.smartcommunitylabdhub
dh-framework-k8s
@@ -313,6 +313,27 @@
+
+ maven-resources-plugin
+
+
+ templates package
+
+ copy-resources
+
+ generate-sources
+
+ ${project.build.outputDirectory}/templates
+
+
+ ${project.basedir}/../templates
+ false
+
+
+
+
+
+
org.springframework.boot
spring-boot-maven-plugin
diff --git a/application/src/main/java/it/smartcommunitylabdhub/core/controllers/v1/base/TemplateController.java b/application/src/main/java/it/smartcommunitylabdhub/core/controllers/v1/base/TemplateController.java
new file mode 100644
index 00000000..952bad6c
--- /dev/null
+++ b/application/src/main/java/it/smartcommunitylabdhub/core/controllers/v1/base/TemplateController.java
@@ -0,0 +1,69 @@
+package it.smartcommunitylabdhub.core.controllers.v1.base;
+
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import it.smartcommunitylabdhub.commons.models.template.Template;
+import it.smartcommunitylabdhub.core.ApplicationKeys;
+import it.smartcommunitylabdhub.core.annotations.ApiVersion;
+import it.smartcommunitylabdhub.core.models.queries.filters.abstracts.TemplateFilter;
+import it.smartcommunitylabdhub.core.models.queries.services.SearchableTemplateService;
+import jakarta.annotation.Nullable;
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.NotNull;
+import org.springdoc.core.annotations.ParameterObject;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.domain.Sort.Direction;
+import org.springframework.data.web.PageableDefault;
+import org.springframework.data.web.SortDefault;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@ApiVersion("v1")
+@RequestMapping("/templates")
+//TODO evaluate permissions for project via lookup in dto
+@PreAuthorize("hasAuthority('ROLE_USER')")
+@Validated
+@Tag(name = "Template base API", description = "Endpoints related to entity templates management")
+public class TemplateController {
+
+ @Autowired
+ SearchableTemplateService templateService;
+
+ @Operation(summary = "List templates", description = "Return a list of all templates")
+ @GetMapping(path = "", produces = "application/json; charset=UTF-8")
+ public Page listTemplates(
+ @ParameterObject @Valid @Nullable TemplateFilter filter,
+ @ParameterObject @PageableDefault(page = 0, size = ApplicationKeys.DEFAULT_PAGE_SIZE) @SortDefault.SortDefaults(
+ { @SortDefault(sort = "id", direction = Direction.ASC) }
+ ) Pageable pageable
+ ) {
+ if (filter == null) filter = new TemplateFilter();
+ return templateService.searchTemplates(pageable, filter);
+ }
+
+ @Operation(summary = "List entity's templates", description = "Return a list of all entity's templates")
+ @GetMapping(path = "/{entity}", produces = "application/json; charset=UTF-8")
+ public Page getTemplates(
+ @PathVariable @NotNull String entity,
+ @ParameterObject @Valid @Nullable TemplateFilter filter,
+ @ParameterObject @PageableDefault(page = 0, size = ApplicationKeys.DEFAULT_PAGE_SIZE) @SortDefault.SortDefaults(
+ { @SortDefault(sort = "id", direction = Direction.ASC) }
+ ) Pageable pageable
+ ) {
+ if (filter == null) filter = new TemplateFilter();
+ return templateService.searchTemplates(pageable, entity, filter);
+ }
+
+ @Operation(summary = "Get specific template", description = "Return a specific template")
+ @GetMapping(path = "/{entity}/{id}", produces = "application/json; charset=UTF-8")
+ public Template getOne(@PathVariable @NotNull String entity, @PathVariable @NotNull String id) {
+ return templateService.getTemplate(entity, id);
+ }
+}
diff --git a/application/src/main/java/it/smartcommunitylabdhub/core/models/queries/filters/abstracts/TemplateFilter.java b/application/src/main/java/it/smartcommunitylabdhub/core/models/queries/filters/abstracts/TemplateFilter.java
new file mode 100644
index 00000000..011e4a90
--- /dev/null
+++ b/application/src/main/java/it/smartcommunitylabdhub/core/models/queries/filters/abstracts/TemplateFilter.java
@@ -0,0 +1,37 @@
+package it.smartcommunitylabdhub.core.models.queries.filters.abstracts;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import it.smartcommunitylabdhub.commons.Keys;
+import jakarta.annotation.Nullable;
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.Pattern;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+@Getter
+@Setter
+@AllArgsConstructor
+@NoArgsConstructor
+@Valid
+public class TemplateFilter {
+
+ @Nullable
+ protected String q;
+
+ @Nullable
+ @Pattern(regexp = Keys.SLUG_PATTERN)
+ @Schema(example = "my-function-1", defaultValue = "", description = "Name identifier")
+ protected String name;
+
+ @Nullable
+ @Pattern(regexp = Keys.SLUG_PATTERN)
+ @Schema(example = "type", defaultValue = "", description = "Type identifier")
+ protected String type;
+
+ @Nullable
+ @Pattern(regexp = Keys.SLUG_PATTERN)
+ @Schema(example = "function", defaultValue = "", description = "Kind identifier")
+ protected String kind;
+}
diff --git a/application/src/main/java/it/smartcommunitylabdhub/core/models/queries/services/SearchableTemplateService.java b/application/src/main/java/it/smartcommunitylabdhub/core/models/queries/services/SearchableTemplateService.java
new file mode 100644
index 00000000..1b5108ad
--- /dev/null
+++ b/application/src/main/java/it/smartcommunitylabdhub/core/models/queries/services/SearchableTemplateService.java
@@ -0,0 +1,21 @@
+package it.smartcommunitylabdhub.core.models.queries.services;
+
+import it.smartcommunitylabdhub.commons.exceptions.SystemException;
+import it.smartcommunitylabdhub.commons.models.template.Template;
+import it.smartcommunitylabdhub.core.models.queries.filters.abstracts.TemplateFilter;
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.NotNull;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+
+/*
+ * Searchable service for managing function
+ */
+public interface SearchableTemplateService {
+ Page searchTemplates(Pageable pageable, @Valid TemplateFilter filter) throws SystemException;
+
+ Page searchTemplates(Pageable pageable, @NotNull String type, @Valid TemplateFilter filter)
+ throws SystemException;
+
+ Template getTemplate(@NotNull String type, @NotNull String id) throws SystemException;
+}
diff --git a/application/src/main/java/it/smartcommunitylabdhub/core/services/TemplateServiceImpl.java b/application/src/main/java/it/smartcommunitylabdhub/core/services/TemplateServiceImpl.java
new file mode 100644
index 00000000..389adfdc
--- /dev/null
+++ b/application/src/main/java/it/smartcommunitylabdhub/core/services/TemplateServiceImpl.java
@@ -0,0 +1,259 @@
+package it.smartcommunitylabdhub.core.services;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.google.common.cache.CacheBuilder;
+import com.google.common.cache.CacheLoader;
+import com.google.common.cache.LoadingCache;
+import it.smartcommunitylabdhub.commons.exceptions.SystemException;
+import it.smartcommunitylabdhub.commons.jackson.JacksonMapper;
+import it.smartcommunitylabdhub.commons.models.entities.EntityName;
+import it.smartcommunitylabdhub.commons.models.metadata.BaseMetadata;
+import it.smartcommunitylabdhub.commons.models.specs.Spec;
+import it.smartcommunitylabdhub.commons.models.template.Template;
+import it.smartcommunitylabdhub.commons.services.SpecRegistry;
+import it.smartcommunitylabdhub.core.components.infrastructure.specs.SpecValidator;
+import it.smartcommunitylabdhub.core.models.queries.filters.abstracts.TemplateFilter;
+import it.smartcommunitylabdhub.core.models.queries.services.SearchableTemplateService;
+import it.smartcommunitylabdhub.core.utils.UUIDKeyGenerator;
+import jakarta.validation.constraints.NotNull;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+import java.util.stream.Collectors;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.InitializingBean;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.core.io.Resource;
+import org.springframework.core.io.ResourceLoader;
+import org.springframework.core.io.support.ResourcePatternResolver;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.PageImpl;
+import org.springframework.data.domain.Pageable;
+import org.springframework.security.crypto.keygen.StringKeyGenerator;
+import org.springframework.stereotype.Service;
+import org.springframework.util.StringUtils;
+import org.springframework.web.bind.MethodArgumentNotValidException;
+
+@Service
+@Slf4j
+public class TemplateServiceImpl implements SearchableTemplateService, InitializingBean {
+
+ private static final int CACHE_TIMEOUT = 60;
+ private static final ObjectMapper mapper = JacksonMapper.YAML_OBJECT_MAPPER;
+ private StringKeyGenerator keyGenerator = new UUIDKeyGenerator();
+
+ @Autowired
+ ResourceLoader resourceLoader;
+
+ @Autowired
+ ResourcePatternResolver resourceResolver;
+
+ @Autowired
+ private SpecRegistry specRegistry;
+
+ @Autowired
+ private SpecValidator validator;
+
+ @Value("${templates.path}")
+ private String templatesPath;
+
+ //loading cache as map type+list
+ LoadingCache> templateCache = CacheBuilder
+ .newBuilder()
+ .expireAfterWrite(CACHE_TIMEOUT, TimeUnit.MINUTES)
+ .build(
+ new CacheLoader>() {
+ @Override
+ public List load(String key) throws Exception {
+ log.debug("reload templates for {} from {}", key);
+ return readTemplates(templatesPath, key);
+ }
+ }
+ );
+
+ @Override
+ public void afterPropertiesSet() throws Exception {
+ log.debug("Initialize template store for all entities...");
+ for (EntityName name : EntityName.values()) {
+ String type = name.name().toLowerCase();
+ List list = templateCache.get(type);
+ log.debug("Initialized {} templates for {}", list.size(), type);
+ }
+ }
+
+ private List readTemplates(String path, String key) {
+ if (!StringUtils.hasText(path) || !StringUtils.hasText(key)) {
+ return Collections.emptyList();
+ }
+
+ List result = new ArrayList<>();
+
+ try {
+ //we will read from a file-like resource where templates are under //*.yaml
+ String filePath = (path.endsWith("/") ? path : path + "/") + key;
+
+ log.debug("read template resources from {}", filePath);
+ Resource[] resources = resourceResolver.getResources(filePath + "/*.yaml");
+ for (Resource resource : resources) {
+ try {
+ //read via mapper
+ Template template = mapper.readValue(resource.getFile(), Template.class);
+ // force inject type
+ template.setType(key);
+ //generate id if missing
+ if (!StringUtils.hasText(template.getId())) {
+ template.setId(keyGenerator.generateKey());
+ }
+
+ //sanitize content
+ sanitize(template);
+
+ //discard invalid
+ validate(template);
+
+ result.add(template);
+ } catch (IllegalArgumentException | IOException e1) {
+ log.error("Error reading template from {}: {}", resource.getFilename(), e1.getMessage());
+ }
+ }
+
+ if (log.isTraceEnabled()) {
+ log.trace("templates: {}", result);
+ }
+ } catch (IOException e) {
+ log.debug("Error reading from {}:{}", path, key);
+ }
+
+ return result;
+ }
+
+ private void validate(Template template) {
+ //minimal validation: base fields + spec
+ if (!StringUtils.hasText(template.getName())) {
+ throw new IllegalArgumentException("invalid or missing name");
+ }
+ if (!StringUtils.hasText(template.getKind())) {
+ throw new IllegalArgumentException("invalid or missing kind");
+ }
+ if (template.getSpec() == null || template.getSpec().isEmpty()) {
+ throw new IllegalArgumentException("invalid or missing spec");
+ }
+
+ // Parse and validate Spec
+ Spec spec = specRegistry.createSpec(template.getKind(), template.getSpec());
+ if (spec == null) {
+ throw new IllegalArgumentException("invalid kind");
+ }
+
+ //validate
+ try {
+ validator.validateSpec(spec);
+ } catch (MethodArgumentNotValidException e) {
+ throw new IllegalArgumentException(e.getMessage());
+ }
+ }
+
+ private Template sanitize(Template template) {
+ //sanitize metadata to keep only base
+ //TODO evaluate supporting more fields
+ if (template.getMetadata() != null) {
+ BaseMetadata base = BaseMetadata.from(template.getMetadata());
+ BaseMetadata meta = new BaseMetadata();
+ meta.setName(base.getName());
+ meta.setDescription(base.getDescription());
+ meta.setLabels(base.getLabels());
+
+ template.setMetadata(meta.toMap());
+ }
+
+ // Parse and export Spec
+ Spec spec = specRegistry.createSpec(template.getKind(), template.getSpec());
+ if (spec == null) {
+ throw new IllegalArgumentException("invalid kind");
+ }
+
+ //update spec as exported
+ template.setSpec(spec.toMap());
+
+ return template;
+ }
+
+ private List filterTemplate(List list, Pageable pageable, TemplateFilter filter)
+ throws Exception {
+ return list
+ .stream()
+ .filter(f -> {
+ boolean isOk = true;
+ if (StringUtils.hasLength(filter.getName())) {
+ if (StringUtils.hasLength(f.getName())) {
+ isOk &= f.getName().toLowerCase().contains(filter.getName().toLowerCase());
+ } else {
+ isOk &= false;
+ }
+ }
+ if (StringUtils.hasLength(filter.getKind())) {
+ if (StringUtils.hasLength(f.getKind())) {
+ isOk &= f.getKind().toLowerCase().equals(filter.getKind().toLowerCase());
+ } else {
+ isOk &= false;
+ }
+ }
+ return isOk;
+ })
+ .collect(Collectors.toList());
+ }
+
+ @Override
+ public Page searchTemplates(Pageable pageable, TemplateFilter filter) throws SystemException {
+ try {
+ List all = new ArrayList<>();
+ //evaluate type filter first
+ if (filter != null && StringUtils.hasText(filter.getType())) {
+ //exact match
+ EntityName type = EntityName.valueOf(filter.getType().toUpperCase());
+ all = templateCache.get(type.name().toLowerCase());
+ } else {
+ //load all
+ for (EntityName type : EntityName.values()) {
+ all.addAll(templateCache.get(type.getValue().toLowerCase()));
+ }
+ }
+
+ List list = filterTemplate(all, pageable, filter);
+ int start = (int) pageable.getOffset();
+ int end = Math.min((start + pageable.getPageSize()), list.size());
+ List pageContent = list.subList(start, end);
+ return new PageImpl<>(pageContent, pageable, list.size());
+ } catch (Exception e) {
+ throw new SystemException("error retrieving templates:" + e.getMessage(), e);
+ }
+ }
+
+ @Override
+ public Page searchTemplates(Pageable pageable, @NotNull String type, TemplateFilter filter)
+ throws SystemException {
+ try {
+ List templates = templateCache.get(type);
+ List list = filterTemplate(templates, pageable, filter);
+ int start = (int) pageable.getOffset();
+ int end = Math.min((start + pageable.getPageSize()), list.size());
+ List pageContent = list.subList(start, end);
+ return new PageImpl<>(pageContent, pageable, list.size());
+ } catch (Exception e) {
+ throw new SystemException("error retrieving templates:" + e.getMessage(), e);
+ }
+ }
+
+ @Override
+ public Template getTemplate(@NotNull String type, @NotNull String id) throws SystemException {
+ try {
+ List list = templateCache.get(type);
+ return list.stream().filter(t -> t.getId().equals(id)).findFirst().orElse(null);
+ } catch (Exception e) {
+ throw new SystemException("error retrieving templates:" + e.getMessage(), e);
+ }
+ }
+}
diff --git a/application/src/main/resources/application.yml b/application/src/main/resources/application.yml
index 34fb676b..9e389a94 100644
--- a/application/src/main/resources/application.yml
+++ b/application/src/main/resources/application.yml
@@ -239,4 +239,9 @@ jwt:
duration: ${JWT_REFRESH_TOKEN_DURATION:}
client-id: ${JWT_CLIENT_ID:${security.basic.username}}
client-secret: ${JWT_CLIENT_SECRET:${security.basic.password}}
- cache-control: ${JWKS_CACHE_CONTROL:public, max-age=900, must-revalidate, no-transform}
\ No newline at end of file
+ cache-control: ${JWKS_CACHE_CONTROL:public, max-age=900, must-revalidate, no-transform}
+
+
+# Templates
+templates:
+ path: ${TEMPLATES_PATH:classpath:/templates}
\ No newline at end of file
diff --git a/modules/commons/src/main/java/it/smartcommunitylabdhub/commons/models/template/Template.java b/modules/commons/src/main/java/it/smartcommunitylabdhub/commons/models/template/Template.java
new file mode 100644
index 00000000..2e3737cf
--- /dev/null
+++ b/modules/commons/src/main/java/it/smartcommunitylabdhub/commons/models/template/Template.java
@@ -0,0 +1,74 @@
+package it.smartcommunitylabdhub.commons.models.template;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonPropertyOrder;
+import it.smartcommunitylabdhub.commons.Keys;
+import it.smartcommunitylabdhub.commons.models.base.BaseDTO;
+import it.smartcommunitylabdhub.commons.models.metadata.MetadataDTO;
+import it.smartcommunitylabdhub.commons.models.specs.SpecDTO;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Pattern;
+import java.io.Serializable;
+import java.util.HashMap;
+import java.util.Map;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+import lombok.ToString;
+import org.springframework.lang.Nullable;
+
+@AllArgsConstructor
+@NoArgsConstructor
+@Getter
+@Setter
+@Builder
+@ToString
+@JsonPropertyOrder(alphabetic = true)
+public class Template implements BaseDTO, MetadataDTO, SpecDTO {
+
+ @Nullable
+ @Pattern(regexp = Keys.SLUG_PATTERN)
+ private String id;
+
+ @NotNull
+ @Pattern(regexp = Keys.SLUG_PATTERN)
+ private String name;
+
+ @NotNull
+ @Pattern(regexp = Keys.SLUG_PATTERN)
+ private String kind;
+
+ private String type;
+
+ @Builder.Default
+ private Map metadata = new HashMap<>();
+
+ @Builder.Default
+ @JsonInclude(JsonInclude.Include.NON_NULL)
+ private Map spec = new HashMap<>();
+
+ @Override
+ public String getKey() {
+ StringBuilder sb = new StringBuilder();
+ sb.append(Keys.STORE_PREFIX).append(getProject());
+ sb.append(Keys.PATH_DIVIDER).append(type);
+ sb.append(Keys.PATH_DIVIDER).append(getKind());
+ sb.append(Keys.PATH_DIVIDER).append(getName());
+ if (getId() != null) {
+ sb.append(Keys.ID_DIVIDER).append(getId());
+ }
+ return sb.toString();
+ }
+
+ @Override
+ public String getProject() {
+ return null;
+ }
+
+ @Override
+ public String getUser() {
+ return null;
+ }
+}
diff --git a/templates/function/python-hello-world.yaml b/templates/function/python-hello-world.yaml
new file mode 100644
index 00000000..1613d9a1
--- /dev/null
+++ b/templates/function/python-hello-world.yaml
@@ -0,0 +1,13 @@
+kind: python
+metadata:
+ name: hello-world
+ description: "Hello world with python: write a string to stdout"
+ version: 3d64037407ee4c95989a205bb4674cad
+ labels: []
+name: hello-world
+spec:
+ python_version: PYTHON3_10
+ source:
+ lang: python
+ handler: main
+ base64: ZGVmIG1haW4oKToKICAgIHByaW50KCItLS0gSGVsbG8gd29ybGQgLS0tIikK