From 6379eeece34ed77683b14a2cb1b863c60e72c733 Mon Sep 17 00:00:00 2001 From: Jack Date: Mon, 21 Aug 2023 09:38:55 +0800 Subject: [PATCH] Implement JPA through Elide --- .github/actions/ci-setup/action.yml | 15 +- .github/workflows/ci-cd.yml | 49 +- docker-compose.yml | 45 ++ mysql-init.sql | 16 + pom.xml | 143 ++--- settings.xml.example | 20 + .../template/application/BinderFactory.java | 243 +++++++- .../template/application/ResourceConfig.java | 42 +- .../ws/jersey/template/cache/LruCache.java | 70 --- .../template/config/ApplicationConfig.java | 10 +- .../template/config/JpaDatastoreConfig.java | 115 ++++ .../template/web/endpoints/DataServlet.java | 62 -- .../jersey/template/DataServletITSpec.groovy | 85 --- .../template/JettyServerFactorySpec.groovy | 72 --- .../application/AbstractITSpec.groovy | 558 ++++++++++++++++++ .../application/DockerComposeITSpec.groovy | 28 + .../application/ResourceConfigITSpec.groovy | 72 +++ .../application/ResourceConfigSpec.groovy | 8 +- .../jersey/template/cache/LruCacheSpec.groovy | 50 -- .../web/endpoints/DataServletSpec.groovy | 52 -- .../jersey/template/JettyServerFactory.java | 94 --- .../template/resource/TestEndpoint.java | 52 -- src/test/resources/application.properties | 1 + src/test/resources/jpadatastore.properties | 4 + 24 files changed, 1234 insertions(+), 672 deletions(-) create mode 100644 docker-compose.yml create mode 100644 mysql-init.sql create mode 100644 settings.xml.example delete mode 100755 src/main/java/com/qubitpi/ws/jersey/template/cache/LruCache.java create mode 100644 src/main/java/com/qubitpi/ws/jersey/template/config/JpaDatastoreConfig.java delete mode 100644 src/main/java/com/qubitpi/ws/jersey/template/web/endpoints/DataServlet.java delete mode 100644 src/test/groovy/com/qubitpi/ws/jersey/template/DataServletITSpec.groovy delete mode 100755 src/test/groovy/com/qubitpi/ws/jersey/template/JettyServerFactorySpec.groovy create mode 100644 src/test/groovy/com/qubitpi/ws/jersey/template/application/AbstractITSpec.groovy create mode 100644 src/test/groovy/com/qubitpi/ws/jersey/template/application/DockerComposeITSpec.groovy create mode 100644 src/test/groovy/com/qubitpi/ws/jersey/template/application/ResourceConfigITSpec.groovy delete mode 100644 src/test/groovy/com/qubitpi/ws/jersey/template/cache/LruCacheSpec.groovy delete mode 100644 src/test/groovy/com/qubitpi/ws/jersey/template/web/endpoints/DataServletSpec.groovy delete mode 100755 src/test/java/com/qubitpi/ws/jersey/template/JettyServerFactory.java delete mode 100755 src/test/java/com/qubitpi/ws/jersey/template/resource/TestEndpoint.java create mode 100644 src/test/resources/application.properties create mode 100644 src/test/resources/jpadatastore.properties diff --git a/.github/actions/ci-setup/action.yml b/.github/actions/ci-setup/action.yml index cda83144..4d86e0da 100644 --- a/.github/actions/ci-setup/action.yml +++ b/.github/actions/ci-setup/action.yml @@ -12,11 +12,22 @@ # See the License for the specific language governing permissions and # limitations under the License. -name: 'setup' -description: 'CI setup, such as installing JDK' +name: 'CI Setup' +description: 'Environment setup for CI phase such as installing JDK and Elide data models' + +inputs: + data-models-repo-org: + description: 'Elide data models repo owner, e.g. paion-data' + required: true + data-models-repo-name: + description: 'Elide data models repo name, e.g. my-jpa-data-model' + required: true runs: using: "composite" steps: - name: Set up JDK uses: QubitPi/jersey-webservice-deployment-actions/.github/actions/jdk-setup@master + - name: Load Maven settings.xml + shell: bash + run: cp settings.xml.example ~/.m2/settings.xml diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index fce1626e..9f4f8cec 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -19,16 +19,18 @@ name: Template CI/CD push: branches: - master + - jpa-elide env: JDK_VERSION: 17 JDK_DISTRIBUTION: 'adopt' USER: QubitPi EMAIL: jack20220723@gmail.com + MODEL_PACKAGE_NAME: 'io.github.qubitpi.ws.jersey.template.models' jobs: yml-md-style-and-link-checks: - uses: QubitPi/hashistack/.github/workflows/yml-md-style-and-link-checks.yml@master + uses: QubitPi/hashicorp-aws/.github/workflows/yml-md-style-and-link-checks.yml@master tests: name: Unit & Integration Tests @@ -38,6 +40,9 @@ jobs: - uses: actions/checkout@v3 - name: Test environment setup uses: ./.github/actions/ci-setup + with: + data-models-repo-org: QubitPi + data-models-repo-name: jersey-webservice-template-jpa-data-models - name: Set up Docker for Integration Tests uses: docker-practice/actions-setup-docker@master - name: Run unit & integration tests @@ -54,6 +59,9 @@ jobs: node-version: 18 - name: Test environment setup uses: ./.github/actions/ci-setup + with: + data-models-repo-org: QubitPi + data-models-repo-name: jersey-webservice-template-jpa-data-models - name: Install dependencies working-directory: docs run: yarn @@ -75,36 +83,15 @@ jobs: user_name: ${{ env.USER }} user_email: ${{ env.EMAIL }} - docker-image: - name: Build Test & Release Development Docker Image - needs: tests + triggering: + name: Triggering data model CI/CD + needs: documentation + if: github.ref == 'refs/heads/jpa-elide' runs-on: ubuntu-latest steps: - - name: Checkout - uses: actions/checkout@v3 - - name: Test environment setup - uses: ./.github/actions/ci-setup - - name: Build App WAR file so that Docker can pickup during image build - run: mvn clean package - - name: Set up QEMU - uses: docker/setup-qemu-action@v2 - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2 - - name: Test image build - uses: docker/build-push-action@v3 - with: - context: . - push: false - - name: Login to DockerHub - if: github.ref == 'refs/heads/master' - uses: docker/login-action@v2 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Push image to DockerHub - if: github.ref == 'refs/heads/master' - uses: docker/build-push-action@v3 + - name: Trigger data model CI/CD + uses: peter-evans/repository-dispatch@v2 with: - context: . - push: true - tags: ${{ secrets.DOCKERHUB_USERNAME }}/jersey-webservice-template:latest + token: ${{ secrets.JWT_DOWNSTREAM_CICD_TRIGGER_TOKEN }} + repository: QubitPi/jersey-webservice-template-jpa-data-models + event-type: jersey-webservice-template-jpa-elide-changes diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..6c7ed639 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,45 @@ +# Copyright Jiaqi Liu +# +# 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 +# +# http://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. +version: "3.9" +services: + web: + build: . + ports: + - "8080:8080" + environment: + - MODEL_PACKAGE_NAME=${MODEL_PACKAGE_NAME} + - DB_USER=root + - DB_PASSWORD=root + - DB_DRIVER=com.mysql.jdbc.Driver + - DB_DIALECT=org.hibernate.dialect.MySQLDialect + - DB_URL=jdbc:mysql://db/elide?serverTimezone=UTC + - OAUTH_ENABLED=${OAUTH_ENABLED:-false} + - JWKS_URL=${JWKS_URL} + - HIBERNATE_HBM2DDL_AUTO=create + depends_on: + db: + condition: service_healthy + db: + image: "mysql:5.7" + ports: + - "3306:3306" + volumes: + - "${MYSQL_INIT_SCRIPT_PATH:-./mysql-init.sql}:/docker-entrypoint-initdb.d/mysql-init.sql" + environment: + MYSQL_ROOT_PASSWORD: root + command: --character-set-server=utf8 --collation-server=utf8_general_ci + healthcheck: + test: mysqladmin ping -h localhost -u root -proot + timeout: 3s + retries: 3 diff --git a/mysql-init.sql b/mysql-init.sql new file mode 100644 index 00000000..704d2f9b --- /dev/null +++ b/mysql-init.sql @@ -0,0 +1,16 @@ +-- Copyright Jiaqi Liu +-- +-- 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 +-- +-- http://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. + +CREATE DATABASE IF NOT EXISTS elide; +USE elide; diff --git a/pom.xml b/pom.xml index a1c40eb7..e2900d66 100644 --- a/pom.xml +++ b/pom.xml @@ -33,14 +33,12 @@ 17 17 + 7.1.0 3.0.1 + 3.1.1 + 6.2.4.Final 1.0 - 1.7.25 - 1.2.3 - 2.13.3 1.0.12 - 6.0.0 - 3.1.1 4.0.6 11.0.15 @@ -62,15 +60,6 @@ - - - com.fasterxml.jackson - jackson-bom - ${version.jackson} - import - pom - - org.spockframework @@ -91,85 +80,75 @@ - org.glassfish.jaxb - jaxb-runtime - 4.0.2 + ${model.package.jar.group.id} + ${model.package.jar.artifact.id} + ${model.package.jar.version} + - jakarta.validation - jakarta.validation-api - ${version.validation.api} + com.yahoo.elide + elide-core + ${version.elide} - - - jakarta.servlet - jakarta.servlet-api - ${version.servlet} + com.yahoo.elide + elide-datastore-jpa + ${version.elide} - jakarta.ws.rs - jakarta.ws.rs-api - 3.1.0 + com.yahoo.elide + elide-graphql + ${version.elide} + compile - - - org.glassfish.jersey.core - jersey-server - ${version.jersey} + com.yahoo.elide + elide-test-helpers + ${version.elide} + test + - org.glassfish.jersey.containers - jersey-container-servlet - ${version.jersey} + io.github.graphql-java + graphql-java-annotations + 21.5 + - org.glassfish.jersey.media - jersey-media-multipart - ${version.jersey} + mysql + mysql-connector-java + 5.1.49 + - org.glassfish.jersey.media - jersey-media-json-jackson - ${version.jersey} + jakarta.validation + jakarta.validation-api + ${version.validation.api} - org.glassfish.jersey.test-framework - jersey-test-framework-core - ${version.jersey} - test - - - org.glassfish.jersey.test-framework.providers - jersey-test-framework-provider-grizzly2 + org.glassfish.jersey.containers + jersey-container-servlet ${version.jersey} - test + compile - - org.glassfish.jersey.inject jersey-hk2 3.0.0 - - - org.glassfish.hk2 - hk2-locator - 3.0.0 + compile - + - com.fasterxml.jackson.core - jackson-databind + org.hibernate.orm + hibernate-core + ${hibernate.version} - - org.asynchttpclient - async-http-client - 3.0.0.Beta2 + org.hibernate.orm + hibernate-hikaricp + ${hibernate.version} @@ -188,27 +167,18 @@ - org.slf4j - slf4j-api - ${version.slf4j} - - - ch.qos.logback - logback-classic - ${version.logback} - - - net.logstash.logback - logstash-logback-encoder - 4.11 - - - io.sentry - sentry-logback - 6.25.2 + commons-logging + commons-logging + 1.1.1 + + io.github.qubitpi + jersey-webservice-template-jpa-data-models + 1.0.1 + test + org.spockframework spock-core @@ -224,7 +194,6 @@ net.bytebuddy byte-buddy 1.14.11 - test org.objenesis @@ -269,6 +238,12 @@ 1.19.0 test + + org.testcontainers + mysql + 1.19.0 + test + diff --git a/settings.xml.example b/settings.xml.example new file mode 100644 index 00000000..67ff8b37 --- /dev/null +++ b/settings.xml.example @@ -0,0 +1,20 @@ + + + + + elide-data-models-properties + + io.github.qubitpi + jersey-webservice-template-jpa-data-models + 1.0.20 + + + + + + elide-data-models-properties + + diff --git a/src/main/java/com/qubitpi/ws/jersey/template/application/BinderFactory.java b/src/main/java/com/qubitpi/ws/jersey/template/application/BinderFactory.java index d80c1121..c820cf95 100644 --- a/src/main/java/com/qubitpi/ws/jersey/template/application/BinderFactory.java +++ b/src/main/java/com/qubitpi/ws/jersey/template/application/BinderFactory.java @@ -15,13 +15,51 @@ */ package com.qubitpi.ws.jersey.template.application; -import org.glassfish.hk2.utilities.Binder; -import org.glassfish.hk2.utilities.binding.AbstractBinder; +import static com.yahoo.elide.ElideSettings.ElideSettingsBuilder; +import static com.yahoo.elide.graphql.GraphQLSettings.GraphQLSettingsBuilder; +import static com.yahoo.elide.jsonapi.JsonApiSettings.JsonApiSettingsBuilder; +import com.yahoo.elide.Elide; +import com.yahoo.elide.ElideSettings; +import com.yahoo.elide.core.TransactionRegistry; +import com.yahoo.elide.core.datastore.DataStore; +import com.yahoo.elide.core.dictionary.EntityDictionary; +import com.yahoo.elide.core.dictionary.Injector; +import com.yahoo.elide.core.utils.ClassScanner; +import com.yahoo.elide.core.utils.DefaultClassScanner; +import com.yahoo.elide.core.utils.coerce.CoerceUtil; +import com.yahoo.elide.datastores.jpa.JpaDataStore; +import com.yahoo.elide.datastores.jpa.PersistenceUnitInfoImpl; +import com.yahoo.elide.datastores.jpa.transaction.NonJtaTransaction; +import com.yahoo.elide.graphql.AnnotationGraphQLFieldDefinitionDescriptionCustomizer; + +import com.qubitpi.ws.jersey.template.config.ApplicationConfig; +import com.qubitpi.ws.jersey.template.config.JpaDatastoreConfig; + +import org.aeonbits.owner.ConfigFactory; +import org.glassfish.hk2.api.ServiceLocator; +import org.glassfish.jersey.internal.inject.AbstractBinder; +import org.glassfish.jersey.internal.inject.Binder; +import org.hibernate.Session; +import org.hibernate.jpa.boot.internal.EntityManagerFactoryBuilderImpl; +import org.hibernate.jpa.boot.internal.PersistenceUnitInfoDescriptor; + +import graphql.annotations.annotationTypes.GraphQLDescription; +import jakarta.persistence.Entity; +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; +import jakarta.persistence.spi.PersistenceUnitInfo; import jakarta.validation.constraints.NotNull; import net.jcip.annotations.Immutable; import net.jcip.annotations.ThreadSafe; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Properties; +import java.util.function.Consumer; +import java.util.stream.Collectors; + /** * A binder factory builds a custom binder for the Jersey application. *

