All Downloads are FREE. Search and download functionalities are using the official Maven repository.

ca.ibodrov.mica.server.api.ViewResource Maven / Gradle / Ivy

There is a newer version: 0.0.25
Show newest version
package ca.ibodrov.mica.server.api;

import ca.ibodrov.mica.api.model.*;
import ca.ibodrov.mica.db.MicaDB;
import ca.ibodrov.mica.server.data.*;
import ca.ibodrov.mica.server.data.ViewRenderer.RenderOverrides;
import ca.ibodrov.mica.server.exceptions.ApiException;
import ca.ibodrov.mica.server.exceptions.StoreException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.IntNode;
import com.fasterxml.jackson.databind.node.NullNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.collect.ImmutableMap;
import com.walmartlabs.concord.server.sdk.rest.Resource;
import com.walmartlabs.concord.server.sdk.validation.Validate;
import com.walmartlabs.concord.server.security.UserPrincipal;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.jooq.DSLContext;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.inject.Inject;
import javax.validation.Valid;
import javax.ws.rs.*;
import javax.ws.rs.core.Context;
import java.net.URI;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.regex.Pattern;
import java.util.regex.PatternSyntaxException;
import java.util.stream.Stream;

import static ca.ibodrov.mica.server.data.BuiltinSchemas.INTERNAL_ENTITY_STORE_URI;
import static java.util.Objects.requireNonNull;
import static javax.ws.rs.core.MediaType.APPLICATION_JSON;
import static javax.ws.rs.core.MediaType.TEXT_PLAIN;

@Tag(name = "View")
@Path("/api/mica/v1/view")
@Produces(APPLICATION_JSON)
public class ViewResource implements Resource {

    private static final Logger log = LoggerFactory.getLogger(ViewResource.class);

    private static final String RESULT_ENTITY_KIND = "/mica/rendered-view/v1";

    private final DSLContext dsl;
    private final EntityStore entityStore;
    private final EntityKindStore entityKindStore;
    private final Set includeFetchers;
    private final ObjectMapper objectMapper;
    private final ViewInterpolator viewInterpolator;
    private final ViewRenderer viewRenderer;
    private final Validator validator;

    @Inject
    public ViewResource(@MicaDB DSLContext dsl,
                        EntityStore entityStore,
                        EntityKindStore entityKindStore,
                        Set includeFetchers,
                        ObjectMapper objectMapper) {

        this.dsl = requireNonNull(dsl);
        this.entityStore = requireNonNull(entityStore);
        this.entityKindStore = requireNonNull(entityKindStore);
        this.includeFetchers = requireNonNull(includeFetchers);
        this.objectMapper = requireNonNull(objectMapper);
        var schemaFetcher = new EntityKindStoreSchemaFetcher(entityKindStore, objectMapper);
        this.viewInterpolator = new ViewInterpolator(objectMapper, schemaFetcher);
        this.viewRenderer = new ViewRenderer(objectMapper);
        this.validator = Validator.getDefault(objectMapper, schemaFetcher);
    }

    @POST
    @Path("render")
    @Consumes(APPLICATION_JSON)
    @Operation(summary = "Render a view", operationId = "render")
    @Validate
    public PartialEntity render(@Valid RenderRequest request) {
        var parameters = request.parameters().orElseGet(NullNode::getInstance);
        var view = interpolateView(assertViewEntity(request), parameters);
        return renderViewAsEntity(view, request.limit());
    }

    @POST
    @Path("renderProperties")
    @Consumes(APPLICATION_JSON)
    @Produces(TEXT_PLAIN)
    @Operation(summary = "Render a view into a .properties file", operationId = "renderProperties")
    @Validate
    public String renderProperties(@Valid RenderRequest request) {
        var parameters = request.parameters().orElseGet(NullNode::getInstance);
        var view = interpolateView(assertViewEntity(request), parameters);
        var entities = select(view, request.limit());

        var renderedView = viewRenderer.render(view, RenderOverrides.merged(), entities);
        if (renderedView.data().size() != 1) {
            throw ApiException.badRequest("Expected a view flattened down to a single entity, got "
                    + renderedView.data().size() + " entities");
        }

        var validation = validateRenderedView(view, renderedView);
        if (validation.isPresent() && !validation.get().isEmpty()) {
            throw ApiException.badRequest("Validation failed: " + validation.get());
        }

        var properties = formatAsProperties((ObjectNode) renderedView.data().get(0));
        return properties.entrySet().stream()
                .map(e -> e.getKey() + "=" + e.getValue())
                .sorted()
                .reduce((a, b) -> a + "\n" + b)
                .orElse("# empty") + "\n";
    }

