From 7d8126b5d934683469c16fd715f5dc11a6307e6b Mon Sep 17 00:00:00 2001 From: Tan Yee Jian Date: Mon, 17 Aug 2020 17:51:14 +0800 Subject: [PATCH] Add REST endpoints for Feast UI (#878) * Add RestController in Core with version method * Add exception handlers for missing URL parameters * Mark variables as optional * Make format-java * Make feature_set_id non-required per gRPC * Add Unit Tests for Core REST Controller * Add javadoc, remove json printer * Fix Javadocs Errors and typo * Modify Unit tests to not rely on Mockmvc * Add Integration Tests for CoreServiceRestController * Remove labels as it is not yet fully tested * Remove getFeatureSet REST endpoint Since it is a subset of the listFeatureSet endpoint. * Add integration tests for REST controller * Remove default query params for grpc consistency * Remove Unit Tests * Keep get feature-statistics consistent with grpc * Add comments on exception handlers for core http * Use WebTestClient for core HTTP ITs * Use reactor-test and remove autowired params * Use RestAssured for REST IT * Replace ProjectService w/ AccessManagementService * Handle retrieval errors caused by getFeatureStats * Add disableRestControllerAuth flag * Add documentation comment on application.yml * Fix integration test Co-authored-by: Terence --- .../feast/auth/config/SecurityProperties.java | 3 + core/pom.xml | 21 +- .../feast/core/config/WebSecurityConfig.java | 17 +- .../controller/CoreServiceRestController.java | 203 +++++++++++++ .../RestResponseEntityExceptionHandler.java | 115 ++++++++ core/src/main/resources/application.yml | 5 + .../core/controller/CoreServiceRestIT.java | 273 ++++++++++++++++++ .../src/test/resources/application.properties | 1 + 8 files changed, 635 insertions(+), 3 deletions(-) create mode 100644 core/src/main/java/feast/core/controller/CoreServiceRestController.java create mode 100644 core/src/main/java/feast/core/controller/exception/handler/RestResponseEntityExceptionHandler.java create mode 100644 core/src/test/java/feast/core/controller/CoreServiceRestIT.java diff --git a/auth/src/main/java/feast/auth/config/SecurityProperties.java b/auth/src/main/java/feast/auth/config/SecurityProperties.java index 11eb2f2c13..2cc90f750b 100644 --- a/auth/src/main/java/feast/auth/config/SecurityProperties.java +++ b/auth/src/main/java/feast/auth/config/SecurityProperties.java @@ -27,6 +27,9 @@ public class SecurityProperties { private AuthenticationProperties authentication; private AuthorizationProperties authorization; + // Bypass Authentication and Authorization at all HTTP endpoints at /api/v1 + private boolean disableRestControllerAuth; + @Getter @Setter public static class AuthenticationProperties { diff --git a/core/pom.xml b/core/pom.xml index 346010af5f..e7ddb1a26d 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -19,7 +19,6 @@ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd"> 4.0.0 - dev.feast feast-parent @@ -394,5 +393,23 @@ 1.6.6 provided - + + io.rest-assured + rest-assured + 4.2.0 + test + + + io.rest-assured + json-path + 4.2.0 + test + + + io.rest-assured + xml-path + 4.2.0 + test + + diff --git a/core/src/main/java/feast/core/config/WebSecurityConfig.java b/core/src/main/java/feast/core/config/WebSecurityConfig.java index c457cb08b3..858361a670 100644 --- a/core/src/main/java/feast/core/config/WebSecurityConfig.java +++ b/core/src/main/java/feast/core/config/WebSecurityConfig.java @@ -16,6 +16,9 @@ */ package feast.core.config; +import java.util.ArrayList; +import java.util.List; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; @@ -29,6 +32,13 @@ @EnableWebSecurity public class WebSecurityConfig extends WebSecurityConfigurerAdapter { + private final FeastProperties feastProperties; + + @Autowired + public WebSecurityConfig(FeastProperties feastProperties) { + this.feastProperties = feastProperties; + } + /** * Allows for custom web security rules to be applied. * @@ -37,10 +47,15 @@ public class WebSecurityConfig extends WebSecurityConfigurerAdapter { */ @Override protected void configure(HttpSecurity http) throws Exception { + List matchersToBypass = new ArrayList<>(List.of("/actuator/**", "/metrics/**")); + + if (feastProperties.securityProperties().isDisableRestControllerAuth()) { + matchersToBypass.add("/api/v1/**"); + } // Bypasses security/authentication for the following paths http.authorizeRequests() - .antMatchers("/actuator/**", "/metrics/**") + .antMatchers(matchersToBypass.toArray(new String[0])) .permitAll() .anyRequest() .authenticated() diff --git a/core/src/main/java/feast/core/controller/CoreServiceRestController.java b/core/src/main/java/feast/core/controller/CoreServiceRestController.java new file mode 100644 index 0000000000..2673ebffb8 --- /dev/null +++ b/core/src/main/java/feast/core/controller/CoreServiceRestController.java @@ -0,0 +1,203 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2019 The Feast Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package feast.core.controller; + +import com.google.protobuf.InvalidProtocolBufferException; +import com.google.protobuf.Timestamp; +import feast.core.config.FeastProperties; +import feast.core.model.Project; +import feast.core.service.ProjectService; +import feast.core.service.SpecService; +import feast.core.service.StatsService; +import feast.proto.core.CoreServiceProto.GetFeastCoreVersionResponse; +import feast.proto.core.CoreServiceProto.GetFeatureStatisticsRequest; +import feast.proto.core.CoreServiceProto.GetFeatureStatisticsRequest.Builder; +import feast.proto.core.CoreServiceProto.GetFeatureStatisticsResponse; +import feast.proto.core.CoreServiceProto.ListFeatureSetsRequest; +import feast.proto.core.CoreServiceProto.ListFeatureSetsResponse; +import feast.proto.core.CoreServiceProto.ListFeaturesRequest; +import feast.proto.core.CoreServiceProto.ListFeaturesResponse; +import feast.proto.core.CoreServiceProto.ListProjectsResponse; +import java.io.IOException; +import java.time.LocalDate; +import java.time.LocalTime; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.CrossOrigin; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * EXPERIMENTAL: Controller for HTTP endpoints to Feast Core. These endpoints are subject to change. + */ +@RestController +@CrossOrigin +@Slf4j +@RequestMapping(value = "/api/v1", produces = "application/json") +public class CoreServiceRestController { + + private final FeastProperties feastProperties; + private SpecService specService; + private StatsService statsService; + private ProjectService projectService; + + @Autowired + public CoreServiceRestController( + FeastProperties feastProperties, + SpecService specService, + StatsService statsService, + ProjectService projectService) { + this.feastProperties = feastProperties; + this.specService = specService; + this.statsService = statsService; + this.projectService = projectService; + } + + /** + * GET /version : Fetches the version of Feast Core. + * + * @return (200 OK) Returns {@link GetFeastCoreVersionResponse} in JSON. + */ + @RequestMapping(value = "/version", method = RequestMethod.GET) + public GetFeastCoreVersionResponse getVersion() { + GetFeastCoreVersionResponse response = + GetFeastCoreVersionResponse.newBuilder().setVersion(feastProperties.getVersion()).build(); + return response; + } + + /** + * GET /feature-sets : Retrieve a list of Feature Sets according to filtering parameters of Feast + * project name and feature set name. If none matches, an empty JSON response is returned. + * + * @param project Request Parameter: Name of feast project to search in. If set to "*" + * , all existing projects will be filtered. However, asterisk can NOT be + * combined with other strings (for example "merchant_*") to use as wildcard to + * filter feature sets. + * @param name Request Parameter: Feature set name. If set to "*", filter * all feature sets by + * default. Asterisk can be used as wildcard to filter * feature sets. + * @return (200 OK) Return {@link ListFeatureSetsResponse} in JSON. + */ + @RequestMapping(value = "/feature-sets", method = RequestMethod.GET) + public ListFeatureSetsResponse listFeatureSets( + @RequestParam(defaultValue = Project.DEFAULT_NAME) String project, @RequestParam String name) + throws InvalidProtocolBufferException { + ListFeatureSetsRequest.Filter.Builder filterBuilder = + ListFeatureSetsRequest.Filter.newBuilder().setProject(project).setFeatureSetName(name); + return specService.listFeatureSets(filterBuilder.build()); + } + + /** + * GET /features : List Features based on project and entities. + * + * @param entities Request Parameter: List of all entities every returned feature should belong + * to. At least one entity is required. For example, if entity1 and entity2 + * are given, then all features returned (if any) will belong to BOTH + * entities. + * @param project (Optional) Request Parameter: A single project where the feature set of all + * features returned is under. If not provided, the default project will be used, usually + * default. + * @return (200 OK) Return {@link ListFeaturesResponse} in JSON. + */ + @RequestMapping(value = "/features", method = RequestMethod.GET) + public ListFeaturesResponse listFeatures( + @RequestParam String[] entities, @RequestParam(required = false) Optional project) { + ListFeaturesRequest.Filter.Builder filterBuilder = + ListFeaturesRequest.Filter.newBuilder().addAllEntities(Arrays.asList(entities)); + project.ifPresent(filterBuilder::setProject); + return specService.listFeatures(filterBuilder.build()); + } + + /** + * GET /feature-statistics : Fetches statistics for a dataset speficied by the parameters. Either + * both (start_date, end_date) need to be given or ingestion_ids are required. If both are given, + * (start_date, end_date) will be ignored. + * + * @param ingestionIds Request Parameter: List of ingestion IDs. If missing, both startDate and + * endDate should be provided. + * @param startDate Request Parameter: UTC+0 starting date (inclusive) in the ISO format, from + * 0001-01-01 to 9999-12-31. Time given will be ignored. This + * parameter will be ignored if any ingestionIds is provided. + * @param endDate Request Parameter: UTC+0 ending date (exclusive) in the ISO format, from + * 0001-01-01 to 9999-12-31. Time given will be ignored. This parameter + * will be ignored if any ingestionIds is provided. + * @param store Request Parameter: The name of the historical store used in Feast Serving. Online + * store is not allowed. + * @param featureSetId Request Parameter: Feature set ID, which has the form of + * project/feature_set_name. + * @param forceRefresh Request Parameter: whether to override the values in the cache. Accepts + * true, false. + * @param features (Optional) Request Parameter: List of features. If none provided, all features + * in the feature set will be used for statistics. + * @return (200 OK) Returns {@link GetFeatureStatisticsResponse} in JSON. + */ + @RequestMapping(value = "/feature-statistics", method = RequestMethod.GET) + public GetFeatureStatisticsResponse getFeatureStatistics( + @RequestParam(name = "feature_set_id") String featureSetId, + @RequestParam(required = false) Optional features, + @RequestParam String store, + @RequestParam(name = "start_date", required = false) Optional startDate, + @RequestParam(name = "end_date", required = false) Optional endDate, + @RequestParam(name = "ingestion_ids", required = false) Optional ingestionIds, + @RequestParam(name = "force_refresh") boolean forceRefresh) + throws IOException { + + Builder requestBuilder = + GetFeatureStatisticsRequest.newBuilder() + .setForceRefresh(forceRefresh) + .setFeatureSetId(featureSetId) + .setStore(store); + + // set optional request parameters if they are provided + features.ifPresent(theFeatures -> requestBuilder.addAllFeatures(Arrays.asList(theFeatures))); + startDate.ifPresent( + startDateStr -> requestBuilder.setStartDate(utcTimeStringToTimestamp(startDateStr))); + endDate.ifPresent( + endDateStr -> requestBuilder.setEndDate(utcTimeStringToTimestamp(endDateStr))); + ingestionIds.ifPresent( + theIngestionIds -> requestBuilder.addAllIngestionIds(Arrays.asList(theIngestionIds))); + + return statsService.getFeatureStatistics(requestBuilder.build()); + } + + /** + * GET /projects : Get the list of existing feast projects. + * + * @return (200 OK) Returns {@link ListProjectsResponse} in JSON. + */ + @RequestMapping(value = "/projects", method = RequestMethod.GET) + public ListProjectsResponse listProjects() { + List projects = projectService.listProjects(); + return ListProjectsResponse.newBuilder() + .addAllProjects(projects.stream().map(Project::getName).collect(Collectors.toList())) + .build(); + } + + private Timestamp utcTimeStringToTimestamp(String utcTimeString) { + long epochSecond = + LocalDate.parse(utcTimeString, DateTimeFormatter.ISO_DATE) + .toEpochSecond(LocalTime.MIN, ZoneOffset.UTC); + return Timestamp.newBuilder().setSeconds(epochSecond).setNanos(0).build(); + } +} diff --git a/core/src/main/java/feast/core/controller/exception/handler/RestResponseEntityExceptionHandler.java b/core/src/main/java/feast/core/controller/exception/handler/RestResponseEntityExceptionHandler.java new file mode 100644 index 0000000000..53fe1bcd45 --- /dev/null +++ b/core/src/main/java/feast/core/controller/exception/handler/RestResponseEntityExceptionHandler.java @@ -0,0 +1,115 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2020 The Feast Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package feast.core.controller.exception.handler; + +import com.google.protobuf.InvalidProtocolBufferException; +import feast.core.exception.RetrievalException; +import java.util.Map; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MissingServletRequestParameterException; +import org.springframework.web.bind.UnsatisfiedServletRequestParameterException; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.context.request.WebRequest; +import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; + +/** A exception handler for some common exceptions while accessing Feast Core via HTTP. */ +@ControllerAdvice +public class RestResponseEntityExceptionHandler extends ResponseEntityExceptionHandler { + + /** + * Handles the case when a request object (such as {@link + * feast.proto.core.CoreServiceProto.GetFeatureSetRequest}) or a response object (such as {@link + * feast.proto.core.CoreServiceProto.GetFeatureSetResponse} is malformed. + * + * @param ex the {@link InvalidProtocolBufferException} that occurred. + * @param request the {@link WebRequest} that caused this exception. + * @return (500 Internal Server Error) + */ + @ExceptionHandler({InvalidProtocolBufferException.class}) + protected ResponseEntity handleInvalidProtocolBuffer( + InvalidProtocolBufferException ex, WebRequest request) { + Map bodyOfResponse = + Map.of("error", "An unexpected error occurred in Feast Core."); + return handleExceptionInternal( + ex, bodyOfResponse, new HttpHeaders(), HttpStatus.INTERNAL_SERVER_ERROR, request); + } + + /** + * Handles the case that retrieval of information from the services triggered and exception. + * Instead of returning 500 with no error message, returns 500 with a body describing the error + * message. + * + * @param ex the {@link RetrievalException} that occurred. + * @param request the {@link WebRequest} that caused this exception. + * @return (500 Internal Server Error) + */ + @ExceptionHandler({RetrievalException.class}) + protected ResponseEntity handleRetrieval(RetrievalException ex, WebRequest request) { + Map bodyOfResponse = Map.of("error", ex.getMessage()); + return handleExceptionInternal( + ex, bodyOfResponse, new HttpHeaders(), HttpStatus.INTERNAL_SERVER_ERROR, request); + } + + /** + * Handles various exceptions that are due to malformed or invalid requests, such as + * + *
    + *
  • {@link UnsatisfiedServletRequestParameterException} where a parameter is requested in + * {@link org.springframework.web.bind.annotation.RequestMapping} but not supplied. + *
  • {@link IllegalArgumentException} where unsupported parameters are provided. + *
+ * + * @param ex the {@link UnsatisfiedServletRequestParameterException} that occurred. + * @param request the {@link WebRequest} that caused this exception. + * @return (400 Bad Request) + */ + @ExceptionHandler({ + UnsatisfiedServletRequestParameterException.class, + IllegalArgumentException.class + }) + protected ResponseEntity handleBadRequest(Exception ex, WebRequest request) { + ex.printStackTrace(); + Map bodyOfResponse = Map.of("error", ex.getMessage()); + return handleExceptionInternal( + ex, bodyOfResponse, new HttpHeaders(), HttpStatus.BAD_REQUEST, request); + } + + /** + * Handles {@link MissingServletRequestParameterException} which occurs when a controller method + * expects a certain parameter but is not supplied by the request. The original implementation + * returns an empty body with (400 Bad Request), we add in the error and stacktrace. + * + * @param ex the {@link MissingServletRequestParameterException} that occurred. + * @param request the {@link WebRequest} that caused this exception. + * @param headers the {@link HttpHeaders} from the request. + * @param status the {@link HttpStatus} generated for the response, (400 Bad Request) + * @return (400 Bad Request) + */ + @Override + protected ResponseEntity handleMissingServletRequestParameter( + MissingServletRequestParameterException ex, + HttpHeaders headers, + HttpStatus status, + WebRequest request) { + ex.printStackTrace(); + Map bodyOfResponse = Map.of("error", ex.getMessage()); + return this.handleExceptionInternal(ex, bodyOfResponse, headers, status, request); + } +} diff --git a/core/src/main/resources/application.yml b/core/src/main/resources/application.yml index e77ace2d67..28e8e3f7a7 100644 --- a/core/src/main/resources/application.yml +++ b/core/src/main/resources/application.yml @@ -115,6 +115,11 @@ feast: authorizationUrl: http://localhost:8082 subjectClaim: email + # If set to true, HTTP REST endpoints at /api/v1 implemented by + # CoreServiceRestController will be accessible in Feast Core WITHOUT + # authentication. + disableRestControllerAuth: false + logging: # Audit logging provides a machine readable structured JSON log that can give better # insight into what is happening in Feast. diff --git a/core/src/test/java/feast/core/controller/CoreServiceRestIT.java b/core/src/test/java/feast/core/controller/CoreServiceRestIT.java new file mode 100644 index 0000000000..f59eba7d50 --- /dev/null +++ b/core/src/test/java/feast/core/controller/CoreServiceRestIT.java @@ -0,0 +1,273 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2020 The Feast Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package feast.core.controller; + +import static io.restassured.RestAssured.get; +import static org.hamcrest.Matchers.aMapWithSize; +import static org.hamcrest.Matchers.notNullValue; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.google.common.collect.ImmutableMap; +import feast.core.it.BaseIT; +import feast.core.it.DataGenerator; +import feast.core.it.SimpleAPIClient; +import feast.core.model.Project; +import feast.proto.core.CoreServiceGrpc; +import feast.proto.core.FeatureSetProto.FeatureSet; +import feast.proto.types.ValueProto.ValueType.Enum; +import io.grpc.ManagedChannel; +import io.grpc.ManagedChannelBuilder; +import io.restassured.RestAssured; +import io.restassured.http.ContentType; +import io.restassured.path.json.JsonPath; +import java.util.List; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.web.server.LocalServerPort; +import org.springframework.web.util.UriComponentsBuilder; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureWebTestClient +public class CoreServiceRestIT extends BaseIT { + + static CoreServiceGrpc.CoreServiceBlockingStub stub; + static SimpleAPIClient apiClient; + @LocalServerPort private int port; + + @Test + public void getVersion() { + String uriString = UriComponentsBuilder.fromPath("/api/v1/version").toUriString(); + get(uriString) + .then() + .log() + .everything() + .assertThat() + .contentType(ContentType.JSON) + .body("version", notNullValue()); + } + + // list projects + @Test + public void listProjects() { + // should get 2 projects + String uriString = UriComponentsBuilder.fromPath("/api/v1/projects").toUriString(); + String responseBody = + get(uriString) + .then() + .log() + .everything() + .assertThat() + .contentType(ContentType.JSON) + .extract() + .response() + .getBody() + .asString(); + List projectList = JsonPath.from(responseBody).getList("projects"); + assertEquals(projectList, List.of("default", "merchant")); + } + + // list feature sets + @Test + public void listFeatureSets() { + // project = default + // name = merchant_ratings + // getting a specific feature set + String uri1 = + UriComponentsBuilder.fromPath("/api/v1/feature-sets") + .queryParam("project", "default") + .queryParam("name", "merchant_ratings") + .buildAndExpand() + .toString(); + String responseBody = + get(uri1) + .then() + .log() + .everything() + .assertThat() + .contentType(ContentType.JSON) + .extract() + .response() + .getBody() + .asString(); + List featureSetList = JsonPath.from(responseBody).getList("featureSets"); + assertEquals(featureSetList.size(), 1); + + // project = * + // name = *merchant_ratings + // should have two feature sets named *merchant_ratings + String uri2 = + UriComponentsBuilder.fromPath("/api/v1/feature-sets") + .queryParam("project", "*") + .queryParam("name", "*merchant_ratings") + .buildAndExpand() + .toString(); + responseBody = + get(uri2) + .then() + .log() + .everything() + .assertThat() + .contentType(ContentType.JSON) + .extract() + .response() + .getBody() + .asString(); + featureSetList = JsonPath.from(responseBody).getList("featureSets"); + assertEquals(featureSetList.size(), 2); + + // project = * + // name = * + // should have three feature sets + String uri3 = + UriComponentsBuilder.fromPath("/api/v1/feature-sets") + .queryParam("project", "*") + .queryParam("name", "*") + .buildAndExpand() + .toString(); + responseBody = + get(uri3) + .then() + .log() + .everything() + .assertThat() + .contentType(ContentType.JSON) + .extract() + .response() + .getBody() + .asString(); + featureSetList = JsonPath.from(responseBody).getList("featureSets"); + assertEquals(featureSetList.size(), 3); + } + + @Test + public void listFeatures() { + // entities = [merchant_id] + // project = default + // should return 4 features + String uri1 = + UriComponentsBuilder.fromPath("/api/v1/features") + .queryParam("entities", "merchant_id") + .buildAndExpand() + .toString(); + get(uri1) + .then() + .log() + .everything() + .assertThat() + .contentType(ContentType.JSON) + .body("features", aMapWithSize(4)); + + // entities = [merchant_id] + // project = merchant + // should return 2 features + String uri2 = + UriComponentsBuilder.fromPath("/api/v1/features") + .queryParam("entities", "merchant_id") + .queryParam("project", "merchant") + .buildAndExpand() + .toString(); + get(uri2) + .then() + .log() + .everything() + .assertThat() + .contentType(ContentType.JSON) + .body("features", aMapWithSize(2)); + } + + @TestConfiguration + public static class TestConfig extends BaseTestConfig {} + + @BeforeAll + public static void globalSetUp(@Value("${grpc.server.port}") int port) { + ManagedChannel channel = + ManagedChannelBuilder.forAddress("localhost", port).usePlaintext().build(); + stub = CoreServiceGrpc.newBlockingStub(channel); + apiClient = new SimpleAPIClient(stub); + } + + @BeforeEach + private void createFakeFeatureSets() { + // spec: + // name: merchant_ratings + // entities: + // - name: merchant_id + // valueType: STRING + // features: + // - name: average_rating + // valueType: DOUBLE + // - name: total_ratings + // valueType: INT64 + // project: default + FeatureSet merchantFeatureSet = + DataGenerator.createFeatureSet( + DataGenerator.getDefaultSource(), + Project.DEFAULT_NAME, + "merchant_ratings", + ImmutableMap.of("merchant_id", Enum.STRING), + ImmutableMap.of("average_rating", Enum.DOUBLE, "total_ratings", Enum.INT64)); + apiClient.simpleApplyFeatureSet(merchantFeatureSet); + + // spec: + // name: another_merchant_ratings + // entities: + // - name: merchant_id + // valueType: STRING + // features: + // - name: another_average_rating + // valueType: DOUBLE + // - name: another_total_ratings + // valueType: INT64 + // project: default + FeatureSet anotherMerchantFeatureSet = + DataGenerator.createFeatureSet( + DataGenerator.getDefaultSource(), + Project.DEFAULT_NAME, + "another_merchant_ratings", + ImmutableMap.of("merchant_id", Enum.STRING), + ImmutableMap.of( + "another_average_rating", Enum.DOUBLE, + "another_total_ratings", Enum.INT64)); + apiClient.simpleApplyFeatureSet(anotherMerchantFeatureSet); + + // spec: + // name: yet_another_merchant_feature_set + // entities: + // - name: merchant_id + // valueType: STRING + // features: + // - name: merchant_prop1 + // valueType: BOOL + // - name: merchant_prop2 + // valueType: FLOAT + // project: merchant + FeatureSet yetAnotherMerchantFeatureSet = + DataGenerator.createFeatureSet( + DataGenerator.getDefaultSource(), + "merchant", + "yet_another_merchant_feature_set", + ImmutableMap.of("merchant_id", Enum.STRING), + ImmutableMap.of("merchant_prop1", Enum.BOOL, "merchant_prop2", Enum.FLOAT)); + apiClient.simpleApplyFeatureSet(yetAnotherMerchantFeatureSet); + RestAssured.port = port; + } +} diff --git a/core/src/test/resources/application.properties b/core/src/test/resources/application.properties index 7e14f3c120..cbc2e8ef0d 100644 --- a/core/src/test/resources/application.properties +++ b/core/src/test/resources/application.properties @@ -17,6 +17,7 @@ grpc.port=${GRPC_PORT:6565} feast.security.authentication.enabled = true +feast.security.disableRestControllerAuth = true feast.security.authorization.enabled = false feast.core.projectId = ${PROJECT_ID:}