@@ -32,20 +70,217 @@ @ThreadSafe public class BinderFactory { + /** + * Custom GraphQLFieldDefinitionCustomizer that uses {@link GraphQLDescription} as a source of field documentation. + */ + @Immutable + @ThreadSafe + private static class GraphQLFieldDefinitionCustomizer extends + AnnotationGraphQLFieldDefinitionDescriptionCustomizer { + + private static final GraphQLFieldDefinitionCustomizer INSTANCE = new GraphQLFieldDefinitionCustomizer(); + + /** + * Private constructor. + */ + private GraphQLFieldDefinitionCustomizer() { + super(GraphQLDescription.class, GraphQLDescription::value); + } + } + /** * Builds a hk2 Binder instance. *

* This binder should bind all relevant resources for runtime dependency injection. * + * @param injector A standard HK2 service locator + * * @return a binder instance that will be registered by putting as a parameter to * {@link org.glassfish.jersey.server.ResourceConfig#register(Object)} */ @NotNull - public Binder buildBinder() { + public Binder buildBinder(final ServiceLocator injector) { return new AbstractBinder() { + + private static final Consumer TXCANCEL = em -> em.unwrap(Session.class).cancelQuery(); + + private static final ApplicationConfig APPLICATION_CONFIG = ConfigFactory.create(ApplicationConfig.class); + private static final JpaDatastoreConfig JPA_DATASTORE_CONFIG = ConfigFactory.create( + JpaDatastoreConfig.class + ); + + private final ClassScanner classScanner = new DefaultClassScanner(); + @Override protected void configure() { - // intentionally left blank + final ElideSettings elideSettings = buildElideSettings(); + + bind(buildElide(elideSettings)).to(Elide.class).named("elide"); + bind(elideSettings).to(ElideSettings.class); + bind(elideSettings.getEntityDictionary()).to(EntityDictionary.class); + bind(elideSettings.getDataStore()).to(DataStore.class).named("elideDataStore"); + } + + /** + * Initializes Elide middleware service. + * + * @param elideSettings An object for configuring various aspect of the Elide middleware + * + * @return a new instance + */ + @NotNull + private Elide buildElide(@NotNull final ElideSettings elideSettings) { + return new Elide( + elideSettings, + new TransactionRegistry(), + elideSettings.getEntityDictionary().getScanner(), + false + ); + } + + /** + * Initializes Elide config object. + * + * @return a new instance + */ + @NotNull + private ElideSettings buildElideSettings() { + final EntityDictionary entityDictionary = buildEntityDictionary(injector); + return new ElideSettingsBuilder() + .settings( + GraphQLSettingsBuilder + .withDefaults(entityDictionary) + .graphqlFieldDefinitionCustomizer(GraphQLFieldDefinitionCustomizer.INSTANCE) + ) + .settings(JsonApiSettingsBuilder.withDefaults(entityDictionary)) + .dataStore(buildDataStore(buildEntityManagerFactory())) + .entityDictionary(entityDictionary) + .build(); + } + + /** + * Initializes the Elide {@link DataStore} service with the specified {@link EntityManagerFactory}. + * + * @param entityManagerFactory An object used to initialize JPA + * + * @return a new instance + */ + @NotNull + private DataStore buildDataStore(@NotNull final EntityManagerFactory entityManagerFactory) { + return new JpaDataStore( + entityManagerFactory::createEntityManager, + em -> new NonJtaTransaction(em, TXCANCEL), + entityManagerFactory::getMetamodel); + } + + /** + * Initializes the {@link EntityManagerFactory} service used by Elide JPA. + * + * @return a new instance + */ + @NotNull + private EntityManagerFactory buildEntityManagerFactory() { + final String modelPackageName = APPLICATION_CONFIG.modelPackageName(); + + final ClassLoader classLoader = null; + + final PersistenceUnitInfo persistenceUnitInfo = new PersistenceUnitInfoImpl( + "jersey-ws-template", + getAllEntities(classScanner, modelPackageName), + getDefaultDbConfigs(), + classLoader + ); + + return new EntityManagerFactoryBuilderImpl( + new PersistenceUnitInfoDescriptor(persistenceUnitInfo), + new HashMap<>(), + classLoader + ).build(); + } + + /** + * Get all the entities in a package. + * + * @param scanner An object that picks up entities by Elide annotation + * @param packageName A fully qualified package name under which contains all entities + * + * @return all entities found in the provided package. + */ + @NotNull + public static List getAllEntities( + @NotNull final ClassScanner scanner, + @NotNull final String packageName + ) { + return scanner.getAnnotatedClasses(packageName, Entity.class).stream() + .map(Class::getName) + .collect(Collectors.toList()); + } + + /** + * Returns a collection of DB configurations, including connecting credentials. + *

+ * In addition, the configurations consumes all configs defined in {@link JpaDatastoreConfig} + * + * @return a new instance + */ + @NotNull + @SuppressWarnings("MultipleStringLiterals") + private static Properties getDefaultDbConfigs() { + final Properties dbProperties = new Properties(); + + dbProperties.put("hibernate.show_sql", "true"); + dbProperties.put("hibernate.hbm2ddl.auto", JPA_DATASTORE_CONFIG.hibernateMbm2ddlAuto()); + dbProperties.put("hibernate.dialect", JPA_DATASTORE_CONFIG.dbDialect()); + dbProperties.put("hibernate.current_session_context_class", "thread"); + dbProperties.put("hibernate.jdbc.use_scrollable_resultset", "true"); + + // Collection Proxy & JDBC Batching + dbProperties.put("hibernate.jdbc.batch_size", "50"); + dbProperties.put("hibernate.jdbc.fetch_size", "50"); + dbProperties.put("hibernate.default_batch_fetch_size", "100"); + + // Hikari Connection Pool Settings + dbProperties.putIfAbsent("hibernate.connection.provider_class", + "com.zaxxer.hikari.hibernate.HikariConnectionProvider"); + dbProperties.putIfAbsent("hibernate.hikari.connectionTimeout", "20000"); + dbProperties.putIfAbsent("hibernate.hikari.maximumPoolSize", "30"); + dbProperties.putIfAbsent("hibernate.hikari.idleTimeout", "30000"); + + dbProperties.put("jakarta.persistence.jdbc.driver", JPA_DATASTORE_CONFIG.dbDriver()); + dbProperties.put("jakarta.persistence.jdbc.url", JPA_DATASTORE_CONFIG.dbUrl()); + dbProperties.put("jakarta.persistence.jdbc.user", JPA_DATASTORE_CONFIG.dbUser()); + dbProperties.put("jakarta.persistence.jdbc.password", JPA_DATASTORE_CONFIG.dbPassword()); + + return dbProperties; + } + + /** + * Initializes the Elide {@link EntityDictionary} service with a given dependency injector. + * + * @param injector A standard HK2 service locator used by Elide + * + * @return a new instance + */ + @NotNull + private EntityDictionary buildEntityDictionary(@NotNull final ServiceLocator injector) { + return new EntityDictionary( + new HashMap<>(), + new HashMap<>(), + new Injector() { + @Override + public void inject(final Object entity) { + injector.inject(entity); + } + + @Override + public T instantiate(final Class cls) { + return injector.create(cls); + } + }, + CoerceUtil::lookup, + new HashSet<>(), + classScanner + ); } }; } diff --git a/src/main/java/com/qubitpi/ws/jersey/template/application/ResourceConfig.java b/src/main/java/com/qubitpi/ws/jersey/template/application/ResourceConfig.java index 4d23727a..113a7612 100644 --- a/src/main/java/com/qubitpi/ws/jersey/template/application/ResourceConfig.java +++ b/src/main/java/com/qubitpi/ws/jersey/template/application/ResourceConfig.java @@ -15,11 +15,14 @@ */ package com.qubitpi.ws.jersey.template.application; +import com.yahoo.elide.Elide; + import com.qubitpi.ws.jersey.template.web.filters.CorsFilter; -import org.glassfish.hk2.utilities.Binder; +import org.glassfish.hk2.api.ServiceLocator; import jakarta.inject.Inject; +import jakarta.validation.constraints.NotNull; import jakarta.ws.rs.ApplicationPath; import net.jcip.annotations.Immutable; import net.jcip.annotations.ThreadSafe; @@ -29,21 +32,44 @@ */ @Immutable @ThreadSafe -@ApplicationPath("v1") +@ApplicationPath("/v1/data/") public class ResourceConfig extends org.glassfish.jersey.server.ResourceConfig { - private static final String ENDPOINT_PACKAGE = "com.qubitpi.ws.jersey.template.web.endpoints"; + private static final String GRAPHQL_ENDPOINT_PACKAGE = "com.yahoo.elide.graphql"; + private static final String JAON_API_ENDPOINT_PACKAGE = "com.yahoo.elide.jsonapi.resources"; /** - * DI Constructor that allows for finer dependency injection control. + * DI Constructor. + * + * @param injector A standard HK2 service locator */ @Inject - public ResourceConfig() { - packages(ENDPOINT_PACKAGE); + public ResourceConfig(final ServiceLocator injector) { + this(injector, new BinderFactory()); + } + + /** + * Constructor that allows for finer dependency injection control. + * + * @param injector A standard HK2 service locator + * @param binderFactory An object that produces resource binder + */ + private ResourceConfig(@NotNull final ServiceLocator injector, @NotNull final BinderFactory binderFactory) { + packages(JAON_API_ENDPOINT_PACKAGE, GRAPHQL_ENDPOINT_PACKAGE); register(CorsFilter.class); - final Binder binder = new BinderFactory().buildBinder(); - register(binder); + register(binderFactory.buildBinder(injector)); + + // Bind api docs to given endpoint + // This looks strange, but Jersey binds its Abstract binders first, and then later it binds 'external' + // binders (like this HK2 version). This allows breaking dependency injection into two phases. + // Everything bound in the first phase can be accessed in the second phase. + register(new org.glassfish.hk2.utilities.binding.AbstractBinder() { + @Override + protected void configure() { + injector.getService(Elide.class, "elide").doScans(); + } + }); } } diff --git a/src/main/java/com/qubitpi/ws/jersey/template/cache/LruCache.java b/src/main/java/com/qubitpi/ws/jersey/template/cache/LruCache.java deleted file mode 100755 index 97f9e712..00000000 --- a/src/main/java/com/qubitpi/ws/jersey/template/cache/LruCache.java +++ /dev/null @@ -1,70 +0,0 @@ -/* - * Copyright Jiaqi Liu - * - * 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 - * - * http://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 com.qubitpi.ws.jersey.template.cache; - -import jakarta.validation.constraints.NotNull; -import net.jcip.annotations.NotThreadSafe; - -import java.util.LinkedHashMap; -import java.util.Map; - -/** - * LRU Cache. - * - * @param The type of keys maintained by this cache - * @param The type of cached values - */ -@NotThreadSafe -public class LruCache extends LinkedHashMap { - - private static final long serialVersionUID = -5727315380707628908L; - - private final int cacheSize; - - /** - * Constructs an empty {@link LruCache} instance with the provided maximum number of entries hold in the cache. - * - * @param cacheSize Maximum number of entries in cache - */ - private LruCache(final int cacheSize) { - super(cacheSize * 4 / 3, 0.75f, true); - this.cacheSize = cacheSize; - } - - /** - * Creates a new instance of {@link LruCache} with the provided maximum number of entries hold in the cache. - * - * @param cacheSize Maximum number of entries in cache - * - * @param The type of keys maintained by this cache - * @param The type of cached values - * - * @return a new initialized {@link LruCache} instance - */ - @NotNull - public static LruCache ofSize(final int cacheSize) { - return new LruCache<>(cacheSize); - } - - @Override - protected boolean removeEldestEntry(final Map.Entry eldest) { - return size() > getCacheSize(); - } - - private int getCacheSize() { - return cacheSize; - } -} diff --git a/src/main/java/com/qubitpi/ws/jersey/template/config/ApplicationConfig.java b/src/main/java/com/qubitpi/ws/jersey/template/config/ApplicationConfig.java index a92c6e97..43ff9263 100644 --- a/src/main/java/com/qubitpi/ws/jersey/template/config/ApplicationConfig.java +++ b/src/main/java/com/qubitpi/ws/jersey/template/config/ApplicationConfig.java @@ -17,6 +17,7 @@ import org.aeonbits.owner.Config; +import jakarta.validation.constraints.NotNull; import net.jcip.annotations.Immutable; import net.jcip.annotations.ThreadSafe; @@ -45,10 +46,11 @@ public interface ApplicationConfig extends Config { /** - * Example config definition. + * The fully qualified package name that contains a set of Elide JPA models. * - * @return a config value as string. + * @return a standard package name under which each class is a JPA entity class */ - @Key("EXAMPLE_CONFIG_KEY_NAME") - String exampleConfigKey(); + @NotNull + @Key("MODEL_PACKAGE_NAME") + String modelPackageName(); } diff --git a/src/main/java/com/qubitpi/ws/jersey/template/config/JpaDatastoreConfig.java b/src/main/java/com/qubitpi/ws/jersey/template/config/JpaDatastoreConfig.java new file mode 100644 index 00000000..4a19311a --- /dev/null +++ b/src/main/java/com/qubitpi/ws/jersey/template/config/JpaDatastoreConfig.java @@ -0,0 +1,115 @@ +/* + * Copyright Jiaqi Liu + * + * 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 + * + * http://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 com.qubitpi.ws.jersey.template.config; + +import org.aeonbits.owner.Config; + +import jakarta.validation.constraints.NotNull; +import net.jcip.annotations.Immutable; +import net.jcip.annotations.ThreadSafe; + +/** + * {@link JpaDatastoreConfig} provides an interface for retrieving configuration values, allowing for implicit type + * conversion, defaulting, and use of a runtime properties interface to override configured settings. + *

+ * {@link JpaDatastoreConfig} supports overriding between properties: + *

    + *
  1. It will try to load the given property from the + * operating system's + * environment variables; if an environment variable with the same name is found, its value will be + * returned. For instance, an environment variable can be set with + * {@code export EXAMPLE_CONFIG_KEY_NAME="some-value"} + *
  2. Otherwise, it will try to load the given property from the + * Java system properties + * ; if such property is defined, the associated value is returned. For example, a Java system property can + * be set using {@code System.setProperty("EXAMPLE_CONFIG_KEY_NAME", "some-value")} + *
  3. The first resource defining the property will prevail. + *
+ */ +@Immutable +@ThreadSafe +@Config.LoadPolicy(Config.LoadType.MERGE) +@Config.Sources({"system:env", "system:properties", "classpath:jpadatastore.properties"}) +public interface JpaDatastoreConfig extends Config { + + /** + * Persistence DB username (needs have both Read and Write permissions). + * + * @return a credential + */ + @NotNull + @Key("DB_USER") + String dbUser(); + + /** + * The persistence DB user password. + * + * @return a credential + */ + @NotNull + @Key("DB_PASSWORD") + String dbPassword(); + + /** + * The persistence DB URL, such as "jdbc:mysql://localhost/elide?serverTimezone=UTC". + * + * @return a JDBC connection URL + */ + @NotNull + @Key("DB_URL") + String dbUrl(); + + /** + * The SQL DB driver class name, such as "com.mysql.jdbc.Driver". + * + * @return a DB config string + */ + @NotNull + @Key("DB_DRIVER") + String dbDriver(); + + /** + * The SQL DB dialect name, such as "org.hibernate.dialect.MySQLDialect". + * + * @return a DB config string + */ + @NotNull + @Key("DB_DIALECT") + String dbDialect(); + + + /** + * What to do with existing JPA database when webservice starts. + *

+ * Can be one of the 4 values: + *

    + *
  1. validate - validate that the schema matches, make no changes to the schema of the database. This is the + * default value + *
  2. update - update the schema to reflect the entities being persisted + *
  3. create - creates the schema necessary for your entities, destroying any previous data. + *
  4. create-drop - create the schema as in create above, but also drop the schema at the end of the session. + * This is great in development or for testing. + *
+ * See https://stackoverflow.com/questions/18077327/hibernate-hbm2ddl-auto-possible-values-and-what-they-do for more + * details. + * + * @return a DB config string + */ + @NotNull + @DefaultValue("validate") + @Key("HIBERNATE_HBM2DDL_AUTO") + String hibernateMbm2ddlAuto(); +} diff --git a/src/main/java/com/qubitpi/ws/jersey/template/web/endpoints/DataServlet.java b/src/main/java/com/qubitpi/ws/jersey/template/web/endpoints/DataServlet.java deleted file mode 100644 index fb5cd901..00000000 --- a/src/main/java/com/qubitpi/ws/jersey/template/web/endpoints/DataServlet.java +++ /dev/null @@ -1,62 +0,0 @@ -/* - * Copyright Jiaqi Liu - * - * 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 - * - * http://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 com.qubitpi.ws.jersey.template.web.endpoints; - -import com.fasterxml.jackson.databind.ObjectMapper; - -import jakarta.inject.Inject; -import jakarta.inject.Singleton; -import jakarta.ws.rs.GET; -import jakarta.ws.rs.Path; -import jakarta.ws.rs.Produces; -import jakarta.ws.rs.core.MediaType; -import jakarta.ws.rs.core.Response; -import net.jcip.annotations.Immutable; -import net.jcip.annotations.ThreadSafe; - -/** - * Endpoint that contains a basic sanity-check. - */ -@Singleton -@Immutable -@ThreadSafe -@Path("/data") -@Produces(MediaType.APPLICATION_JSON) -public class DataServlet { - - private static final ObjectMapper JSON_MAPPER = new ObjectMapper(); - - /** - * Constructor for dependency injection. - */ - @Inject - public DataServlet() { - // intentionally left blank - } - - /** - * A webservice sanity-check endpoint. - * - * @return 200 OK response - */ - @GET - @Path("/healthcheck") - public Response healthcheck() { - return Response - .status(Response.Status.OK) - .build(); - } -} diff --git a/src/test/groovy/com/qubitpi/ws/jersey/template/DataServletITSpec.groovy b/src/test/groovy/com/qubitpi/ws/jersey/template/DataServletITSpec.groovy deleted file mode 100644 index 9b5efe2f..00000000 --- a/src/test/groovy/com/qubitpi/ws/jersey/template/DataServletITSpec.groovy +++ /dev/null @@ -1,85 +0,0 @@ -/* - * Copyright Jiaqi Liu - * - * 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 - * - * http://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 com.qubitpi.ws.jersey.template - -import org.testcontainers.containers.GenericContainer -import org.testcontainers.images.PullPolicy -import org.testcontainers.images.builder.ImageFromDockerfile -import org.testcontainers.spock.Testcontainers - -import io.restassured.RestAssured -import io.restassured.builder.RequestSpecBuilder -import spock.lang.Shared -import spock.lang.Specification -import spock.lang.Subject -import spock.lang.Unroll - -import java.nio.file.Paths - -/** - * Integration tests for WS running in Dockerfile. - * - * It uses testcontainers to orchestrate lifecycle of the test container through @Testcontainers annotation - * - * see https://www.testcontainers.org/quickstart/spock_quickstart/ - * see https://www.testcontainers.org/test_framework_integration/spock/#testcontainers-class-annotation - */ -@Testcontainers -class DataServletITSpec extends Specification { - - static final int SUCCESS = 0 - static final List LOCAL_ENVS = ["Mac OS X", "windows"] - static final String CHECK_DOCKER_INSTALLED_COMMAND = "docker -v" - static final String DOCKERFILE_ABS_PATH = String.format("%s/Dockerfile", System.getProperty("user.dir")) - - @Deprecated - @SuppressWarnings('GroovyUnusedCatchParameter') - private static boolean dockerNotInstalled() { - try { - return Runtime.getRuntime().exec(CHECK_DOCKER_INSTALLED_COMMAND).waitFor() != SUCCESS - } catch (Exception exception) { - return true // I hate this - } - } - - private static boolean isLocal() { - return System.properties['os.name'] as String in LOCAL_ENVS - } - - @Shared - @Subject - GenericContainer container = new GenericContainer<>( - new ImageFromDockerfile().withDockerfile(Paths.get(DOCKERFILE_ABS_PATH)) - ) - .withExposedPorts(8080) - .withImagePullPolicy(PullPolicy.defaultPolicy()) - - def setupSpec() { - RestAssured.baseURI = "http://" + container.host - RestAssured.port = container.firstMappedPort - RestAssured.basePath = "/v1" - } - - @Unroll - def "Dockerized WS responds to healthcheck request 200 SUCCESS"() { - expect: - RestAssured.given() - .when() - .get("/data/healthcheck") - .then() - .statusCode(200) - } -} diff --git a/src/test/groovy/com/qubitpi/ws/jersey/template/JettyServerFactorySpec.groovy b/src/test/groovy/com/qubitpi/ws/jersey/template/JettyServerFactorySpec.groovy deleted file mode 100755 index 07d9e2e4..00000000 --- a/src/test/groovy/com/qubitpi/ws/jersey/template/JettyServerFactorySpec.groovy +++ /dev/null @@ -1,72 +0,0 @@ -/* - * Copyright Jiaqi Liu - * - * 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 - * - * http://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 com.qubitpi.ws.jersey.template - -import org.eclipse.jetty.server.Server -import org.glassfish.jersey.server.ResourceConfig - -import io.restassured.RestAssured -import io.restassured.builder.RequestSpecBuilder -import jakarta.inject.Inject -import jakarta.ws.rs.ApplicationPath -import spock.lang.Specification - -class JettyServerFactorySpec extends Specification { - - static final int PORT = 8080 - static final String ENDPOINT_RESOURCE_PACKAGE = "com.qubitpi.ws.jersey.template.resource" - - /** - * DI constructor. - *

- * CAUTION: the {@code @ApplicationPath("v1")} is not taking effects. See {@link JettyServerFactory} for more - * details. - */ - @ApplicationPath("v1") - class TestResourceConfig extends ResourceConfig { - - @Inject - TestResourceConfig() { - packages(ENDPOINT_RESOURCE_PACKAGE) - } - } - - def setupSpec() { - RestAssured.baseURI = "http://localhost" - RestAssured.port = PORT - RestAssured.basePath = "/v1" - } - - def "Factory produces Jsersey-Jetty applications"() { - setup: - Server server = JettyServerFactory.newInstance(PORT, "/v1/*", new TestResourceConfig()) - server.start() - - expect: - RestAssured - .when() - .get("/v1/example/test") - .then() - .statusCode(200) - - RestAssured - .when() - .get("/v1/example/test").asString() == "SUCCESS" - - cleanup: - server.stop() - } -} diff --git a/src/test/groovy/com/qubitpi/ws/jersey/template/application/AbstractITSpec.groovy b/src/test/groovy/com/qubitpi/ws/jersey/template/application/AbstractITSpec.groovy new file mode 100644 index 00000000..9b62c2db --- /dev/null +++ b/src/test/groovy/com/qubitpi/ws/jersey/template/application/AbstractITSpec.groovy @@ -0,0 +1,558 @@ +/* + * Copyright Jiaqi Liu + * + * 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 + * + * http://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 com.qubitpi.ws.jersey.template.application + +import static com.yahoo.elide.test.graphql.GraphQLDSL.argument +import static com.yahoo.elide.test.graphql.GraphQLDSL.arguments +import static com.yahoo.elide.test.graphql.GraphQLDSL.document +import static com.yahoo.elide.test.graphql.GraphQLDSL.field +import static com.yahoo.elide.test.graphql.GraphQLDSL.mutation +import static com.yahoo.elide.test.graphql.GraphQLDSL.query +import static com.yahoo.elide.test.graphql.GraphQLDSL.selection +import static com.yahoo.elide.test.graphql.GraphQLDSL.selections +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.attr +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.attributes +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.data +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.datum +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.id +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.resource +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.type +import static org.hamcrest.Matchers.contains +import static org.hamcrest.Matchers.equalTo + +import com.yahoo.elide.jsonapi.JsonApi + +import org.apache.http.HttpStatus + +import groovy.json.JsonBuilder +import io.github.qubitpi.ws.jersey.template.models.Book +import io.restassured.RestAssured +import io.restassured.response.Response +import jakarta.validation.constraints.NotNull +import jakarta.ws.rs.core.MediaType +import spock.lang.Specification + +abstract class AbstractITSpec extends Specification { + + static final int WS_PORT = 8080 + + def childSetupSpec() { + // intentionally left blank + } + + def childCleanupSpec() { + // intentionally left blank + } + + def setupSpec() { + RestAssured.baseURI = "http://localhost" + RestAssured.port = WS_PORT + RestAssured.basePath = "/v1/data/" + + System.setProperty("HIBERNATE_HBM2DDL_AUTO", "create") + + childSetupSpec() + } + + def cleanupSpec() { + RestAssured.reset() + + childCleanupSpec() + + System.clearProperty("HIBERNATE_HBM2DDL_AUTO") + } + + def "JSON API allows for POSTing, GETing, PATCHing, and DELETing a book"() { + expect: "database is initially empty" + RestAssured + .given() + .when() + .get("book") + .then() + .statusCode(200) + .body(equalTo(data().toJSON())) + + when: "an entity is POSTed via JSON API" + Response response = RestAssured + .given() + .contentType(JsonApi.MEDIA_TYPE) + .accept(JsonApi.MEDIA_TYPE) + .body( + data( + resource( + type("book"), + attributes( + attr("title", "Pride & Prejudice") + ) + ) + ) + ) + .when() + .post("book") + + then: "a new record is inserted into the database" + final String bookId = response.jsonPath().get("data.id") + response + .then() + .statusCode(HttpStatus.SC_CREATED) + .body(equalTo( + datum( + resource( + type("book"), + id(bookId), + attributes( + attr("title", "Pride & Prejudice") + ) + ) + ).toJSON() + )) + + then: "we can GET that entity next" + RestAssured + .given() + .when() + .get("book") + .then() + .statusCode(200) + .body(equalTo( + data( + resource( + type("book"), + id(bookId), + attributes( + attr("title", "Pride & Prejudice") + ) + ) + ).toJSON() + )) + + when: "we update that entity" + RestAssured + .given() + .contentType(JsonApi.MEDIA_TYPE) + .accept(JsonApi.MEDIA_TYPE) + .body( + datum( + resource( + type("book"), + id(bookId), + attributes( + attr("title", "Pride and Prejudice") + ) + ) + ) + ) + .when() + .patch("book/${bookId}") + .then() + .statusCode(HttpStatus.SC_NO_CONTENT) + + then: "we can GET that entity with updated attribute" + RestAssured + .given() + .when() + .get("book") + .then() + .statusCode(200) + .body(equalTo( + data( + resource( + type("book"), + id(bookId), + attributes( + attr("title", "Pride and Prejudice") + ) + ) + ).toJSON() + )) + + when: "the entity is deleted" + RestAssured + .given() + .when() + .delete("book/${bookId}") + .then() + .statusCode(HttpStatus.SC_NO_CONTENT) + + then: "that entity is not found in database anymore" + RestAssured + .given() + .when() + .get("book") + .then() + .statusCode(200) + .body(equalTo(data().toJSON())) + } + + def "GraphQL API allows for POSTing, GETing, PATCHing, and DELETing a book"() { + expect: "database is initially empty" + RestAssured + .given() + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .body( + query: document( + query( + selections( + field( + "book", + selections( + field("id"), + field("title") + ) + ), + ) + ) + ).toQuery() + ) + .when().post().then() + .statusCode(200) + .body(equalTo("""{"data":{"book":{"edges":[]}}}""")) + + when: "an entity is POSTed via GraphQL API" + Response response = RestAssured + .given() + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .body( + query: document( + mutation( + selection( + field( + "book", + arguments( + argument("op", "UPSERT"), + argument("data", new Book(title: "Pride & Prejudice")) + ), + selections( + field("id"), + field("title") + ) + ) + ) + ) + ).toQuery() + ) + .when() + .post() + + then: "a new record is inserted into the database" + final String bookId = response.jsonPath().get("data.book.edges[0].node.id") + response + .then() + .statusCode(200) + .body(equalTo( + document( + selection( + field( + "book", + selections( + field("id", bookId), + field("title", "Pride & Prejudice") + ) + ) + ) + ).toResponse() + )) + + then: "we can retrieve that entity next" + RestAssured + .given() + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .body( + query: document( + query( + selections( + field( + "book", + selections( + field("id"), + field("title") + ) + ), + ) + ) + ).toQuery() + ) + .when().post().then() + .statusCode(200) + .body(equalTo( + document( + selection( + field( + "book", + selections( + field("id", bookId), + field("title", "Pride & Prejudice") + ) + ) + ) + ).toResponse() + )) + + when: "we update that entity" + RestAssured + .given() + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .body( + query: document( + mutation( + selection( + field( + "book", + arguments( + argument("op", "UPSERT"), + argument("data", new Book(id: Long.valueOf(bookId), title: "Pride and Prejudice")) + ), + selections( + field("id"), + field("title") + ) + ) + ) + ) + ).toQuery() + ) + .when().post().then() + .statusCode(200) + .body(equalTo( + document( + selection( + field( + "book", + selections( + field("id", bookId), + field("title", "Pride and Prejudice") + ) + ) + ) + ).toResponse() + )) + + then: "we can retrieve that entity with updated attribute" + RestAssured + .given() + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .body( + query: document( + query( + selections( + field( + "book", + selections( + field("id"), + field("title") + ) + ), + ) + ) + ).toQuery() + ) + .when().post().then() + .statusCode(200) + .body(equalTo( + document( + selection( + field( + "book", + selections( + field("id", bookId), + field("title", "Pride and Prejudice") + ) + ) + ) + ).toResponse() + )) + + when: "the entity is deleted" + RestAssured + .given() + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .body( + query: document( + mutation( + selection( + field( + "book", + arguments( + argument("op", "DELETE"), + argument("ids", [bookId]) + ), + selections( + field("id"), + field("title") + ) + ) + ) + ) + ).toQuery() + ) + .when().post().then() + .statusCode(200) + + then: "that entity is not found in database anymore" + RestAssured + .given() + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .body( + query: document( + query( + selections( + field( + "book", + selections( + field("id"), + field("title") + ) + ), + ) + ) + ).toQuery() + ) + .when().post().then() + .statusCode(200) + .body(equalTo("""{"data":{"book":{"edges":[]}}}""")) + } + + def "GraphQL API can sort and paginate (effectively fetching 1 record with some min/max attribute)"() { + given: "3 entities are inserted into the database" + createBook(new Book(title: "Pride & Prejudice")) + createBook(new Book(title: "Effective Java")) + final String maxBookId = createBook(new Book(title: "Critiques of Pure Reason")) + .jsonPath() + .get("data.book.edges[0].node.id") + + expect: "sorting by ID in descending order and paginating to get the firsts result returns Kant's work" + RestAssured + .given() + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .body( + query: """ + { + book(sort: "-id", first: "1", after: "0") { + edges { + node { + id + title + } + } + pageInfo { + totalRecords + startCursor + endCursor + hasNextPage + } + } + } + """ + ) + .when().post().then() + .statusCode(200) + .body(equalTo( + new JsonBuilder( + data: [ + book: [ + edges:[[ + node: [ + id: "${maxBookId}", + title:"Critiques of Pure Reason" + ] + ]], + pageInfo: [ + totalRecords: 3, + startCursor: "0", + endCursor: "1", + hasNextPage:true + ] + ] + ] + ).toString() + )) + } + + def "GraphQL schema introspection contains the documentation of the fields annotated by @GraphQLDescription"() { + RestAssured + .given() + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .body( + query: """ + { + __type(name: "Book") { + name + fields { + name + description + } + } + } + """ + ) + .when().post().then() + .statusCode(200) + .body(equalTo(new JsonBuilder( + data: { + __type: { + name: "Book" + fields: [ + { + name: "id" + description: null + }, + { + name: "title" + description: "The book title" + } + ] + } + } + ))) + } + + static Response createBook(@NotNull final Book book) { + RestAssured + .given() + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .body( + query: document( + mutation( + selection( + field( + "book", + arguments( + argument("op", "UPSERT"), + argument("data", book) + ), + selections( + field("id"), + field("title") + ) + ) + ) + ) + ).toQuery() + ) + .when() + .post() + } +} diff --git a/src/test/groovy/com/qubitpi/ws/jersey/template/application/DockerComposeITSpec.groovy b/src/test/groovy/com/qubitpi/ws/jersey/template/application/DockerComposeITSpec.groovy new file mode 100644 index 00000000..1646bfdd --- /dev/null +++ b/src/test/groovy/com/qubitpi/ws/jersey/template/application/DockerComposeITSpec.groovy @@ -0,0 +1,28 @@ +/* + * Copyright Paion Data + * + * 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 + * + * http://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 com.qubitpi.ws.jersey.template.application + +import org.testcontainers.containers.DockerComposeContainer +import org.testcontainers.containers.wait.strategy.Wait +import org.testcontainers.spock.Testcontainers + +@Testcontainers +class DockerComposeITSpec extends AbstractITSpec { + + final DockerComposeContainer COMPOSE = new DockerComposeContainer(new File("docker-compose.yml")) + .withEnv("MODEL_PACKAGE_NAME", System.getenv().get("MODEL_PACKAGE_NAME")) + .withExposedService("web", WS_PORT, Wait.forHttp("/v1/data/book").forStatusCode(200)) +} diff --git a/src/test/groovy/com/qubitpi/ws/jersey/template/application/ResourceConfigITSpec.groovy b/src/test/groovy/com/qubitpi/ws/jersey/template/application/ResourceConfigITSpec.groovy new file mode 100644 index 00000000..09864aa1 --- /dev/null +++ b/src/test/groovy/com/qubitpi/ws/jersey/template/application/ResourceConfigITSpec.groovy @@ -0,0 +1,72 @@ +/* + * Copyright Jiaqi Liu + * + * 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 + * + * http://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 com.qubitpi.ws.jersey.template.application + +import org.eclipse.jetty.server.Server +import org.eclipse.jetty.servlet.ServletContextHandler +import org.eclipse.jetty.servlet.ServletHolder +import org.glassfish.jersey.servlet.ServletContainer +import org.testcontainers.containers.MySQLContainer +import org.testcontainers.spock.Testcontainers + +import io.restassured.RestAssured +import io.restassured.builder.RequestSpecBuilder +import spock.lang.Shared + +@Testcontainers +class ResourceConfigITSpec extends AbstractITSpec { + + final Server jettyEmbeddedServer = new Server(WS_PORT) + + @Shared + final MySQLContainer MYSQL = new MySQLContainer("mysql:5.7.43").withDatabaseName("elide") + + @Override + def childSetupSpec() { + System.setProperty( + "DB_URL", + String.format("jdbc:mysql://localhost:%s/elide?serverTimezone=UTC", MYSQL.firstMappedPort) + ) + } + + @Override + def childCleanupSpec() { + System.clearProperty("DB_URL") + } + + @SuppressWarnings('GroovyAccessibility') + def setup() { + ServletContextHandler servletContextHandler = new ServletContextHandler() + servletContextHandler.setContextPath("/") + + jettyEmbeddedServer.setHandler(servletContextHandler) + + ServletHolder jerseyServlet = servletContextHandler.addServlet(ServletContainer.class, "/v1/data/*") + jerseyServlet.setInitOrder(0) + jerseyServlet.setInitParameter( + "jersey.config.server.provider.packages", + [ResourceConfig.JAON_API_ENDPOINT_PACKAGE, ResourceConfig.GRAPHQL_ENDPOINT_PACKAGE].join(";") + ) + jerseyServlet.setInitParameter("jakarta.ws.rs.Application", ResourceConfig.class.getCanonicalName()) + + jettyEmbeddedServer.start() + } + + def cleanup() { + jettyEmbeddedServer.stop() + jettyEmbeddedServer.destroy() + } +} diff --git a/src/test/groovy/com/qubitpi/ws/jersey/template/application/ResourceConfigSpec.groovy b/src/test/groovy/com/qubitpi/ws/jersey/template/application/ResourceConfigSpec.groovy index 861c8b19..347ce16d 100644 --- a/src/test/groovy/com/qubitpi/ws/jersey/template/application/ResourceConfigSpec.groovy +++ b/src/test/groovy/com/qubitpi/ws/jersey/template/application/ResourceConfigSpec.groovy @@ -17,6 +17,7 @@ package com.qubitpi.ws.jersey.template.application import com.qubitpi.ws.jersey.template.web.filters.CorsFilter +import org.glassfish.hk2.api.ServiceLocator import org.glassfish.jersey.internal.inject.Binder import spock.lang.Specification @@ -29,10 +30,13 @@ class ResourceConfigSpec extends Specification { def "Instantiation triggers initialization and binding lifecycles"() { setup: "binder is mocked out" BinderFactory binderFactory = Mock(BinderFactory) - binderFactory.buildBinder() >> Mock(Binder) + binderFactory.buildBinder(_ as ServiceLocator) >> Mock(Binder) when: "injecting resources" - org.glassfish.jersey.server.ResourceConfig resourceConfig = new ResourceConfig() + org.glassfish.jersey.server.ResourceConfig resourceConfig = new ResourceConfig( + Mock(ServiceLocator), + binderFactory + ) then: "all request & response filters are injected" resourceConfig.classes.containsAll(ALWAYS_REGISTERED_FILTERS) diff --git a/src/test/groovy/com/qubitpi/ws/jersey/template/cache/LruCacheSpec.groovy b/src/test/groovy/com/qubitpi/ws/jersey/template/cache/LruCacheSpec.groovy deleted file mode 100644 index f7d30fe3..00000000 --- a/src/test/groovy/com/qubitpi/ws/jersey/template/cache/LruCacheSpec.groovy +++ /dev/null @@ -1,50 +0,0 @@ -/* - * Copyright Jiaqi Liu - * - * 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 - * - * http://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 com.qubitpi.ws.jersey.template.cache - -import spock.lang.Specification - -class LruCacheSpec extends Specification { - - @SuppressWarnings(["GroovyAccessibility"]) - def "the least frequently used cache entry is removed when cache is full"() { - given: "a cache with cache size of 2" - LruCache cache = new LruCache<>(2) - - when: "inserting 2 entries into cache to make it full" - cache.put("foo", "bar") - cache.put("bat", "baz") - - then: "all inserted entries are there" - cache.toString() == "[foo:bar, bat:baz]" - - when: "outdate an entry and putting a new cache entry into the cache" - cache.get("bat") - cache.put("new", "new") - - then: "the outdated entry is reomved" - cache.toString() == "[bat:baz, new:new]" - } - - def "static factory method produces a new instances"() { - given: "two instance produced by the same static factory method" - LruCache first = LruCache.ofSize(3) - LruCache second = LruCache.ofSize(3) - - expect: "the two instances are different objects" - ! first.is(second) - } -} diff --git a/src/test/groovy/com/qubitpi/ws/jersey/template/web/endpoints/DataServletSpec.groovy b/src/test/groovy/com/qubitpi/ws/jersey/template/web/endpoints/DataServletSpec.groovy deleted file mode 100644 index 0dc50b22..00000000 --- a/src/test/groovy/com/qubitpi/ws/jersey/template/web/endpoints/DataServletSpec.groovy +++ /dev/null @@ -1,52 +0,0 @@ -/* - * Copyright Jiaqi Liu - * - * 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 - * - * http://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 com.qubitpi.ws.jersey.template.web.endpoints - -import com.qubitpi.ws.jersey.template.JettyServerFactory -import com.qubitpi.ws.jersey.template.application.ResourceConfig - -import org.eclipse.jetty.server.Server - -import io.restassured.RestAssured -import io.restassured.builder.RequestSpecBuilder -import spock.lang.Specification - -class DataServletSpec extends Specification { - - static final int PORT = 8080 - - def setupSpec() { - RestAssured.baseURI = "http://localhost" - RestAssured.port = PORT - RestAssured.basePath = "/v1" - } - - def "Healthchecking endpoints returns 200"() { - setup: - Server server = JettyServerFactory.newInstance(PORT, "/v1/*", new ResourceConfig()) - server.start() - - expect: - RestAssured.given() - .when() - .get("/data/healthcheck") - .then() - .statusCode(200) - - cleanup: - server.stop() - } -} diff --git a/src/test/java/com/qubitpi/ws/jersey/template/JettyServerFactory.java b/src/test/java/com/qubitpi/ws/jersey/template/JettyServerFactory.java deleted file mode 100755 index c9707271..00000000 --- a/src/test/java/com/qubitpi/ws/jersey/template/JettyServerFactory.java +++ /dev/null @@ -1,94 +0,0 @@ -/* - * Copyright Jiaqi Liu - * - * 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 - * - * http://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 com.qubitpi.ws.jersey.template; - -import org.eclipse.jetty.server.Server; -import org.eclipse.jetty.servlet.ServletContextHandler; -import org.eclipse.jetty.servlet.ServletHolder; -import org.glassfish.jersey.server.ResourceConfig; -import org.glassfish.jersey.servlet.ServletContainer; - -import net.jcip.annotations.Immutable; -import net.jcip.annotations.ThreadSafe; - -import java.util.Objects; - -/** - * {@link JettyServerFactory} is provides embedded Jersey-Jetty instances for testing purposes. - *

- * Note that {@link JettyServerFactory} is designed only for testing purposes. Any production uses are not assumed. - */ -@Immutable -@ThreadSafe -public final class JettyServerFactory { - - /** - * Constructor. - *

- * Suppress default constructor for noninstantiability. - * - * @throws AssertionError when called - */ - private JettyServerFactory() { - throw new AssertionError(); - } - - /** - * Returns a embedded Jersey-Jetty server for local testing purposes. - * - * @param port The port number serving all testing requests on the embedded Jetty - * @param pathSpec The common path of all API's, e.g. "/v1/*" - * @param resourceConfig A Jersey subclass of JAX-RS {@link jakarta.ws.rs.core.Application}. Due to a - * bug) in Jersey, {@code @ApplicationPath} - * annotated on {@code resourceConfig} class is ignored in embedded Jetty. For example - * - *

-     * {@code
-     * @ApplicationPath("v1")
-     * class TestResourceConfig extends ResourceConfig {
-     *
-     *     @Inject
-     *     TestResourceConfig() {
-     *         packages(ENDPOINT_RESOURCE_PACKAGE)
-     *     }
-     * }
-     * }
-     * 
- * - * The {@code @ApplicationPath("v1")} annotation is - * not taking any effects. We must somehow prefix - * "v1" either at endpoint resource (such as {@code @Path("/v1/...")}) or completely remove "v1" in test request - * path. Which option to choose makes no difference. - * - * @return the embedded Jetty server for local testing purposes - * - * @throws NullPointerException if {@code pathSpec} or {@code resourceConfig} is {@code null} - */ - public static Server newInstance(final int port, final String pathSpec, final ResourceConfig resourceConfig) { - Objects.requireNonNull(pathSpec, "pathSpec"); - Objects.requireNonNull(resourceConfig, "resourceConfig"); - - final Server server = new Server(port); - - final ServletContainer servletContainer = new ServletContainer(resourceConfig); - final ServletHolder servletHolder = new ServletHolder(servletContainer); - final ServletContextHandler servletContextHandler = new ServletContextHandler(ServletContextHandler.SESSIONS); - servletContextHandler.addServlet(servletHolder, pathSpec); - server.setHandler(servletContextHandler); - - return server; - } -} diff --git a/src/test/java/com/qubitpi/ws/jersey/template/resource/TestEndpoint.java b/src/test/java/com/qubitpi/ws/jersey/template/resource/TestEndpoint.java deleted file mode 100755 index 86d8bf78..00000000 --- a/src/test/java/com/qubitpi/ws/jersey/template/resource/TestEndpoint.java +++ /dev/null @@ -1,52 +0,0 @@ -/* - * Copyright Jiaqi Liu - * - * 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 - * - * http://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 com.qubitpi.ws.jersey.template.resource; - -import org.glassfish.jersey.server.ResourceConfig; - -import jakarta.inject.Singleton; -import jakarta.ws.rs.GET; -import jakarta.ws.rs.Path; -import jakarta.ws.rs.core.Response; -import net.jcip.annotations.Immutable; -import net.jcip.annotations.ThreadSafe; - -/** - * A JAX-RS resource class used for testing {@link com.qubitpi.ws.jersey.template.JettyServerFactory}. - * - * see {@link com.qubitpi.ws.jersey.template.JettyServerFactory#newInstance(int, String, ResourceConfig)} for why we - * need to prefix @Path with "/v1" - */ -@Singleton -@Immutable -@ThreadSafe -@Path("/v1/example") -public class TestEndpoint { - - /** - * A sanity check endpoint that simply returns a 200 response. - * - * @return a simple success response - */ - @GET - @Path("/test") - public Response test() { - return Response - .status(Response.Status.OK) - .entity("SUCCESS") - .build(); - } -} diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties new file mode 100644 index 00000000..850c3844 --- /dev/null +++ b/src/test/resources/application.properties @@ -0,0 +1 @@ +MODEL_PACKAGE_NAME=io.github.qubitpi.ws.jersey.template.models diff --git a/src/test/resources/jpadatastore.properties b/src/test/resources/jpadatastore.properties new file mode 100644 index 00000000..098e4aa9 --- /dev/null +++ b/src/test/resources/jpadatastore.properties @@ -0,0 +1,4 @@ +DB_USER=root +DB_PASSWORD=test +DB_DRIVER=com.mysql.jdbc.Driver +DB_DIALECT=org.hibernate.dialect.MySQLDialect