    @GET
    @Path("render/{viewId}")
    @Operation(summary = "Render a simple view (without parameters)", operationId = "renderSimple")
    @Validate
    public PartialEntity renderSimple(@PathParam("viewId") EntityId viewId,
                                      @QueryParam("limit") @DefaultValue("-1") int limit) {

        return render(new RenderRequest(Optional.of(viewId), Optional.empty(), limit, Optional.empty()));
    }

    @POST
    @Path("/preview")
    @Consumes(APPLICATION_JSON)
    @Operation(summary = "Preview a view", operationId = "preview")
    @Validate
    public PartialEntity preview(@Valid PreviewRequest request) {
        var parameters = request.parameters().orElseGet(NullNode::getInstance);
        var viewEntity = validate(request.view());
        var view = interpolateView(viewEntity, parameters);
        return renderViewAsEntity(view, request.limit());
    }

    @POST
    @Path("/materialize")
    @Consumes(APPLICATION_JSON)
    @Operation(summary = "Materialize a view", description = "Render a view and save the result as entities", operationId = "materialize")
    @Validate
    public PartialEntity materialize(@Context UserPrincipal session, @Valid RenderRequest request) {
        var parameters = request.parameters().orElseGet(NullNode::getInstance);
        var view = interpolateView(assertViewEntity(request), parameters);
        var entities = select(view, request.limit());
        var renderedView = viewRenderer.render(view, entities);
        // TODO validation
        // TODO optimistic locking
        return dsl.transactionResult(tx -> {
            var data = renderedView.data().stream().map(row -> {
                var entity = objectMapper.convertValue(row, PartialEntity.class);
                var version = entityStore.upsert(tx.dsl(), session, entity)
                        .orElseThrow(() -> ApiException.conflict("Version conflict: " + entity.name()));
                return entity.withVersion(version);
            });
            return buildEntity(view.name(),
                    objectMapper.convertValue(data, JsonNode.class),
                    objectMapper.convertValue(renderedView.entityNames(), JsonNode.class),
                    Optional.empty());
        });
    }

    private PartialEntity validate(PartialEntity entity) {
        var schema = entityKindStore.getSchemaForKind(entity.kind())
                .orElseThrow(() -> ApiException
                        .badRequest("Can't validate the entity, schema not found: " + entity.kind()));
        var input = objectMapper.convertValue(entity, JsonNode.class);
        var validatedInput = validator.validateObject(schema, input);
        if (!validatedInput.isValid()) {
            throw validatedInput.toException();
        }
        return entity;
    }

    private ViewLike interpolateView(EntityLike viewEntity, JsonNode parameters) {
        var view = BuiltinSchemas.asViewLike(objectMapper, viewEntity);
        return viewInterpolator.interpolate(view, parameters);
    }

    private PartialEntity renderViewAsEntity(ViewLike view, int limit) {
        var entities = select(view, limit);
        var renderedView = viewRenderer.render(view, entities);
        var validation = validateRenderedView(view, renderedView);
        return buildEntity(view.name(),
                objectMapper.convertValue(renderedView.data(), JsonNode.class),
                objectMapper.convertValue(renderedView.entityNames(), JsonNode.class),
                validation);
    }

    /**
     * Applies the view's {@code selector} by fetching entities from the specified
     * includes, filtering them by the entity kind and name patterns, and returning
     * the result.
     */
    private Stream select(ViewLike view, int limit) {
        var includes = view.selector().includes().orElse(List.of(INTERNAL_ENTITY_STORE_URI));

        // grab all entities matching the selector's entity kind
        var entities = includes.stream()
                .filter(include -> include != null && !include.isBlank())
                .map(ViewResource::parseUri)
                .flatMap(uri -> fetch(uri, view.selector().entityKind()))
                .toList();

        // TODO filter out invalid entities?

        if (entities.isEmpty()) {
            return Stream.empty();
        }

        var result = entities.stream();
        // if namePatterns are specified, filter the entities and return them in the
        // order of the patterns
        if (view.selector().namePatterns().isPresent()) {
            var patterns = view.selector().namePatterns().get();

            result = Stream.empty();
            for (var regex : patterns) {
                Pattern pattern;
                try {
                    pattern = Pattern.compile(regex);
                } catch (PatternSyntaxException e) {
                    throw ApiException.badRequest("Invalid namePatterns pattern: " + regex
                            + view.parameters().map(p -> " (invalid parameters?)").orElse(""));
                }

                result = Stream.concat(result, entities.stream()
                        .filter(e -> e.name() != null)
                        .filter(e -> pattern.matcher(e.name()).matches()));
            }
        }

        if (limit > 0) {
            result = result.limit(limit);
        }

        return result;
    }

    private Stream fetch(URI uri, String entityKind) {
        var fetcher = includeFetchers.stream()
                .filter(f -> f.isSupported(uri))
                .findAny()
                .orElseThrow(() -> ApiException.badRequest("Unsupported URI in \"includes\": " + uri));

        try {
            return fetcher.getAllByKind(uri, entityKind, -1).stream();
        } catch (StoreException e) {
            log.warn("Error while fetching {} entities: {}", uri.getScheme(), e.getMessage());
            throw ApiException.internalError(e.getMessage());
        }
    }

    private Optional validateRenderedView(ViewLike view, RenderedView renderedView) {
        return view.validation().map(v -> {
            var schema = entityKindStore.getSchemaForKind(v.asEntityKind())
                    .orElseThrow(() -> ApiException
                            .badRequest("Can't validate the view, schema not found: " + v.asEntityKind()));
            var validatedEntities = renderedView.data().stream()
                    .map(row -> validator.validateObject(schema, row))
                    .toList();
            return objectMapper.convertValue(validatedEntities, JsonNode.class);
        });
    }

    private EntityLike assertViewEntity(@Valid RenderRequest request) {
        if (request.viewId().isPresent()) {
            return entityStore.getById(request.viewId().get())
                    .orElseThrow(() -> ApiException.notFound("View not found: " + request.viewId().get()));
        }

        if (request.viewName().isPresent()) {
            return entityStore.getByName(request.viewName().get())
                    .orElseThrow(() -> ApiException.notFound("View not found: " + request.viewName().get()));
        }

        throw ApiException.badRequest("viewId or viewName is required");
    }

    private static PartialEntity buildEntity(String name,
                                             JsonNode data,
                                             JsonNode entityNames,
                                             Optional validation) {
        var entityData = ImmutableMap.builder();
        entityData.put("data", data);
        entityData.put("length", IntNode.valueOf(data.size()));
        entityData.put("entityNames", entityNames);
        validation.ifPresent(v -> entityData.put("validation", v));
        return PartialEntity.create(name, RESULT_ENTITY_KIND, entityData.build());
    }

    private static URI parseUri(String s) {
        try {
            return URI.create(s);
        } catch (IllegalArgumentException e) {
            throw ApiException.badRequest("Invalid URI: " + s);
        }
    }

    @VisibleForTesting
    static Map formatAsProperties(ObjectNode node) {
        var builder = ImmutableMap.builder();
        putProperty(builder, node, "");
        return builder.build();
    }

    private static void putProperty(ImmutableMap.Builder builder, ObjectNode node, String path) {
        node.fields().forEachRemaining(e -> {
            var k = e.getKey();
            var v = e.getValue();
            switch (v.getNodeType()) {
                case OBJECT -> putProperty(builder, (ObjectNode) v, k + ".");
                case NULL -> {
                    // skip
                }
                default -> builder.put(path + k, v.asText());
            }
        });
    }
}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy