diff --git a/.gitignore b/.gitignore index 40c012cba2..b35bcfc796 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,5 @@ dependency-reduced-pom.xml .project */.project */*/.project +*.factorypath +*.vscode diff --git a/.travis.yml b/.travis.yml index 2daa1906d7..deb9b6fbfb 100644 --- a/.travis.yml +++ b/.travis.yml @@ -17,6 +17,7 @@ branches: only: - master + - elide-5.x - "/^[0-9]+\\.[0-9]+(\\.[0-9]+|-(alpha|beta)-[0-9]+)/" install: true diff --git a/README.md b/README.md index 80d92e73f8..99523cd47d 100644 --- a/README.md +++ b/README.md @@ -134,8 +134,9 @@ Add Lifecycle hooks to your models to embed custom business logic that execute i @Include(rootLevel = true) @ReadPermission("Everyone") @CreatePermission("Admin OR Publisher") -@DeletePermission("Noone") -@UpdatePermission("Noone") +@DeletePermission("None") +@UpdatePermission("None") +@LifeCycleHookBinding(operation = UPDATE, hook = BookCreationHook.class, phase = PRECOMMIT) public class Book { @Id @@ -146,9 +147,13 @@ public class Book { @ManyToMany(mappedBy = "books") private Set authors; +} + +public class BookCreationHook implements LifeCycleHook { - @OnCreatePreCommit - public void onCreate(RequestScope scope) { + @Override + public void execute(LifeCycleHookBinding.Operation operation, Book book, + RequestScope requestScope, Optional changes) { //Do something } } diff --git a/checkstyle-suppressions.xml b/checkstyle-suppressions.xml index b79828e727..7be2a7532a 100644 --- a/checkstyle-suppressions.xml +++ b/checkstyle-suppressions.xml @@ -10,8 +10,10 @@ "http://www.puppycrawl.com/dtds/suppressions_1_0.dtd"> - + + + diff --git a/elide-annotations/pom.xml b/elide-annotations/pom.xml index 26bf308125..7df5ec65b2 100644 --- a/elide-annotations/pom.xml +++ b/elide-annotations/pom.xml @@ -9,7 +9,7 @@ com.yahoo.elide elide-parent-pom - 4.6.3-SNAPSHOT + 5.0.0-pr10-SNAPSHOT @@ -53,5 +53,4 @@ - diff --git a/elide-annotations/src/main/java/com/yahoo/elide/annotation/ApiVersion.java b/elide-annotations/src/main/java/com/yahoo/elide/annotation/ApiVersion.java new file mode 100644 index 0000000000..99f04a7f9a --- /dev/null +++ b/elide-annotations/src/main/java/com/yahoo/elide/annotation/ApiVersion.java @@ -0,0 +1,26 @@ +/* + * Copyright 2020, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.annotation; + +import static java.lang.annotation.ElementType.PACKAGE; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +/** + * Versions API Models. + */ +@Target({PACKAGE}) +@Retention(RUNTIME) +public @interface ApiVersion { + + /** + * Models in this package are tied to this API version. + * @return the string (default = "") + */ + String version() default ""; +} diff --git a/elide-annotations/src/main/java/com/yahoo/elide/annotation/Exclude.java b/elide-annotations/src/main/java/com/yahoo/elide/annotation/Exclude.java index 043fa15b70..60dcce6e1e 100644 --- a/elide-annotations/src/main/java/com/yahoo/elide/annotation/Exclude.java +++ b/elide-annotations/src/main/java/com/yahoo/elide/annotation/Exclude.java @@ -11,7 +11,6 @@ import static java.lang.annotation.ElementType.TYPE; import static java.lang.annotation.RetentionPolicy.RUNTIME; -import java.lang.annotation.Inherited; import java.lang.annotation.Retention; import java.lang.annotation.Target; @@ -20,6 +19,5 @@ */ @Target({METHOD, FIELD, TYPE, PACKAGE}) @Retention(RUNTIME) -@Inherited public @interface Exclude { } diff --git a/elide-annotations/src/main/java/com/yahoo/elide/annotation/Include.java b/elide-annotations/src/main/java/com/yahoo/elide/annotation/Include.java index af1faadf13..4b90e31683 100644 --- a/elide-annotations/src/main/java/com/yahoo/elide/annotation/Include.java +++ b/elide-annotations/src/main/java/com/yahoo/elide/annotation/Include.java @@ -9,7 +9,6 @@ import static java.lang.annotation.ElementType.TYPE; import static java.lang.annotation.RetentionPolicy.RUNTIME; -import java.lang.annotation.Inherited; import java.lang.annotation.Retention; import java.lang.annotation.Target; @@ -18,7 +17,6 @@ */ @Target({TYPE, PACKAGE}) @Retention(RUNTIME) -@Inherited public @interface Include { /** diff --git a/elide-annotations/src/main/java/com/yahoo/elide/annotation/LifeCycleHookBinding.java b/elide-annotations/src/main/java/com/yahoo/elide/annotation/LifeCycleHookBinding.java new file mode 100644 index 0000000000..e4915e6d5d --- /dev/null +++ b/elide-annotations/src/main/java/com/yahoo/elide/annotation/LifeCycleHookBinding.java @@ -0,0 +1,63 @@ +/* + * Copyright 2020, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.annotation; + +import com.yahoo.elide.functions.LifeCycleHook; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Repeatable; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Executes arbitrary logic (a lifecycle hook) when an Elide model is read or written. + */ +@Target({ElementType.TYPE, ElementType.METHOD, ElementType.FIELD}) +@Retention(RetentionPolicy.RUNTIME) +@Repeatable(LifeCycleHookBindings.class) +public @interface LifeCycleHookBinding { + + enum Operation { + CREATE, + READ, + UPDATE, + DELETE + }; + + enum TransactionPhase { + PRESECURITY, + PRECOMMIT, + POSTCOMMIT + } + + /** + * The function to invoke when this life cycle triggers. + * @return the function class. + */ + Class hook(); + + /** + * Which CRUD operation to trigger on. + * @return CREATE, READ, UPDATE, or DELETE + */ + Operation operation(); + + /** + * Which transaction phase to trigger on. + * @return PRESECURITY, PRECOMMIT, or POSTCOMMIT + */ + TransactionPhase phase() default TransactionPhase.PRECOMMIT; + + /** + * Controls how often the hook is invoked: + * A hook is invoked once per class per request (when bound to the model). + * A hook is invoked once per field per request (when bound to a model field or method). + * A hook is invoked one or more times per class per request (when bound to a model and oncePerRequest is false). + * @return true or false. + */ + boolean oncePerRequest() default true; +} diff --git a/elide-annotations/src/main/java/com/yahoo/elide/annotation/OnDeletePreSecurity.java b/elide-annotations/src/main/java/com/yahoo/elide/annotation/LifeCycleHookBindings.java similarity index 59% rename from elide-annotations/src/main/java/com/yahoo/elide/annotation/OnDeletePreSecurity.java rename to elide-annotations/src/main/java/com/yahoo/elide/annotation/LifeCycleHookBindings.java index f826635535..5a030a0b0b 100644 --- a/elide-annotations/src/main/java/com/yahoo/elide/annotation/OnDeletePreSecurity.java +++ b/elide-annotations/src/main/java/com/yahoo/elide/annotation/LifeCycleHookBindings.java @@ -1,23 +1,21 @@ /* - * Copyright 2016, Yahoo Inc. + * Copyright 2020, Yahoo Inc. * Licensed under the Apache License, Version 2.0 * See LICENSE file in project root for terms. */ package com.yahoo.elide.annotation; + import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; /** - * On Delete trigger annotation. - * - * The invoked function takes a RequestScope as parameter. - * @see com.yahoo.elide.security.RequestScope + * A group of repeatable LifeCycleHookBinding annotations. */ -@Target({ElementType.METHOD}) +@Target({ElementType.TYPE, ElementType.METHOD, ElementType.FIELD}) @Retention(RetentionPolicy.RUNTIME) -public @interface OnDeletePreSecurity { - +public @interface LifeCycleHookBindings { + LifeCycleHookBinding[] value(); } diff --git a/elide-annotations/src/main/java/com/yahoo/elide/annotation/NonTransferable.java b/elide-annotations/src/main/java/com/yahoo/elide/annotation/NonTransferable.java new file mode 100644 index 0000000000..38c428f265 --- /dev/null +++ b/elide-annotations/src/main/java/com/yahoo/elide/annotation/NonTransferable.java @@ -0,0 +1,30 @@ +/* + * Copyright 2020, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.annotation; + +import static java.lang.annotation.ElementType.PACKAGE; +import static java.lang.annotation.ElementType.TYPE; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +/** + * Marks that the given entity cannot be added to another collection after creation of the entity. + */ +@Target({TYPE, PACKAGE}) +@Retention(RUNTIME) +@Inherited +public @interface NonTransferable { + + /** + * If NonTransferable is used at the package level, it can be disabled for individual entities by setting + * this flag to false. + * @return true if enabled. + */ + boolean enabled() default true; +} diff --git a/elide-annotations/src/main/java/com/yahoo/elide/annotation/OnCreatePostCommit.java b/elide-annotations/src/main/java/com/yahoo/elide/annotation/OnCreatePostCommit.java deleted file mode 100644 index 390ca96e4c..0000000000 --- a/elide-annotations/src/main/java/com/yahoo/elide/annotation/OnCreatePostCommit.java +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright 2016, Yahoo Inc. - * Licensed under the Apache License, Version 2.0 - * See LICENSE file in project root for terms. - */ -package com.yahoo.elide.annotation; - -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -/** - * Post-create hook. This annotation marks a callback that is triggered when a user performs a "create" action. - * This hook will be triggered after all security checks have been run and after the datastore - * has been committed. - * - * The invoked function takes a RequestScope as parameter. - * @see com.yahoo.elide.security.RequestScope - */ -@Target({ElementType.METHOD}) -@Retention(RetentionPolicy.RUNTIME) -public @interface OnCreatePostCommit { - /** - * Field name on which the annotated method is only triggered if that field is modified. - * If value is empty string, then trigger once when the object is created. - * If value is "*", then this method will be triggered once for each field that - * the user sent in the creation request. - * - * @return the field name that triggers the method - */ - String value() default ""; -} diff --git a/elide-annotations/src/main/java/com/yahoo/elide/annotation/OnCreatePreCommit.java b/elide-annotations/src/main/java/com/yahoo/elide/annotation/OnCreatePreCommit.java deleted file mode 100644 index 1624b940f4..0000000000 --- a/elide-annotations/src/main/java/com/yahoo/elide/annotation/OnCreatePreCommit.java +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright 2016, Yahoo Inc. - * Licensed under the Apache License, Version 2.0 - * See LICENSE file in project root for terms. - */ -package com.yahoo.elide.annotation; - -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -/** - * Pre-create hook. This annotation marks a callback that is triggered when a user performs a "create" action. - * This hook will be triggered after all security checks have been run, but before the datastore - * has been committed. - * - * The invoked function takes a RequestScope as parameter. - * @see com.yahoo.elide.security.RequestScope - */ -@Target({ElementType.METHOD}) -@Retention(RetentionPolicy.RUNTIME) -public @interface OnCreatePreCommit { - /** - * Field name on which the annotated method is only triggered if that field is modified. - * If value is empty string, then trigger once when the object is created. - * If value is "*", then this method will be triggered once for each field that - * the user sent in the creation request. - * - * @return the field name that triggers the method - */ - String value() default ""; -} diff --git a/elide-annotations/src/main/java/com/yahoo/elide/annotation/OnCreatePreSecurity.java b/elide-annotations/src/main/java/com/yahoo/elide/annotation/OnCreatePreSecurity.java deleted file mode 100644 index 7bb0d7539e..0000000000 --- a/elide-annotations/src/main/java/com/yahoo/elide/annotation/OnCreatePreSecurity.java +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Copyright 2016, Yahoo Inc. - * Licensed under the Apache License, Version 2.0 - * See LICENSE file in project root for terms. - */ -package com.yahoo.elide.annotation; - -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -/** - * On Create trigger annotation. - * - * The invoked function takes a RequestScope as parameter. - * @see com.yahoo.elide.security.RequestScope - */ -@Target({ElementType.METHOD}) -@Retention(RetentionPolicy.RUNTIME) -public @interface OnCreatePreSecurity { - /** - * Field name on which the annotated method is only triggered if that field is modified. - * If value is empty string, then trigger once when the object is created. - * If value is "*", then this method will be triggered once for each field that - * the user sent in the creation request. - * - * @return the field name that triggers the method - */ - String value() default ""; -} diff --git a/elide-annotations/src/main/java/com/yahoo/elide/annotation/OnDeletePostCommit.java b/elide-annotations/src/main/java/com/yahoo/elide/annotation/OnDeletePostCommit.java deleted file mode 100644 index 32183cf29b..0000000000 --- a/elide-annotations/src/main/java/com/yahoo/elide/annotation/OnDeletePostCommit.java +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright 2016, Yahoo Inc. - * Licensed under the Apache License, Version 2.0 - * See LICENSE file in project root for terms. - */ -package com.yahoo.elide.annotation; - -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -/** - * Post-delete hook. This annotation marks a callback that is triggered when a user performs a "delete" action. - * This hook will be triggered after all security checks have been run and after the datastore - * has been committed. - * - * The invoked function takes a RequestScope as parameter. - * @see com.yahoo.elide.security.RequestScope - */ -@Target({ElementType.METHOD}) -@Retention(RetentionPolicy.RUNTIME) -public @interface OnDeletePostCommit { - -} diff --git a/elide-annotations/src/main/java/com/yahoo/elide/annotation/OnDeletePreCommit.java b/elide-annotations/src/main/java/com/yahoo/elide/annotation/OnDeletePreCommit.java deleted file mode 100644 index 78abed034a..0000000000 --- a/elide-annotations/src/main/java/com/yahoo/elide/annotation/OnDeletePreCommit.java +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright 2016, Yahoo Inc. - * Licensed under the Apache License, Version 2.0 - * See LICENSE file in project root for terms. - */ -package com.yahoo.elide.annotation; - -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -/** - * Pre-delete hook. This annotation marks a callback that is triggered when a user performs a "delete" action. - * This hook will be triggered after all security checks have been run, but before the datastore - * has been committed. - * - * The invoked function takes a RequestScope as parameter. - * @see com.yahoo.elide.security.RequestScope - */ -@Target({ElementType.METHOD}) -@Retention(RetentionPolicy.RUNTIME) -public @interface OnDeletePreCommit { - -} diff --git a/elide-annotations/src/main/java/com/yahoo/elide/annotation/OnReadPostCommit.java b/elide-annotations/src/main/java/com/yahoo/elide/annotation/OnReadPostCommit.java deleted file mode 100644 index 2d2dad821a..0000000000 --- a/elide-annotations/src/main/java/com/yahoo/elide/annotation/OnReadPostCommit.java +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright 2016, Yahoo Inc. - * Licensed under the Apache License, Version 2.0 - * See LICENSE file in project root for terms. - */ -package com.yahoo.elide.annotation; - -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -/** - * Post-read hook. This annotation marks a callback that is triggered when a user performs a "read" action. - * This hook will be triggered after all security checks have been run and after the datastore - * has been committed. - *

- * The invoked function takes a RequestScope as parameter. - * - * @see com.yahoo.elide.security.RequestScope - */ -@Target({ElementType.METHOD}) -@Retention(RetentionPolicy.RUNTIME) -public @interface OnReadPostCommit { - /** - * Field name on which the annotated method is only triggered if that field is read. - * If value is empty string, then trigger once when the object is read. - * If value is "*", then trigger for all field reads. - * - * @return the field name that triggers the method - */ - String value() default ""; -} diff --git a/elide-annotations/src/main/java/com/yahoo/elide/annotation/OnReadPreCommit.java b/elide-annotations/src/main/java/com/yahoo/elide/annotation/OnReadPreCommit.java deleted file mode 100644 index 1607ad300a..0000000000 --- a/elide-annotations/src/main/java/com/yahoo/elide/annotation/OnReadPreCommit.java +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright 2016, Yahoo Inc. - * Licensed under the Apache License, Version 2.0 - * See LICENSE file in project root for terms. - */ -package com.yahoo.elide.annotation; - -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -/** - * Pre-read hook. This annotation marks a callback that is triggered when a user performs a "read" action. - * This hook will be triggered after all security checks have been run, but before the datastore - * has been committed. - *

- * The invoked function takes a RequestScope as parameter. - * - * @see com.yahoo.elide.security.RequestScope - */ -@Target({ElementType.METHOD}) -@Retention(RetentionPolicy.RUNTIME) -public @interface OnReadPreCommit { - /** - * Field name on which the annotated method is only triggered if that field is read. - * If value is empty string, then trigger once when the object is read. - * If value is "*", then trigger for all field reads. - * - * @return the field name that triggers the method - */ - String value() default ""; -} diff --git a/elide-annotations/src/main/java/com/yahoo/elide/annotation/OnReadPreSecurity.java b/elide-annotations/src/main/java/com/yahoo/elide/annotation/OnReadPreSecurity.java deleted file mode 100644 index 26508c3d92..0000000000 --- a/elide-annotations/src/main/java/com/yahoo/elide/annotation/OnReadPreSecurity.java +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Copyright 2016, Yahoo Inc. - * Licensed under the Apache License, Version 2.0 - * See LICENSE file in project root for terms. - */ -package com.yahoo.elide.annotation; - -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -/** - * On read trigger annotation. - *

- * The invoked function takes a RequestScope as parameter. - * - * @see com.yahoo.elide.security.RequestScope - */ -@Target({ElementType.METHOD}) -@Retention(RetentionPolicy.RUNTIME) -public @interface OnReadPreSecurity { - /** - * Field name on which the annotated method is only triggered if that field is read. - * If value is empty string, then trigger once when the object is read. - * If value is "*", then trigger for all field reads. - * - * @return the field name that triggers this method - */ - String value() default ""; -} diff --git a/elide-annotations/src/main/java/com/yahoo/elide/annotation/OnUpdatePostCommit.java b/elide-annotations/src/main/java/com/yahoo/elide/annotation/OnUpdatePostCommit.java deleted file mode 100644 index 9ae6b91022..0000000000 --- a/elide-annotations/src/main/java/com/yahoo/elide/annotation/OnUpdatePostCommit.java +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright 2016, Yahoo Inc. - * Licensed under the Apache License, Version 2.0 - * See LICENSE file in project root for terms. - */ -package com.yahoo.elide.annotation; - -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -/** - * Post-update hook. This annotation marks a callback that is triggered when a user performs a "update" action. - * This hook will be triggered after all security checks have been run and after the datastore - * has been committed. - *

- * The invoked function takes a RequestScope as parameter. - * - * @see com.yahoo.elide.security.RequestScope - */ -@Target({ElementType.METHOD}) -@Retention(RetentionPolicy.RUNTIME) -public @interface OnUpdatePostCommit { - /** - * Field name on which the annotated method is only triggered if that field is modified. - * If value is empty string, then trigger once when the object is updated. - * If value is "*", then trigger for all field modifications. - * - * @return the field name that triggers this method - */ - String value() default ""; -} diff --git a/elide-annotations/src/main/java/com/yahoo/elide/annotation/OnUpdatePreCommit.java b/elide-annotations/src/main/java/com/yahoo/elide/annotation/OnUpdatePreCommit.java deleted file mode 100644 index 624b1344d2..0000000000 --- a/elide-annotations/src/main/java/com/yahoo/elide/annotation/OnUpdatePreCommit.java +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright 2016, Yahoo Inc. - * Licensed under the Apache License, Version 2.0 - * See LICENSE file in project root for terms. - */ -package com.yahoo.elide.annotation; - -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -/** - * Pre-update hook. This annotation marks a callback that is triggered when a user performs a "update" action. - * This hook will be triggered after all security checks have been run, but before the datastore - * has been committed. - *

- * The invoked function takes a RequestScope as parameter. - * - * @see com.yahoo.elide.security.RequestScope - */ -@Target({ElementType.METHOD}) -@Retention(RetentionPolicy.RUNTIME) -public @interface OnUpdatePreCommit { - /** - * Field name on which the annotated method is only triggered if that field is modified. - * If value is empty string, then trigger once when the object is updated. - * If value is "*", then trigger for all field modifications. - * - * @return the field name that triggers the method - */ - String value() default ""; -} diff --git a/elide-annotations/src/main/java/com/yahoo/elide/annotation/OnUpdatePreSecurity.java b/elide-annotations/src/main/java/com/yahoo/elide/annotation/OnUpdatePreSecurity.java deleted file mode 100644 index b6efe8c72c..0000000000 --- a/elide-annotations/src/main/java/com/yahoo/elide/annotation/OnUpdatePreSecurity.java +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Copyright 2016, Yahoo Inc. - * Licensed under the Apache License, Version 2.0 - * See LICENSE file in project root for terms. - */ -package com.yahoo.elide.annotation; - -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -/** - * On Update trigger annotation. - *

- * The invoked function takes a RequestScope as parameter. - * - * @see com.yahoo.elide.security.RequestScope - */ -@Target({ElementType.METHOD}) -@Retention(RetentionPolicy.RUNTIME) -public @interface OnUpdatePreSecurity { - /** - * Field name on which the annotated method is only triggered if that field is modified. - * If value is empty string, then trigger once when the object is updated. - * If value is "*", then trigger for all field modifications. - * - * @return the field name that triggers the method - */ - String value() default ""; -} diff --git a/elide-annotations/src/main/java/com/yahoo/elide/annotation/SharePermission.java b/elide-annotations/src/main/java/com/yahoo/elide/annotation/SharePermission.java deleted file mode 100644 index f53f697a6a..0000000000 --- a/elide-annotations/src/main/java/com/yahoo/elide/annotation/SharePermission.java +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright 2016, Yahoo Inc. - * Licensed under the Apache License, Version 2.0 - * See LICENSE file in project root for terms. - */ -package com.yahoo.elide.annotation; - -import static java.lang.annotation.ElementType.PACKAGE; -import static java.lang.annotation.ElementType.TYPE; -import static java.lang.annotation.RetentionPolicy.RUNTIME; - -import java.lang.annotation.Inherited; -import java.lang.annotation.Retention; -import java.lang.annotation.Target; - -/** - * A permission that is checked whenever an object is loaded without the context of a lineage and assigned - * to a relationship or collection. If SharePermission is specified, checking SharePermission falls back to checking - * ReadPermission. Otherwise, the entity is not shareable. - */ -@Target({TYPE, PACKAGE}) -@Retention(RUNTIME) -@Inherited -public @interface SharePermission { - - /** - * A boolean value indicating if the entity is shareable. If not specifying, shareable is true. Setting shareable to - * false provide a way to override package level SharePermission. - * - * @return the boolean if entity is shareable - */ - boolean sharable() default true; -} diff --git a/elide-annotations/src/main/java/com/yahoo/elide/functions/LifeCycleHook.java b/elide-annotations/src/main/java/com/yahoo/elide/functions/LifeCycleHook.java index c0a6339df5..0cf569d4f0 100644 --- a/elide-annotations/src/main/java/com/yahoo/elide/functions/LifeCycleHook.java +++ b/elide-annotations/src/main/java/com/yahoo/elide/functions/LifeCycleHook.java @@ -5,6 +5,7 @@ */ package com.yahoo.elide.functions; +import com.yahoo.elide.annotation.LifeCycleHookBinding; import com.yahoo.elide.security.ChangeSpec; import com.yahoo.elide.security.RequestScope; @@ -18,11 +19,13 @@ public interface LifeCycleHook { /** * Run for a lifecycle event. + * @param operation CREATE, READ, UPDATE, or DELETE * @param elideEntity The entity that triggered the event * @param requestScope The request scope * @param changes Optionally, the changes that were made to the entity */ - public abstract void execute(T elideEntity, + public abstract void execute(LifeCycleHookBinding.Operation operation, + T elideEntity, RequestScope requestScope, Optional changes); } diff --git a/elide-annotations/src/main/java/com/yahoo/elide/security/PersistentResource.java b/elide-annotations/src/main/java/com/yahoo/elide/security/PersistentResource.java index 557e24c673..b05663f07f 100644 --- a/elide-annotations/src/main/java/com/yahoo/elide/security/PersistentResource.java +++ b/elide-annotations/src/main/java/com/yahoo/elide/security/PersistentResource.java @@ -22,12 +22,4 @@ public interface PersistentResource { T getObject(); Class getResourceClass(); RequestScope getRequestScope(); - - /** - * Returns whether or not this resource was created in this transaction. - * @return True if this resource is newly created. - */ - default boolean isNewlyCreated() { - return getRequestScope().getNewResources().contains(this); - } } diff --git a/elide-annotations/src/main/java/com/yahoo/elide/security/RequestScope.java b/elide-annotations/src/main/java/com/yahoo/elide/security/RequestScope.java index 19e925694f..08c6356c84 100644 --- a/elide-annotations/src/main/java/com/yahoo/elide/security/RequestScope.java +++ b/elide-annotations/src/main/java/com/yahoo/elide/security/RequestScope.java @@ -1,16 +1,14 @@ /* - * Copyright 2016, Yahoo Inc. + * Copyright 2020, Yahoo Inc. * Licensed under the Apache License, Version 2.0 * See LICENSE file in project root for terms. */ package com.yahoo.elide.security; -import java.util.Set; - /** * The request scope interface passed to checks. */ public interface RequestScope { User getUser(); - Set getNewResources(); + String getApiVersion(); } diff --git a/elide-annotations/src/main/java/com/yahoo/elide/security/User.java b/elide-annotations/src/main/java/com/yahoo/elide/security/User.java index a1e95414fc..3328225b11 100644 --- a/elide-annotations/src/main/java/com/yahoo/elide/security/User.java +++ b/elide-annotations/src/main/java/com/yahoo/elide/security/User.java @@ -1,19 +1,28 @@ /* - * Copyright 2016, Yahoo Inc. + * Copyright 2020, Yahoo Inc. * Licensed under the Apache License, Version 2.0 * See LICENSE file in project root for terms. */ package com.yahoo.elide.security; import lombok.Getter; +import java.security.Principal; /** * Wrapper for opaque user passed in every request. */ public class User { - @Getter private final Object opaqueUser; + @Getter private final Principal principal; - public User(Object opaqueUser) { - this.opaqueUser = opaqueUser; + public User(Principal principal) { + this.principal = principal; + } + + public String getName() { + return principal.getName(); + } + + public boolean isInRole(String role) { + return false; } } diff --git a/elide-annotations/src/main/java/com/yahoo/elide/security/checks/Check.java b/elide-annotations/src/main/java/com/yahoo/elide/security/checks/Check.java index 6a18e17cb4..d2fd64d6c7 100644 --- a/elide-annotations/src/main/java/com/yahoo/elide/security/checks/Check.java +++ b/elide-annotations/src/main/java/com/yahoo/elide/security/checks/Check.java @@ -4,39 +4,9 @@ * See LICENSE file in project root for terms. */ package com.yahoo.elide.security.checks; - -import com.yahoo.elide.security.ChangeSpec; -import com.yahoo.elide.security.RequestScope; -import com.yahoo.elide.security.User; - -import java.util.Optional; - /** * Custom security access that verifies whether a user belongs to a role. * Permissions are assigned as a set of checks that grant access to the permission. - * @param Type of record for Check */ -public interface Check { - - /** - * Determines whether the user can access the resource. - * - * @param object Fully modified object - * @param requestScope Request scope object - * @param changeSpec Summary of modifications - * @return true if security check passed - */ - boolean ok(T object, RequestScope requestScope, Optional changeSpec); - - /** - * Method reserved for user checks. - * - * @param user User to check - * @return True if user check passes, false otherwise - */ - boolean ok(User user); - - default String checkIdentifier() { - return this.getClass().getName(); - } +public interface Check { } diff --git a/elide-annotations/src/main/java/com/yahoo/elide/security/checks/CommitCheck.java b/elide-annotations/src/main/java/com/yahoo/elide/security/checks/CommitCheck.java deleted file mode 100644 index e62d3909aa..0000000000 --- a/elide-annotations/src/main/java/com/yahoo/elide/security/checks/CommitCheck.java +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright 2016, Yahoo Inc. - * Licensed under the Apache License, Version 2.0 - * See LICENSE file in project root for terms. - */ -package com.yahoo.elide.security.checks; - -import com.yahoo.elide.security.User; - -/** - * Commit check interface. - * @see Check - * - * Commit checks are run immediately before a transaction is about to commit but after all changes have been made. - * Objects passed to this check are guaranteed to be in their final state. - * - * @param Type parameter - */ -public abstract class CommitCheck implements Check { - /* NOTE: Operation checks and user checks are intended to be _distinct_ */ - @Override - public final boolean ok(User user) { - throw new UnsupportedOperationException(); - } -} diff --git a/elide-annotations/src/main/java/com/yahoo/elide/security/checks/InlineCheck.java b/elide-annotations/src/main/java/com/yahoo/elide/security/checks/InlineCheck.java deleted file mode 100644 index 8f9c38cb3a..0000000000 --- a/elide-annotations/src/main/java/com/yahoo/elide/security/checks/InlineCheck.java +++ /dev/null @@ -1,16 +0,0 @@ -/* - * Copyright 2016, Yahoo Inc. - * Licensed under the Apache License, Version 2.0 - * See LICENSE file in project root for terms. - */ -package com.yahoo.elide.security.checks; - -/** - * Intermediate check representing the hierarchical structure of checks. - * For instance, Read/Delete permissions can take any type of InlineCheck - * while Create/Update permissions can be of any Check type. - * - * @param type parameter - */ -public abstract class InlineCheck implements Check { -} diff --git a/elide-annotations/src/main/java/com/yahoo/elide/security/checks/OperationCheck.java b/elide-annotations/src/main/java/com/yahoo/elide/security/checks/OperationCheck.java index d02472239b..8b72185f13 100644 --- a/elide-annotations/src/main/java/com/yahoo/elide/security/checks/OperationCheck.java +++ b/elide-annotations/src/main/java/com/yahoo/elide/security/checks/OperationCheck.java @@ -5,7 +5,10 @@ */ package com.yahoo.elide.security.checks; -import com.yahoo.elide.security.User; +import com.yahoo.elide.security.ChangeSpec; +import com.yahoo.elide.security.RequestScope; + +import java.util.Optional; /** * Operation check interface. @@ -18,10 +21,14 @@ * * @param Type parameter */ -public abstract class OperationCheck extends InlineCheck { - /* NOTE: Operation checks and user checks are intended to be _distinct_ */ - @Override - public final boolean ok(User user) { - throw new UnsupportedOperationException(); - } +public abstract class OperationCheck implements Check { + /** + * Determines whether the user can access the resource. + * + * @param object Fully modified object + * @param requestScope Request scope object + * @param changeSpec Summary of modifications + * @return true if security check passed + */ + public abstract boolean ok(T object, RequestScope requestScope, Optional changeSpec); } diff --git a/elide-annotations/src/main/java/com/yahoo/elide/security/checks/UserCheck.java b/elide-annotations/src/main/java/com/yahoo/elide/security/checks/UserCheck.java index f44a953060..41e62c68bc 100644 --- a/elide-annotations/src/main/java/com/yahoo/elide/security/checks/UserCheck.java +++ b/elide-annotations/src/main/java/com/yahoo/elide/security/checks/UserCheck.java @@ -5,19 +5,18 @@ */ package com.yahoo.elide.security.checks; -import com.yahoo.elide.security.ChangeSpec; -import com.yahoo.elide.security.RequestScope; - -import java.util.Optional; +import com.yahoo.elide.security.User; /** * Custom security access that verifies whether a user belongs to a role. * Permissions are assigned as a set of checks that grant access to the permission. */ -public abstract class UserCheck extends InlineCheck { - /* NOTE: Operation checks and user checks are intended to be _distinct_ */ - @Override - public final boolean ok(Object object, RequestScope requestScope, Optional changeSpec) { - return ok(requestScope.getUser()); - } +public abstract class UserCheck implements Check { + /** + * Method reserved for user checks. + * + * @param user User to check + * @return True if user check passes, false otherwise + */ + public abstract boolean ok(User user); } diff --git a/elide-annotations/src/main/java/com/yahoo/elide/security/checks/prefab/Collections.java b/elide-annotations/src/main/java/com/yahoo/elide/security/checks/prefab/Collections.java index e91bfc77a9..7a21a3668b 100644 --- a/elide-annotations/src/main/java/com/yahoo/elide/security/checks/prefab/Collections.java +++ b/elide-annotations/src/main/java/com/yahoo/elide/security/checks/prefab/Collections.java @@ -8,7 +8,7 @@ import com.yahoo.elide.security.ChangeSpec; import com.yahoo.elide.security.RequestScope; -import com.yahoo.elide.security.checks.CommitCheck; +import com.yahoo.elide.security.checks.OperationCheck; import java.util.Collection; import java.util.Optional; @@ -27,7 +27,7 @@ private Collections() { * * @param type collection to be validated */ - public static class AppendOnly extends CommitCheck { + public static class AppendOnly extends OperationCheck { @Override public boolean ok(T record, RequestScope requestScope, Optional changeSpec) { @@ -47,7 +47,7 @@ public boolean ok(T record, RequestScope requestScope, Optional chan * * @param type parameter */ - public static class RemoveOnly extends CommitCheck { + public static class RemoveOnly extends OperationCheck { @Override public boolean ok(T record, RequestScope requestScope, Optional changeSpec) { diff --git a/elide-annotations/src/main/java/com/yahoo/elide/security/checks/prefab/Common.java b/elide-annotations/src/main/java/com/yahoo/elide/security/checks/prefab/Common.java index cd6600983c..7b863fecec 100644 --- a/elide-annotations/src/main/java/com/yahoo/elide/security/checks/prefab/Common.java +++ b/elide-annotations/src/main/java/com/yahoo/elide/security/checks/prefab/Common.java @@ -7,9 +7,7 @@ package com.yahoo.elide.security.checks.prefab; import com.yahoo.elide.security.ChangeSpec; -import com.yahoo.elide.security.PersistentResource; import com.yahoo.elide.security.RequestScope; -import com.yahoo.elide.security.checks.CommitCheck; import com.yahoo.elide.security.checks.OperationCheck; import java.util.Optional; @@ -18,31 +16,13 @@ * Checks that are generally applicable. */ public class Common { - /** - * A check that enables users to update objects or fields during a create operation. This check allows - * users to be able to set values during object creation which are normally unmodifiable. - * - * @param the type of object that this check guards - */ - public static class UpdateOnCreate extends OperationCheck { - @Override - public boolean ok(T record, RequestScope requestScope, Optional changeSpec) { - for (PersistentResource resource : requestScope.getNewResources()) { - if (record == resource.getObject()) { - return true; - } - } - return false; - } - } - /** * A generic check which denies any mutation that sets a field value to anything other than null. * The check is handy in case where we want to prevent the sharing of the child entity with a different parent * but at the same time allows the removal of the child from the relationship with the existing parent * @param the type of object that this check guards */ - public static class FieldSetToNull extends CommitCheck { + public static class FieldSetToNull extends OperationCheck { @Override public boolean ok(T record, RequestScope requestScope, Optional changeSpec) { return changeSpec.map((c) -> { return c.getModified() == null; }) diff --git a/elide-annotations/src/main/java/com/yahoo/elide/security/checks/prefab/Role.java b/elide-annotations/src/main/java/com/yahoo/elide/security/checks/prefab/Role.java index b9387dd671..5ba7aaff77 100644 --- a/elide-annotations/src/main/java/com/yahoo/elide/security/checks/prefab/Role.java +++ b/elide-annotations/src/main/java/com/yahoo/elide/security/checks/prefab/Role.java @@ -31,4 +31,19 @@ public boolean ok(User user) { return false; } } + + /** + * Check which verifies if the user is a member of a particular role. + */ + public static class RoleMemberCheck extends UserCheck { + private String role; + + public RoleMemberCheck(String role) { + this.role = role; + } + @Override + public boolean ok(User user) { + return user.isInRole(role); + } + } } diff --git a/elide-async/pom.xml b/elide-async/pom.xml new file mode 100644 index 0000000000..e116c9c31d --- /dev/null +++ b/elide-async/pom.xml @@ -0,0 +1,150 @@ + + + + 4.0.0 + elide-async + jar + Elide Async + Elide Async + https://github.com/yahoo/elide + + com.yahoo.elide + elide-parent-pom + 5.0.0-pr10-SNAPSHOT + + + + + The Apache Software License, Version 2.0 + http://www.apache.org/licenses/LICENSE-2.0.txt + repo + + + + + Yahoo! Inc. + http://www.yahoo.com + + + + + Yahoo Inc. + https://github.com/yahoo + + + + + scm:git:ssh://git@github.com/yahoo/elide.git + https://github.com/yahoo/elide.git + HEAD + + + + 5.5.2 + + + + + com.yahoo.elide + elide-graphql + 5.0.0-pr10-SNAPSHOT + + + + javax.persistence + javax.persistence-api + 2.2 + provided + + + + com.fasterxml.jackson.core + jackson-core + ${version.jackson} + test + + + + com.fasterxml.jackson.core + jackson-databind + ${version.jackson} + test + + + + org.apache.httpcomponents + httpclient + 4.5.3 + + + + + + org.junit.jupiter + junit-jupiter-api + test + + + + org.junit.jupiter + junit-jupiter-params + ${junit.version} + test + + + + org.junit.jupiter + junit-jupiter-engine + test + + + + + com.h2database + h2 + test + + + + + org.mockito + mockito-core + test + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + false + 1 + + + + org.apache.maven.plugins + maven-checkstyle-plugin + + + org.codehaus.gmaven + gmaven-plugin + + + + generateStubs + compile + generateTestStubs + testCompile + + + + + + + diff --git a/elide-async/src/main/java/com/yahoo/elide/async/hooks/ExecuteQueryHook.java b/elide-async/src/main/java/com/yahoo/elide/async/hooks/ExecuteQueryHook.java new file mode 100644 index 0000000000..e191e5ef9a --- /dev/null +++ b/elide-async/src/main/java/com/yahoo/elide/async/hooks/ExecuteQueryHook.java @@ -0,0 +1,30 @@ +/* + * Copyright 2020, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.async.hooks; + +import com.yahoo.elide.annotation.LifeCycleHookBinding; +import com.yahoo.elide.async.models.AsyncQuery; +import com.yahoo.elide.async.service.AsyncExecutorService; +import com.yahoo.elide.functions.LifeCycleHook; +import com.yahoo.elide.security.ChangeSpec; +import com.yahoo.elide.security.RequestScope; + +import java.util.Optional; + +public class ExecuteQueryHook implements LifeCycleHook { + + private AsyncExecutorService asyncExecutorService; + + public ExecuteQueryHook (AsyncExecutorService asyncExecutorService) { + this.asyncExecutorService = asyncExecutorService; + } + + @Override + public void execute(LifeCycleHookBinding.Operation operation, AsyncQuery query, + RequestScope requestScope, Optional changes) { + asyncExecutorService.executeQuery(query, requestScope.getUser(), requestScope.getApiVersion()); + } +} diff --git a/elide-async/src/main/java/com/yahoo/elide/async/hooks/UpdatePrincipalNameHook.java b/elide-async/src/main/java/com/yahoo/elide/async/hooks/UpdatePrincipalNameHook.java new file mode 100644 index 0000000000..efdcb4b632 --- /dev/null +++ b/elide-async/src/main/java/com/yahoo/elide/async/hooks/UpdatePrincipalNameHook.java @@ -0,0 +1,27 @@ +/* + * Copyright 2020, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.async.hooks; + +import com.yahoo.elide.annotation.LifeCycleHookBinding; +import com.yahoo.elide.async.models.AsyncQuery; +import com.yahoo.elide.functions.LifeCycleHook; +import com.yahoo.elide.security.ChangeSpec; +import com.yahoo.elide.security.RequestScope; + +import java.security.Principal; +import java.util.Optional; + +public class UpdatePrincipalNameHook implements LifeCycleHook { + + @Override + public void execute(LifeCycleHookBinding.Operation operation, AsyncQuery query, + RequestScope requestScope, Optional changes) { + Principal principal = requestScope.getUser().getPrincipal(); + if (principal != null) { + query.setPrincipalName(principal.getName()); + } + } +} diff --git a/elide-async/src/main/java/com/yahoo/elide/async/models/AsyncBase.java b/elide-async/src/main/java/com/yahoo/elide/async/models/AsyncBase.java new file mode 100644 index 0000000000..b58e05fdc3 --- /dev/null +++ b/elide-async/src/main/java/com/yahoo/elide/async/models/AsyncBase.java @@ -0,0 +1,52 @@ +/* + * Copyright 2020, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.async.models; + +import com.yahoo.elide.annotation.Exclude; + +import lombok.Getter; + +import java.util.Date; +import java.util.UUID; + +import javax.persistence.MappedSuperclass; +import javax.persistence.PrePersist; +import javax.persistence.PreUpdate; + +@MappedSuperclass +public abstract class AsyncBase { + + @Getter private Date createdOn; + + @Getter private Date updatedOn; + + @Exclude + protected String naturalKey = UUID.randomUUID().toString(); + + @PrePersist + public void prePersist() { + createdOn = updatedOn = new Date(); + } + + @PreUpdate + public void preUpdate() { + this.updatedOn = new Date(); + } + + @Override + public int hashCode() { + return naturalKey.hashCode(); + } + + @Override + public boolean equals(Object obj) { + if (obj == null || !(obj instanceof AsyncBase) || this.getClass() != obj.getClass()) { + return false; + } + + return ((AsyncBase) obj).naturalKey.equals(naturalKey); + } +} diff --git a/elide-async/src/main/java/com/yahoo/elide/async/models/AsyncQuery.java b/elide-async/src/main/java/com/yahoo/elide/async/models/AsyncQuery.java new file mode 100644 index 0000000000..2443509aa6 --- /dev/null +++ b/elide-async/src/main/java/com/yahoo/elide/async/models/AsyncQuery.java @@ -0,0 +1,64 @@ +/* + * Copyright 2020, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.async.models; + +import com.yahoo.elide.annotation.DeletePermission; +import com.yahoo.elide.annotation.Exclude; +import com.yahoo.elide.annotation.Include; +import com.yahoo.elide.annotation.ReadPermission; +import com.yahoo.elide.annotation.UpdatePermission; + +import lombok.Data; + +import javax.persistence.CascadeType; +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Id; +import javax.persistence.OneToOne; +import javax.persistence.PrePersist; +import javax.validation.constraints.Pattern; + +/** + * Model for Async Query. + * ExecuteQueryHook and UpdatePrincipalNameHook is binded manually during the elide startup, + * after asyncexecutorservice is initialized. + */ +@Entity +@Include(type = "asyncQuery", rootLevel = true) +@ReadPermission(expression = "Principal is Owner") +@UpdatePermission(expression = "Prefab.Role.None") +@DeletePermission(expression = "Prefab.Role.None") +@Data +public class AsyncQuery extends AsyncBase implements PrincipalOwned { + @Id + @Column(columnDefinition = "varchar(36)") + @Pattern(regexp = "^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$", + message = "id not of pattern UUID") + private String id; //Provided. + + private String query; //JSON-API PATH or GraphQL payload. + + private QueryType queryType; //GRAPHQL, JSONAPI + + @UpdatePermission(expression = "Principal is Owner AND value is Cancelled") + private QueryStatus status; + + @OneToOne(mappedBy = "query", cascade = CascadeType.REMOVE) + private AsyncQueryResult result; + + @Exclude + private String principalName; + + @PrePersist + public void prePersistStatus() { + status = QueryStatus.QUEUED; + } + + @Override + public String getPrincipalName() { + return principalName; + } +} diff --git a/elide-async/src/main/java/com/yahoo/elide/async/models/AsyncQueryResult.java b/elide-async/src/main/java/com/yahoo/elide/async/models/AsyncQueryResult.java new file mode 100644 index 0000000000..fc3e192057 --- /dev/null +++ b/elide-async/src/main/java/com/yahoo/elide/async/models/AsyncQueryResult.java @@ -0,0 +1,50 @@ +/* + * Copyright 2020, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.async.models; + +import com.yahoo.elide.annotation.CreatePermission; +import com.yahoo.elide.annotation.DeletePermission; +import com.yahoo.elide.annotation.Exclude; +import com.yahoo.elide.annotation.Include; +import com.yahoo.elide.annotation.ReadPermission; +import com.yahoo.elide.annotation.UpdatePermission; + +import lombok.Data; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Id; +import javax.persistence.OneToOne; + +/** + * Model for Async Query Result. + */ +@Entity +@Include(type = "asyncQueryResult") +@ReadPermission(expression = "Principal is Owner") +@UpdatePermission(expression = "Prefab.Role.None") +@CreatePermission(expression = "Prefab.Role.None") +@DeletePermission(expression = "Prefab.Role.None") +@Data +public class AsyncQueryResult extends AsyncBase implements PrincipalOwned { + @Id + @Column(columnDefinition = "varchar(36)") + private String id; //Matches id in query. + + private Integer contentLength; + + private String responseBody; //success or errors + + private Integer status; // HTTP Status + + @OneToOne + private AsyncQuery query; + + @Exclude + public String getPrincipalName() { + return query.getPrincipalName(); + } +} diff --git a/elide-async/src/main/java/com/yahoo/elide/async/models/PrincipalOwned.java b/elide-async/src/main/java/com/yahoo/elide/async/models/PrincipalOwned.java new file mode 100644 index 0000000000..792c56efa6 --- /dev/null +++ b/elide-async/src/main/java/com/yahoo/elide/async/models/PrincipalOwned.java @@ -0,0 +1,13 @@ +/* + * Copyright 2020, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.async.models; + +/** + * Get principal owner name interface. + */ +public interface PrincipalOwned { + public String getPrincipalName(); +} diff --git a/elide-async/src/main/java/com/yahoo/elide/async/models/QueryStatus.java b/elide-async/src/main/java/com/yahoo/elide/async/models/QueryStatus.java new file mode 100644 index 0000000000..39e4cf64f8 --- /dev/null +++ b/elide-async/src/main/java/com/yahoo/elide/async/models/QueryStatus.java @@ -0,0 +1,18 @@ +/* + * Copyright 2020, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.async.models; + +/** + * ENUM of possible query statuses. + */ +public enum QueryStatus { + COMPLETE, + QUEUED, + PROCESSING, + CANCELLED, + TIMEDOUT, + FAILURE +} diff --git a/elide-async/src/main/java/com/yahoo/elide/async/models/QueryType.java b/elide-async/src/main/java/com/yahoo/elide/async/models/QueryType.java new file mode 100644 index 0000000000..d1b5dcd598 --- /dev/null +++ b/elide-async/src/main/java/com/yahoo/elide/async/models/QueryType.java @@ -0,0 +1,14 @@ +/* + * Copyright 2020, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.async.models; + +/** + * ENUM of supported query types. + */ +public enum QueryType { + GRAPHQL_V1_0, + JSONAPI_V1_0 +} diff --git a/elide-async/src/main/java/com/yahoo/elide/async/models/security/AsyncQueryOperationChecks.java b/elide-async/src/main/java/com/yahoo/elide/async/models/security/AsyncQueryOperationChecks.java new file mode 100644 index 0000000000..24477afaf3 --- /dev/null +++ b/elide-async/src/main/java/com/yahoo/elide/async/models/security/AsyncQueryOperationChecks.java @@ -0,0 +1,52 @@ +/* + * Copyright 2020, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.async.models.security; + +import com.yahoo.elide.annotation.SecurityCheck; +import com.yahoo.elide.async.models.AsyncQuery; +import com.yahoo.elide.async.models.PrincipalOwned; +import com.yahoo.elide.async.models.QueryStatus; +import com.yahoo.elide.security.ChangeSpec; +import com.yahoo.elide.security.RequestScope; +import com.yahoo.elide.security.checks.OperationCheck; + +import java.security.Principal; +import java.util.Optional; + +/** + * Operation Checks on the Async Query and Result objects. + */ +public class AsyncQueryOperationChecks { + @SecurityCheck(AsyncQueryOwner.PRINCIPAL_IS_OWNER) + public static class AsyncQueryOwner extends OperationCheck { + + public static final String PRINCIPAL_IS_OWNER = "Principal is Owner"; + + @Override + public boolean ok(Object object, RequestScope requestScope, Optional changeSpec) { + Principal principal = requestScope.getUser().getPrincipal(); + boolean status = false; + String principalName = ((PrincipalOwned) object).getPrincipalName(); + if (principalName == null && (principal == null || principal.getName() == null)) { + status = true; + } else { + status = principalName.equals(principal.getName()); + } + return status; + } + } + + @SecurityCheck(AsyncQueryStatusValue.VALUE_IS_CANCELLED) + public static class AsyncQueryStatusValue extends OperationCheck { + + public static final String VALUE_IS_CANCELLED = "value is Cancelled"; + + @Override + public boolean ok(AsyncQuery object, RequestScope requestScope, Optional changeSpec) { + return changeSpec.get().getModified().toString().equals(QueryStatus.CANCELLED.name()); + } + } +} diff --git a/elide-async/src/main/java/com/yahoo/elide/async/service/AsyncCleanerService.java b/elide-async/src/main/java/com/yahoo/elide/async/service/AsyncCleanerService.java new file mode 100644 index 0000000000..f29efa9df8 --- /dev/null +++ b/elide-async/src/main/java/com/yahoo/elide/async/service/AsyncCleanerService.java @@ -0,0 +1,81 @@ +/* + * Copyright 2020, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.async.service; + +import com.yahoo.elide.Elide; + +import lombok.extern.slf4j.Slf4j; + +import java.util.Random; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +import javax.inject.Inject; +/** + * Service to execute Async queries. + * It will schedule task to track long running queries and kills them. + * It will also schedule task to update orphan query statuses + * after host/app crash or restart. + */ +@Slf4j +public class AsyncCleanerService { + + private final int defaultCleanupDelayMinutes = 360; + private final int maxCleanupInitialDelayMinutes = 100; + + private static AsyncCleanerService asyncCleanerService = null; + + @Inject + private AsyncCleanerService(Elide elide, Integer maxRunTimeMinutes, Integer queryCleanupDays, + AsyncQueryDAO asyncQueryDao) { + + //If query is still running for twice than maxRunTime, then interrupt did not work due to host/app crash. + int queryRunTimeThresholdMinutes = maxRunTimeMinutes * 2; + + // Setting up query cleaner that marks long running query as TIMEDOUT. + ScheduledExecutorService cleaner = Executors.newSingleThreadScheduledExecutor(); + AsyncQueryCleanerThread cleanUpTask = new AsyncQueryCleanerThread(queryRunTimeThresholdMinutes, elide, + queryCleanupDays, asyncQueryDao); + + // Since there will be multiple hosts running the elide service, + // setting up random delays to avoid all of them trying to cleanup at the same time. + Random random = new Random(); + int initialDelayMinutes = random.ints(0, maxCleanupInitialDelayMinutes).limit(1).findFirst().getAsInt(); + log.debug("Initial Delay for cleaner service is {}", initialDelayMinutes); + + //Having a delay of at least DEFAULT_CLEANUP_DELAY between two cleanup attempts. + //Or maxRunTimeMinutes * 2 so that this process does not coincides with query + //interrupt process. + cleaner.scheduleWithFixedDelay(cleanUpTask, initialDelayMinutes, Math.max(defaultCleanupDelayMinutes, + queryRunTimeThresholdMinutes), TimeUnit.MINUTES); + } + + /** + * Initialize the singleton AsyncCleanerService object. + * If already initialized earlier, no new object is created. + * @param elide Elide Instance + * @param maxRunTimeMinutes max run times in minutes + * @param queryCleanupDays Async Query Clean up days + * @param asyncQueryDao DAO Object + */ + public static void init(Elide elide, Integer maxRunTimeMinutes, Integer queryCleanupDays, + AsyncQueryDAO asyncQueryDao) { + if (asyncCleanerService == null) { + asyncCleanerService = new AsyncCleanerService(elide, maxRunTimeMinutes, queryCleanupDays, asyncQueryDao); + } else { + log.debug("asyncCleanerService is already initialized."); + } + } + + /** + * Get instance of AsyncCleanerService. + * @return AsyncCleanerService Object + */ + public synchronized static AsyncCleanerService getInstance() { + return asyncCleanerService; + } +} diff --git a/elide-async/src/main/java/com/yahoo/elide/async/service/AsyncExecutorService.java b/elide-async/src/main/java/com/yahoo/elide/async/service/AsyncExecutorService.java new file mode 100644 index 0000000000..a4a0f56f53 --- /dev/null +++ b/elide-async/src/main/java/com/yahoo/elide/async/service/AsyncExecutorService.java @@ -0,0 +1,101 @@ +/* + * Copyright 2020, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.async.service; + +import com.yahoo.elide.Elide; +import com.yahoo.elide.async.models.AsyncQuery; +import com.yahoo.elide.core.exceptions.InvalidOperationException; +import com.yahoo.elide.graphql.QueryRunner; +import com.yahoo.elide.security.User; + +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; + +import java.util.Date; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import javax.inject.Inject; + +/** + * Service to execute Async queries. + * It will schedule task to track long running queries and kills them. + * It will also schedule task to update orphan query statuses after + * host/app crash or restart. + */ +@Getter +@Slf4j +public class AsyncExecutorService { + + private final int defaultThreadpoolSize = 6; + + private Elide elide; + private Map runners; + private ExecutorService executor; + private ExecutorService interruptor; + private int maxRunTime; + private AsyncQueryDAO asyncQueryDao; + private static AsyncExecutorService asyncExecutorService = null; + + @Inject + private AsyncExecutorService(Elide elide, Integer threadPoolSize, Integer maxRunTime, AsyncQueryDAO asyncQueryDao) { + this.elide = elide; + runners = new HashMap(); + + for (String apiVersion : elide.getElideSettings().getDictionary().getApiVersions()) { + runners.put(apiVersion, new QueryRunner(elide, apiVersion)); + } + + this.maxRunTime = maxRunTime; + executor = Executors.newFixedThreadPool(threadPoolSize == null ? defaultThreadpoolSize : threadPoolSize); + interruptor = Executors.newFixedThreadPool(threadPoolSize == null ? defaultThreadpoolSize : threadPoolSize); + this.asyncQueryDao = asyncQueryDao; + } + + /** + * Initialize the singleton AsyncExecutorService object. + * If already initialized earlier, no new object is created. + * @param elide Elide Instance + * @param threadPoolSize thred pool size + * @param maxRunTime max run times in minutes + * @param asyncQueryDao DAO Object + */ + public static void init(Elide elide, Integer threadPoolSize, Integer maxRunTime, AsyncQueryDAO asyncQueryDao) { + if (asyncExecutorService == null) { + asyncExecutorService = new AsyncExecutorService(elide, threadPoolSize, maxRunTime, asyncQueryDao); + } else { + log.debug("asyncExecutorService is already initialized."); + } + } + + /** + * Get instance of AsyncExecutorService. + * @return AsyncExecutorService Object + */ + public synchronized static AsyncExecutorService getInstance() { + return asyncExecutorService; + } + + /** + * Execute Query asynchronously. + * @param queryObj Query Object + * @param user User + */ + public void executeQuery(AsyncQuery queryObj, User user, String apiVersion) { + QueryRunner runner = runners.get(apiVersion); + if (runner == null) { + throw new InvalidOperationException("Invalid API Version"); + } + + AsyncQueryThread queryWorker = new AsyncQueryThread(queryObj, user, elide, runner, asyncQueryDao, apiVersion); + + AsyncQueryInterruptThread queryInterruptWorker = new AsyncQueryInterruptThread(elide, + executor.submit(queryWorker), queryObj, new Date(), maxRunTime, asyncQueryDao); + interruptor.execute(queryInterruptWorker); + } +} diff --git a/elide-async/src/main/java/com/yahoo/elide/async/service/AsyncQueryCleanerThread.java b/elide-async/src/main/java/com/yahoo/elide/async/service/AsyncQueryCleanerThread.java new file mode 100644 index 0000000000..fb24bb4872 --- /dev/null +++ b/elide-async/src/main/java/com/yahoo/elide/async/service/AsyncQueryCleanerThread.java @@ -0,0 +1,85 @@ +/* + * Copyright 2020, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.async.service; + +import com.yahoo.elide.Elide; +import com.yahoo.elide.async.models.QueryStatus; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.extern.slf4j.Slf4j; + +import java.text.Format; +import java.text.SimpleDateFormat; +import java.util.Calendar; +import java.util.Date; + +/** + * Runnable thread for updating AsyncQueryThread status. + * beyond the max run time and if not terminated by interrupt process + * due to app/host crash or restart. + */ +@Slf4j +@Data +@AllArgsConstructor +public class AsyncQueryCleanerThread implements Runnable { + + private int maxRunTimeMinutes; + private Elide elide; + private int queryCleanupDays; + private AsyncQueryDAO asyncQueryDao; + + @Override + public void run() { + deleteAsyncQuery(); + timeoutAsyncQuery(); + } + + /** + * This method deletes the historical queries based on threshold. + * */ + @SuppressWarnings("unchecked") + protected void deleteAsyncQuery() { + + String cleanupDateFormatted = evaluateFormattedFilterDate(Calendar.DATE, queryCleanupDays); + + String filterExpression = "createdOn=le='" + cleanupDateFormatted + "'"; + + asyncQueryDao.deleteAsyncQueryAndResultCollection(filterExpression); + + } + + /** + * This method updates the status of long running async query which + * were interrupted due to host crash/app shutdown to TIMEDOUT. + * */ + @SuppressWarnings("unchecked") + protected void timeoutAsyncQuery() { + + String filterDateFormatted = evaluateFormattedFilterDate(Calendar.MINUTE, maxRunTimeMinutes); + String filterExpression = "status=in=(" + QueryStatus.PROCESSING.toString() + "," + + QueryStatus.QUEUED.toString() + ");createdOn=le='" + filterDateFormatted + "'"; + + asyncQueryDao.updateStatusAsyncQueryCollection(filterExpression, QueryStatus.TIMEDOUT); + } + + /** + * Evaluates and subtracts the amount based on the calendar unit and amount from current date. + * @param calendarUnit Enum such as Calendar.DATE or Calendar.MINUTE + * @param amount Amount of days to be subtracted from current time + * @return formatted filter date + */ + private String evaluateFormattedFilterDate(int calendarUnit, int amount) { + Calendar cal = Calendar.getInstance(); + cal.setTime(new Date()); + cal.add(calendarUnit, -(amount)); + Date filterDate = cal.getTime(); + Format dateFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm'Z'"); + String filterDateFormatted = dateFormat.format(filterDate); + log.debug("FilterDateFormatted = {}", filterDateFormatted); + return filterDateFormatted; + } +} diff --git a/elide-async/src/main/java/com/yahoo/elide/async/service/AsyncQueryDAO.java b/elide-async/src/main/java/com/yahoo/elide/async/service/AsyncQueryDAO.java new file mode 100644 index 0000000000..a62cde77bb --- /dev/null +++ b/elide-async/src/main/java/com/yahoo/elide/async/service/AsyncQueryDAO.java @@ -0,0 +1,56 @@ +/* + * Copyright 2020, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.async.service; + +import com.yahoo.elide.async.models.AsyncQuery; +import com.yahoo.elide.async.models.AsyncQueryResult; +import com.yahoo.elide.async.models.QueryStatus; + +import java.util.Collection; + +/** + * Utility interface which uses the elide datastore to modify and create AsyncQuery and AsyncQueryResult Objects. + */ +public interface AsyncQueryDAO { + + /** + * This method updates the QueryStatus for AsyncQuery for given QueryStatus. + * @param asyncQuery The AsyncQuery Object to be updated + * @param status Status from Enum QueryStatus + * @return AsyncQuery Updated AsyncQuery Object + */ + public AsyncQuery updateStatus(AsyncQuery asyncQuery, QueryStatus status); + + /** + * This method persists the model for AsyncQueryResult, AsyncQuery object and establishes the relationship. + * @param status ElideResponse status from AsyncQuery + * @param responseBody ElideResponse responseBody from AsyncQuery + * @param asyncQuery AsyncQuery object to be associated with the AsyncQueryResult object + * @param asyncQueryId UUID of the AsyncQuery to be associated with the AsyncQueryResult object + * @return AsyncQueryResult Object + */ + public AsyncQueryResult createAsyncQueryResult(Integer status, String responseBody, AsyncQuery asyncQuery, + String asyncQueryId); + + /** + * This method deletes a collection of AsyncQuery and its associated AsyncQueryResult objects from database and + * returns the objects deleted. + * @param filterExpression filter expression to delete AsyncQuery Objects based on + * @return query object list deleted + */ + public Collection deleteAsyncQueryAndResultCollection(String filterExpression); + + /** + * This method updates the status for a collection of AsyncQuery objects from database and + * returns the objects updated. + * @param filterExpression filter expression to update AsyncQuery Objects based on + * @param status status to be updated + * @return query object list updated + */ + public Collection updateStatusAsyncQueryCollection(String filterExpression, + QueryStatus status); + +} diff --git a/elide-async/src/main/java/com/yahoo/elide/async/service/AsyncQueryInterruptThread.java b/elide-async/src/main/java/com/yahoo/elide/async/service/AsyncQueryInterruptThread.java new file mode 100644 index 0000000000..59b7e3571b --- /dev/null +++ b/elide-async/src/main/java/com/yahoo/elide/async/service/AsyncQueryInterruptThread.java @@ -0,0 +1,80 @@ +/* + * Copyright 2020, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.async.service; + +import com.yahoo.elide.Elide; +import com.yahoo.elide.async.models.AsyncQuery; +import com.yahoo.elide.async.models.QueryStatus; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.extern.slf4j.Slf4j; + +import java.util.Date; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +/** + * Runnable thread for terminating AsyncQueryThread executing + * beyond the max run time and update status. + */ +@Slf4j +@Data +@AllArgsConstructor +public class AsyncQueryInterruptThread implements Runnable { + + private Elide elide; + private Future task; + private AsyncQuery asyncQuery; + private Date submittedOn; + private int maxRunTimeMinutes; + private AsyncQueryDAO asyncQueryDao; + + @Override + public void run() { + interruptQuery(); + } + + /** + * This is the main method which interrupts the Async Query request, if it has executed beyond + * the maximum run time. + */ + protected void interruptQuery() { + try { + long interruptTimeMillies = calculateTimeOut(maxRunTimeMinutes, submittedOn); + + if (interruptTimeMillies > 0) { + log.debug("Waiting on the future with the given timeout for {}", interruptTimeMillies); + task.get(interruptTimeMillies, TimeUnit.MILLISECONDS); + } + } catch (InterruptedException e) { + // Incase the future.get is interrupted , the underlying query may still have succeeded + log.error("InterruptedException: {}", e); + } catch (ExecutionException e) { + // Query Status set to failure will be handled by the processQuery method + log.error("ExecutionException: {}", e); + } catch (TimeoutException e) { + log.error("TimeoutException: {}", e); + task.cancel(true); + asyncQueryDao.updateStatus(asyncQuery, QueryStatus.TIMEDOUT); + } + } + + /** + * Method to calculate the time left to interrupt since submission of thread in Milliseconds. + * @param interruptTimeMinutes max duration to run the query + * @param submittedOn time when query was submitted + * @return Interrupt time left + */ + private long calculateTimeOut(long maxRunTimeMinutes, Date submittedOn) { + long maxRunTimeMinutesMillies = maxRunTimeMinutes * 60 * 1000; + long interruptTimeMillies = maxRunTimeMinutesMillies - ((new Date()).getTime() - submittedOn.getTime()); + + return interruptTimeMillies; + } +} diff --git a/elide-async/src/main/java/com/yahoo/elide/async/service/AsyncQueryThread.java b/elide-async/src/main/java/com/yahoo/elide/async/service/AsyncQueryThread.java new file mode 100644 index 0000000000..c06cce503c --- /dev/null +++ b/elide-async/src/main/java/com/yahoo/elide/async/service/AsyncQueryThread.java @@ -0,0 +1,129 @@ +/* + * Copyright 2020, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.async.service; + +import com.yahoo.elide.Elide; +import com.yahoo.elide.ElideResponse; +import com.yahoo.elide.async.models.AsyncQuery; +import com.yahoo.elide.async.models.QueryStatus; +import com.yahoo.elide.async.models.QueryType; +import com.yahoo.elide.graphql.QueryRunner; +import com.yahoo.elide.security.User; + +import org.apache.http.NameValuePair; +import org.apache.http.NoHttpResponseException; +import org.apache.http.client.utils.URIBuilder; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.extern.slf4j.Slf4j; + +import java.net.URISyntaxException; + +import javax.ws.rs.core.MultivaluedHashMap; +import javax.ws.rs.core.MultivaluedMap; + +/** + * Runnable thread for executing the query provided in Async Query. + * It will also update the query status and result object at different + * stages of execution. + */ +@Slf4j +@Data +@AllArgsConstructor +public class AsyncQueryThread implements Runnable { + + private AsyncQuery queryObj; + private User user; + private Elide elide; + private final QueryRunner runner; + private AsyncQueryDAO asyncQueryDao; + private String apiVersion; + + @Override + public void run() { + processQuery(); + } + + /** + * This is the main method which processes the Async Query request, executes the query and updates + * values for AsyncQuery and AsyncQueryResult models accordingly. + */ + protected void processQuery() { + try { + // Change async query to processing + asyncQueryDao.updateStatus(queryObj, QueryStatus.PROCESSING); + ElideResponse response = null; + log.debug("AsyncQuery Object from request: {}", queryObj); + if (queryObj.getQueryType().equals(QueryType.JSONAPI_V1_0)) { + MultivaluedMap queryParams = getQueryParams(queryObj.getQuery()); + log.debug("Extracted QueryParams from AsyncQuery Object: {}", queryParams); + response = elide.get(getPath(queryObj.getQuery()), queryParams, user, apiVersion); + log.debug("JSONAPI_V1_0 getResponseCode: {}, JSONAPI_V1_0 getBody: {}", + response.getResponseCode(), response.getBody()); + } + else if (queryObj.getQueryType().equals(QueryType.GRAPHQL_V1_0)) { + response = runner.run(queryObj.getQuery(), user); + log.debug("GRAPHQL_V1_0 getResponseCode: {}, GRAPHQL_V1_0 getBody: {}", + response.getResponseCode(), response.getBody()); + } + if (response == null) { + throw new NoHttpResponseException("Response for request returned as null"); + } + + // Create AsyncQueryResult entry for AsyncQuery and + // add queryResult object to query object + asyncQueryDao.createAsyncQueryResult(response.getResponseCode(), response.getBody(), + queryObj, queryObj.getId()); + + // If we receive a response update Query Status to complete + asyncQueryDao.updateStatus(queryObj, QueryStatus.COMPLETE); + + } catch (Exception e) { + log.error("Exception: {}", e); + if (e.getClass().equals(InterruptedException.class)) { + // An InterruptedException is encountered when we interrupt the query when it goes beyond max run time + // We set the QueryStatus to TIMEDOUT + // No AsyncQueryResult will be set for this case + asyncQueryDao.updateStatus(queryObj, QueryStatus.TIMEDOUT); + } else { + // If an Exception is encountered we set the QueryStatus to FAILURE + // No AsyncQueryResult will be set for this case + asyncQueryDao.updateStatus(queryObj, QueryStatus.FAILURE); + } + } + } + + /** + * This method parses the url and gets the query params. + * And adds them into a MultivaluedMap to be used by underlying Elide.get method + * @param query query from the Async request + * @throws URISyntaxException URISyntaxException from malformed or incorrect URI + * @return MultivaluedMap with query parameters + */ + protected MultivaluedMap getQueryParams(String query) throws URISyntaxException { + URIBuilder uri; + uri = new URIBuilder(query); + MultivaluedMap queryParams = new MultivaluedHashMap(); + for (NameValuePair queryParam : uri.getQueryParams()) { + queryParams.add(queryParam.getName(), queryParam.getValue()); + } + return queryParams; + } + + /** + * This method parses the url and gets the query params. + * And retrieves path to be used by underlying Elide.get method + * @param query query from the Async request + * @throws URISyntaxException URISyntaxException from malformed or incorrect URI + * @return Path extracted from URI + */ + protected String getPath(String query) throws URISyntaxException { + URIBuilder uri; + uri = new URIBuilder(query); + return uri.getPath(); + } +} diff --git a/elide-async/src/main/java/com/yahoo/elide/async/service/DefaultAsyncQueryDAO.java b/elide-async/src/main/java/com/yahoo/elide/async/service/DefaultAsyncQueryDAO.java new file mode 100644 index 0000000000..1d928df8c9 --- /dev/null +++ b/elide-async/src/main/java/com/yahoo/elide/async/service/DefaultAsyncQueryDAO.java @@ -0,0 +1,209 @@ +/* + * Copyright 2020, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.async.service; + +import static com.yahoo.elide.core.EntityDictionary.NO_VERSION; + +import com.yahoo.elide.Elide; +import com.yahoo.elide.async.models.AsyncQuery; +import com.yahoo.elide.async.models.AsyncQueryResult; +import com.yahoo.elide.async.models.QueryStatus; +import com.yahoo.elide.core.DataStore; +import com.yahoo.elide.core.DataStoreTransaction; +import com.yahoo.elide.core.EntityDictionary; +import com.yahoo.elide.core.RequestScope; +import com.yahoo.elide.core.filter.dialect.ParseException; +import com.yahoo.elide.core.filter.dialect.RSQLFilterDialect; +import com.yahoo.elide.core.filter.expression.FilterExpression; +import com.yahoo.elide.jsonapi.models.JsonApiDocument; +import com.yahoo.elide.request.EntityProjection; + +import lombok.Getter; +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; + +import java.io.IOException; +import java.util.Collection; +import java.util.Iterator; + +import javax.inject.Singleton; +import javax.ws.rs.core.MultivaluedHashMap; +import javax.ws.rs.core.MultivaluedMap; + +/** + * Utility class which implements AsyncQueryDAO. + */ +@Singleton +@Slf4j +@Getter +public class DefaultAsyncQueryDAO implements AsyncQueryDAO { + + @Setter private Elide elide; + @Setter private DataStore dataStore; + private EntityDictionary dictionary; + private RSQLFilterDialect filterParser; + + // Default constructor is needed for standalone implementation for override in getAsyncQueryDao + public DefaultAsyncQueryDAO() { + } + + public DefaultAsyncQueryDAO(Elide elide, DataStore dataStore) { + this.elide = elide; + this.dataStore = dataStore; + dictionary = elide.getElideSettings().getDictionary(); + filterParser = new RSQLFilterDialect(dictionary); + } + + @Override + public AsyncQuery updateStatus(AsyncQuery asyncQuery, QueryStatus status) { + return updateAsyncQuery(asyncQuery, (asyncQueryObj) -> { + asyncQueryObj.setStatus(status); + }); + } + + /** + * This method updates the model for AsyncQuery with passed value. + * @param asyncQuery The AsyncQuery Object which will be updated + * @param updateFunction Functional interface for updating AsyncQuery Object + * @return AsyncQuery Object + */ + private AsyncQuery updateAsyncQuery(AsyncQuery asyncQuery, UpdateQuery updateFunction) { + log.debug("updateAsyncQuery"); + AsyncQuery queryObj = (AsyncQuery) executeInTransaction(dataStore, (tx, scope) -> { + updateFunction.update(asyncQuery); + tx.save(asyncQuery, scope); + return asyncQuery; + }); + return queryObj; + } + + @Override + public Collection updateStatusAsyncQueryCollection(String filterExpression, + QueryStatus status) { + return updateAsyncQueryCollection(filterExpression, (asyncQuery) -> { + asyncQuery.setStatus(status); + }); + } + + /** + * This method updates a collection of AsyncQuery objects from database and + * returns the objects updated. + * @param filterExpression Filter expression to update AsyncQuery Objects based on + * @param updateFunction Functional interface for updating AsyncQuery Object + * @return query object list updated + */ + @SuppressWarnings("unchecked") + private Collection updateAsyncQueryCollection(String filterExpression, + UpdateQuery updateFunction) { + log.debug("updateAsyncQueryCollection"); + + Collection asyncQueryList = null; + + try { + FilterExpression filter = filterParser.parseFilterExpression(filterExpression, + AsyncQuery.class, false); + asyncQueryList = (Collection) executeInTransaction(dataStore, + (tx, scope) -> { + EntityProjection asyncQueryCollection = EntityProjection.builder() + .type(AsyncQuery.class) + .filterExpression(filter) + .build(); + + Iterable loaded = tx.loadObjects(asyncQueryCollection, scope); + Iterator itr = loaded.iterator(); + + while (itr.hasNext()) { + AsyncQuery query = (AsyncQuery) itr.next(); + updateFunction.update(query); + tx.save(query, scope); + } + return loaded; + }); + } catch (ParseException e) { + log.error("Exception: {}", e); + } + return asyncQueryList; + } + + @Override + @SuppressWarnings("unchecked") + public Collection deleteAsyncQueryAndResultCollection(String filterExpression) { + log.debug("deleteAsyncQueryAndResultCollection"); + + Collection asyncQueryList = null; + + try { + FilterExpression filter = filterParser.parseFilterExpression(filterExpression, + AsyncQuery.class, false); + asyncQueryList = (Collection) executeInTransaction(dataStore, (tx, scope) -> { + + EntityProjection asyncQueryCollection = EntityProjection.builder() + .type(AsyncQuery.class) + .filterExpression(filter) + .build(); + + Iterable loaded = tx.loadObjects(asyncQueryCollection, scope); + Iterator itr = loaded.iterator(); + + while (itr.hasNext()) { + AsyncQuery query = (AsyncQuery) itr.next(); + if (query != null) { + tx.delete(query, scope); + } + } + return loaded; + }); + } catch (ParseException e) { + log.error("Exception: {}", e); + } + return asyncQueryList; + } + + @Override + public AsyncQueryResult createAsyncQueryResult(Integer status, String responseBody, + AsyncQuery asyncQuery, String asyncQueryId) { + log.debug("createAsyncQueryResult"); + AsyncQueryResult queryResultObj = (AsyncQueryResult) executeInTransaction(dataStore, (tx, scope) -> { + AsyncQueryResult asyncQueryResult = new AsyncQueryResult(); + asyncQueryResult.setStatus(status); + asyncQueryResult.setResponseBody(responseBody); + asyncQueryResult.setContentLength(responseBody.length()); + asyncQueryResult.setQuery(asyncQuery); + asyncQueryResult.setId(asyncQueryId); + asyncQuery.setResult(asyncQueryResult); + tx.createObject(asyncQueryResult, scope); + tx.save(asyncQuery, scope); + return asyncQueryResult; + }); + return queryResultObj; + } + + /** + * This method creates a transaction from the datastore, performs the DB action using + * a generic functional interface and closes the transaction. + * @param dataStore Elide datastore retrieved from Elide object + * @param action Functional interface to perform DB action + * @return Object Returns Entity Object (AsyncQueryResult or AsyncResult) + */ + protected Object executeInTransaction(DataStore dataStore, Transactional action) { + log.debug("executeInTransaction"); + Object result = null; + try (DataStoreTransaction tx = dataStore.beginTransaction()) { + JsonApiDocument jsonApiDoc = new JsonApiDocument(); + MultivaluedMap queryParams = new MultivaluedHashMap(); + RequestScope scope = new RequestScope("query", NO_VERSION, jsonApiDoc, + tx, null, queryParams, elide.getElideSettings()); + result = action.execute(tx, scope); + tx.flush(scope); + tx.commit(scope); + } catch (IOException e) { + log.error("IOException: {}", e); + } catch (Exception e) { + log.error("Exception: {}", e); + } + return result; + } +} diff --git a/elide-async/src/main/java/com/yahoo/elide/async/service/Transactional.java b/elide-async/src/main/java/com/yahoo/elide/async/service/Transactional.java new file mode 100644 index 0000000000..8360316a70 --- /dev/null +++ b/elide-async/src/main/java/com/yahoo/elide/async/service/Transactional.java @@ -0,0 +1,17 @@ +/* + * Copyright 2020, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.async.service; + +import com.yahoo.elide.core.DataStoreTransaction; +import com.yahoo.elide.core.RequestScope; + +/** + * Function which will be invoked for executing elide async transactions. + */ +@FunctionalInterface +public interface Transactional { + public Object execute(DataStoreTransaction tx, RequestScope scope); +} diff --git a/elide-async/src/main/java/com/yahoo/elide/async/service/UpdateQuery.java b/elide-async/src/main/java/com/yahoo/elide/async/service/UpdateQuery.java new file mode 100644 index 0000000000..9e9b68e421 --- /dev/null +++ b/elide-async/src/main/java/com/yahoo/elide/async/service/UpdateQuery.java @@ -0,0 +1,16 @@ +/* + * Copyright 2020, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.async.service; + +import com.yahoo.elide.async.models.AsyncQuery; + +/** + * Function which will be invoked for updating elide async query. + */ +@FunctionalInterface +public interface UpdateQuery { + public void update(AsyncQuery query); +} diff --git a/elide-async/src/test/java/com/yahoo/elide/async/service/AsyncCleanerServiceTest.java b/elide-async/src/test/java/com/yahoo/elide/async/service/AsyncCleanerServiceTest.java new file mode 100644 index 0000000000..d10b3f07fd --- /dev/null +++ b/elide-async/src/test/java/com/yahoo/elide/async/service/AsyncCleanerServiceTest.java @@ -0,0 +1,35 @@ +/* + * Copyright 2020, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.async.service; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.Mockito.mock; + +import com.yahoo.elide.Elide; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +public class AsyncCleanerServiceTest { + + private AsyncCleanerService service; + + @BeforeAll + public void setupMocks() { + Elide elide = mock(Elide.class); + + AsyncQueryDAO dao = mock(DefaultAsyncQueryDAO.class); + AsyncCleanerService.init(elide, 5, 60, dao); + service = AsyncCleanerService.getInstance(); + } + + @Test + public void testCleanerSet() { + assertNotNull(service); + } +} diff --git a/elide-async/src/test/java/com/yahoo/elide/async/service/AsyncExecutorServiceTest.java b/elide-async/src/test/java/com/yahoo/elide/async/service/AsyncExecutorServiceTest.java new file mode 100644 index 0000000000..38d690e83a --- /dev/null +++ b/elide-async/src/test/java/com/yahoo/elide/async/service/AsyncExecutorServiceTest.java @@ -0,0 +1,74 @@ +/* + * Copyright 2020, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.async.service; + +import static com.yahoo.elide.core.EntityDictionary.NO_VERSION; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import com.yahoo.elide.Elide; +import com.yahoo.elide.ElideSettingsBuilder; +import com.yahoo.elide.async.models.AsyncQuery; +import com.yahoo.elide.async.models.QueryStatus; +import com.yahoo.elide.core.EntityDictionary; +import com.yahoo.elide.core.datastore.inmemory.HashMapDataStore; +import com.yahoo.elide.security.User; +import com.yahoo.elide.security.checks.Check; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; + +import java.util.HashMap; +import java.util.Map; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +public class AsyncExecutorServiceTest { + + private AsyncExecutorService service; + private Elide elide; + private AsyncQueryDAO asyncQueryDao; + + @BeforeAll + public void setupMocks() { + HashMapDataStore inMemoryStore = new HashMapDataStore(AsyncQuery.class.getPackage()); + Map> checkMappings = new HashMap<>(); + + elide = new Elide( + new ElideSettingsBuilder(inMemoryStore) + .withEntityDictionary(new EntityDictionary(checkMappings)) + .build()); + + asyncQueryDao = mock(DefaultAsyncQueryDAO.class); + + AsyncExecutorService.init(elide, 5, 60, asyncQueryDao); + + service = AsyncExecutorService.getInstance(); + } + + @Test + public void testAsyncExecutorServiceSet() { + assertEquals(elide, service.getElide()); + assertNotNull(service.getRunners()); + assertEquals(60, service.getMaxRunTime()); + assertNotNull(service.getExecutor()); + assertNotNull(service.getInterruptor()); + assertEquals(asyncQueryDao, service.getAsyncQueryDao()); + } + + @Test + public void testExecuteQuery() { + AsyncQuery queryObj = mock(AsyncQuery.class); + User testUser = mock(User.class); + + service.executeQuery(queryObj, testUser, NO_VERSION); + + verify(asyncQueryDao, times(0)).updateStatus(queryObj, QueryStatus.QUEUED); + } +} diff --git a/elide-async/src/test/java/com/yahoo/elide/async/service/AsyncQueryCleanerThreadTest.java b/elide-async/src/test/java/com/yahoo/elide/async/service/AsyncQueryCleanerThreadTest.java new file mode 100644 index 0000000000..a9e1b987c4 --- /dev/null +++ b/elide-async/src/test/java/com/yahoo/elide/async/service/AsyncQueryCleanerThreadTest.java @@ -0,0 +1,55 @@ +/* + * Copyright 2020, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.async.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import com.yahoo.elide.Elide; +import com.yahoo.elide.async.models.QueryStatus; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +public class AsyncQueryCleanerThreadTest { + + private AsyncQueryCleanerThread cleanerThread; + private Elide elide; + private AsyncQueryDAO asyncQueryDao; + + @BeforeEach + public void setupMocks() { + elide = mock(Elide.class); + asyncQueryDao = mock(DefaultAsyncQueryDAO.class); + cleanerThread = new AsyncQueryCleanerThread(7, elide, 7, asyncQueryDao); + } + + @Test + public void testAsyncQueryCleanerThreadSet() { + assertEquals(elide, cleanerThread.getElide()); + assertEquals(asyncQueryDao, cleanerThread.getAsyncQueryDao()); + assertEquals(7, cleanerThread.getMaxRunTimeMinutes()); + assertEquals(7, cleanerThread.getQueryCleanupDays()); + } + + @Test + public void testDeleteAsyncQuery() { + cleanerThread.deleteAsyncQuery(); + + verify(asyncQueryDao, times(1)).deleteAsyncQueryAndResultCollection(anyString()); + } + + @Test + public void timeoutAsyncQuery() { + cleanerThread.timeoutAsyncQuery(); + + verify(asyncQueryDao, times(1)).updateStatusAsyncQueryCollection(anyString(), any(QueryStatus.class)); + } +} diff --git a/elide-async/src/test/java/com/yahoo/elide/async/service/AsyncQueryThreadTest.java b/elide-async/src/test/java/com/yahoo/elide/async/service/AsyncQueryThreadTest.java new file mode 100644 index 0000000000..609009b07c --- /dev/null +++ b/elide-async/src/test/java/com/yahoo/elide/async/service/AsyncQueryThreadTest.java @@ -0,0 +1,105 @@ +/* + * Copyright 2020, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.async.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.yahoo.elide.Elide; +import com.yahoo.elide.ElideResponse; +import com.yahoo.elide.async.models.AsyncQuery; +import com.yahoo.elide.async.models.QueryStatus; +import com.yahoo.elide.async.models.QueryType; +import com.yahoo.elide.graphql.QueryRunner; +import com.yahoo.elide.security.User; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +public class AsyncQueryThreadTest { + + private AsyncQueryThread queryThread; + private User user; + private Elide elide; + private QueryRunner runner; + private AsyncQuery queryObj; + private AsyncQueryDAO asyncQueryDao; + + @BeforeEach + public void setupMocks() { + user = mock(User.class); + elide = mock(Elide.class); + runner = mock(QueryRunner.class); + queryObj = mock(AsyncQuery.class); + asyncQueryDao = mock(DefaultAsyncQueryDAO.class); + queryThread = new AsyncQueryThread(queryObj, user, elide, runner, asyncQueryDao, "v1"); + } + + @Test + public void testAsyncQueryCleanerThreadSet() { + assertEquals(queryObj, queryThread.getQueryObj()); + assertEquals(user, queryThread.getUser()); + assertEquals(elide, queryThread.getElide()); + assertEquals(runner, queryThread.getRunner()); + assertEquals(asyncQueryDao, queryThread.getAsyncQueryDao()); + } + + @Test + public void testProcessQueryJsonApi() { + String query = "/group?sort=commonName&fields%5Bgroup%5D=commonName,description"; + ElideResponse response = mock(ElideResponse.class); + + when(queryObj.getQuery()).thenReturn(query); + when(queryObj.getQueryType()).thenReturn(QueryType.JSONAPI_V1_0); + when(elide.get(anyString(), any(), any(), anyString())).thenReturn(response); + when(response.getResponseCode()).thenReturn(200); + when(response.getBody()).thenReturn("ResponseBody"); + + queryThread.processQuery(); + + verify(asyncQueryDao, times(1)).updateStatus(queryObj, QueryStatus.PROCESSING); + verify(asyncQueryDao, times(1)).updateStatus(queryObj, QueryStatus.COMPLETE); + verify(asyncQueryDao, times(1)).createAsyncQueryResult(anyInt(), anyString(), any(), any()); + } + + @Test + public void testProcessQueryGraphQl() { + String query = "{\"query\":\"{ group { edges { node { name commonName description } } } }\",\"variables\":null}"; + ElideResponse response = mock(ElideResponse.class); + + when(queryObj.getQuery()).thenReturn(query); + when(queryObj.getQueryType()).thenReturn(QueryType.GRAPHQL_V1_0); + when(runner.run(query, user)).thenReturn(response); + when(response.getResponseCode()).thenReturn(200); + when(response.getBody()).thenReturn("ResponseBody"); + + queryThread.processQuery(); + + verify(asyncQueryDao, times(1)).updateStatus(queryObj, QueryStatus.PROCESSING); + verify(asyncQueryDao, times(1)).updateStatus(queryObj, QueryStatus.COMPLETE); + verify(asyncQueryDao, times(1)).createAsyncQueryResult(anyInt(), anyString(), any(), any()); + } + + @Test + public void testProcessQueryException() { + String query = "{\"query\":\"{ group { edges { node { name commonName description } } } }\",\"variables\":null}"; + + when(queryObj.getQuery()).thenReturn(query); + when(queryObj.getQueryType()).thenReturn(QueryType.GRAPHQL_V1_0); + when(runner.run(query, user)).thenThrow(RuntimeException.class); + + queryThread.processQuery(); + + verify(asyncQueryDao, times(1)).updateStatus(queryObj, QueryStatus.PROCESSING); + verify(asyncQueryDao, times(1)).updateStatus(queryObj, QueryStatus.FAILURE); + } +} diff --git a/elide-async/src/test/java/com/yahoo/elide/async/service/DefaultAsyncQueryDAOTest.java b/elide-async/src/test/java/com/yahoo/elide/async/service/DefaultAsyncQueryDAOTest.java new file mode 100644 index 0000000000..c2665c8172 --- /dev/null +++ b/elide-async/src/test/java/com/yahoo/elide/async/service/DefaultAsyncQueryDAOTest.java @@ -0,0 +1,124 @@ +/* + * Copyright 2020, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.async.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.yahoo.elide.Elide; +import com.yahoo.elide.ElideSettings; +import com.yahoo.elide.ElideSettingsBuilder; +import com.yahoo.elide.async.models.AsyncQuery; +import com.yahoo.elide.async.models.AsyncQueryResult; +import com.yahoo.elide.async.models.QueryStatus; +import com.yahoo.elide.core.DataStore; +import com.yahoo.elide.core.DataStoreTransaction; +import com.yahoo.elide.core.EntityDictionary; +import com.yahoo.elide.core.RequestScope; +import com.yahoo.elide.core.filter.dialect.RSQLFilterDialect; +import com.yahoo.elide.security.checks.Check; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; +import java.util.TimeZone; + +public class DefaultAsyncQueryDAOTest { + + private DefaultAsyncQueryDAO asyncQueryDAO; + private Elide elide; + private DataStore dataStore; + private AsyncQuery asyncQuery; + private DataStoreTransaction tx; + private EntityDictionary dictionary; + + @BeforeEach + public void setupMocks() { + dataStore = mock(DataStore.class); + asyncQuery = mock(AsyncQuery.class); + tx = mock(DataStoreTransaction.class); + + Map> checkMappings = new HashMap<>(); + + dictionary = new EntityDictionary(checkMappings); + dictionary.bindEntity(AsyncQuery.class); + dictionary.bindEntity(AsyncQueryResult.class); + + ElideSettings elideSettings = new ElideSettingsBuilder(dataStore) + .withEntityDictionary(dictionary) + .withJoinFilterDialect(new RSQLFilterDialect(dictionary)) + .withSubqueryFilterDialect(new RSQLFilterDialect(dictionary)) + .withISO8601Dates("yyyy-MM-dd'T'HH:mm'Z'", TimeZone.getTimeZone("UTC")) + .build(); + + elide = new Elide(elideSettings); + + when(dataStore.beginTransaction()).thenReturn(tx); + + asyncQueryDAO = new DefaultAsyncQueryDAO(elide, dataStore); + } + + @Test + public void testAsyncQueryCleanerThreadSet() { + assertEquals(elide, asyncQueryDAO.getElide()); + assertEquals(dictionary, asyncQueryDAO.getDictionary()); + } + + @Test + public void testUpdateStatus() { + AsyncQuery result = asyncQueryDAO.updateStatus(asyncQuery, QueryStatus.PROCESSING); + + assertEquals(result, asyncQuery); + verify(tx, times(1)).save(any(AsyncQuery.class), any(RequestScope.class)); + verify(asyncQuery, times(1)).setStatus(QueryStatus.PROCESSING); + } + + @Test + public void testUpdateStatusAsyncQueryCollection() { + Iterable loaded = Arrays.asList(asyncQuery, asyncQuery); + when(tx.loadObjects(any(), any())).thenReturn(loaded); + + asyncQueryDAO.updateStatusAsyncQueryCollection("status=in=(PROCESSING,QUEUED);createdOn=le='2020-04-22T13:28Z'", QueryStatus.TIMEDOUT); + + verify(tx, times(2)).save(any(AsyncQuery.class), any(RequestScope.class)); + verify(asyncQuery, times(2)).setStatus(QueryStatus.TIMEDOUT); + } + + @Test + public void testDeleteAsyncQueryAndResultCollection() { + Iterable loaded = Arrays.asList(asyncQuery, asyncQuery, asyncQuery); + when(tx.loadObjects(any(), any())).thenReturn(loaded); + + asyncQueryDAO.deleteAsyncQueryAndResultCollection("createdOn=le='2020-03-23T02:02Z'"); + + verify(dataStore, times(1)).beginTransaction(); + verify(tx, times(1)).loadObjects(any(), any()); + verify(tx, times(3)).delete(any(AsyncQuery.class), any(RequestScope.class)); + } + + @Test + public void testCreateAsyncQueryResult() { + Integer status = 200; + String responseBody = "responseBody"; + String uuid = "ba31ca4e-ed8f-4be0-a0f3-12088fa9263e"; + AsyncQueryResult result = asyncQueryDAO.createAsyncQueryResult(status, "responseBody", asyncQuery, uuid); + + assertEquals(status, result.getStatus()); + assertEquals(responseBody, result.getResponseBody()); + assertEquals(asyncQuery, result.getQuery()); + assertEquals(uuid, result.getId()); + verify(tx, times(1)).createObject(any(), any(RequestScope.class)); + verify(tx, times(1)).save(any(), any(RequestScope.class)); + + } +} diff --git a/elide-contrib/elide-dynamic-config-helpers/pom.xml b/elide-contrib/elide-dynamic-config-helpers/pom.xml new file mode 100644 index 0000000000..828104f409 --- /dev/null +++ b/elide-contrib/elide-dynamic-config-helpers/pom.xml @@ -0,0 +1,163 @@ + + + + 4.0.0 + elide-dynamic-config-helpers + jar + Elide Dynamic Config Helpers + Dynamic config helpers + https://github.com/yahoo/elide + + elide-contrib-parent-pom + com.yahoo.elide + 5.0.0-pr10-SNAPSHOT + + + + + The Apache Software License, Version 2.0 + http://www.apache.org/licenses/LICENSE-2.0.txt + repo + + + + + + Yahoo Inc. + https://github.com/yahoo + + + + + scm:git:ssh://git@github.com/yahoo/elide.git + https://github.com/yahoo/elide.git + HEAD + + + + 3.0.0 + 2.10.4 + 4.2.0 + 2.2.12 + 1.3.0 + 2.6 + + + + + com.fasterxml.jackson.core + jackson-databind + + + com.fasterxml.jackson.core + jackson-annotations + ${version.jackson} + + + com.fasterxml.jackson.core + jackson-core + + + org.apache.commons + commons-lang3 + + + commons-io + commons-io + ${commons-io.version} + + + org.slf4j + slf4j-api + + + org.projectlombok + lombok + + + com.github.java-json-tools + json-schema-validator + ${json-schema-validator.version} + test + + + org.hjson + hjson + ${hjson.version} + + + org.junit.jupiter + junit-jupiter-api + test + + + org.junit.jupiter + junit-jupiter-engine + test + + + com.github.jknack + handlebars + ${handlebars.version} + + + org.junit.platform + junit-platform-launcher + test + + + org.mdkt.compiler + InMemoryJavaCompiler + ${mdkt.compiler.version} + + + com.google.guava + guava + + + + + + + org.codehaus.mojo + build-helper-maven-plugin + + + org.apache.maven.plugins + maven-dependency-plugin + + + org.apache.maven.plugins + maven-surefire-plugin + + + org.apache.maven.plugins + maven-failsafe-plugin + + + org.apache.maven.plugins + maven-checkstyle-plugin + + + maven-assembly-plugin + + + package + + single + + + + + + jar-with-dependencies + + + + + + diff --git a/elide-contrib/elide-dynamic-config-helpers/src/main/java/com/yahoo/elide/contrib/dynamicconfighelpers/DynamicConfigHelpers.java b/elide-contrib/elide-dynamic-config-helpers/src/main/java/com/yahoo/elide/contrib/dynamicconfighelpers/DynamicConfigHelpers.java new file mode 100644 index 0000000000..33bb35fe28 --- /dev/null +++ b/elide-contrib/elide-dynamic-config-helpers/src/main/java/com/yahoo/elide/contrib/dynamicconfighelpers/DynamicConfigHelpers.java @@ -0,0 +1,169 @@ +/* + * Copyright 2020, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.contrib.dynamicconfighelpers; + +import com.yahoo.elide.contrib.dynamicconfighelpers.model.ElideSecurityConfig; +import com.yahoo.elide.contrib.dynamicconfighelpers.model.ElideTableConfig; +import com.yahoo.elide.contrib.dynamicconfighelpers.model.Table; +import com.yahoo.elide.contrib.dynamicconfighelpers.parser.handlebars.HandlebarsHydrator; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +import org.apache.commons.io.FileUtils; +import org.hjson.JsonValue; + +import lombok.extern.slf4j.Slf4j; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Collection; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +@Slf4j +/** + * Util class for Dynamic config helper module. + */ +public class DynamicConfigHelpers { + + private static final String TABLE_CONFIG_PATH = "tables" + File.separator; + private static final String SECURITY_CONFIG_PATH = "security.hjson"; + private static final String VARIABLE_CONFIG_PATH = "variables.hjson"; + private static final String NEW_LINE = "\n"; + + /** + * Checks whether input is null or empty. + * @param input : input string + * @return true or false + */ + public static boolean isNullOrEmpty(String input) { + return (input == null || input.trim().length() == 0); + } + + /** + * format config file path. + * @param basePath : path to hjson config. + * @return formatted file path. + */ + public static String formatFilePath(String basePath) { + String path = basePath; + if (!path.endsWith(File.separator)) { + path += File.separator; + } + return path; + } + + /** + * converts variable.hjson to map of variables. + * @param basePath : root path to model dir + * @return Map of variables + * @throws JsonProcessingException + */ + @SuppressWarnings("unchecked") + public static Map getVariablesPojo(String basePath) throws JsonProcessingException { + String filePath = basePath + VARIABLE_CONFIG_PATH; + File variableFile = new File(filePath); + if (variableFile.exists()) { + String jsonConfig = hjsonToJson(readConfigFile(variableFile)); + return getModelPojo(jsonConfig, Map.class); + } else { + log.info("Variables config file not found at " + filePath); + return null; + } + } + + /** + * converts all available table config to ElideTableConfig Pojo. + * @param basePath : root path to model dir + * @param variables : variables to resolve. + * @return ElideTableConfig pojo + * @throws IOException + */ + public static ElideTableConfig getElideTablePojo(String basePath, Map variables) + throws IOException { + return getElideTablePojo(basePath, variables, TABLE_CONFIG_PATH); + } + + /** + * converts all available table config to ElideTableConfig Pojo. + * @param basePath : root path to model dir + * @param variables : variables to resolve. + * @param tableDirName : dir name for table configs + * @return ElideTableConfig pojo + * @throws IOException + */ + public static ElideTableConfig getElideTablePojo(String basePath, Map variables, + String tableDirName) throws IOException { + Collection tableConfigs = FileUtils.listFiles(new File(basePath + tableDirName), + new String[] {"hjson"}, false); + Set tables = new HashSet<>(); + for (File tableConfig : tableConfigs) { + String jsonConfig = hjsonToJson(resolveVariables(readConfigFile(tableConfig), variables)); + ElideTableConfig table = getModelPojo(jsonConfig, ElideTableConfig.class); + tables.addAll(table.getTables()); + } + ElideTableConfig elideTableConfig = new ElideTableConfig(); + elideTableConfig.setTables(tables); + return elideTableConfig; + } + + /** + * converts security.hjson to ElideSecurityConfig Pojo. + * @param basePath : root path to model dir. + * @param variables : variables to resolve. + * @return ElideSecurityConfig Pojo + * @throws IOException + */ + public static ElideSecurityConfig getElideSecurityPojo(String basePath, Map variables) + throws IOException { + String filePath = basePath + SECURITY_CONFIG_PATH; + File securityFile = new File(filePath); + if (securityFile.exists()) { + String jsonConfig = hjsonToJson(resolveVariables(readConfigFile(securityFile), variables)); + return getModelPojo(jsonConfig, ElideSecurityConfig.class); + } else { + log.info("Security config file not found at " + filePath); + return null; + } + } + + /** + * resolves variables in table and security config. + * @param jsonConfig of table or security + * @param variables map from config + * @return json string with resolved variables + * @throws IOException + */ + public static String resolveVariables(String jsonConfig, Map variables) throws IOException { + HandlebarsHydrator hydrator = new HandlebarsHydrator(); + return hydrator.hydrateConfigTemplate(jsonConfig, variables); + } + + private static String hjsonToJson(String hjson) { + return JsonValue.readHjson(hjson).toString(); + } + + private static T getModelPojo(String jsonConfig, final Class configPojo) throws JsonProcessingException { + return new ObjectMapper().readValue(jsonConfig, configPojo); + } + + private static String readConfigFile(File configFile) { + StringBuffer sb = new StringBuffer(); + try { + for (String line : FileUtils.readLines(configFile, StandardCharsets.UTF_8)) { + sb.append(line); + sb.append(NEW_LINE); + } + } catch (IOException e) { + log.error("error while reading config file " + configFile.getName()); + log.error(e.getMessage()); + } + return sb.toString(); + } +} diff --git a/elide-contrib/elide-dynamic-config-helpers/src/main/java/com/yahoo/elide/contrib/dynamicconfighelpers/compile/ElideDynamicEntityCompiler.java b/elide-contrib/elide-dynamic-config-helpers/src/main/java/com/yahoo/elide/contrib/dynamicconfighelpers/compile/ElideDynamicEntityCompiler.java new file mode 100644 index 0000000000..5973b3baea --- /dev/null +++ b/elide-contrib/elide-dynamic-config-helpers/src/main/java/com/yahoo/elide/contrib/dynamicconfighelpers/compile/ElideDynamicEntityCompiler.java @@ -0,0 +1,150 @@ +/* + * Copyright 2020, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.contrib.dynamicconfighelpers.compile; + +import com.yahoo.elide.contrib.dynamicconfighelpers.model.ElideSecurityConfig; +import com.yahoo.elide.contrib.dynamicconfighelpers.model.ElideTableConfig; +import com.yahoo.elide.contrib.dynamicconfighelpers.parser.ElideConfigParser; +import com.yahoo.elide.contrib.dynamicconfighelpers.parser.handlebars.HandlebarsHydrator; + +import com.google.common.collect.Sets; + +import org.mdkt.compiler.InMemoryJavaCompiler; + +import lombok.extern.slf4j.Slf4j; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * Compiles dynamic model pojos generated from hjson files. + * + */ +@Slf4j +public class ElideDynamicEntityCompiler { + + public static ArrayList classNames = new ArrayList(); + + public static final String PACKAGE_NAME = "dynamicconfig.models."; + private Map> compiledObjects; + + private InMemoryJavaCompiler compiler = InMemoryJavaCompiler.newInstance().ignoreWarnings(); + + private Map tableClasses = new HashMap(); + private Map securityClasses = new HashMap(); + + /** + * Parse dynamic config path. + * @param path : Dynamic config hjsons root location + * @throws Exception Exception thrown + */ + public ElideDynamicEntityCompiler(String path) throws Exception { + + ElideTableConfig tableConfig = new ElideTableConfig(); + ElideSecurityConfig securityConfig = new ElideSecurityConfig(); + ElideConfigParser elideConfigParser = new ElideConfigParser(path); + HandlebarsHydrator hydrator = new HandlebarsHydrator(); + + tableConfig = elideConfigParser.getElideTableConfig(); + securityConfig = elideConfigParser.getElideSecurityConfig(); + tableClasses = hydrator.hydrateTableTemplate(tableConfig); + securityClasses = hydrator.hydrateSecurityTemplate(securityConfig); + + for (Entry entry : tableClasses.entrySet()) { + classNames.add(PACKAGE_NAME + entry.getKey()); + } + + for (Entry entry : securityClasses.entrySet()) { + classNames.add(PACKAGE_NAME + entry.getKey()); + } + + compiler.useParentClassLoader( + new ElideDynamicInMemoryClassLoader(ClassLoader.getSystemClassLoader(), + Sets.newHashSet(classNames))); + compile(); + } + + /** + * Compile table and security model pojos. + * @throws Exception + */ + private void compile() throws Exception { + + for (Map.Entry tablePojo : tableClasses.entrySet()) { + log.debug("key: " + tablePojo.getKey() + ", value: " + tablePojo.getValue()); + compiler.addSource(PACKAGE_NAME + tablePojo.getKey(), tablePojo.getValue()); + } + + for (Map.Entry secPojo : securityClasses.entrySet()) { + log.debug("key: " + secPojo.getKey() + ", value: " + secPojo.getValue()); + compiler.addSource(PACKAGE_NAME + secPojo.getKey(), secPojo.getValue()); + } + + compiledObjects = compiler.compileAll(); + } + + /** + * Get Inmemorycompiler's classloader. + * @return ClassLoader + */ + public ClassLoader getClassLoader() { + return compiler.getClassloader(); + } + + /** + * Get the class from compiled class lists. + * @param name name of the class + * @return Class + */ + public Class getCompiled(String name) { + return compiledObjects.get(name); + } + + /** + * Find classes with a particular annotation from dynamic compiler. + * @param annotationClass Annotation to search for. + * @return Set of Classes matching the annotation. + * @throws ClassNotFoundException + */ + @SuppressWarnings({ "rawtypes", "unchecked" }) + public Set> findAnnotatedClasses(Class annotationClass) + throws ClassNotFoundException { + + Set> annotatedClasses = new HashSet>(); + ArrayList dynamicClasses = classNames; + + for (String dynamicClass : dynamicClasses) { + Class classz = compiledObjects.get(dynamicClass); + if (classz.getAnnotation(annotationClass) != null) { + annotatedClasses.add(classz); + } + } + + return annotatedClasses; + } + + /** + * Find classes with a particular annotation from dynamic compiler. + * @param annotationClass Annotation to search for. + * @return Set of Classes matching the annotation. + * @throws ClassNotFoundException + */ + @SuppressWarnings({ "rawtypes" }) + public List findAnnotatedClassNames(Class annotationClass) + throws ClassNotFoundException { + + return findAnnotatedClasses(annotationClass) + .stream() + .map(Class::getName) + .collect(Collectors.toList()); + } +} diff --git a/elide-contrib/elide-dynamic-config-helpers/src/main/java/com/yahoo/elide/contrib/dynamicconfighelpers/compile/ElideDynamicInMemoryClassLoader.java b/elide-contrib/elide-dynamic-config-helpers/src/main/java/com/yahoo/elide/contrib/dynamicconfighelpers/compile/ElideDynamicInMemoryClassLoader.java new file mode 100644 index 0000000000..1165f6a6ca --- /dev/null +++ b/elide-contrib/elide-dynamic-config-helpers/src/main/java/com/yahoo/elide/contrib/dynamicconfighelpers/compile/ElideDynamicInMemoryClassLoader.java @@ -0,0 +1,55 @@ +/* + * Copyright 2020, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.contrib.dynamicconfighelpers.compile; + +import com.google.common.collect.Sets; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.extern.slf4j.Slf4j; + +import java.net.MalformedURLException; +import java.net.URL; +import java.util.Set; + +/** + * ClassLoader for dynamic configuration. + */ +@Slf4j +@Data +@AllArgsConstructor +public class ElideDynamicInMemoryClassLoader extends ClassLoader { + + private Set classNames = Sets.newHashSet(); + + public ElideDynamicInMemoryClassLoader(ClassLoader parent, Set classNames) { + super(parent); + setClassNames(classNames); + } + + @Override + protected Class findClass(String name) throws ClassNotFoundException { + return super.findClass(name); + } + + @Override + public Class loadClass(String name) throws ClassNotFoundException { + return super.loadClass(name); + } + + @Override + protected URL findResource(String name) { + log.debug("Finding Resource " + name + " in " + classNames); + if (classNames.contains(name.replace("/", ".").replace(".class", ""))) { + try { + log.debug("Returning Resource " + "file://" + name); + return new URL("file://" + name); + } catch (MalformedURLException e) { + throw new IllegalStateException(e); + } + } + return super.findResource(name); + } +} diff --git a/elide-contrib/elide-dynamic-config-helpers/src/main/java/com/yahoo/elide/contrib/dynamicconfighelpers/model/Dimension.java b/elide-contrib/elide-dynamic-config-helpers/src/main/java/com/yahoo/elide/contrib/dynamicconfighelpers/model/Dimension.java new file mode 100644 index 0000000000..320f24d8cf --- /dev/null +++ b/elide-contrib/elide-dynamic-config-helpers/src/main/java/com/yahoo/elide/contrib/dynamicconfighelpers/model/Dimension.java @@ -0,0 +1,82 @@ +/* + * Copyright 2020, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.contrib.dynamicconfighelpers.model; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +/** + * Dimensions represent labels for measures. + * Dimensions are used to filter and group measures. + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonPropertyOrder({ + "name", + "description", + "category", + "hidden", + "readAccess", + "definition", + "type", + "grains", + "tags" +}) +@Data +@EqualsAndHashCode() +@AllArgsConstructor +@NoArgsConstructor +public class Dimension { + + @JsonProperty("name") + private String name; + + @JsonProperty("description") + private String description; + + @JsonProperty("category") + private String category; + + + @JsonProperty("hidden") + private Boolean hidden = false; + + @JsonProperty("readAccess") + private String readAccess = "Prefab.Role.All"; + + @JsonProperty("definition") + private String definition = ""; + + @JsonProperty("type") + private Type type = Type.TEXT; + + @JsonProperty("grains") + private List grains = new ArrayList(); + + @JsonProperty("tags") + @JsonDeserialize(as = LinkedHashSet.class) + private Set tags = new LinkedHashSet(); + + /** + * Returns description of the dimension. + * If null, returns the name. + * @return description + */ + public String getDescription() { + return (this.description == null ? getName() : this.description); + } +} diff --git a/elide-contrib/elide-dynamic-config-helpers/src/main/java/com/yahoo/elide/contrib/dynamicconfighelpers/model/ElideSecurityConfig.java b/elide-contrib/elide-dynamic-config-helpers/src/main/java/com/yahoo/elide/contrib/dynamicconfighelpers/model/ElideSecurityConfig.java new file mode 100644 index 0000000000..9ef99e6797 --- /dev/null +++ b/elide-contrib/elide-dynamic-config-helpers/src/main/java/com/yahoo/elide/contrib/dynamicconfighelpers/model/ElideSecurityConfig.java @@ -0,0 +1,42 @@ +/* + * Copyright 2020, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.contrib.dynamicconfighelpers.model; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +import java.util.LinkedHashSet; +import java.util.Set; + +/** + * Elide Security POJO. + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonPropertyOrder({ + "roles", + "rules" +}) +@Data +@EqualsAndHashCode() +@AllArgsConstructor +@NoArgsConstructor +public class ElideSecurityConfig { + + @JsonProperty("roles") + @JsonDeserialize(as = LinkedHashSet.class) + private Set roles = new LinkedHashSet(); + + @JsonProperty("rules") + @JsonDeserialize(as = LinkedHashSet.class) + private Set rules = new LinkedHashSet(); +} diff --git a/elide-contrib/elide-dynamic-config-helpers/src/main/java/com/yahoo/elide/contrib/dynamicconfighelpers/model/ElideTableConfig.java b/elide-contrib/elide-dynamic-config-helpers/src/main/java/com/yahoo/elide/contrib/dynamicconfighelpers/model/ElideTableConfig.java new file mode 100644 index 0000000000..ed85357d2e --- /dev/null +++ b/elide-contrib/elide-dynamic-config-helpers/src/main/java/com/yahoo/elide/contrib/dynamicconfighelpers/model/ElideTableConfig.java @@ -0,0 +1,37 @@ +/* + * Copyright 2020, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.contrib.dynamicconfighelpers.model; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +import java.util.LinkedHashSet; +import java.util.Set; + +/** + * Elide Table POJO. + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonPropertyOrder({ + "tables" +}) +@Data +@EqualsAndHashCode() +@AllArgsConstructor +@NoArgsConstructor +public class ElideTableConfig { + + @JsonProperty("tables") + @JsonDeserialize(as = LinkedHashSet.class) + private Set
tables = new LinkedHashSet
(); +} diff --git a/elide-contrib/elide-dynamic-config-helpers/src/main/java/com/yahoo/elide/contrib/dynamicconfighelpers/model/Grains.java b/elide-contrib/elide-dynamic-config-helpers/src/main/java/com/yahoo/elide/contrib/dynamicconfighelpers/model/Grains.java new file mode 100644 index 0000000000..dc2e9b801e --- /dev/null +++ b/elide-contrib/elide-dynamic-config-helpers/src/main/java/com/yahoo/elide/contrib/dynamicconfighelpers/model/Grains.java @@ -0,0 +1,79 @@ +/* + * Copyright 2020, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.contrib.dynamicconfighelpers.model; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import com.fasterxml.jackson.annotation.JsonValue; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +import java.util.HashMap; +import java.util.Map; + +/** + * Grains can have SQL expressions that can substitute column + * with the dimension definition expression. + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonPropertyOrder({ + "grain", + "sql" +}) +@Data +@EqualsAndHashCode() +@AllArgsConstructor +@NoArgsConstructor +public class Grains { + + + @JsonProperty("grain") + private Grains.Grain grain; + + @JsonProperty("sql") + private String sql; + + public enum Grain { + + DAY("DAY"), + WEEK("WEEK"), + MONTH("MONTH"), + YEAR("YEAR"); + private final String value; + private final static Map CONSTANTS = new HashMap(); + + static { + for (Grains.Grain c: values()) { + CONSTANTS.put(c.value, c); + } + } + + private Grain(String value) { + this.value = value; + } + + @JsonValue + @Override + public String toString() { + return this.value; + } + + @JsonCreator + public static Grains.Grain fromValue(String value) { + Grains.Grain constant = CONSTANTS.get(value); + if (constant == null) { + throw new IllegalArgumentException(value); + } else { + return constant; + } + } + } +} diff --git a/elide-contrib/elide-dynamic-config-helpers/src/main/java/com/yahoo/elide/contrib/dynamicconfighelpers/model/Join.java b/elide-contrib/elide-dynamic-config-helpers/src/main/java/com/yahoo/elide/contrib/dynamicconfighelpers/model/Join.java new file mode 100644 index 0000000000..f5e2d2d7b5 --- /dev/null +++ b/elide-contrib/elide-dynamic-config-helpers/src/main/java/com/yahoo/elide/contrib/dynamicconfighelpers/model/Join.java @@ -0,0 +1,85 @@ +/* + * Copyright 2020, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.contrib.dynamicconfighelpers.model; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import com.fasterxml.jackson.annotation.JsonValue; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +import java.util.HashMap; +import java.util.Map; + +/** + * Joins describe the SQL expression necessary to join two physical tables. + * Joins can be used when defining dimension columns that reference other tables. + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonPropertyOrder({ + "name", + "to", + "type", + "definition" +}) +@Data +@EqualsAndHashCode() +@AllArgsConstructor +@NoArgsConstructor +public class Join { + + + @JsonProperty("name") + private String name; + + @JsonProperty("to") + private String to; + + @JsonProperty("type") + private Join.Type type; + + @JsonProperty("definition") + private String definition; + + public enum Type { + + TO_ONE("toOne"), + TO_MANY("toMany"); + private final String value; + private final static Map CONSTANTS = new HashMap(); + + static { + for (Join.Type c: values()) { + CONSTANTS.put(c.value, c); + } + } + + private Type(String value) { + this.value = value; + } + + @JsonValue + @Override + public String toString() { + return this.value; + } + + @JsonCreator + public static Join.Type fromValue(String value) { + Join.Type constant = CONSTANTS.get(value); + if (constant == null) { + throw new IllegalArgumentException(value); + } else { + return constant; + } + } + } +} diff --git a/elide-contrib/elide-dynamic-config-helpers/src/main/java/com/yahoo/elide/contrib/dynamicconfighelpers/model/Measure.java b/elide-contrib/elide-dynamic-config-helpers/src/main/java/com/yahoo/elide/contrib/dynamicconfighelpers/model/Measure.java new file mode 100644 index 0000000000..b95174a171 --- /dev/null +++ b/elide-contrib/elide-dynamic-config-helpers/src/main/java/com/yahoo/elide/contrib/dynamicconfighelpers/model/Measure.java @@ -0,0 +1,65 @@ +/* + * Copyright 2020, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.contrib.dynamicconfighelpers.model; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +/** + * Measures represent metrics that can be aggregated at query time. + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonPropertyOrder({ + "name", + "description", + "category", + "hidden", + "readAccess", + "definition", + "type" +}) +@Data +@EqualsAndHashCode() +@AllArgsConstructor +@NoArgsConstructor +public class Measure { + + @JsonProperty("name") + private String name; + + @JsonProperty("description") + private String description; + + @JsonProperty("category") + private String category; + + @JsonProperty("hidden") + private Boolean hidden = false; + + @JsonProperty("readAccess") + private String readAccess = "Prefab.Role.All"; + + @JsonProperty("definition") + private String definition; + + @JsonProperty("type") + private Type type = Type.INTEGER; + + /** + * Returns description of the measure. + * If null, returns the name. + * @return description + */ + public String getDescription() { + return (this.description == null ? getName() : this.description); + } +} diff --git a/elide-contrib/elide-dynamic-config-helpers/src/main/java/com/yahoo/elide/contrib/dynamicconfighelpers/model/Rule.java b/elide-contrib/elide-dynamic-config-helpers/src/main/java/com/yahoo/elide/contrib/dynamicconfighelpers/model/Rule.java new file mode 100644 index 0000000000..7e97311532 --- /dev/null +++ b/elide-contrib/elide-dynamic-config-helpers/src/main/java/com/yahoo/elide/contrib/dynamicconfighelpers/model/Rule.java @@ -0,0 +1,74 @@ +/* + * Copyright 2020, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.contrib.dynamicconfighelpers.model; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import com.fasterxml.jackson.annotation.JsonValue; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +/** + * Rules are a list of RSQL filter expression templates that + * support property expansion on the principal object. + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonPropertyOrder({ + "type", + "filter", + "name" +}) +@Data +@EqualsAndHashCode() +@AllArgsConstructor +@NoArgsConstructor +public class Rule { + + @JsonProperty("type") + private Rule.Type type; + + @JsonProperty("filter") + private String filter; + + @JsonProperty("name") + private String name; + + public enum Type { + + FILTER("filter"); + private final String value; + + private Type(String value) { + this.value = value; + } + + @JsonValue + @Override + public String toString() { + return this.value; + } + } + + public enum Filter { + + FILTER("filter"); + private final String value; + + private Filter(String value) { + this.value = value; + } + + @JsonValue + @Override + public String toString() { + return this.value; + } + } +} diff --git a/elide-contrib/elide-dynamic-config-helpers/src/main/java/com/yahoo/elide/contrib/dynamicconfighelpers/model/Table.java b/elide-contrib/elide-dynamic-config-helpers/src/main/java/com/yahoo/elide/contrib/dynamicconfighelpers/model/Table.java new file mode 100644 index 0000000000..f610cc4ce6 --- /dev/null +++ b/elide-contrib/elide-dynamic-config-helpers/src/main/java/com/yahoo/elide/contrib/dynamicconfighelpers/model/Table.java @@ -0,0 +1,137 @@ +/* + * Copyright 2020, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.contrib.dynamicconfighelpers.model; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import com.fasterxml.jackson.annotation.JsonValue; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Table Model JSON. + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonPropertyOrder({ + "name", + "schema", + "hidden", + "description", + "cardinality", + "readAccess", + "joins", + "measures", + "dimensions", + "tags", + "extends", + "sql", + "table" +}) +@Data +@EqualsAndHashCode() +@AllArgsConstructor +@NoArgsConstructor +public class Table { + + @JsonProperty("name") + private String name; + + @JsonProperty("schema") + private String schema = ""; + + @JsonProperty("hidden") + private Boolean hidden = false; + + @JsonProperty("description") + private String description; + + @JsonProperty("cardinality") + private Table.Cardinality cardinality = Table.Cardinality.fromValue("tiny"); + + @JsonProperty("readAccess") + private String readAccess = "Prefab.Role.All"; + + @JsonProperty("joins") + private List joins = new ArrayList(); + + @JsonProperty("measures") + private List measures = new ArrayList(); + + @JsonProperty("dimensions") + private List dimensions = new ArrayList(); + + @JsonProperty("tags") + @JsonDeserialize(as = LinkedHashSet.class) + private Set tags = new LinkedHashSet(); + + @JsonProperty("extends") + private String extend = ""; + + @JsonProperty("sql") + private String sql = ""; + + @JsonProperty("table") + private String table = ""; + + /** + * Returns description of the table object. + * If null, returns the name. + * @return description + */ + public String getDescription() { + return (this.description == null ? getName() : this.description); + } + + public enum Cardinality { + + TINY("tiny"), + SMALL("small"), + MEDIUM("medium"), + LARGE("large"), + HUGE("huge"); + private final String value; + private final static Map CONSTANTS = new HashMap(); + + static { + for (Table.Cardinality c: values()) { + CONSTANTS.put(c.value, c); + } + } + + private Cardinality(String value) { + this.value = value; + } + + @JsonValue + @Override + public String toString() { + return this.value; + } + + @JsonCreator + public static Table.Cardinality fromValue(String value) { + Table.Cardinality constant = CONSTANTS.get(value); + if (constant == null) { + throw new IllegalArgumentException(value); + } else { + return constant; + } + } + } +} diff --git a/elide-contrib/elide-dynamic-config-helpers/src/main/java/com/yahoo/elide/contrib/dynamicconfighelpers/model/Type.java b/elide-contrib/elide-dynamic-config-helpers/src/main/java/com/yahoo/elide/contrib/dynamicconfighelpers/model/Type.java new file mode 100644 index 0000000000..703fa50c23 --- /dev/null +++ b/elide-contrib/elide-dynamic-config-helpers/src/main/java/com/yahoo/elide/contrib/dynamicconfighelpers/model/Type.java @@ -0,0 +1,31 @@ +/* + * Copyright 2020, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.contrib.dynamicconfighelpers.model; + +/** + * Data Type of the field. + */ +public enum Type { + + TIME("TIME"), + INTEGER("INTEGER"), + DECIMAL("DECIMAL"), + MONEY("MONEY"), + TEXT("TEXT"), + COORDINATE("COORDINATE"), + BOOLEAN("BOOLEAN"); + + private final String value; + + private Type(String value) { + this.value = value; + } + + @Override + public String toString() { + return this.value; + } +} diff --git a/elide-contrib/elide-dynamic-config-helpers/src/main/java/com/yahoo/elide/contrib/dynamicconfighelpers/parser/ElideConfigParser.java b/elide-contrib/elide-dynamic-config-helpers/src/main/java/com/yahoo/elide/contrib/dynamicconfighelpers/parser/ElideConfigParser.java new file mode 100644 index 0000000000..9117383518 --- /dev/null +++ b/elide-contrib/elide-dynamic-config-helpers/src/main/java/com/yahoo/elide/contrib/dynamicconfighelpers/parser/ElideConfigParser.java @@ -0,0 +1,52 @@ +/* + * Copyright 2020, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.contrib.dynamicconfighelpers.parser; + +import com.yahoo.elide.contrib.dynamicconfighelpers.DynamicConfigHelpers; +import com.yahoo.elide.contrib.dynamicconfighelpers.model.ElideSecurityConfig; +import com.yahoo.elide.contrib.dynamicconfighelpers.model.ElideTableConfig; + +import lombok.Data; +import lombok.extern.slf4j.Slf4j; + +import java.io.IOException; +import java.util.Map; + +@Slf4j +/** + * Parses Hjson configuration from local file path and initializes Dynamic Model POJOs + */ +@Data +public class ElideConfigParser { + + private ElideTableConfig elideTableConfig; + private ElideSecurityConfig elideSecurityConfig; + private Map variables; + + /** + * Initialize Dynamic config objects. + * @param localFilePath : Path to dynamic model config dir. + * @throws IllegalArgumentException + */ + public ElideConfigParser(String localFilePath) { + + if (DynamicConfigHelpers.isNullOrEmpty(localFilePath)) { + throw new IllegalArgumentException("Config path is null"); + } + try { + String basePath = DynamicConfigHelpers.formatFilePath(localFilePath); + + this.variables = DynamicConfigHelpers.getVariablesPojo(basePath); + this.elideTableConfig = DynamicConfigHelpers.getElideTablePojo(basePath, this.variables); + this.elideSecurityConfig = DynamicConfigHelpers.getElideSecurityPojo(basePath, this.variables); + + } catch (IOException e) { + log.error("Error while parsing dynamic config at location " + localFilePath); + log.error(e.getMessage()); + throw new IllegalStateException(e); + } + } +} diff --git a/elide-contrib/elide-dynamic-config-helpers/src/main/java/com/yahoo/elide/contrib/dynamicconfighelpers/parser/handlebars/HandlebarsHelper.java b/elide-contrib/elide-dynamic-config-helpers/src/main/java/com/yahoo/elide/contrib/dynamicconfighelpers/parser/handlebars/HandlebarsHelper.java new file mode 100644 index 0000000000..285ec7dabb --- /dev/null +++ b/elide-contrib/elide-dynamic-config-helpers/src/main/java/com/yahoo/elide/contrib/dynamicconfighelpers/parser/handlebars/HandlebarsHelper.java @@ -0,0 +1,125 @@ +/* + * Copyright 2020, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.contrib.dynamicconfighelpers.parser.handlebars; + +import com.yahoo.elide.contrib.dynamicconfighelpers.model.Type; +import com.github.jknack.handlebars.Options; + +import java.io.IOException; +import java.util.Arrays; +import java.util.Locale; +import java.util.stream.Collectors; + +/** + * Helper class for handlebar template hydration. + */ +public class HandlebarsHelper { + + private static final String EMPTY_STRING = ""; + private static final String STRING = "String"; + private static final String DATE = "Date"; + private static final String BIGDECIMAL = "BigDecimal"; + private static final String LONG = "Long"; + private static final String BOOLEAN = "Boolean"; + private static final String WHITESPACE_REGEX = "\\s+"; + + /** + * Capitalize first letter of the string. + * @param str string to capitalize first letter + * @return string with first letter capitalized + */ + public String capitalizeFirstLetter(String str) { + + return (str == null || str.length() == 0) ? str : str.substring(0, 1).toUpperCase(Locale.ENGLISH) + + str.substring(1); + } + + /** + * LowerCase first letter of the string. + * @param str string to lower case first letter + * @return string with first letter lower cased + */ + public String lowerCaseFirstLetter(String str) { + + return (str == null || str.length() == 0) ? str : str.substring(0, 1).toLowerCase(Locale.ENGLISH) + + str.substring(1); + } + + /** + * Transform string to capitalize first character of each word, change other + * characters to lower case and remove spaces. + * @param str String to be transformed + * @return Capitalize First Letter of Each Word and remove spaces + */ + public String titleCaseRemoveSpaces(String str) { + + return (str == null || str.length() == 0) ? str + : String.join(EMPTY_STRING, Arrays.asList(str.trim().split(WHITESPACE_REGEX)).stream().map( + s -> toUpperCase(s.substring(0, 1)) + toLowerCase(s.substring(1))) + .collect(Collectors.toList())); + } + + /** + * Transform string to upper case. + * @param obj Object representation of the string + * @return string converted to upper case + */ + public String toUpperCase(Object obj) { + + return (obj == null) ? EMPTY_STRING : obj.toString().toUpperCase(Locale.ENGLISH); + } + + /** + * Transform string to lower case. + * @param obj Object representation of the string + * @return string converted to lower case + */ + public String toLowerCase(Object obj) { + + return (obj == null) ? EMPTY_STRING : obj.toString().toLowerCase(Locale.ENGLISH); + } + + /** + * If type matches passed value. + * @param type Elide model type object + * @param options options object with type/string to match + * @return template if matched + * @throws IOException IOException + */ + public CharSequence ifTypeMatches(Object type, Options options) throws IOException { + + String inputType = type.toString(); + String typeToMatch = options.param(0, null); + return inputType.equals(typeToMatch) ? options.fn() : options.inverse(); + } + + /** + * Get java type name corresponding to the Elide model type. + * @param type Elide model type object + * @return The corresponding java type name + */ + public String getJavaType(Type type) { + + switch (type) { + case BOOLEAN: + return BOOLEAN; + case COORDINATE: + return STRING; + case INTEGER: + return LONG; + case TEXT: + return STRING; + case TIME: + return DATE; + case DECIMAL: + return BIGDECIMAL; + case MONEY: + return BIGDECIMAL; + default: + return STRING; + } + } +} diff --git a/elide-contrib/elide-dynamic-config-helpers/src/main/java/com/yahoo/elide/contrib/dynamicconfighelpers/parser/handlebars/HandlebarsHydrator.java b/elide-contrib/elide-dynamic-config-helpers/src/main/java/com/yahoo/elide/contrib/dynamicconfighelpers/parser/handlebars/HandlebarsHydrator.java new file mode 100644 index 0000000000..30fd5a0b23 --- /dev/null +++ b/elide-contrib/elide-dynamic-config-helpers/src/main/java/com/yahoo/elide/contrib/dynamicconfighelpers/parser/handlebars/HandlebarsHydrator.java @@ -0,0 +1,107 @@ +/* + * Copyright 2020, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.contrib.dynamicconfighelpers.parser.handlebars; + +import com.yahoo.elide.contrib.dynamicconfighelpers.model.ElideSecurityConfig; +import com.yahoo.elide.contrib.dynamicconfighelpers.model.ElideTableConfig; +import com.yahoo.elide.contrib.dynamicconfighelpers.model.Table; +import com.github.jknack.handlebars.Context; +import com.github.jknack.handlebars.EscapingStrategy; +import com.github.jknack.handlebars.EscapingStrategy.Hbs; +import com.github.jknack.handlebars.Handlebars; +import com.github.jknack.handlebars.Template; +import com.github.jknack.handlebars.helper.ConditionalHelpers; +import com.github.jknack.handlebars.io.ClassPathTemplateLoader; +import com.github.jknack.handlebars.io.TemplateLoader; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +/** + * Class for handlebars hydration. + */ +public class HandlebarsHydrator { + + public static final String SECURITY_CLASS_PREFIX = "DynamicConfigOperationChecksPrincipalIs"; + public static final String HANDLEBAR_START_DELIMITER = "<%"; + public static final String HANDLEBAR_END_DELIMITER = "%>"; + public static final EscapingStrategy MY_ESCAPING_STRATEGY = new Hbs(new String[][]{ + {"<", "<" }, + {">", ">" }, + {"\"", """ }, + {"`", "`" }, + {"&", "&" } + }); + + /** + * Method to hydrate the Table template. + * @param table ElideTable object + * @return map with key as table java class name and value as table java class definition + * @throws IOException IOException + */ + public Map hydrateTableTemplate(ElideTableConfig table) throws IOException { + + Map tableClasses = new HashMap<>(); + + TemplateLoader loader = new ClassPathTemplateLoader("/templates"); + Handlebars handlebars = new Handlebars(loader).with(MY_ESCAPING_STRATEGY); + HandlebarsHelper helper = new HandlebarsHelper(); + handlebars.registerHelpers(ConditionalHelpers.class); + handlebars.registerHelpers(helper); + Template template = handlebars.compile("table", HANDLEBAR_START_DELIMITER, HANDLEBAR_END_DELIMITER); + + for (Table t : table.getTables()) { + tableClasses.put(helper.capitalizeFirstLetter(t.getName()), template.apply(t)); + } + + return tableClasses; + } + + /** + * Method to replace variables in hjson config. + * @param config hjson config string + * @param replacements Map of variable key value pairs + * @return hjson config string with variables replaced + * @throws IOException IOException + */ + public String hydrateConfigTemplate(String config, Map replacements) throws IOException { + + Context context = Context.newBuilder(replacements).build(); + Handlebars handlebars = new Handlebars(); + Template template = handlebars.compileInline(config, HANDLEBAR_START_DELIMITER, HANDLEBAR_END_DELIMITER); + + return template.apply(context); + } + + /** + * Method to hydrate the Security template. + * @param security ElideSecurity Object + * @return security java class string + * @throws IOException IOException + */ + public Map hydrateSecurityTemplate(ElideSecurityConfig security) throws IOException { + + Map securityClasses = new HashMap<>(); + + if (security == null) { + return securityClasses; + } + + TemplateLoader loader = new ClassPathTemplateLoader("/templates"); + Handlebars handlebars = new Handlebars(loader).with(MY_ESCAPING_STRATEGY); + HandlebarsHelper helper = new HandlebarsHelper(); + handlebars.registerHelpers(ConditionalHelpers.class); + handlebars.registerHelpers(helper); + Template template = handlebars.compile("security", HANDLEBAR_START_DELIMITER, HANDLEBAR_END_DELIMITER); + + for (String role : security.getRoles()) { + securityClasses.put(SECURITY_CLASS_PREFIX + helper.titleCaseRemoveSpaces(role), template.apply(role)); + } + + return securityClasses; + } +} diff --git a/elide-contrib/elide-dynamic-config-helpers/src/main/resources/elideSecuritySchema.json b/elide-contrib/elide-dynamic-config-helpers/src/main/resources/elideSecuritySchema.json new file mode 100644 index 0000000000..1f89dff286 --- /dev/null +++ b/elide-contrib/elide-dynamic-config-helpers/src/main/resources/elideSecuritySchema.json @@ -0,0 +1,54 @@ +{ + "$schema": "https://json-schema.org/draft-06/schema#", + "$id": "https://elide.io/schemas/security_schema_v1#", + "description": "Elide Security config json/hjson schema", + "type": "object", + "properties": { + "roles": { + "title": "Security Roles", + "description": "List of Roles that will map to security checks", + "type": "array", + "uniqueItems": true, + "items": { + "type": "string" + } + }, + "rules": { + "title": "Security Rules", + "description": "List of RSQL filter expression templates", + "type": "array", + "uniqueItems": true, + "items": { + "properties": { + "type": { + "title": "Rule Type", + "description": "Type of security rule", + "type": "string", + "enum": [ + "filter" + ] + }, + "filter": { + "title": "Rule Filter", + "description": "Rule filter expression", + "type": "string", + "enum": [ + "filter" + ] + }, + "name": { + "title": "Rule Name", + "description": "Name of the security rule", + "type": "string" + } + }, + "required": [ + "filter", + "name" + ], + "additionalProperties": false + } + } + }, + "additionalProperties": false +} diff --git a/elide-contrib/elide-dynamic-config-helpers/src/main/resources/elideTableSchema.json b/elide-contrib/elide-dynamic-config-helpers/src/main/resources/elideTableSchema.json new file mode 100644 index 0000000000..3342f4edc3 --- /dev/null +++ b/elide-contrib/elide-dynamic-config-helpers/src/main/resources/elideTableSchema.json @@ -0,0 +1,406 @@ +{ + "$schema": "https://json-schema.org/draft-06/schema#", + "$id": "https://elide.io/schemas/table_schema_v1#", + "description": "Elide Table config json/hjson schema", + "definitions": { + "grain": { + "title": "Grains", + "description": "Grains can have SQL expressions that can substitute column with the dimension definition expression", + "type": "object", + "properties": { + "grain": { + "title": "Time granularity", + "description": "Indicates grain time granularity", + "type": "string", + "enum": [ + "DAY", + "WEEK", + "MONTH", + "YEAR" + ] + }, + "sql": { + "title": "Grain SQL", + "description": "Grain SQL query", + "type": "string" + } + }, + "required": [ + "grain", + "sql" + ], + "additionalProperties": false + }, + "join": { + "title": "Join", + "description": "Joins describe the SQL expression necessary to join two physical tables. Joins can be used when defining dimension columns that reference other tables.", + "type": "object", + "properties": { + "name": { + "title": "Join name", + "description": "The name of the join relationship.", + "type": "string" + }, + "to": { + "title": "Join table name", + "description": "The name of the table that is being joined to", + "type": "string", + "pattern": "^[A-Za-z]([0-9A-Za-z]*_?[0-9A-Za-z]*)*$" + }, + "type": { + "title": "Type of Join", + "description": "Type of the join - toOne or toMany", + "type": "string", + "enum": [ + "toOne", + "toMany" + ] + }, + "definition": { + "title": "Join definition SQL", + "description": "Templated SQL expression that represents the ON clause of the join", + "type": "string" + } + }, + "required": [ + "name", + "to", + "type", + "definition" + ], + "additionalProperties": false + }, + "enumtype": { + "title": "Dimension field type", + "description": "The data type of the dimension field", + "type": "string", + "enum": [ + "INTEGER", + "DECIMAL", + "MONEY", + "TEXT", + "COORDINATE", + "BOOLEAN" + ] + }, + "measure": { + "title": "Measure", + "description": "Metric definitions are extensible objects that contain a type field and one or more additional attributes. Each type is tied to logic in Elide that generates a metric function.", + "type": "object", + "properties": { + "name": { + "title": "Metric name", + "description": "The name of the metric. This will be the same as the POJO field name.", + "type": "string", + "pattern": "^[A-Za-z]([0-9A-Za-z]*_?[0-9A-Za-z]*)*$" + }, + "description": { + "title": "Metric description", + "description": "A long description of the metric.", + "type": "string" + }, + "category": { + "title": "Measure group category", + "description": "Category for grouping", + "type": "string" + }, + "hidden": { + "title": "Hide/Show measure", + "description": "Whether this metric is exposed via API metadata", + "type": "boolean", + "default": false + }, + "readAccess": { + "title": "Measure read access", + "description": "Read permission for the metric.", + "type": "string", + "default": "Prefab.Role.All" + }, + "definition": { + "title": "Metric definition", + "description": "The definition of the metric", + "type": "string" + }, + "type": { + "oneOf":[ + {"$ref": "#/definitions/enumtype"}, + {"enum": [ + "TIME" + ]} + ], + "default": "INTEGER" + } + }, + "required": [ + "name", + "definition" + ], + "additionalProperties": false + }, + "dimensionRef": { + "title": "Dimension", + "description": "Dimensions represent labels for measures. Dimensions are used to filter and group measures.", + "type": "object", + "properties": { + "name": { + "title": "Dimension name", + "description": "The name of the dimension. This will be the same as the POJO field name.", + "type": "string", + "pattern": "^[A-Za-z]([0-9A-Za-z]*_?[0-9A-Za-z]*)*$" + }, + "description": { + "title": "Dimension description", + "description": "A long description of the dimension.", + "type": "string" + }, + "category": { + "title": "Dimension group category", + "description": "Category for grouping dimension", + "type": "string" + }, + "hidden": { + "title": "Hide/Show dimension", + "description": "Whether this dimension is exposed via API metadata", + "type": "boolean", + "default": false + }, + "readAccess": { + "title": "Dimension read access", + "description": "Read permission for the dimension.", + "type": "string", + "default": "Prefab.Role.All" + }, + "definition": { + "title": "Dimension definition", + "description": "The definition of the dimension", + "type": "string", + "default": "" + }, + "tags": { + "title": "Dimension tags", + "description": "An array of string based tags for dimensions", + "type": "array", + "uniqueItems": true, + "items": { + "type": "string" + }, + "default": [] + } + } + }, + "dimension": { + "title": "Dimension", + "description": "Dimensions represent labels for measures. Dimensions are used to filter and group measures.", + "type": "object", + "allOf": [ + { "$ref": "#/definitions/dimensionRef" }, + { + "properties": { + "type": { + "oneOf":[ + {"$ref": "#/definitions/enumtype"}, + {"enum": [ + "RELATIONSHIP","ID" + ]} + ], + "default": "TEXT" + } + } + } + ], + "required": [ + "name", + "type", + "definition" + ] + }, + "timeDimension": { + "title": "Time Dimension", + "description": "Time Dimensions represent labels for measures. Dimensions are used to filter and group measures.", + "type": "object", + "allOf": [ + { "$ref": "#/definitions/dimensionRef" }, + { + "properties": { + "type": { + "title": "Dimension field type", + "description": "The data type of the dimension field", + "type": "string", + "enum": [ + "TIME" + ], + "default": "TIME" + }, + "grains": { + "title": "Time Dimension grains", + "description": "Time Dimension granularity and Sqls", + "type": "array", + "items": { + "$ref": "#/definitions/grain" + }, + "default": [] + } + } + } + ], + "required": [ + "name", + "type", + "definition", + "grains" + ] + } + }, + "type": "object", + "properties": { + "tables": { + "title": "Elide Table Models", + "description": "Array of elide Table Models", + "type": "array", + "uniqueItems": true, + "items": { + "title": "Elide Table Models", + "description": "Array of elide Table Models", + "type": "object", + "properties": { + "name": { + "title": "Table Model Name", + "description": "The name of the model. This will be the same as the POJO class name.", + "type": "string", + "pattern": "^[A-Z][0-9A-Za-z]*$" + }, + "schema": { + "title": "Table Schema", + "description": "The database or schema where the model lives.", + "type": "string", + "pattern": "^[A-Za-z]([0-9A-Za-z]*_?[0-9A-Za-z]*)*$", + "default": "" + }, + "hidden": { + "title": "Hide/Show Table", + "description": "Whether this table is exposed via API metadata", + "type": "boolean", + "default": false + }, + "description": { + "title": "Table Model description", + "description": "A long description of the model.", + "type": "string" + }, + "cardinality": { + "title": "Model cardinality", + "description": "The number of rows in the table: (tiny, small, medium, large, huge). The relative sizes are decided by the table designer(s).", + "type": "string", + "enum": [ + "tiny", + "small", + "medium", + "large", + "huge" + ], + "default": "tiny" + }, + "readAccess": { + "title": "Table read access", + "description": "Read permission for the table.", + "type": "string", + "default": "Prefab.Role.All" + }, + "joins": { + "title": "Table joins", + "description": "Describes SQL joins to other tables for column references.", + "type": "array", + "items": { + "$ref": "#/definitions/join" + }, + "default": [] + }, + "measures": { + "title": "Table measures", + "description": "Zero or more metric definitions.", + "type": "array", + "items": { + "$ref": "#/definitions/measure" + }, + "default": [] + }, + "dimensions": { + "title": "Table dimensions", + "description": "One or more dimension definitions.", + "type": "array", + "items": { + "anyOf": [ + {"$ref": "#/definitions/dimension"} , + {"$ref": "#/definitions/timeDimension"} + ] + }, + "default": [] + }, + "tags": { + "title": "Table tags", + "description": "An array of string based tags", + "type": "array", + "uniqueItems": true, + "items": { + "type": "string" + }, + "default": [] + } + }, + "oneOf": [ + { + "properties": { + "sql": { + "title": "Table SQL", + "description": "SQL query which is used to populate the table.", + "type": "string", + "default": "" + } + }, + "required": [ + "name", + "sql", + "dimensions" + ] + } , + { + "properties": { + "table": { + "title": "Table name", + "description": "The physical table name where the model lives.", + "type": "string", + "pattern": "^[A-Za-z]([0-9A-Za-z]*_?[0-9A-Za-z]*)*$", + "default": "" + } + }, + "required": [ + "name", + "table", + "dimensions" + ] + }, + { + "properties": { + "extends": { + "title": "Table Extends", + "description": "Extends another logical table.", + "type": "string", + "default": "" + } + }, + "required": [ + "name", + "extends", + "dimensions" + ] + } + ] + } + } + }, + "minProperties": 1, + "required": [ + "tables" + ], + "additionalProperties": false +} diff --git a/elide-contrib/elide-dynamic-config-helpers/src/main/resources/elideVariableSchema.json b/elide-contrib/elide-dynamic-config-helpers/src/main/resources/elideVariableSchema.json new file mode 100644 index 0000000000..1e8c38bb1a --- /dev/null +++ b/elide-contrib/elide-dynamic-config-helpers/src/main/resources/elideVariableSchema.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://json-schema.org/draft-06/schema#", + "$id": "https://elide.io/schemas/variable_schema_v1#", + "description": "Elide Variable config json/hjson schema", + "type": "object", + "patternProperties": { + "^([A-Za-z]*_?[A-Za-z]*)*$": { + "anyOf": [ + {"type": "string"}, + {"type": "array"}, + {"type": "object"}, + {"type": "null"} + ] + } + }, + "additionalProperties": false, + "minProperties": 1 +} diff --git a/elide-contrib/elide-dynamic-config-helpers/src/main/resources/templates/security.hbs b/elide-contrib/elide-dynamic-config-helpers/src/main/resources/templates/security.hbs new file mode 100644 index 0000000000..d9f3ee8afd --- /dev/null +++ b/elide-contrib/elide-dynamic-config-helpers/src/main/resources/templates/security.hbs @@ -0,0 +1,18 @@ +/* + * Copyright 2020, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package dynamicconfig.models; + +import com.yahoo.elide.annotation.SecurityCheck; +import com.yahoo.elide.security.checks.prefab.Role.RoleMemberCheck; + +@SecurityCheck(DynamicConfigOperationChecksPrincipalIs<%#titleCaseRemoveSpaces this%><%/titleCaseRemoveSpaces%>.PRINCIPAL_IS_<%#toUpperCase this%><%/toUpperCase%>) +public class DynamicConfigOperationChecksPrincipalIs<%#titleCaseRemoveSpaces this%><%/titleCaseRemoveSpaces%> extends RoleMemberCheck { + + public static final String PRINCIPAL_IS_<%#toUpperCase this%><%/toUpperCase%> = "Principal is <%this%>"; + public DynamicConfigOperationChecksPrincipalIs<%#titleCaseRemoveSpaces this%><%/titleCaseRemoveSpaces%>() { + super("<%this%>"); + } +} diff --git a/elide-contrib/elide-dynamic-config-helpers/src/main/resources/templates/table.hbs b/elide-contrib/elide-dynamic-config-helpers/src/main/resources/templates/table.hbs new file mode 100644 index 0000000000..5a2f6f7f75 --- /dev/null +++ b/elide-contrib/elide-dynamic-config-helpers/src/main/resources/templates/table.hbs @@ -0,0 +1,90 @@ +/* + * Copyright 2020, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package dynamicconfig.models; + +import com.yahoo.elide.annotation.DeletePermission; +import com.yahoo.elide.annotation.Include; +import com.yahoo.elide.annotation.Exclude; +import com.yahoo.elide.annotation.ReadPermission; +import com.yahoo.elide.annotation.UpdatePermission; +import com.yahoo.elide.datastores.aggregation.annotation.Cardinality; +import com.yahoo.elide.datastores.aggregation.annotation.CardinalitySize; +import com.yahoo.elide.datastores.aggregation.annotation.DimensionFormula; +import com.yahoo.elide.datastores.aggregation.annotation.MetricFormula; +import com.yahoo.elide.datastores.aggregation.annotation.Join; +import com.yahoo.elide.datastores.aggregation.annotation.Meta; +import com.yahoo.elide.datastores.aggregation.annotation.Temporal; +import com.yahoo.elide.datastores.aggregation.annotation.TimeGrainDefinition; +import com.yahoo.elide.datastores.aggregation.metadata.enums.TimeGrain; +import com.yahoo.elide.datastores.aggregation.queryengines.sql.annotation.FromSubquery; +import com.yahoo.elide.datastores.aggregation.queryengines.sql.annotation.FromTable; + +import lombok.EqualsAndHashCode; +import lombok.ToString; +import lombok.Data; + +import java.util.Date; +import javax.persistence.Column; +import javax.persistence.Id; + +/** + * A root level entity for testing AggregationDataStore. + */ +@Cardinality(size = CardinalitySize.<%#toUpperCase cardinality%><%/toUpperCase%>) +@EqualsAndHashCode +@ToString +@Data +<%#if table%>@FromTable(name = "<%#if schema%><%schema%>.<%/if%><%table%>") +<%else if sql%>@FromSubquery(sql = "<%sql%>") +<%/if%> +<%#if readAccess%>@ReadPermission(expression = "<%readAccess%>")<%/if%> +<%#if description%>@Meta(description = "<%description%>")<%/if%> +<%#if hidden%>@Exclude<%else%>@Include(rootLevel = true, type = "<%#lowerCaseFirstLetter name%><%/lowerCaseFirstLetter%>")<%/if%> +public class <%#capitalizeFirstLetter name%><%/capitalizeFirstLetter%> <%#if extend%>extends <%#capitalizeFirstLetter extend%><%/capitalizeFirstLetter%><%/if%>{ + + @Id + private String id; + +<%#each dimensions%> + +<%#ifTypeMatches type "TIME"%> + @Temporal(grains = { + <%#each grains%> + @TimeGrainDefinition(grain = TimeGrain.<%grain%>, expression = "<%sql%>")<%#if @last%><%else%>, <%/if%> + <%/each%> + }, timeZone = "UTC") +<%/ifTypeMatches%> + + <%#if readAccess%>@ReadPermission(expression = "<%readAccess%>")<%/if%> + <%#if description%>@Meta(description = "<%description%>")<%/if%> + <%#if hidden%>@Exclude<%/if%> + @DimensionFormula("<%definition%>") + private <%#getJavaType type%><%/getJavaType%> <%name%>; + +<%/each%> + + +<%#each joins%> + + @Join("<% definition %>") +<%#ifTypeMatches type "toMany"%> + private Set<<%#capitalizeFirstLetter to%><%/capitalizeFirstLetter%>> <%name%>; +<%else%> + private <%#capitalizeFirstLetter to%><%/capitalizeFirstLetter%> <%name%>; +<%/ifTypeMatches%> + +<%/each%> + +<%#each measures%> + + @MetricFormula("<%definition%>") + <%#if readAccess%>@ReadPermission(expression = "<%readAccess%>")<%/if%> + <%#if description%>@Meta(description = "<%description%>")<%/if%> + <%#if hidden%>@Exclude<%/if%> + private <%#getJavaType type%><%/getJavaType%> <%name%>; + +<%/each%> +} diff --git a/elide-contrib/elide-dynamic-config-helpers/src/test/java/com/yahoo/elide/contrib/dynamicconfighelpers/DynamicConfigHelpersTest.java b/elide-contrib/elide-dynamic-config-helpers/src/test/java/com/yahoo/elide/contrib/dynamicconfighelpers/DynamicConfigHelpersTest.java new file mode 100644 index 0000000000..0ab9b555a2 --- /dev/null +++ b/elide-contrib/elide-dynamic-config-helpers/src/test/java/com/yahoo/elide/contrib/dynamicconfighelpers/DynamicConfigHelpersTest.java @@ -0,0 +1,59 @@ +/* + * Copyright 2020, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.contrib.dynamicconfighelpers; + +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import com.yahoo.elide.contrib.dynamicconfighelpers.model.ElideSecurityConfig; +import com.yahoo.elide.contrib.dynamicconfighelpers.model.ElideTableConfig; + +import com.fasterxml.jackson.core.JsonProcessingException; + +import org.junit.jupiter.api.Test; + +import lombok.extern.slf4j.Slf4j; + +import java.io.File; +import java.io.IOException; +import java.util.Map; + +@Slf4j +public class DynamicConfigHelpersTest { + + @Test + public void testValidSecuritySchema() throws IOException { + String path = "src/test/resources/security/valid"; + File file = new File(path); + String absolutePath = file.getAbsolutePath(); + Map vars = DynamicConfigHelpers.getVariablesPojo( + DynamicConfigHelpers.formatFilePath(absolutePath)); + ElideSecurityConfig config = DynamicConfigHelpers.getElideSecurityPojo( + DynamicConfigHelpers.formatFilePath(absolutePath), vars); + assertNotNull(config); + } + + @Test + public void testValidVariableSchema() throws JsonProcessingException { + String path = "src/test/resources/variables/valid"; + File file = new File(path); + String absolutePath = file.getAbsolutePath(); + Map config = DynamicConfigHelpers.getVariablesPojo( + DynamicConfigHelpers.formatFilePath(absolutePath)); + assertNotNull(config); + } + + @Test + public void testValidTableSchema() throws IOException { + String path = "src/test/resources/tables"; + File file = new File(path); + String absolutePath = file.getAbsolutePath(); + Map vars = DynamicConfigHelpers.getVariablesPojo( + DynamicConfigHelpers.formatFilePath(absolutePath)); + ElideTableConfig config = DynamicConfigHelpers.getElideTablePojo( + DynamicConfigHelpers.formatFilePath(absolutePath), vars, "valid/"); + assertNotNull(config); + } +} diff --git a/elide-contrib/elide-dynamic-config-helpers/src/test/java/com/yahoo/elide/contrib/dynamicconfighelpers/SchemaTest.java b/elide-contrib/elide-dynamic-config-helpers/src/test/java/com/yahoo/elide/contrib/dynamicconfighelpers/SchemaTest.java new file mode 100644 index 0000000000..0b92b173bb --- /dev/null +++ b/elide-contrib/elide-dynamic-config-helpers/src/test/java/com/yahoo/elide/contrib/dynamicconfighelpers/SchemaTest.java @@ -0,0 +1,42 @@ +/* + * Copyright 2020, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.contrib.dynamicconfighelpers; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.hjson.JsonValue; + +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.Reader; + +public class SchemaTest { + + private InputStream loadStreamFromClasspath(String resource) throws Exception { + return TableSchemaValidationTest.class.getResourceAsStream(resource); + } + + private Reader loadReaderFromClasspath(String resource) throws Exception { + return new InputStreamReader(loadStreamFromClasspath(resource)); + } + + protected JsonNode loadJsonFromClasspath(String resource, boolean translate) throws Exception { + ObjectMapper objectMapper = new ObjectMapper(); + + Reader reader = loadReaderFromClasspath(resource); + + if (translate) { + String jsonText = JsonValue.readHjson(reader).toString(); + return objectMapper.readTree(jsonText); + } + + return objectMapper.readTree(reader); + } + + protected JsonNode loadJsonFromClasspath(String resource) throws Exception { + return loadJsonFromClasspath(resource, false); + } +} diff --git a/elide-contrib/elide-dynamic-config-helpers/src/test/java/com/yahoo/elide/contrib/dynamicconfighelpers/SecuritySchemaValidationTest.java b/elide-contrib/elide-dynamic-config-helpers/src/test/java/com/yahoo/elide/contrib/dynamicconfighelpers/SecuritySchemaValidationTest.java new file mode 100644 index 0000000000..8455aa5bd3 --- /dev/null +++ b/elide-contrib/elide-dynamic-config-helpers/src/test/java/com/yahoo/elide/contrib/dynamicconfighelpers/SecuritySchemaValidationTest.java @@ -0,0 +1,63 @@ +/* + * Copyright 2020, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.contrib.dynamicconfighelpers; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.fasterxml.jackson.databind.JsonNode; +import com.github.fge.jsonschema.core.report.ProcessingReport; +import com.github.fge.jsonschema.main.JsonSchema; +import com.github.fge.jsonschema.main.JsonSchemaFactory; +import org.junit.jupiter.api.Test; + +/** + * Security Schema functional test. + */ +public class SecuritySchemaValidationTest extends SchemaTest { + + private final JsonSchema schema; + + public SecuritySchemaValidationTest() throws Exception { + JsonSchemaFactory factory = JsonSchemaFactory.byDefault(); + schema = factory.getJsonSchema("resource:/elideSecuritySchema.json"); + } + + @Test + public void testValidSecuritySchema() throws Exception { + JsonNode testNode = loadJsonFromClasspath("/security/valid/security.json"); + ProcessingReport results = schema.validate(testNode); + assertTrue(results.isSuccess()); + } + + @Test + public void testInValidSecuritySchema() throws Exception { + JsonNode testNode = loadJsonFromClasspath("/security/invalid/security.json"); + ProcessingReport results = schema.validate(testNode); + assertFalse(results.isSuccess()); + } + + @Test + public void testValidSecurityHJson() throws Exception { + JsonNode testNode = loadJsonFromClasspath("/security/valid/security.hjson", true); + ProcessingReport results = schema.validate(testNode); + assertTrue(results.isSuccess()); + } + + @Test + public void testInvalidSecurityHJson() throws Exception { + JsonNode testNode = loadJsonFromClasspath("/security/invalid/security.hjson", true); + ProcessingReport results = schema.validate(testNode); + assertFalse(results.isSuccess()); + } + + @Test + public void testModelecurityHJson() throws Exception { + JsonNode testNode = loadJsonFromClasspath("/models/security.hjson", true); + ProcessingReport results = schema.validate(testNode); + assertTrue(results.isSuccess()); + } +} diff --git a/elide-contrib/elide-dynamic-config-helpers/src/test/java/com/yahoo/elide/contrib/dynamicconfighelpers/TableSchemaValidationTest.java b/elide-contrib/elide-dynamic-config-helpers/src/test/java/com/yahoo/elide/contrib/dynamicconfighelpers/TableSchemaValidationTest.java new file mode 100644 index 0000000000..a2bdc9f77b --- /dev/null +++ b/elide-contrib/elide-dynamic-config-helpers/src/test/java/com/yahoo/elide/contrib/dynamicconfighelpers/TableSchemaValidationTest.java @@ -0,0 +1,77 @@ +/* + * Copyright 2020, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.contrib.dynamicconfighelpers; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.fasterxml.jackson.databind.JsonNode; +import com.github.fge.jsonschema.core.report.ProcessingReport; +import com.github.fge.jsonschema.main.JsonSchema; +import com.github.fge.jsonschema.main.JsonSchemaFactory; +import org.junit.jupiter.api.Test; + +/** + * Security Schema functional test. + */ +public class TableSchemaValidationTest extends SchemaTest { + + private final JsonSchema schema; + + public TableSchemaValidationTest() throws Exception { + JsonSchemaFactory factory = JsonSchemaFactory.byDefault(); + schema = factory.getJsonSchema("resource:/elideTableSchema.json"); + } + + @Test + public void testValidTableSchema() throws Exception { + JsonNode testNode = loadJsonFromClasspath("/tables/valid/table.json"); + ProcessingReport results = schema.validate(testNode); + assertTrue(results.isSuccess()); + } + + @Test + public void testInvalidTableSchema() throws Exception { + JsonNode testNode = loadJsonFromClasspath("/tables/invalid/table.json"); + ProcessingReport results = schema.validate(testNode); + assertFalse(results.isSuccess()); + } + + @Test + public void testValidTableHJson() throws Exception { + JsonNode testNode = loadJsonFromClasspath("/tables/valid/table.hjson", true); + ProcessingReport results = schema.validate(testNode); + assertTrue(results.isSuccess()); + } + + @Test + public void testInvalidTableHJson() throws Exception { + JsonNode testNode = loadJsonFromClasspath("/tables/invalid/table.hjson", true); + ProcessingReport results = schema.validate(testNode); + assertFalse(results.isSuccess()); + } + + @Test + public void testModelsTable1HJson() throws Exception { + JsonNode testNode = loadJsonFromClasspath("/models/tables/table1.hjson", true); + ProcessingReport results = schema.validate(testNode); + assertTrue(results.isSuccess()); + } + + @Test + public void testModelsTable2HJson() throws Exception { + JsonNode testNode = loadJsonFromClasspath("/models/tables/table2.hjson", true); + ProcessingReport results = schema.validate(testNode); + assertTrue(results.isSuccess()); + } + + @Test + public void testModelsTable3HJson() throws Exception { + JsonNode testNode = loadJsonFromClasspath("/models_missing/tables/table1.hjson", true); + ProcessingReport results = schema.validate(testNode); + assertTrue(results.isSuccess()); + } +} diff --git a/elide-contrib/elide-dynamic-config-helpers/src/test/java/com/yahoo/elide/contrib/dynamicconfighelpers/VariableSchemaValidationTest.java b/elide-contrib/elide-dynamic-config-helpers/src/test/java/com/yahoo/elide/contrib/dynamicconfighelpers/VariableSchemaValidationTest.java new file mode 100644 index 0000000000..3cc47d8c37 --- /dev/null +++ b/elide-contrib/elide-dynamic-config-helpers/src/test/java/com/yahoo/elide/contrib/dynamicconfighelpers/VariableSchemaValidationTest.java @@ -0,0 +1,63 @@ +/* + * Copyright 2020, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.contrib.dynamicconfighelpers; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.fasterxml.jackson.databind.JsonNode; +import com.github.fge.jsonschema.core.report.ProcessingReport; +import com.github.fge.jsonschema.main.JsonSchema; +import com.github.fge.jsonschema.main.JsonSchemaFactory; +import org.junit.jupiter.api.Test; + +/** + * Security Schema functional test. + */ +public class VariableSchemaValidationTest extends SchemaTest { + + private final JsonSchema schema; + + public VariableSchemaValidationTest() throws Exception { + JsonSchemaFactory factory = JsonSchemaFactory.byDefault(); + schema = factory.getJsonSchema("resource:/elideVariableSchema.json"); + } + + @Test + public void testValidVariableSchema() throws Exception { + JsonNode testNode = loadJsonFromClasspath("/variables/valid/variables.json"); + ProcessingReport results = schema.validate(testNode); + assertTrue(results.isSuccess()); + } + + @Test + public void testInValidVariableSchema() throws Exception { + JsonNode testNode = loadJsonFromClasspath("/variables/invalid/variables.json"); + ProcessingReport results = schema.validate(testNode); + assertFalse(results.isSuccess()); + } + + @Test + public void testValidVariableHJson() throws Exception { + JsonNode testNode = loadJsonFromClasspath("/variables/valid/variables.hjson", true); + ProcessingReport results = schema.validate(testNode); + assertTrue(results.isSuccess()); + } + + @Test + public void testInvalidVariableHJson() throws Exception { + JsonNode testNode = loadJsonFromClasspath("/variables/invalid/variables.hjson", true); + ProcessingReport results = schema.validate(testNode); + assertFalse(results.isSuccess()); + } + + @Test + public void testModelsVariableHJson() throws Exception { + JsonNode testNode = loadJsonFromClasspath("/models/variables.hjson", true); + ProcessingReport results = schema.validate(testNode); + assertTrue(results.isSuccess()); + } +} diff --git a/elide-contrib/elide-dynamic-config-helpers/src/test/java/com/yahoo/elide/contrib/dynamicconfighelpers/parser/ElideConfigParserTest.java b/elide-contrib/elide-dynamic-config-helpers/src/test/java/com/yahoo/elide/contrib/dynamicconfighelpers/parser/ElideConfigParserTest.java new file mode 100644 index 0000000000..1a267f8bd3 --- /dev/null +++ b/elide-contrib/elide-dynamic-config-helpers/src/test/java/com/yahoo/elide/contrib/dynamicconfighelpers/parser/ElideConfigParserTest.java @@ -0,0 +1,68 @@ +/* + * Copyright 2020, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.contrib.dynamicconfighelpers.parser; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import com.yahoo.elide.contrib.dynamicconfighelpers.model.ElideSecurityConfig; +import com.yahoo.elide.contrib.dynamicconfighelpers.model.ElideTableConfig; +import com.yahoo.elide.contrib.dynamicconfighelpers.model.Table; +import com.yahoo.elide.contrib.dynamicconfighelpers.model.Type; + +import org.junit.jupiter.api.Test; + +import java.io.File; +import java.util.Map; + +public class ElideConfigParserTest { + + @Test + public void testValidateVariablePath() throws Exception { + + String path = "src/test/resources/models"; + File file = new File(path); + String absolutePath = file.getAbsolutePath(); + ElideConfigParser testClass = new ElideConfigParser(absolutePath); + + Map variable = testClass.getVariables(); + assertEquals(6, variable.size()); + assertEquals("blah", variable.get("bar")); + + ElideSecurityConfig security = testClass.getElideSecurityConfig(); + assertEquals(3, security.getRoles().size()); + + ElideTableConfig tables = testClass.getElideTableConfig(); + assertEquals(2, tables.getTables().size()); + for (Table t : tables.getTables()) { + assertEquals(t.getMeasures().get(0).getName() , t.getMeasures().get(0).getDescription()); + assertEquals("MAX(score)", t.getMeasures().get(0).getDefinition()); + assertEquals(Table.Cardinality.LARGE, t.getCardinality()); + // test hydration, variable substitution + assertEquals(Type.INTEGER, t.getMeasures().get(0).getType()); + } + } + + @Test + public void testNullConfig() { + try { + new ElideConfigParser(null); + } catch (IllegalArgumentException e) { + assertEquals("Config path is null", e.getMessage()); + } + } + + @Test + public void testMissingConfig() { + String path = "src/test/resources/models_missing"; + File file = new File(path); + String absolutePath = file.getAbsolutePath(); + ElideConfigParser testClass = new ElideConfigParser(absolutePath); + + assertNull(testClass.getVariables()); + assertNull(testClass.getElideSecurityConfig()); + } +} diff --git a/elide-contrib/elide-dynamic-config-helpers/src/test/java/com/yahoo/elide/contrib/dynamicconfighelpers/parser/handlebars/HandlebarsHydratorTest.java b/elide-contrib/elide-dynamic-config-helpers/src/test/java/com/yahoo/elide/contrib/dynamicconfighelpers/parser/handlebars/HandlebarsHydratorTest.java new file mode 100644 index 0000000000..d7baefa88d --- /dev/null +++ b/elide-contrib/elide-dynamic-config-helpers/src/test/java/com/yahoo/elide/contrib/dynamicconfighelpers/parser/handlebars/HandlebarsHydratorTest.java @@ -0,0 +1,286 @@ +/* + * Copyright 2020, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.contrib.dynamicconfighelpers.parser.handlebars; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.yahoo.elide.contrib.dynamicconfighelpers.parser.ElideConfigParser; + +import org.junit.jupiter.api.Test; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.Map; + +public class HandlebarsHydratorTest { + + private static final String VALID_TABLE_WITH_VARIABLES = "{\n" + + " tables: [{\n" + + " name: <% name %>\n" + + " table: <% table %>\n" + + " schema: gamedb\n" + + " description:\n" + + " '''\n" + + " A long description\n" + + " '''\n" + + " cardinality : large\n" + + " hidden : false\n" + + " readAccess : A user is admin or is a player in the game\n" + + " joins: [\n" + + " {\n" + + " name: playerCountry\n" + + " to: country\n" + + " type: toOne\n" + + " definition: '${to}.id = ${from}.country_id'\n" + + " },\n" + + " {\n" + + " name: playerTeam\n" + + " to: team\n" + + " type: toMany\n" + + " definition: '${to}.id = ${from}.team_id'\n" + + " }\n" + + " ]\n" + + "\n" + + " measures : [\n" + + " {\n" + + " name : highScore\n" + + " type : INTEGER\n" + + " definition: 'MAX(score)'\n" + + " }\n" + + " ]\n" + + " dimensions : [\n" + + " {\n" + + " name : countryIsoCode\n" + + " type : TEXT\n" + + " definition : '{{playerCountry.isoCode}}'\n" + + " },\n" + + " {\n" + + " name : createdOn\n" + + " type : TIME\n" + + " definition : create_on\n" + + " grains: [\n" + + " {\n" + + " grain : DAY\n" + + " sql : '''\n" + + " PARSEDATETIME(FORMATDATETIME(${column}, 'yyyy-MM-dd'), 'yyyy-MM-dd')\n" + + " '''\n" + + " },\n" + + " {\n" + + " grain : MONTH\n" + + " sql : '''\n" + + " PARSEDATETIME(FORMATDATETIME(${column}, 'yyyy-MM-01'), 'yyyy-MM-dd')\n" + + " '''\n" + + " }\n" + + " ]\n" + + " }\n" + + " ]\n" + + " }]\n" + + "}\n"; + + private static final String VALID_TABLE_JAVA_NAME = "PlayerStats"; + + private static final String VALID_TABLE_JAVA = "/*\n" + + " * Copyright 2020, Yahoo Inc.\n" + + " * Licensed under the Apache License, Version 2.0\n" + + " * See LICENSE file in project root for terms.\n" + + " */\n" + + "package dynamicconfig.models;\n" + + "\n" + + "import com.yahoo.elide.annotation.DeletePermission;\n" + + "import com.yahoo.elide.annotation.Include;\n" + + "import com.yahoo.elide.annotation.Exclude;\n" + + "import com.yahoo.elide.annotation.ReadPermission;\n" + + "import com.yahoo.elide.annotation.UpdatePermission;\n" + + "import com.yahoo.elide.datastores.aggregation.annotation.Cardinality;\n" + + "import com.yahoo.elide.datastores.aggregation.annotation.CardinalitySize;\n" + + "import com.yahoo.elide.datastores.aggregation.annotation.DimensionFormula;\n" + + "import com.yahoo.elide.datastores.aggregation.annotation.MetricFormula;\n" + + "import com.yahoo.elide.datastores.aggregation.annotation.Join;\n" + + "import com.yahoo.elide.datastores.aggregation.annotation.Meta;\n" + + "import com.yahoo.elide.datastores.aggregation.annotation.Temporal;\n" + + "import com.yahoo.elide.datastores.aggregation.annotation.TimeGrainDefinition;\n" + + "import com.yahoo.elide.datastores.aggregation.metadata.enums.TimeGrain;\n" + + "import com.yahoo.elide.datastores.aggregation.queryengines.sql.annotation.FromSubquery;\n" + + "import com.yahoo.elide.datastores.aggregation.queryengines.sql.annotation.FromTable;\n" + + "\n" + + "import lombok.EqualsAndHashCode;\n" + + "import lombok.ToString;\n" + + "import lombok.Data;\n" + + "\n" + + "import java.util.Date;\n" + + "import javax.persistence.Column;\n" + + "import javax.persistence.Id;\n" + + "\n" + + "/**\n" + + " * A root level entity for testing AggregationDataStore.\n" + + " */\n" + + "@Cardinality(size = CardinalitySize.LARGE)\n" + + "@EqualsAndHashCode\n" + + "@ToString\n" + + "@Data\n" + + "@FromTable(name = \"gamedb.player_stats\")\n" + + "\n" + + "@ReadPermission(expression = \"A user is admin or is a player in the game\")\n" + + "@Meta(description = \"A long description\")\n" + + "@Include(rootLevel = true, type = \"playerStats\")\n" + + "public class PlayerStats {\n" + + "\n" + + " @Id\n" + + " private String id;\n" + + "\n" + + "\n" + + "\n" + + "\n" + + "\n" + + " @ReadPermission(expression = \"Prefab.Role.All\")\n" + + " @Meta(description = \"countryIsoCode\")\n" + + " \n" + + " @DimensionFormula(\"{{playerCountry.isoCode}}\")\n" + + " private String countryIsoCode;\n" + + "\n" + + "\n" + + "\n" + + "\n" + + " @Temporal(grains = {\n" + + " \n" + + " @TimeGrainDefinition(grain = TimeGrain.DAY, expression = \"PARSEDATETIME(FORMATDATETIME(${column}, 'yyyy-MM-dd'), 'yyyy-MM-dd')\"), \n" + + " \n" + + " @TimeGrainDefinition(grain = TimeGrain.MONTH, expression = \"PARSEDATETIME(FORMATDATETIME(${column}, 'yyyy-MM-01'), 'yyyy-MM-dd')\")\n" + + " \n" + + " }, timeZone = \"UTC\")\n" + + "\n" + + "\n" + + " @ReadPermission(expression = \"Prefab.Role.All\")\n" + + " @Meta(description = \"createdOn\")\n" + + " \n" + + " @DimensionFormula(\"create_on\")\n" + + " private Date createdOn;\n" + + "\n" + + "\n" + + "\n" + + "\n" + + "\n" + + "\n" + + " @Join(\"${to}.id = ${from}.country_id\")\n" + + "\n" + + " private Country playerCountry;\n" + + "\n" + + "\n" + + "\n" + + "\n" + + " @Join(\"${to}.id = ${from}.team_id\")\n" + + "\n" + + " private Set playerTeam;\n" + + "\n" + + "\n" + + "\n" + + "\n" + + "\n" + + "\n" + + " @MetricFormula(\"MAX(score)\")\n" + + " @ReadPermission(expression = \"Prefab.Role.All\")\n" + + " @Meta(description = \"highScore\")\n" + + " \n" + + " private Long highScore;\n" + + "\n" + + "\n" + + "}\n"; + + + private static final String VALID_SECURITY_ADMIN_JAVA_NAME = "DynamicConfigOperationChecksPrincipalIsAdmin"; + private static final String VALID_SECURITY_GUEST_JAVA_NAME = "DynamicConfigOperationChecksPrincipalIsGuest"; + + private static final String VALID_SECURITY_ADMIN_JAVA = "/*\n" + + " * Copyright 2020, Yahoo Inc.\n" + + " * Licensed under the Apache License, Version 2.0\n" + + " * See LICENSE file in project root for terms.\n" + + " */\n" + + "package dynamicconfig.models;\n" + + "\n" + + "import com.yahoo.elide.annotation.SecurityCheck;\n" + + "import com.yahoo.elide.security.checks.prefab.Role.RoleMemberCheck;\n" + + "\n" + + "@SecurityCheck(DynamicConfigOperationChecksPrincipalIsAdmin.PRINCIPAL_IS_ADMIN)\n" + + "public class DynamicConfigOperationChecksPrincipalIsAdmin extends RoleMemberCheck {\n" + + "\n" + + " public static final String PRINCIPAL_IS_ADMIN = \"Principal is admin\";\n" + + " public DynamicConfigOperationChecksPrincipalIsAdmin() {\n" + + " super(\"admin\");\n" + + " }\n" + + "}\n"; + + private static final String VALID_SECURITY_GUEST_JAVA = "/*\n" + + " * Copyright 2020, Yahoo Inc.\n" + + " * Licensed under the Apache License, Version 2.0\n" + + " * See LICENSE file in project root for terms.\n" + + " */\n" + + "package dynamicconfig.models;\n" + + "\n" + + "import com.yahoo.elide.annotation.SecurityCheck;\n" + + "import com.yahoo.elide.security.checks.prefab.Role.RoleMemberCheck;\n" + + "\n" + + "@SecurityCheck(DynamicConfigOperationChecksPrincipalIsGuest.PRINCIPAL_IS_GUEST)\n" + + "public class DynamicConfigOperationChecksPrincipalIsGuest extends RoleMemberCheck {\n" + + "\n" + + " public static final String PRINCIPAL_IS_GUEST = \"Principal is guest\";\n" + + " public DynamicConfigOperationChecksPrincipalIsGuest() {\n" + + " super(\"guest\");\n" + + " }\n" + + "}\n"; + + @Test + public void testConfigHydration() throws IOException { + + HandlebarsHydrator obj = new HandlebarsHydrator(); + String path = "src/test/resources/models"; + File file = new File(path); + String absolutePath = file.getAbsolutePath(); + String hjsonPath = absolutePath + "/tables/table1.hjson"; + + ElideConfigParser testClass = new ElideConfigParser(absolutePath); + + Map map = testClass.getVariables(); + + String content = new String (Files.readAllBytes(Paths.get(hjsonPath))); + + assertEquals(content, obj.hydrateConfigTemplate(VALID_TABLE_WITH_VARIABLES, map)); + } + + @Test + public void testTableHydration() throws IOException { + + HandlebarsHydrator obj = new HandlebarsHydrator(); + String path = "src/test/resources/models"; + File file = new File(path); + String absolutePath = file.getAbsolutePath(); + + ElideConfigParser testClass = new ElideConfigParser(absolutePath); + + Map tableClasses = obj.hydrateTableTemplate(testClass.getElideTableConfig()); + + assertEquals(true, tableClasses.keySet().contains(VALID_TABLE_JAVA_NAME)); + assertEquals(VALID_TABLE_JAVA, tableClasses.get(VALID_TABLE_JAVA_NAME)); + } + + @Test + public void testSecurityHydration() throws IOException { + HandlebarsHydrator obj = new HandlebarsHydrator(); + String path = "src/test/resources/models"; + File file = new File(path); + String absolutePath = file.getAbsolutePath(); + + ElideConfigParser testClass = new ElideConfigParser(absolutePath); + + Map securityClasses = obj.hydrateSecurityTemplate(testClass.getElideSecurityConfig()); + + assertEquals(true, securityClasses.keySet().contains(VALID_SECURITY_ADMIN_JAVA_NAME)); + assertEquals(true, securityClasses.keySet().contains(VALID_SECURITY_GUEST_JAVA_NAME)); + assertEquals(VALID_SECURITY_ADMIN_JAVA, securityClasses.get(VALID_SECURITY_ADMIN_JAVA_NAME)); + assertEquals(VALID_SECURITY_GUEST_JAVA, securityClasses.get(VALID_SECURITY_GUEST_JAVA_NAME)); + } +} diff --git a/elide-contrib/elide-dynamic-config-helpers/src/test/resources/models/security.hjson b/elide-contrib/elide-dynamic-config-helpers/src/test/resources/models/security.hjson new file mode 100644 index 0000000000..effa2da850 --- /dev/null +++ b/elide-contrib/elide-dynamic-config-helpers/src/test/resources/models/security.hjson @@ -0,0 +1,18 @@ +{ + roles : [ + admin + guest + member + ] + rules: [ + { + type: filter + filter: filter + name: User belongs to company + }, + { + filter: filter + name: Principal is owner + }, + ] +} diff --git a/elide-contrib/elide-dynamic-config-helpers/src/test/resources/models/tables/table1.hjson b/elide-contrib/elide-dynamic-config-helpers/src/test/resources/models/tables/table1.hjson new file mode 100644 index 0000000000..90cdaf69d7 --- /dev/null +++ b/elide-contrib/elide-dynamic-config-helpers/src/test/resources/models/tables/table1.hjson @@ -0,0 +1,62 @@ +{ + tables: [{ + name: PlayerStats + table: player_stats + schema: gamedb + description: + ''' + A long description + ''' + cardinality : large + hidden : false + readAccess : A user is admin or is a player in the game + joins: [ + { + name: playerCountry + to: country + type: toOne + definition: '${to}.id = ${from}.country_id' + }, + { + name: playerTeam + to: team + type: toMany + definition: '${to}.id = ${from}.team_id' + } + ] + + measures : [ + { + name : highScore + type : INTEGER + definition: 'MAX(score)' + } + ] + dimensions : [ + { + name : countryIsoCode + type : TEXT + definition : '{{playerCountry.isoCode}}' + }, + { + name : createdOn + type : TIME + definition : create_on + grains: [ + { + grain : DAY + sql : ''' + PARSEDATETIME(FORMATDATETIME(${column}, 'yyyy-MM-dd'), 'yyyy-MM-dd') + ''' + }, + { + grain : MONTH + sql : ''' + PARSEDATETIME(FORMATDATETIME(${column}, 'yyyy-MM-01'), 'yyyy-MM-dd') + ''' + } + ] + } + ] + }] +} diff --git a/elide-contrib/elide-dynamic-config-helpers/src/test/resources/models/tables/table2.hjson b/elide-contrib/elide-dynamic-config-helpers/src/test/resources/models/tables/table2.hjson new file mode 100644 index 0000000000..848bab22f5 --- /dev/null +++ b/elide-contrib/elide-dynamic-config-helpers/src/test/resources/models/tables/table2.hjson @@ -0,0 +1,48 @@ +{ + tables: [{ + name: Player + table: player + schema: playerdb + description: + ''' + A long description + ''' + cardinality : large + readAccess : A user is admin or is a player in the game + joins: [ + { + name: playerCountry + to: country + type: toOne + definition: '${to}.id = ${from}.country_id' + } + ] + measures : [ + { + name : highScore + type : "INTEGER" + definition: '<%measure_type%>(score)' + } + ] + dimensions : [ + { + name : countryCode + type : TEXT + definition : '{{playerCountry.isoCode}}' + }, + { + name : createdOn + type : TIME + definition : create_on + grains: [ + { + grain : MONTH + sql : ''' + PARSEDATETIME(FORMATDATETIME(${column}, 'yyyy-MM-01'), 'yyyy-MM-dd') + ''' + } + ] + } + ] + }] +} diff --git a/elide-contrib/elide-dynamic-config-helpers/src/test/resources/models/variables.hjson b/elide-contrib/elide-dynamic-config-helpers/src/test/resources/models/variables.hjson new file mode 100644 index 0000000000..6d534ed7b7 --- /dev/null +++ b/elide-contrib/elide-dynamic-config-helpers/src/test/resources/models/variables.hjson @@ -0,0 +1,8 @@ +{ + foo: [1, 2, 3] + bar: blah + hour: hour_replace + measure_type: MAX + name: PlayerStats + table: player_stats +} diff --git a/elide-contrib/elide-dynamic-config-helpers/src/test/resources/models_missing/tables/table1.hjson b/elide-contrib/elide-dynamic-config-helpers/src/test/resources/models_missing/tables/table1.hjson new file mode 100644 index 0000000000..8f0b8014d1 --- /dev/null +++ b/elide-contrib/elide-dynamic-config-helpers/src/test/resources/models_missing/tables/table1.hjson @@ -0,0 +1,48 @@ +{ + tables: [{ + name: PlayerStats + table: player_stats + schema: gamedb + description: + ''' + A long description + ''' + cardinality : large + readAccess : A user is admin or is a player in the game + joins: [ + { + name: playerCountry + to: country + type: toOne + definition: '${to}.id = ${from}.country_id' + } + ] + measures : [ + { + name : highScore + type : INTEGER + definition: 'MAX(score)' + } + ] + dimensions : [ + { + name : countryCode + type : TEXT + definition : playerCountry.isoCode + }, + { + name : createdOn + type : TIME + definition : create_on + grains: [ + { + grain : MONTH + sql : ''' + PARSEDATETIME(FORMATDATETIME(${column}, 'yyyy-MM-01'), 'yyyy-MM-dd') + ''' + } + ] + } + ] + }] +} diff --git a/elide-contrib/elide-dynamic-config-helpers/src/test/resources/security/invalid/security.hjson b/elide-contrib/elide-dynamic-config-helpers/src/test/resources/security/invalid/security.hjson new file mode 100644 index 0000000000..d166716f97 --- /dev/null +++ b/elide-contrib/elide-dynamic-config-helpers/src/test/resources/security/invalid/security.hjson @@ -0,0 +1,11 @@ +{ + //Comment + name : book + table : book + schema$ : [123] + description : + ''' + valid schema for a book + ''' + cardinality : small +} diff --git a/elide-contrib/elide-dynamic-config-helpers/src/test/resources/security/invalid/security.json b/elide-contrib/elide-dynamic-config-helpers/src/test/resources/security/invalid/security.json new file mode 100644 index 0000000000..c1e1422253 --- /dev/null +++ b/elide-contrib/elide-dynamic-config-helpers/src/test/resources/security/invalid/security.json @@ -0,0 +1,8 @@ + +{ + "name!" : "book", + "table" : "book", + "schema$" : "testdb", + "description" : "valid schema for a book", + "cardinality" : "invalid" +} diff --git a/elide-contrib/elide-dynamic-config-helpers/src/test/resources/security/valid/security.hjson b/elide-contrib/elide-dynamic-config-helpers/src/test/resources/security/valid/security.hjson new file mode 100644 index 0000000000..ad33e38836 --- /dev/null +++ b/elide-contrib/elide-dynamic-config-helpers/src/test/resources/security/valid/security.hjson @@ -0,0 +1,14 @@ +{ + roles : ["admin", "guest", "member"] + rules: [ + { + type: filter + filter: filter + name: User belongs to company + }, + { + filter: filter + name: Principal is owner + } + ] +} diff --git a/elide-contrib/elide-dynamic-config-helpers/src/test/resources/security/valid/security.json b/elide-contrib/elide-dynamic-config-helpers/src/test/resources/security/valid/security.json new file mode 100644 index 0000000000..e3a52f6e1c --- /dev/null +++ b/elide-contrib/elide-dynamic-config-helpers/src/test/resources/security/valid/security.json @@ -0,0 +1,14 @@ +{ + "roles" : ["admin", "guest", "member"], + "rules": [ + { + "type": "filter", + "filter": "filter", + "name": "User belongs to company" + }, + { + "filter": "filter", + "name": "Principal is owner" + } + ] +} diff --git a/elide-contrib/elide-dynamic-config-helpers/src/test/resources/tables/invalid/table.hjson b/elide-contrib/elide-dynamic-config-helpers/src/test/resources/tables/invalid/table.hjson new file mode 100644 index 0000000000..d166716f97 --- /dev/null +++ b/elide-contrib/elide-dynamic-config-helpers/src/test/resources/tables/invalid/table.hjson @@ -0,0 +1,11 @@ +{ + //Comment + name : book + table : book + schema$ : [123] + description : + ''' + valid schema for a book + ''' + cardinality : small +} diff --git a/elide-contrib/elide-dynamic-config-helpers/src/test/resources/tables/invalid/table.json b/elide-contrib/elide-dynamic-config-helpers/src/test/resources/tables/invalid/table.json new file mode 100644 index 0000000000..0c3f97c1bd --- /dev/null +++ b/elide-contrib/elide-dynamic-config-helpers/src/test/resources/tables/invalid/table.json @@ -0,0 +1,7 @@ +{ + "fname" : "book", + "ftable" : "book", + "fschema$" : "testdb", + "description" : "valid schema for a book", + "cardinality" : "invalid" +} diff --git a/elide-contrib/elide-dynamic-config-helpers/src/test/resources/tables/valid/table.hjson b/elide-contrib/elide-dynamic-config-helpers/src/test/resources/tables/valid/table.hjson new file mode 100644 index 0000000000..8f0b8014d1 --- /dev/null +++ b/elide-contrib/elide-dynamic-config-helpers/src/test/resources/tables/valid/table.hjson @@ -0,0 +1,48 @@ +{ + tables: [{ + name: PlayerStats + table: player_stats + schema: gamedb + description: + ''' + A long description + ''' + cardinality : large + readAccess : A user is admin or is a player in the game + joins: [ + { + name: playerCountry + to: country + type: toOne + definition: '${to}.id = ${from}.country_id' + } + ] + measures : [ + { + name : highScore + type : INTEGER + definition: 'MAX(score)' + } + ] + dimensions : [ + { + name : countryCode + type : TEXT + definition : playerCountry.isoCode + }, + { + name : createdOn + type : TIME + definition : create_on + grains: [ + { + grain : MONTH + sql : ''' + PARSEDATETIME(FORMATDATETIME(${column}, 'yyyy-MM-01'), 'yyyy-MM-dd') + ''' + } + ] + } + ] + }] +} diff --git a/elide-contrib/elide-dynamic-config-helpers/src/test/resources/tables/valid/table.json b/elide-contrib/elide-dynamic-config-helpers/src/test/resources/tables/valid/table.json new file mode 100644 index 0000000000..309f59ae45 --- /dev/null +++ b/elide-contrib/elide-dynamic-config-helpers/src/test/resources/tables/valid/table.json @@ -0,0 +1,63 @@ +{ + "tables": [ + { + "name": "PlayerStats", + "table": "player_stats", + "schema": "gamedb", + "cardinality" : "large", + "readAccess" : "A user is admin or is a player in the game", + "joins": [ + { + "name": "playerCountry", + "to": "country", + "type": "toOne", + "definition": "${to}.id = ${from}.country_id" + } + ], + "measures" : [ + { + "name" : "highScore", + "type" : "INTEGER", + "definition": "MAX(score)" + }, + { + "name" : "highScoreCoord", + "type" : "COORDINATE", + "definition": "MAX(score)" + } + ], + "dimensions" : [ + { + "name" : "countryCode", + "type" : "RELATIONSHIP", + "definition" : "playerCountry.isoCode" + }, + { + "name" : "countryCode", + "type" : "TEXT", + "definition" : "playerCountry.isoCode" + }, + { + "name" : "createdOn", + "type" : "TIME", + "definition" : "create_on", + "grains":[{ + + "grain": "MONTH", + "sql": "PARSEDATETIME(FORMATDATETIME(${column}, 'yyyy-MM-01'), 'yyyy-MM-dd')" + }] + }, + { + "name" : "createdOn", + "type" : "TIME", + "definition" : "create_on", + "grains":[{ + + "grain": "MONTH", + "sql": "PARSEDATETIME(FORMATDATETIME(${column}, 'yyyy-MM-01'), 'yyyy-MM-dd')" + }] + } + ] + } + ] +} diff --git a/elide-contrib/elide-dynamic-config-helpers/src/test/resources/variables/invalid/variables.hjson b/elide-contrib/elide-dynamic-config-helpers/src/test/resources/variables/invalid/variables.hjson new file mode 100644 index 0000000000..d166716f97 --- /dev/null +++ b/elide-contrib/elide-dynamic-config-helpers/src/test/resources/variables/invalid/variables.hjson @@ -0,0 +1,11 @@ +{ + //Comment + name : book + table : book + schema$ : [123] + description : + ''' + valid schema for a book + ''' + cardinality : small +} diff --git a/elide-contrib/elide-dynamic-config-helpers/src/test/resources/variables/invalid/variables.json b/elide-contrib/elide-dynamic-config-helpers/src/test/resources/variables/invalid/variables.json new file mode 100644 index 0000000000..c1e1422253 --- /dev/null +++ b/elide-contrib/elide-dynamic-config-helpers/src/test/resources/variables/invalid/variables.json @@ -0,0 +1,8 @@ + +{ + "name!" : "book", + "table" : "book", + "schema$" : "testdb", + "description" : "valid schema for a book", + "cardinality" : "invalid" +} diff --git a/elide-contrib/elide-dynamic-config-helpers/src/test/resources/variables/valid/variables.hjson b/elide-contrib/elide-dynamic-config-helpers/src/test/resources/variables/valid/variables.hjson new file mode 100644 index 0000000000..e0358bfc8b --- /dev/null +++ b/elide-contrib/elide-dynamic-config-helpers/src/test/resources/variables/valid/variables.hjson @@ -0,0 +1,9 @@ +{ + desc_blah: blah blah blah + def_on: create_on + grain_dmy: ["{{day}}", "{{month}}", "{{year}}"] + grain_hd: ["{{hour}}", "{{day}}"] + foo: [1, 2, 3] + foobar: "[1, 2, 3]" + nullCheck: null +} diff --git a/elide-contrib/elide-dynamic-config-helpers/src/test/resources/variables/valid/variables.json b/elide-contrib/elide-dynamic-config-helpers/src/test/resources/variables/valid/variables.json new file mode 100644 index 0000000000..0d0b85e3fe --- /dev/null +++ b/elide-contrib/elide-dynamic-config-helpers/src/test/resources/variables/valid/variables.json @@ -0,0 +1,11 @@ +{ + "desc_blah": "blah blah blah", + "def_on_test": "create_on", + "grain_dmy": "[{{day}}, {{month}}, {{year}}]", + "grain_hd": ["{{hour}}", "{{day}}"], + "nullCheck": null, + "grainVariable":[{ + "grain": "MONTH", + "sql": "PARSEDATETIME(FORMATDATETIME(${column}, 'yyyy-MM-01'), 'yyyy-MM-dd')" + }] +} diff --git a/elide-contrib/elide-swagger/pom.xml b/elide-contrib/elide-swagger/pom.xml index d0c8016c18..318baf6ae6 100644 --- a/elide-contrib/elide-swagger/pom.xml +++ b/elide-contrib/elide-swagger/pom.xml @@ -14,7 +14,7 @@ elide-contrib-parent-pom com.yahoo.elide - 4.6.3-SNAPSHOT + 5.0.0-pr10-SNAPSHOT @@ -42,7 +42,7 @@ com.yahoo.elide elide-core - 4.6.3-SNAPSHOT + 5.0.0-pr10-SNAPSHOT @@ -54,7 +54,7 @@ com.yahoo.elide elide-integration-tests - 4.6.3-SNAPSHOT + 5.0.0-pr10-SNAPSHOT test-jar test diff --git a/elide-contrib/elide-swagger/src/main/java/com/yahoo/elide/contrib/swagger/JsonApiModelResolver.java b/elide-contrib/elide-swagger/src/main/java/com/yahoo/elide/contrib/swagger/JsonApiModelResolver.java index 2bad09a5bd..aaf6f7e846 100644 --- a/elide-contrib/elide-swagger/src/main/java/com/yahoo/elide/contrib/swagger/JsonApiModelResolver.java +++ b/elide-contrib/elide-swagger/src/main/java/com/yahoo/elide/contrib/swagger/JsonApiModelResolver.java @@ -31,7 +31,7 @@ import java.util.stream.Collectors; /** - * Swagger ModelResolvers map POJO classes to Swagger com.yahoo.elide.contrib.swagger.models. + * Swagger ModelResolvers map POJO classes to Swagger example.models. * This resolver maps the POJO to a JSON-API Resource. */ public class JsonApiModelResolver extends ModelResolver { diff --git a/elide-contrib/elide-swagger/src/main/java/com/yahoo/elide/contrib/swagger/SwaggerBuilder.java b/elide-contrib/elide-swagger/src/main/java/com/yahoo/elide/contrib/swagger/SwaggerBuilder.java index ca8f1798ed..9ec92cd7c5 100644 --- a/elide-contrib/elide-swagger/src/main/java/com/yahoo/elide/contrib/swagger/SwaggerBuilder.java +++ b/elide-contrib/elide-swagger/src/main/java/com/yahoo/elide/contrib/swagger/SwaggerBuilder.java @@ -5,6 +5,8 @@ */ package com.yahoo.elide.contrib.swagger; +import static com.yahoo.elide.core.EntityDictionary.NO_VERSION; + import com.yahoo.elide.contrib.swagger.model.Data; import com.yahoo.elide.contrib.swagger.model.Datum; import com.yahoo.elide.contrib.swagger.property.Relationship; @@ -676,10 +678,15 @@ public Swagger build() { ModelConverters converters = ModelConverters.getInstance(); converters.addConverter(new JsonApiModelResolver(dictionary)); + String apiVersion = swagger.getInfo().getVersion(); + if (apiVersion == null) { + apiVersion = NO_VERSION; + } + if (allClasses.isEmpty()) { - allClasses = dictionary.getBindings(); + allClasses = dictionary.getBoundClassesByVersion(apiVersion); } else { - allClasses = Sets.intersection(dictionary.getBindings(), allClasses); + allClasses = Sets.intersection(dictionary.getBoundClassesByVersion(apiVersion), allClasses); if (allClasses.isEmpty()) { throw new IllegalArgumentException("None of the provided classes are exported by Elide"); } diff --git a/elide-contrib/elide-swagger/src/main/java/com/yahoo/elide/contrib/swagger/resources/DocEndpoint.java b/elide-contrib/elide-swagger/src/main/java/com/yahoo/elide/contrib/swagger/resources/DocEndpoint.java index bfc06c5341..a941f206c7 100644 --- a/elide-contrib/elide-swagger/src/main/java/com/yahoo/elide/contrib/swagger/resources/DocEndpoint.java +++ b/elide-contrib/elide-swagger/src/main/java/com/yahoo/elide/contrib/swagger/resources/DocEndpoint.java @@ -5,17 +5,25 @@ */ package com.yahoo.elide.contrib.swagger.resources; +import static com.yahoo.elide.core.EntityDictionary.NO_VERSION; + import com.yahoo.elide.contrib.swagger.SwaggerBuilder; +import org.apache.commons.lang3.tuple.Pair; + import io.swagger.models.Swagger; +import lombok.AllArgsConstructor; +import lombok.Data; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.stream.Collectors; import javax.inject.Inject; import javax.inject.Named; import javax.ws.rs.GET; +import javax.ws.rs.HeaderParam; import javax.ws.rs.Path; import javax.ws.rs.PathParam; import javax.ws.rs.Produces; @@ -28,7 +36,15 @@ @Path("/doc") @Produces("application/json") public class DocEndpoint { - protected Map documents; + //Maps api version & path to a swagger document. + protected Map, String> documents; + + @Data + @AllArgsConstructor + public static class SwaggerRegistration { + private String path; + private Swagger document; + } /** * Constructs the resource. @@ -36,18 +52,29 @@ public class DocEndpoint { * @param docs Map of path parameter name to swagger document. */ @Inject - public DocEndpoint(@Named("swagger") Map docs) { + public DocEndpoint(@Named("swagger") List docs) { documents = new HashMap<>(); - docs.forEach((key, value) -> { - documents.put(key, SwaggerBuilder.getDocument(value)); + docs.forEach((doc) -> { + String apiVersion = doc.document.getInfo().getVersion(); + apiVersion = apiVersion == null ? NO_VERSION : apiVersion; + String apiPath = doc.path; + + documents.put(Pair.of(apiVersion, apiPath), SwaggerBuilder.getDocument(doc.document)); }); } @GET @Path("/") - public Response list() { - String body = "[" + documents.keySet().stream() + public Response list(@HeaderParam("ApiVersion") String apiVersion) { + String safeApiVersion = apiVersion == null ? NO_VERSION : apiVersion; + + List documentPaths = documents.keySet().stream() + .filter(key -> key.getLeft().equals(safeApiVersion)) + .map(key -> key.getRight()) + .collect(Collectors.toList()); + + String body = "[" + documentPaths.stream() .map(key -> '"' + key + '"') .collect(Collectors.joining(",")) + "]"; @@ -62,9 +89,11 @@ public Response list() { */ @GET @Path("/{name}") - public Response get(@PathParam("name") String name) { - if (documents.containsKey(name)) { - return Response.ok(documents.get(name)).build(); + public Response get(@HeaderParam("ApiVersion") String apiVersion, @PathParam("name") String name) { + String safeApiVersion = apiVersion == null ? NO_VERSION : apiVersion; + Pair lookupKey = Pair.of(safeApiVersion, name); + if (documents.containsKey(lookupKey)) { + return Response.ok(documents.get(lookupKey)).build(); } return Response.status(404).entity("Unknown document: " + name).build(); } diff --git a/elide-contrib/elide-swagger/src/test/java/com/yahoo/elide/contrib/swagger/JsonApiModelResolverTest.java b/elide-contrib/elide-swagger/src/test/java/com/yahoo/elide/contrib/swagger/JsonApiModelResolverTest.java index ed617dd9b6..010159ac6a 100644 --- a/elide-contrib/elide-swagger/src/test/java/com/yahoo/elide/contrib/swagger/JsonApiModelResolverTest.java +++ b/elide-contrib/elide-swagger/src/test/java/com/yahoo/elide/contrib/swagger/JsonApiModelResolverTest.java @@ -11,13 +11,13 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import com.yahoo.elide.contrib.swagger.model.Resource; -import com.yahoo.elide.contrib.swagger.models.Author; -import com.yahoo.elide.contrib.swagger.models.Book; -import com.yahoo.elide.contrib.swagger.models.Publisher; import com.yahoo.elide.core.EntityDictionary; import com.google.common.collect.ImmutableMap; import com.google.common.collect.Maps; +import example.models.Author; +import example.models.Book; +import example.models.Publisher; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; diff --git a/elide-contrib/elide-swagger/src/test/java/com/yahoo/elide/contrib/swagger/SwaggerBuilderTest.java b/elide-contrib/elide-swagger/src/test/java/com/yahoo/elide/contrib/swagger/SwaggerBuilderTest.java index 40ade616fa..e2b92430b7 100644 --- a/elide-contrib/elide-swagger/src/test/java/com/yahoo/elide/contrib/swagger/SwaggerBuilderTest.java +++ b/elide-contrib/elide-swagger/src/test/java/com/yahoo/elide/contrib/swagger/SwaggerBuilderTest.java @@ -5,6 +5,7 @@ */ package com.yahoo.elide.contrib.swagger; +import static com.yahoo.elide.core.EntityDictionary.NO_VERSION; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -13,15 +14,15 @@ import com.yahoo.elide.annotation.Include; import com.yahoo.elide.contrib.swagger.model.Resource; -import com.yahoo.elide.contrib.swagger.models.Author; -import com.yahoo.elide.contrib.swagger.models.Book; -import com.yahoo.elide.contrib.swagger.models.Publisher; import com.yahoo.elide.contrib.swagger.property.Data; import com.yahoo.elide.contrib.swagger.property.Datum; import com.yahoo.elide.contrib.swagger.property.Relationship; import com.yahoo.elide.core.EntityDictionary; import com.google.common.collect.Maps; +import example.models.Author; +import example.models.Book; +import example.models.Publisher; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; @@ -66,7 +67,7 @@ public void setup() { dictionary.bindEntity(Book.class); dictionary.bindEntity(Author.class); dictionary.bindEntity(Publisher.class); - Info info = new Info().title("Test Service").version("1.0"); + Info info = new Info().title("Test Service").version(NO_VERSION); SwaggerBuilder builder = new SwaggerBuilder(dictionary, info); swagger = builder.build(); @@ -533,7 +534,7 @@ public void testTagGeneration() throws Exception { public void testGlobalErrorResponses() throws Exception { Info info = new Info() .title("Test Service") - .version("1.0"); + .version(NO_VERSION); SwaggerBuilder builder = new SwaggerBuilder(dictionary, info); @@ -582,7 +583,7 @@ class NothingToSort { EntityDictionary entityDictionary = new EntityDictionary(Maps.newHashMap()); entityDictionary.bindEntity(NothingToSort.class); - Info info = new Info().title("Test Service").version("1.0"); + Info info = new Info().title("Test Service").version(NO_VERSION); SwaggerBuilder builder = new SwaggerBuilder(entityDictionary, info); Swagger testSwagger = builder.build(); diff --git a/elide-contrib/elide-swagger/src/test/java/com/yahoo/elide/contrib/swagger/SwaggerIT.java b/elide-contrib/elide-swagger/src/test/java/com/yahoo/elide/contrib/swagger/SwaggerIT.java index 5ebce42e84..1964562b2e 100644 --- a/elide-contrib/elide-swagger/src/test/java/com/yahoo/elide/contrib/swagger/SwaggerIT.java +++ b/elide-contrib/elide-swagger/src/test/java/com/yahoo/elide/contrib/swagger/SwaggerIT.java @@ -6,7 +6,10 @@ package com.yahoo.elide.contrib.swagger; import static io.restassured.RestAssured.get; +import static io.restassured.RestAssured.given; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; import com.yahoo.elide.contrib.swagger.resources.DocEndpoint; import com.yahoo.elide.initialization.AbstractApiResourceInitializer; @@ -25,6 +28,17 @@ public SwaggerIT() { void testDocumentFetch() throws Exception { ObjectMapper mapper = new ObjectMapper(); JsonNode node = mapper.readTree(get("/doc/test").asString()); + assertTrue(node.get("paths").size() > 1); assertNotNull(node.get("paths").get("/book")); + assertNotNull(node.get("paths").get("/publisher")); + } + + @Test + void testVersion2DocumentFetch() throws Exception { + ObjectMapper mapper = new ObjectMapper(); + JsonNode node = mapper.readTree(given().header("ApiVersion", "1.0").get("/doc/test").asString()); + assertEquals(2, node.get("paths").size()); + assertNotNull(node.get("paths").get("/book")); + assertNotNull(node.get("paths").get("/book/{bookId}")); } } diff --git a/elide-contrib/elide-swagger/src/test/java/com/yahoo/elide/contrib/swagger/SwaggerResourceConfig.java b/elide-contrib/elide-swagger/src/test/java/com/yahoo/elide/contrib/swagger/SwaggerResourceConfig.java index 52220ae32d..280ba98b84 100644 --- a/elide-contrib/elide-swagger/src/test/java/com/yahoo/elide/contrib/swagger/SwaggerResourceConfig.java +++ b/elide-contrib/elide-swagger/src/test/java/com/yahoo/elide/contrib/swagger/SwaggerResourceConfig.java @@ -5,13 +5,17 @@ */ package com.yahoo.elide.contrib.swagger; -import com.yahoo.elide.contrib.swagger.models.Author; -import com.yahoo.elide.contrib.swagger.models.Book; -import com.yahoo.elide.contrib.swagger.models.Publisher; + +import com.yahoo.elide.contrib.swagger.resources.DocEndpoint; import com.yahoo.elide.core.EntityDictionary; import com.google.common.collect.Maps; +import example.models.Author; +import example.models.Book; +import example.models.Publisher; +import example.models.versioned.BookV2; + import org.glassfish.hk2.api.Factory; import org.glassfish.hk2.api.TypeLiteral; import org.glassfish.hk2.utilities.binding.AbstractBinder; @@ -20,8 +24,8 @@ import io.swagger.models.Info; import io.swagger.models.Swagger; -import java.util.HashMap; -import java.util.Map; +import java.util.ArrayList; +import java.util.List; public class SwaggerResourceConfig extends ResourceConfig { @@ -29,30 +33,36 @@ public SwaggerResourceConfig() { register(new AbstractBinder() { @Override protected void configure() { - bindFactory(new Factory>() { + bindFactory(new Factory>() { @Override - public Map provide() { + public List provide() { EntityDictionary dictionary = new EntityDictionary(Maps.newHashMap()); dictionary.bindEntity(Book.class); + dictionary.bindEntity(BookV2.class); dictionary.bindEntity(Author.class); dictionary.bindEntity(Publisher.class); - Info info = new Info().title("Test Service").version("1.0"); + Info info1 = new Info().title("Test Service"); + + SwaggerBuilder builder1 = new SwaggerBuilder(dictionary, info1); + Swagger swagger1 = builder1.build(); - SwaggerBuilder builder = new SwaggerBuilder(dictionary, info); - Swagger swagger = builder.build(); + Info info2 = new Info().title("Test Service").version("1.0"); + SwaggerBuilder builder2 = new SwaggerBuilder(dictionary, info2); + Swagger swagger2 = builder2.build(); - Map docs = new HashMap<>(); - docs.put("test", swagger); + List docs = new ArrayList<>(); + docs.add(new DocEndpoint.SwaggerRegistration("test", swagger1)); + docs.add(new DocEndpoint.SwaggerRegistration("test", swagger2)); return docs; } @Override - public void dispose(Map instance) { + public void dispose(List instance) { //NOP } - }).to(new TypeLiteral>() { + }).to(new TypeLiteral>() { }).named("swagger"); } }); diff --git a/elide-contrib/elide-swagger/src/test/java/com/yahoo/elide/contrib/swagger/models/Author.java b/elide-contrib/elide-swagger/src/test/java/example/models/Author.java similarity index 93% rename from elide-contrib/elide-swagger/src/test/java/com/yahoo/elide/contrib/swagger/models/Author.java rename to elide-contrib/elide-swagger/src/test/java/example/models/Author.java index 5fe81694a3..6c4f0583c2 100644 --- a/elide-contrib/elide-swagger/src/test/java/com/yahoo/elide/contrib/swagger/models/Author.java +++ b/elide-contrib/elide-swagger/src/test/java/example/models/Author.java @@ -3,7 +3,7 @@ * Licensed under the Apache License, Version 2.0 * See LICENSE file in project root for terms. */ -package com.yahoo.elide.contrib.swagger.models; +package example.models; import com.yahoo.elide.annotation.Include; diff --git a/elide-contrib/elide-swagger/src/test/java/com/yahoo/elide/contrib/swagger/models/AuthorType.java b/elide-contrib/elide-swagger/src/test/java/example/models/AuthorType.java similarity index 80% rename from elide-contrib/elide-swagger/src/test/java/com/yahoo/elide/contrib/swagger/models/AuthorType.java rename to elide-contrib/elide-swagger/src/test/java/example/models/AuthorType.java index a4a282cb65..0d33c043ef 100644 --- a/elide-contrib/elide-swagger/src/test/java/com/yahoo/elide/contrib/swagger/models/AuthorType.java +++ b/elide-contrib/elide-swagger/src/test/java/example/models/AuthorType.java @@ -3,7 +3,7 @@ * Licensed under the Apache License, Version 2.0 * See LICENSE file in project root for terms. */ -package com.yahoo.elide.contrib.swagger.models; +package example.models; public enum AuthorType { SIGNED, diff --git a/elide-contrib/elide-swagger/src/test/java/com/yahoo/elide/contrib/swagger/models/Book.java b/elide-contrib/elide-swagger/src/test/java/example/models/Book.java similarity index 96% rename from elide-contrib/elide-swagger/src/test/java/com/yahoo/elide/contrib/swagger/models/Book.java rename to elide-contrib/elide-swagger/src/test/java/example/models/Book.java index 793aab19cb..e82dc8365c 100644 --- a/elide-contrib/elide-swagger/src/test/java/com/yahoo/elide/contrib/swagger/models/Book.java +++ b/elide-contrib/elide-swagger/src/test/java/example/models/Book.java @@ -3,7 +3,7 @@ * Licensed under the Apache License, Version 2.0 * See LICENSE file in project root for terms. */ -package com.yahoo.elide.contrib.swagger.models; +package example.models; import com.yahoo.elide.annotation.CreatePermission; import com.yahoo.elide.annotation.DeletePermission; diff --git a/elide-contrib/elide-swagger/src/test/java/com/yahoo/elide/contrib/swagger/models/Publisher.java b/elide-contrib/elide-swagger/src/test/java/example/models/Publisher.java similarity index 96% rename from elide-contrib/elide-swagger/src/test/java/com/yahoo/elide/contrib/swagger/models/Publisher.java rename to elide-contrib/elide-swagger/src/test/java/example/models/Publisher.java index 43b4375ea3..71f4695ec1 100644 --- a/elide-contrib/elide-swagger/src/test/java/com/yahoo/elide/contrib/swagger/models/Publisher.java +++ b/elide-contrib/elide-swagger/src/test/java/example/models/Publisher.java @@ -3,7 +3,7 @@ * Licensed under the Apache License, Version 2.0 * See LICENSE file in project root for terms. */ -package com.yahoo.elide.contrib.swagger.models; +package example.models; import com.yahoo.elide.annotation.Include; import io.swagger.annotations.ApiModelProperty; diff --git a/elide-contrib/elide-test-helpers/pom.xml b/elide-contrib/elide-test-helpers/pom.xml index 6ea8d1653f..9ca40ddd8f 100644 --- a/elide-contrib/elide-test-helpers/pom.xml +++ b/elide-contrib/elide-test-helpers/pom.xml @@ -14,7 +14,7 @@ elide-contrib-parent-pom com.yahoo.elide - 4.6.3-SNAPSHOT + 5.0.0-pr10-SNAPSHOT diff --git a/elide-contrib/elide-test-helpers/src/main/java/com/yahoo/elide/contrib/testhelpers/graphql/GraphQLDSL.java b/elide-contrib/elide-test-helpers/src/main/java/com/yahoo/elide/contrib/testhelpers/graphql/GraphQLDSL.java index 4b53d99e1d..ea101ddf7f 100644 --- a/elide-contrib/elide-test-helpers/src/main/java/com/yahoo/elide/contrib/testhelpers/graphql/GraphQLDSL.java +++ b/elide-contrib/elide-test-helpers/src/main/java/com/yahoo/elide/contrib/testhelpers/graphql/GraphQLDSL.java @@ -416,6 +416,10 @@ public static Selection field(String name, Arguments arguments, SelectionSet... return new Field(null, name, arguments, relayWrap(Arrays.asList(selectionSet))); } + public static Selection field(String alias, String name, Arguments arguments, SelectionSet... selectionSet) { + return new Field(alias, name, arguments, relayWrap(Arrays.asList(selectionSet))); + } + /** * Creates an attribute(scalar field) selection. * diff --git a/elide-contrib/elide-test-helpers/src/main/java/com/yahoo/elide/contrib/testhelpers/graphql/elements/Field.java b/elide-contrib/elide-test-helpers/src/main/java/com/yahoo/elide/contrib/testhelpers/graphql/elements/Field.java index 902d575af3..09e18b329c 100644 --- a/elide-contrib/elide-test-helpers/src/main/java/com/yahoo/elide/contrib/testhelpers/graphql/elements/Field.java +++ b/elide-contrib/elide-test-helpers/src/main/java/com/yahoo/elide/contrib/testhelpers/graphql/elements/Field.java @@ -83,7 +83,7 @@ public String toGraphQLSpec() { @Override public String toResponse() { - if (selectionSet instanceof String) { + if (selectionSet instanceof String || selectionSet instanceof Number) { // scalar response field return String.format( "\"%s\":%s", diff --git a/elide-contrib/pom.xml b/elide-contrib/pom.xml index ed81d56ee2..d57b8faa43 100644 --- a/elide-contrib/pom.xml +++ b/elide-contrib/pom.xml @@ -14,7 +14,7 @@ elide-parent-pom com.yahoo.elide - 4.6.3-SNAPSHOT + 5.0.0-pr10-SNAPSHOT @@ -46,6 +46,7 @@ elide-swagger elide-test-helpers + elide-dynamic-config-helpers @@ -53,7 +54,7 @@ com.yahoo.elide elide-core - 4.6.3-SNAPSHOT + 5.0.0-pr10-SNAPSHOT diff --git a/elide-core/pom.xml b/elide-core/pom.xml index 0ac31db56b..f249a53e38 100644 --- a/elide-core/pom.xml +++ b/elide-core/pom.xml @@ -14,7 +14,7 @@ com.yahoo.elide elide-parent-pom - 4.6.3-SNAPSHOT + 5.0.0-pr10-SNAPSHOT @@ -183,6 +183,13 @@ test + + com.google.inject + guice + 4.2.2 + test + + ch.qos.logback logback-classic @@ -201,6 +208,7 @@ org.eclipse.jetty jetty-webapp + ${version.jetty} test diff --git a/elide-core/src/main/java/com/yahoo/elide/Elide.java b/elide-core/src/main/java/com/yahoo/elide/Elide.java index 7509d7bdad..07c1394afa 100644 --- a/elide-core/src/main/java/com/yahoo/elide/Elide.java +++ b/elide-core/src/main/java/com/yahoo/elide/Elide.java @@ -8,21 +8,21 @@ import com.yahoo.elide.audit.AuditLogger; import com.yahoo.elide.core.DataStore; import com.yahoo.elide.core.DataStoreTransaction; -import com.yahoo.elide.core.ErrorObjects; import com.yahoo.elide.core.HttpStatus; import com.yahoo.elide.core.RequestScope; import com.yahoo.elide.core.datastore.inmemory.InMemoryDataStore; -import com.yahoo.elide.core.exceptions.CustomErrorException; import com.yahoo.elide.core.exceptions.ForbiddenAccessException; import com.yahoo.elide.core.exceptions.HttpStatusException; import com.yahoo.elide.core.exceptions.InternalServerErrorException; import com.yahoo.elide.core.exceptions.InvalidConstraintException; import com.yahoo.elide.core.exceptions.InvalidURLException; import com.yahoo.elide.core.exceptions.JsonPatchExtensionException; +import com.yahoo.elide.core.exceptions.TimeoutException; import com.yahoo.elide.core.exceptions.TransactionException; import com.yahoo.elide.core.exceptions.UnableToAddSerdeException; import com.yahoo.elide.extensions.JsonApiPatch; import com.yahoo.elide.extensions.PatchRequestScope; +import com.yahoo.elide.jsonapi.EntityProjectionMaker; import com.yahoo.elide.jsonapi.JsonApiMapper; import com.yahoo.elide.jsonapi.models.JsonApiDocument; import com.yahoo.elide.parsers.BaseVisitor; @@ -50,7 +50,6 @@ import org.apache.commons.collections4.IterableUtils; import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.tuple.Pair; -import org.owasp.encoder.Encode; import lombok.Getter; import lombok.extern.slf4j.Slf4j; @@ -161,10 +160,14 @@ protected Set> registerCustomSerdeScan() { * @param opaqueUser the opaque user * @return Elide response object */ - public ElideResponse get(String path, MultivaluedMap queryParams, Object opaqueUser) { + public ElideResponse get(String path, MultivaluedMap queryParams, + User opaqueUser, String apiVersion) { return handleRequest(true, opaqueUser, dataStore::beginReadTransaction, (tx, user) -> { JsonApiDocument jsonApiDoc = new JsonApiDocument(); - RequestScope requestScope = new RequestScope(path, jsonApiDoc, tx, user, queryParams, elideSettings); + RequestScope requestScope = new RequestScope(path, apiVersion, jsonApiDoc, + tx, user, queryParams, elideSettings); + requestScope.setEntityProjection(new EntityProjectionMaker(elideSettings.getDictionary(), + requestScope).parsePath(path)); BaseVisitor visitor = new GetVisitor(requestScope); return visit(path, requestScope, visitor); }); @@ -179,10 +182,13 @@ public ElideResponse get(String path, MultivaluedMap queryParams * @param opaqueUser the opaque user * @return Elide response object */ - public ElideResponse post(String path, String jsonApiDocument, Object opaqueUser) { + public ElideResponse post(String path, String jsonApiDocument, User opaqueUser, String apiVersion) { return handleRequest(false, opaqueUser, dataStore::beginTransaction, (tx, user) -> { JsonApiDocument jsonApiDoc = mapper.readJsonApiDocument(jsonApiDocument); - RequestScope requestScope = new RequestScope(path, jsonApiDoc, tx, user, null, elideSettings); + RequestScope requestScope = new RequestScope(path, apiVersion, + jsonApiDoc, tx, user, null, elideSettings); + requestScope.setEntityProjection(new EntityProjectionMaker(elideSettings.getDictionary(), + requestScope).parsePath(path)); BaseVisitor visitor = new PostVisitor(requestScope); return visit(path, requestScope, visitor); }); @@ -199,12 +205,12 @@ public ElideResponse post(String path, String jsonApiDocument, Object opaqueUser * @return Elide response object */ public ElideResponse patch(String contentType, String accept, - String path, String jsonApiDocument, Object opaqueUser) { + String path, String jsonApiDocument, User opaqueUser, String apiVersion) { Handler handler; if (JsonApiPatch.isPatchExtension(contentType) && JsonApiPatch.isPatchExtension(accept)) { handler = (tx, user) -> { - PatchRequestScope requestScope = new PatchRequestScope(path, tx, user, elideSettings); + PatchRequestScope requestScope = new PatchRequestScope(path, apiVersion, tx, user, elideSettings); try { Supplier> responder = JsonApiPatch.processJsonPatch(dataStore, path, jsonApiDocument, requestScope); @@ -216,7 +222,10 @@ public ElideResponse patch(String contentType, String accept, } else { handler = (tx, user) -> { JsonApiDocument jsonApiDoc = mapper.readJsonApiDocument(jsonApiDocument); - RequestScope requestScope = new RequestScope(path, jsonApiDoc, tx, user, null, elideSettings); + RequestScope requestScope = new RequestScope(path, apiVersion, jsonApiDoc, + tx, user, null, elideSettings); + requestScope.setEntityProjection(new EntityProjectionMaker(elideSettings.getDictionary(), + requestScope).parsePath(path)); BaseVisitor visitor = new PatchVisitor(requestScope); return visit(path, requestScope, visitor); }; @@ -233,12 +242,15 @@ public ElideResponse patch(String contentType, String accept, * @param opaqueUser the opaque user * @return Elide response object */ - public ElideResponse delete(String path, String jsonApiDocument, Object opaqueUser) { + public ElideResponse delete(String path, String jsonApiDocument, User opaqueUser, String apiVersion) { return handleRequest(false, opaqueUser, dataStore::beginTransaction, (tx, user) -> { JsonApiDocument jsonApiDoc = StringUtils.isEmpty(jsonApiDocument) ? new JsonApiDocument() : mapper.readJsonApiDocument(jsonApiDocument); - RequestScope requestScope = new RequestScope(path, jsonApiDoc, tx, user, null, elideSettings); + RequestScope requestScope = new RequestScope(path, apiVersion, jsonApiDoc, + tx, user, null, elideSettings); + requestScope.setEntityProjection(new EntityProjectionMaker(elideSettings.getDictionary(), + requestScope).parsePath(path)); BaseVisitor visitor = new DeleteVisitor(requestScope); return visit(path, requestScope, visitor); }); @@ -257,17 +269,16 @@ public HandlerResult visit(String path, RequestScope requestScope, BaseVisitor v * Handle JSON API requests. * * @param isReadOnly if the transaction is read only - * @param opaqueUser the user object from the container + * @param user the user object from the container * @param transaction a transaction supplier * @param handler a function that creates the request scope and request handler * @return the response */ - protected ElideResponse handleRequest(boolean isReadOnly, Object opaqueUser, + protected ElideResponse handleRequest(boolean isReadOnly, User user, Supplier transaction, Handler handler) { boolean isVerbose = false; try (DataStoreTransaction tx = transaction.get()) { - final User user = tx.accessUser(opaqueUser); HandlerResult result = handler.handle(tx, user); RequestScope requestScope = result.getRequestScope(); isVerbose = requestScope.getPermissionExecutor().isVerbose(); @@ -284,7 +295,7 @@ protected ElideResponse handleRequest(boolean isReadOnly, Object opaqueUser, ElideResponse response = buildResponse(responder.get()); - auditLogger.commit(requestScope); + auditLogger.commit(); tx.commit(requestScope); requestScope.runQueuedPostCommitTriggers(); @@ -327,8 +338,11 @@ protected ElideResponse handleRequest(boolean isReadOnly, Object opaqueUser, message = IterableUtils.first(e.getConstraintViolations()).getMessage(); } return buildErrorResponse(new InvalidConstraintException(message), isVerbose); - } catch (Exception | Error e) { + if (e instanceof InterruptedException) { + log.debug("Request Thread interrupted.", e); + return buildErrorResponse(new TimeoutException(e), isVerbose); + } log.error("Error or exception uncaught by Elide", e); throw e; @@ -342,19 +356,8 @@ protected ElideResponse buildErrorResponse(HttpStatusException error, boolean is log.error("Internal Server Error", error); } - boolean encodeErrorResponse = elideSettings.isEncodeErrorResponses(); - if (!(error instanceof CustomErrorException) && elideSettings.isReturnErrorObjects()) { - String errorDetail = isVerbose ? error.getVerboseMessage() : error.toString(); - if (encodeErrorResponse) { - errorDetail = Encode.forHtml(errorDetail); - } - ErrorObjects errors = ErrorObjects.builder().addError().withDetail(errorDetail).build(); - JsonNode responseBody = mapper.getObjectMapper().convertValue(errors, JsonNode.class); - return buildResponse(Pair.of(error.getStatus(), responseBody)); - } - - return buildResponse(isVerbose ? error.getVerboseErrorResponse(encodeErrorResponse) - : error.getErrorResponse(encodeErrorResponse)); + return buildResponse(isVerbose ? error.getVerboseErrorResponse() + : error.getErrorResponse()); } protected ElideResponse buildResponse(Pair response) { diff --git a/elide-core/src/main/java/com/yahoo/elide/ElideSettings.java b/elide-core/src/main/java/com/yahoo/elide/ElideSettings.java index 6b2dadeb6e..bd64e8417e 100644 --- a/elide-core/src/main/java/com/yahoo/elide/ElideSettings.java +++ b/elide-core/src/main/java/com/yahoo/elide/ElideSettings.java @@ -36,9 +36,6 @@ public class ElideSettings { @Getter private final List subqueryFilterDialects; @Getter private final int defaultMaxPageSize; @Getter private final int defaultPageSize; - @Getter private final boolean useFilterExpressions; @Getter private final int updateStatusCode; - @Getter private final boolean returnErrorObjects; @Getter private final Map serdes; - @Getter private final boolean encodeErrorResponses; } diff --git a/elide-core/src/main/java/com/yahoo/elide/ElideSettingsBuilder.java b/elide-core/src/main/java/com/yahoo/elide/ElideSettingsBuilder.java index e898f6e85c..f824d28a8b 100644 --- a/elide-core/src/main/java/com/yahoo/elide/ElideSettingsBuilder.java +++ b/elide-core/src/main/java/com/yahoo/elide/ElideSettingsBuilder.java @@ -15,16 +15,15 @@ import com.yahoo.elide.core.filter.dialect.JoinFilterDialect; import com.yahoo.elide.core.filter.dialect.RSQLFilterDialect; import com.yahoo.elide.core.filter.dialect.SubqueryFilterDialect; -import com.yahoo.elide.core.pagination.Pagination; +import com.yahoo.elide.core.pagination.PaginationImpl; import com.yahoo.elide.jsonapi.JsonApiMapper; import com.yahoo.elide.security.PermissionExecutor; import com.yahoo.elide.security.executors.ActivePermissionExecutor; +import com.yahoo.elide.security.executors.VerbosePermissionExecutor; import com.yahoo.elide.utils.coerce.converters.EpochToDateConverter; import com.yahoo.elide.utils.coerce.converters.ISO8601DateSerde; import com.yahoo.elide.utils.coerce.converters.Serde; -import java.lang.reflect.Constructor; -import java.lang.reflect.InvocationTargetException; import java.util.ArrayList; import java.util.Date; import java.util.HashMap; @@ -45,12 +44,9 @@ public class ElideSettingsBuilder { private List joinFilterDialects; private List subqueryFilterDialects; private Map serdes; - private int defaultMaxPageSize = Pagination.MAX_PAGE_LIMIT; - private int defaultPageSize = Pagination.DEFAULT_PAGE_LIMIT; - private boolean useFilterExpressions; + private int defaultMaxPageSize = PaginationImpl.MAX_PAGE_LIMIT; + private int defaultPageSize = PaginationImpl.DEFAULT_PAGE_LIMIT; private int updateStatusCode; - private boolean returnErrorObjects; - private boolean encodeErrorResponses; /** * A new builder used to generate Elide instances. Instantiates an {@link EntityDictionary} without @@ -92,11 +88,8 @@ public ElideSettings build() { subqueryFilterDialects, defaultMaxPageSize, defaultPageSize, - useFilterExpressions, updateStatusCode, - returnErrorObjects, - serdes, - encodeErrorResponses); + serdes); } public ElideSettingsBuilder withAuditLogger(AuditLogger auditLogger) { @@ -114,33 +107,6 @@ public ElideSettingsBuilder withJsonApiMapper(JsonApiMapper jsonApiMapper) { return this; } - public ElideSettingsBuilder withPermissionExecutor( - Function permissionExecutorFunction) { - this.permissionExecutorFunction = permissionExecutorFunction; - return this; - } - - public ElideSettingsBuilder withPermissionExecutor(Class permissionExecutorClass) { - permissionExecutorFunction = (requestScope) -> { - try { - try { - // Try to find a constructor with request scope - Constructor ctor = - permissionExecutorClass.getDeclaredConstructor(RequestScope.class); - return ctor.newInstance(requestScope); - } catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException - | InstantiationException e) { - // If that fails, try blank constructor - return permissionExecutorClass.newInstance(); - } - } catch (IllegalAccessException | InstantiationException e) { - // Everything failed. Throw hands up, not sure how to proceed. - throw new RuntimeException(e); - } - }; - return this; - } - public ElideSettingsBuilder withJoinFilterDialect(JoinFilterDialect dialect) { joinFilterDialects.add(dialect); return this; @@ -171,8 +137,8 @@ public ElideSettingsBuilder withUpdate204Status() { return this; } - public ElideSettingsBuilder withUseFilterExpressions(boolean useFilterExpressions) { - this.useFilterExpressions = useFilterExpressions; + public ElideSettingsBuilder withVerboseErrors() { + permissionExecutorFunction = VerbosePermissionExecutor::new; return this; } @@ -191,14 +157,4 @@ public ElideSettingsBuilder withEpochDates() { serdes.put(java.sql.Timestamp.class, new EpochToDateConverter(java.sql.Timestamp.class)); return this; } - - public ElideSettingsBuilder withReturnErrorObjects(boolean returnErrorObjects) { - this.returnErrorObjects = returnErrorObjects; - return this; - } - - public ElideSettingsBuilder withEncodeErrorResponses(boolean encodeErrorResponses) { - this.encodeErrorResponses = encodeErrorResponses; - return this; - } } diff --git a/elide-core/src/main/java/com/yahoo/elide/audit/AuditLogger.java b/elide-core/src/main/java/com/yahoo/elide/audit/AuditLogger.java index be5c78721d..a3e5683c11 100644 --- a/elide-core/src/main/java/com/yahoo/elide/audit/AuditLogger.java +++ b/elide-core/src/main/java/com/yahoo/elide/audit/AuditLogger.java @@ -5,8 +5,6 @@ */ package com.yahoo.elide.audit; -import com.yahoo.elide.core.RequestScope; - import java.io.IOException; import java.util.ArrayList; import java.util.List; @@ -27,7 +25,7 @@ public void log(LogMessage message) { messages.get().add(message); } - public abstract void commit(RequestScope requestScope) throws IOException; + public abstract void commit() throws IOException; public void clear() { List remainingMessages = messages.get(); diff --git a/elide-core/src/main/java/com/yahoo/elide/audit/LogMessage.java b/elide-core/src/main/java/com/yahoo/elide/audit/LogMessage.java index 6b821b0ecd..18e383de07 100644 --- a/elide-core/src/main/java/com/yahoo/elide/audit/LogMessage.java +++ b/elide-core/src/main/java/com/yahoo/elide/audit/LogMessage.java @@ -1,202 +1,55 @@ /* - * Copyright 2015, Yahoo Inc. + * Copyright 2019, Yahoo Inc. * Licensed under the Apache License, Version 2.0 * See LICENSE file in project root for terms. */ + package com.yahoo.elide.audit; -import com.yahoo.elide.annotation.Audit; -import com.yahoo.elide.core.PersistentResource; -import com.yahoo.elide.core.RequestScope; -import com.yahoo.elide.core.ResourceLineage; import com.yahoo.elide.security.ChangeSpec; - +import com.yahoo.elide.security.PersistentResource; import com.yahoo.elide.security.User; -import de.odysseus.el.ExpressionFactoryImpl; -import de.odysseus.el.util.SimpleContext; -import java.text.MessageFormat; -import java.util.List; import java.util.Optional; -import java.util.stream.Collectors; - -import javax.el.ELException; -import javax.el.ExpressionFactory; -import javax.el.PropertyNotFoundException; -import javax.el.ValueExpression; /** - * An audit log message that can be logged to a logger. + * Elide audit entity for a CRUD action. */ -public class LogMessage { - //Supposedly this is thread safe. - private static final ExpressionFactory EXPRESSION_FACTORY = new ExpressionFactoryImpl(); - private static final String[] EMPTY_STRING_ARRAY = new String[0]; - - private final String template; - private final PersistentResource record; - private final String[] expressions; - private final int operationCode; - private final Optional changeSpec; +public interface LogMessage { /** - * Construct a log message that does not involve any templating. - * @param template - The unsubstituted text that will be logged. - * @param code - The operation code of the auditable action. + * Gets message. + * + * @return the message */ - public LogMessage(String template, int code) { - this(template, null, EMPTY_STRING_ARRAY, code, Optional.empty()); - } + public String getMessage(); /** - * Construct a log message from an Audit annotation and the record that was updated in some way. - * @param audit - The annotation containing the type of operation (UPDATE, DELETE, CREATE) - * @param record - The modified record - * @param changeSpec - Change spec of modified elements (if logging object change). empty otherwise - * @throws InvalidSyntaxException if the Audit annotation has invalid syntax. + * Gets operation code. The operation code is assigned by the developer to uniquely identify + * the type of change that is being audited. Operation code definitions are outside the scope of Elide. + * + * @return the operation code */ - public LogMessage(Audit audit, PersistentResource record, Optional changeSpec) - throws InvalidSyntaxException { - this(audit.logStatement(), record, audit.logExpressions(), audit.operation(), changeSpec); - } + public int getOperationCode(); /** - * Construct a log message. - * @param template - The log message template that requires variable substitution. - * @param record - The record which will serve as the data to substitute. - * @param expressions - A set of UEL expressions that reference record. - * @param code - The operation code of the auditable action. - * @param changeSpec - the change spec that we want to log - * @throws InvalidSyntaxException the invalid syntax exception + * Get the user principal associated with the request. + * + * @return the user principal. */ - public LogMessage(String template, - PersistentResource record, - String[] expressions, - int code, - Optional changeSpec) throws InvalidSyntaxException { - this.template = template; - this.record = record; - this.expressions = expressions; - this.operationCode = code; - this.changeSpec = changeSpec; - } + public User getUser(); /** - * Gets operation code. + * Get the change specification * - * @return the operation code + * @return the change specification. */ - public int getOperationCode() { - return operationCode; - } + public Optional getChangeSpec(); /** - * Gets message. + * Get the resource that was manipulated. * - * @return the message + * @return the resource. */ - public String getMessage() { - final SimpleContext ctx = new SimpleContext(); - final SimpleContext singleElementContext = new SimpleContext(); - - if (record != null) { - /* Create a new lineage which includes the passed in record */ - ResourceLineage lineage = new ResourceLineage(record.getLineage(), record); - - for (String name : lineage.getKeys()) { - List values = lineage.getRecord(name); - - final ValueExpression expression; - final ValueExpression singleElementExpression; - if (values.size() == 1) { - expression = EXPRESSION_FACTORY.createValueExpression(values.get(0).getObject(), Object.class); - singleElementExpression = expression; - } else { - List objects = values.stream().map(PersistentResource::getObject) - .collect(Collectors.toList()); - expression = EXPRESSION_FACTORY.createValueExpression(objects, List.class); - singleElementExpression = EXPRESSION_FACTORY.createValueExpression(values.get(values.size() - 1) - .getObject(), Object.class); - } - ctx.setVariable(name, expression); - singleElementContext.setVariable(name, singleElementExpression); - } - - final Object user = getUser(); - if (user != null) { - final ValueExpression opaqueUserValueExpression = EXPRESSION_FACTORY - .createValueExpression( - user, Object.class - ); - ctx.setVariable("opaqueUser", opaqueUserValueExpression); - singleElementContext.setVariable("opaqueUser", opaqueUserValueExpression); - } - } - - Object[] results = new Object[expressions.length]; - for (int idx = 0; idx < results.length; idx++) { - String expressionText = expressions[idx]; - - final ValueExpression expression; - final ValueExpression singleElementExpression; - try { - expression = EXPRESSION_FACTORY.createValueExpression(ctx, expressionText, Object.class); - singleElementExpression = - EXPRESSION_FACTORY.createValueExpression(singleElementContext, expressionText, Object.class); - } catch (ELException e) { - throw new InvalidSyntaxException(e); - } - - Object result; - try { - // Single element expressions are intended to allow for access to ${entityType.field} when there are - // multiple "entityType" types listed in the lineage. Without this, any access to an entityType - // without an explicit list index would otherwise result in a 500. Similarly, since we already - // supported lists (i.e. the ${entityType[idx].field} syntax), this also continues to support that. - // It should be noted, however, that list indexing is somewhat brittle unless properly accounted for - // from all possible paths. - result = singleElementExpression.getValue(singleElementContext); - } catch (PropertyNotFoundException e) { - // Try list syntax if not single element - result = expression.getValue(ctx); - } - results[idx] = result; - } - - try { - return MessageFormat.format(template, results); - } catch (IllegalArgumentException e) { - throw new InvalidSyntaxException(e); - } - } - - public RequestScope getRequestScope() { - if (record != null) { - return record.getRequestScope(); - } - return null; - } - - public Object getUser() { - RequestScope requestScope = getRequestScope(); - if (requestScope != null) { - User user = requestScope.getUser(); - if (user != null) { - return user.getOpaqueUser(); - } - } - return null; - } - - public Optional getChangeSpec() { - return changeSpec; - } - - @Override - public String toString() { - return "LogMessage{" - + "message='" + getMessage() + '\'' - + ", operationCode=" + getOperationCode() - + '}'; - } + public PersistentResource getPersistentResource(); } diff --git a/elide-core/src/main/java/com/yahoo/elide/audit/LogMessageImpl.java b/elide-core/src/main/java/com/yahoo/elide/audit/LogMessageImpl.java new file mode 100644 index 0000000000..637797e168 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/audit/LogMessageImpl.java @@ -0,0 +1,176 @@ +/* + * Copyright 2020, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.audit; + +import com.yahoo.elide.annotation.Audit; +import com.yahoo.elide.core.ResourceLineage; +import com.yahoo.elide.security.ChangeSpec; +import com.yahoo.elide.security.PersistentResource; +import com.yahoo.elide.security.User; + +import de.odysseus.el.ExpressionFactoryImpl; +import de.odysseus.el.util.SimpleContext; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.ToString; + +import java.security.Principal; +import java.text.MessageFormat; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +import javax.el.ELException; +import javax.el.ExpressionFactory; +import javax.el.PropertyNotFoundException; +import javax.el.ValueExpression; + +/** + * An audit log message that can be logged to a logger. + */ +@ToString +@EqualsAndHashCode +public class LogMessageImpl implements LogMessage { + //Supposedly this is thread safe. + private static final ExpressionFactory EXPRESSION_FACTORY = new ExpressionFactoryImpl(); + private static final String[] EMPTY_STRING_ARRAY = new String[0]; + + private final String template; + private final String[] expressions; + + @Getter + private final int operationCode; + + @Getter + private final Optional changeSpec; + + @Getter + private final User user; + + @Getter + private final PersistentResource persistentResource; + + /** + * Construct a log message that does not involve any templating. + * @param template - The unsubstituted text that will be logged. + * @param code - The operation code of the auditable action. + */ + public LogMessageImpl(String template, int code) { + this(template, null, EMPTY_STRING_ARRAY, code, Optional.empty()); + } + + /** + * Construct a log message from an Audit annotation and the record that was updated in some way. + * @param audit - The annotation containing the type of operation (UPDATE, DELETE, CREATE) + * @param record - The modified record + * @param changeSpec - Change spec of modified elements (if logging object change). empty otherwise + * @throws InvalidSyntaxException if the Audit annotation has invalid syntax. + */ + public LogMessageImpl(Audit audit, PersistentResource record, Optional changeSpec) + throws InvalidSyntaxException { + this(audit.logStatement(), record, audit.logExpressions(), audit.operation(), changeSpec); + } + + /** + * Construct a log message. + * @param template - The log message template that requires variable substitution. + * @param record - The record which will serve as the data to substitute. + * @param expressions - A set of UEL expressions that reference record. + * @param code - The operation code of the auditable action. + * @param changeSpec - the change spec that we want to log + * @throws InvalidSyntaxException the invalid syntax exception + */ + public LogMessageImpl(String template, + PersistentResource record, + String[] expressions, + int code, + Optional changeSpec) throws InvalidSyntaxException { + this.template = template; + this.persistentResource = record; + this.expressions = expressions; + this.operationCode = code; + this.changeSpec = changeSpec; + this.user = (record == null ? null : record.getRequestScope().getUser()); + } + + @Override + public String getMessage() { + final SimpleContext ctx = new SimpleContext(); + final SimpleContext singleElementContext = new SimpleContext(); + + if (persistentResource != null) { + /* Create a new lineage which includes the passed in record */ + com.yahoo.elide.core.PersistentResource internalResource = ( + com.yahoo.elide.core.PersistentResource) persistentResource; + ResourceLineage lineage = new ResourceLineage(internalResource.getLineage(), internalResource); + + for (String name : lineage.getKeys()) { + List values = lineage.getRecord(name); + + final ValueExpression expression; + final ValueExpression singleElementExpression; + if (values.size() == 1) { + expression = EXPRESSION_FACTORY.createValueExpression(values.get(0).getObject(), Object.class); + singleElementExpression = expression; + } else { + List objects = values.stream().map(PersistentResource::getObject) + .collect(Collectors.toList()); + expression = EXPRESSION_FACTORY.createValueExpression(objects, List.class); + singleElementExpression = EXPRESSION_FACTORY.createValueExpression(values.get(values.size() - 1) + .getObject(), Object.class); + } + ctx.setVariable(name, expression); + singleElementContext.setVariable(name, singleElementExpression); + } + + final Principal user = getUser().getPrincipal(); + if (user != null) { + final ValueExpression opaqueUserValueExpression = EXPRESSION_FACTORY + .createValueExpression( + user, Object.class + ); + ctx.setVariable("opaqueUser", opaqueUserValueExpression); + singleElementContext.setVariable("opaqueUser", opaqueUserValueExpression); + } + } + + Object[] results = new Object[expressions.length]; + for (int idx = 0; idx < results.length; idx++) { + String expressionText = expressions[idx]; + + final ValueExpression expression; + final ValueExpression singleElementExpression; + try { + expression = EXPRESSION_FACTORY.createValueExpression(ctx, expressionText, Object.class); + singleElementExpression = + EXPRESSION_FACTORY.createValueExpression(singleElementContext, expressionText, Object.class); + } catch (ELException e) { + throw new InvalidSyntaxException(e); + } + + Object result; + try { + // Single element expressions are intended to allow for access to ${entityType.field} when there are + // multiple "entityType" types listed in the lineage. Without this, any access to an entityType + // without an explicit list index would otherwise result in a 500. Similarly, since we already + // supported lists (i.e. the ${entityType[idx].field} syntax), this also continues to support that. + // It should be noted, however, that list indexing is somewhat brittle unless properly accounted for + // from all possible paths. + result = singleElementExpression.getValue(singleElementContext); + } catch (PropertyNotFoundException e) { + // Try list syntax if not single element + result = expression.getValue(ctx); + } + results[idx] = result; + } + + try { + return MessageFormat.format(template, results); + } catch (IllegalArgumentException e) { + throw new InvalidSyntaxException(e); + } + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/audit/Slf4jLogger.java b/elide-core/src/main/java/com/yahoo/elide/audit/Slf4jLogger.java index f649c369f5..181e1b2f73 100644 --- a/elide-core/src/main/java/com/yahoo/elide/audit/Slf4jLogger.java +++ b/elide-core/src/main/java/com/yahoo/elide/audit/Slf4jLogger.java @@ -5,10 +5,7 @@ */ package com.yahoo.elide.audit; -import com.yahoo.elide.core.RequestScope; - import lombok.extern.slf4j.Slf4j; - import java.io.IOException; /** @@ -18,7 +15,7 @@ public class Slf4jLogger extends AuditLogger { @Override - public void commit(RequestScope requestScope) throws IOException { + public void commit() throws IOException { try { for (LogMessage message : messages.get()) { log.info("{} {} {}", System.currentTimeMillis(), message.getOperationCode(), message.getMessage()); diff --git a/elide-core/src/main/java/com/yahoo/elide/core/ArgumentType.java b/elide-core/src/main/java/com/yahoo/elide/core/ArgumentType.java new file mode 100644 index 0000000000..ffe0773280 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/ArgumentType.java @@ -0,0 +1,23 @@ +/* + * Copyright 2015, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.core; + +import lombok.Getter; + +/** + * Argument Type wraps an argument to the type of value it accepts. + */ +public class ArgumentType { + @Getter + private String name; + @Getter + private Class type; + + public ArgumentType(String name, Class type) { + this.name = name; + this.type = type; + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/CRUDEvent.java b/elide-core/src/main/java/com/yahoo/elide/core/CRUDEvent.java index e275d7b1aa..dbf5e4c880 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/CRUDEvent.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/CRUDEvent.java @@ -6,6 +6,12 @@ package com.yahoo.elide.core; +import static com.yahoo.elide.annotation.LifeCycleHookBinding.Operation.CREATE; +import static com.yahoo.elide.annotation.LifeCycleHookBinding.Operation.DELETE; +import static com.yahoo.elide.annotation.LifeCycleHookBinding.Operation.READ; +import static com.yahoo.elide.annotation.LifeCycleHookBinding.Operation.UPDATE; + +import com.yahoo.elide.annotation.LifeCycleHookBinding; import com.yahoo.elide.security.ChangeSpec; import lombok.AllArgsConstructor; @@ -19,31 +25,24 @@ @Data @AllArgsConstructor public class CRUDEvent { - private CRUDAction eventType; + private LifeCycleHookBinding.Operation eventType; private PersistentResource resource; private String fieldName; private Optional changes; public boolean isCreateEvent() { - return eventType == CRUDAction.CREATE; + return eventType == CREATE; } public boolean isUpdateEvent() { - return eventType == CRUDAction.UPDATE; + return eventType == UPDATE; } public boolean isDeleteEvent() { - return eventType == CRUDAction.DELETE; + return eventType == DELETE; } public boolean isReadEvent() { - return eventType == CRUDAction.READ; - } - - /** - * Enum describing possible CRUD actions. - */ - public static enum CRUDAction { - CREATE, READ, UPDATE, DELETE + return eventType == READ; } } diff --git a/elide-core/src/main/java/com/yahoo/elide/core/CheckInstantiator.java b/elide-core/src/main/java/com/yahoo/elide/core/CheckInstantiator.java index 43773fe074..c1b25701ea 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/CheckInstantiator.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/CheckInstantiator.java @@ -6,6 +6,7 @@ package com.yahoo.elide.core; +import com.yahoo.elide.Injector; import com.yahoo.elide.security.checks.Check; import java.util.Objects; @@ -25,7 +26,7 @@ public interface CheckInstantiator { */ default Check getCheck(EntityDictionary dictionary, String checkName) { Class checkCls = dictionary.getCheck(checkName); - return instantiateCheck(checkCls); + return instantiateCheck(checkCls, dictionary.getInjector()); } /** @@ -34,9 +35,11 @@ default Check getCheck(EntityDictionary dictionary, String checkName) { * @return the instance of the check * @throws IllegalArgumentException if the check class cannot be instantiated with a zero argument constructor */ - default Check instantiateCheck(Class checkCls) { + default Check instantiateCheck(Class checkCls, Injector injector) { try { - return Objects.requireNonNull(checkCls).newInstance(); + Check check = Objects.requireNonNull(checkCls).newInstance(); + injector.inject(check); + return check; } catch (InstantiationException | IllegalAccessException | NullPointerException e) { String checkName = (checkCls != null) ? checkCls.getName() : "null"; throw new IllegalArgumentException("Could not instantiate specified check '" + checkName + "'.", e); diff --git a/elide-core/src/main/java/com/yahoo/elide/core/DataStoreTransaction.java b/elide-core/src/main/java/com/yahoo/elide/core/DataStoreTransaction.java index 9286603661..8f171ee41e 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/DataStoreTransaction.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/DataStoreTransaction.java @@ -5,17 +5,18 @@ */ package com.yahoo.elide.core; +import com.yahoo.elide.core.exceptions.InvalidEntityBodyException; import com.yahoo.elide.core.filter.InPredicate; import com.yahoo.elide.core.filter.expression.AndFilterExpression; import com.yahoo.elide.core.filter.expression.FilterExpression; -import com.yahoo.elide.core.pagination.Pagination; -import com.yahoo.elide.core.sort.Sorting; -import com.yahoo.elide.security.User; +import com.yahoo.elide.request.Attribute; +import com.yahoo.elide.request.EntityProjection; +import com.yahoo.elide.request.Relationship; +import com.yahoo.elide.request.Sorting; import java.io.Closeable; import java.io.Serializable; import java.util.Iterator; -import java.util.Optional; import java.util.Set; /** @@ -32,16 +33,6 @@ public enum FeatureSupport { NONE } - /** - * Wrap the opaque user. - * - * @param opaqueUser the opaque user - * @return wrapped user context - */ - default User accessUser(Object opaqueUser) { - return new User(opaqueUser); - } - /** * Save the updated object. * @@ -106,25 +97,28 @@ default T createNewObject(Class entityClass) { try { obj = entityClass.newInstance(); } catch (java.lang.InstantiationException | IllegalAccessException e) { - obj = null; + throw new InvalidEntityBodyException("Cannot create an entity model of type: " + + entityClass.getSimpleName()); } return obj; } /** - * Loads an object by ID. + * Loads an object by ID. The reason we support both load by ID and load by filter is that + * some legacy stores are optimized to load by ID. * - * @param entityClass the type of class to load + * @param entityProjection the collection to load. * @param id - the ID of the object to load. - * @param filterExpression - security filters that can be evaluated in the data store. * @param scope - the current request scope * It is optional for the data store to attempt evaluation. * @return the loaded object if it exists AND any provided security filters pass. */ - default Object loadObject(Class entityClass, - Serializable id, - Optional filterExpression, - RequestScope scope) { + default Object loadObject(EntityProjection entityProjection, + Serializable id, + RequestScope scope) { + Class entityClass = entityProjection.getType(); + FilterExpression filterExpression = entityProjection.getFilterExpression(); + EntityDictionary dictionary = scope.getDictionary(); Class idType = dictionary.getIdType(entityClass); String idField = dictionary.getIdFieldName(entityClass); @@ -132,14 +126,15 @@ default Object loadObject(Class entityClass, new Path.PathElement(entityClass, idType, idField), id ); - FilterExpression joinedFilterExpression = filterExpression - .map(fe -> (FilterExpression) new AndFilterExpression(idFilter, fe)) - .orElse(idFilter); - Iterable results = loadObjects(entityClass, - Optional.of(joinedFilterExpression), - Optional.empty(), - Optional.empty(), + FilterExpression joinedFilterExpression = (filterExpression != null) + ? new AndFilterExpression(idFilter, filterExpression) + : idFilter; + + Iterable results = loadObjects(entityProjection.copyOf() + .filterExpression(joinedFilterExpression) + .build(), scope); + Iterator it = results == null ? null : results.iterator(); if (it != null && it.hasNext()) { return it.next(); @@ -150,19 +145,12 @@ default Object loadObject(Class entityClass, /** * Loads a collection of objects. * - * @param entityClass - the class to load - * @param filterExpression - filters that can be evaluated in the data store. - * It is optional for the data store to attempt evaluation. - * @param sorting - sorting which can be pushed down to the data store. - * @param pagination - pagination which can be pushed down to the data store. + * @param entityProjection - the class to load * @param scope - contains request level metadata. * @return a collection of the loaded objects */ Iterable loadObjects( - Class entityClass, - Optional filterExpression, - Optional sorting, - Optional pagination, + EntityProjection entityProjection, RequestScope scope); /** @@ -170,25 +158,18 @@ Iterable loadObjects( * * @param relationTx - The datastore that governs objects of the relationhip's type. * @param entity - The object which owns the relationship. - * @param relationName - name of the relationship. - * @param filterExpression - filtering which can be pushed down to the data store. - * It is optional for the data store to attempt evaluation. - * @param sorting - sorting which can be pushed down to the data store. - * @param pagination - pagination which can be pushed down to the data store. + * @param relationship - the relationship to fetch. * @param scope - contains request level metadata. * @return the object in the relation */ default Object getRelation( DataStoreTransaction relationTx, Object entity, - String relationName, - Optional filterExpression, - Optional sorting, - Optional pagination, + Relationship relationship, RequestScope scope) { - return PersistentResource.getValue(entity, relationName, scope); - } + return PersistentResource.getValue(entity, relationship.getName(), scope); + } /** * Elide core will update the in memory representation of the objects to the requested state. @@ -230,14 +211,14 @@ default void updateToOneRelation(DataStoreTransaction relationTx, * Get an attribute from an object. * * @param entity - The object which owns the attribute. - * @param attributeName - name of the attribute. + * @param attribute - The attribute to fetch * @param scope - contains request level metadata. * @return the value of the attribute */ default Object getAttribute(Object entity, - String attributeName, + Attribute attribute, RequestScope scope) { - return PersistentResource.getValue(entity, attributeName, scope); + return PersistentResource.getValue(entity, attribute.getName(), scope); } @@ -248,13 +229,11 @@ default Object getAttribute(Object entity, * This function allow a data store to optionally persist the attribute if needed. * * @param entity - The object which owns the attribute. - * @param attributeName - name of the attribute. - * @param attributeValue - the desired attribute value. + * @param attribute - the attribute to set. * @param scope - contains request level metadata. */ default void setAttribute(Object entity, - String attributeName, - Object attributeValue, + Attribute attribute, RequestScope scope) { } @@ -270,7 +249,7 @@ default FeatureSupport supportsFiltering(Class entityClass, FilterExpression /** * Whether or not the transaction can sort the provided class. - * @param entityClass + * @param entityClass The entity class that is being sorted. * @return true if sorting is possible */ default boolean supportsSorting(Class entityClass, Sorting sorting) { @@ -279,10 +258,11 @@ default boolean supportsSorting(Class entityClass, Sorting sorting) { /** * Whether or not the transaction can paginate the provided class. - * @param entityClass + * @param entityClass The entity class that is being paged. + * @param expression The filter expression * @return true if pagination is possible */ - default boolean supportsPagination(Class entityClass) { + default boolean supportsPagination(Class entityClass, FilterExpression expression) { return true; } } diff --git a/elide-core/src/main/java/com/yahoo/elide/core/EntityBinding.java b/elide-core/src/main/java/com/yahoo/elide/core/EntityBinding.java index ab35dac888..3ae883bace 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/EntityBinding.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/EntityBinding.java @@ -5,38 +5,28 @@ */ package com.yahoo.elide.core; +import static com.yahoo.elide.core.EntityDictionary.NO_VERSION; import static com.yahoo.elide.core.EntityDictionary.REGULAR_ID_NAME; +import com.yahoo.elide.Injector; import com.yahoo.elide.annotation.ComputedAttribute; import com.yahoo.elide.annotation.ComputedRelationship; import com.yahoo.elide.annotation.Exclude; -import com.yahoo.elide.annotation.OnCreatePostCommit; -import com.yahoo.elide.annotation.OnCreatePreCommit; -import com.yahoo.elide.annotation.OnCreatePreSecurity; -import com.yahoo.elide.annotation.OnDeletePostCommit; -import com.yahoo.elide.annotation.OnDeletePreCommit; -import com.yahoo.elide.annotation.OnDeletePreSecurity; -import com.yahoo.elide.annotation.OnReadPostCommit; -import com.yahoo.elide.annotation.OnReadPreCommit; -import com.yahoo.elide.annotation.OnReadPreSecurity; -import com.yahoo.elide.annotation.OnUpdatePostCommit; -import com.yahoo.elide.annotation.OnUpdatePreCommit; -import com.yahoo.elide.annotation.OnUpdatePreSecurity; +import com.yahoo.elide.annotation.LifeCycleHookBinding; import com.yahoo.elide.annotation.ToMany; import com.yahoo.elide.annotation.ToOne; import com.yahoo.elide.core.exceptions.DuplicateMappingException; import com.yahoo.elide.functions.LifeCycleHook; -import com.google.common.base.Throwables; import com.google.common.collect.ImmutableList; import org.apache.commons.collections4.MultiValuedMap; import org.apache.commons.collections4.multimap.HashSetValuedHashMap; import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.reflect.TypeUtils; import org.apache.commons.lang3.tuple.Pair; +import org.apache.commons.lang3.tuple.Triple; import lombok.Getter; -import lombok.Setter; import java.lang.annotation.Annotation; import java.lang.reflect.AccessibleObject; @@ -51,14 +41,16 @@ import java.util.Collection; import java.util.Collections; import java.util.Deque; +import java.util.HashSet; import java.util.List; import java.util.Optional; +import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentLinkedDeque; import java.util.function.Predicate; import java.util.stream.Collectors; import java.util.stream.Stream; - +import javax.inject.Inject; import javax.persistence.AccessType; import javax.persistence.CascadeType; import javax.persistence.Column; @@ -83,6 +75,7 @@ public class EntityBinding { Arrays.asList(ManyToMany.class, ManyToOne.class, OneToMany.class, OneToOne.class, ToOne.class, ToMany.class); + @Getter public final Class entityClass; public final String jsonApiType; public final String entityName; @@ -95,14 +88,19 @@ public class EntityBinding { @Getter private Class idType; @Getter - @Setter - private Initializer initializer; - @Getter private AccessType accessType; + @Getter + private final boolean injected; + + private EntityDictionary dictionary; + + @Getter + private String apiVersion; + public final EntityPermissions entityPermissions; - public final List attributes; - public final List relationships; + public final List apiAttributes; + public final List apiRelationships; public final List> inheritedTypes; public final ConcurrentLinkedDeque attributesDeque = new ConcurrentLinkedDeque<>(); public final ConcurrentLinkedDeque relationshipsDeque = new ConcurrentLinkedDeque<>(); @@ -111,39 +109,78 @@ public class EntityBinding { public final ConcurrentHashMap relationshipToInverse = new ConcurrentHashMap<>(); public final ConcurrentHashMap relationshipToCascadeTypes = new ConcurrentHashMap<>(); public final ConcurrentHashMap fieldsToValues = new ConcurrentHashMap<>(); - public final MultiValuedMap, LifeCycleHook> fieldsToTriggers = new HashSetValuedHashMap<>(); - public final MultiValuedMap classToTriggers = new HashSetValuedHashMap<>(); + public final MultiValuedMap, + LifeCycleHook> fieldTriggers = new HashSetValuedHashMap<>(); + public final MultiValuedMap, + LifeCycleHook> classTriggers = new HashSetValuedHashMap<>(); public final ConcurrentHashMap> fieldsToTypes = new ConcurrentHashMap<>(); public final ConcurrentHashMap aliasesToFields = new ConcurrentHashMap<>(); public final ConcurrentHashMap requestScopeableMethods = new ConcurrentHashMap<>(); + public final ConcurrentHashMap> attributeArguments = new ConcurrentHashMap<>(); public final ConcurrentHashMap annotations = new ConcurrentHashMap<>(); public static final EntityBinding EMPTY_BINDING = new EntityBinding(); + public static final Set EMPTY_ATTRIBUTES_ARGS = Collections.unmodifiableSet(new HashSet<>()); private static final String ALL_FIELDS = "*"; /* empty binding constructor */ private EntityBinding() { + injected = false; jsonApiType = null; entityName = null; - attributes = new ArrayList<>(); - relationships = new ArrayList<>(); + apiVersion = NO_VERSION; + apiAttributes = new ArrayList<>(); + apiRelationships = new ArrayList<>(); inheritedTypes = new ArrayList<>(); idField = null; idType = null; entityClass = null; entityPermissions = EntityPermissions.EMPTY_PERMISSIONS; idGenerated = false; + dictionary = null; + } + + /** + * Constructor + * + * @param dictionary Dictionary to use + * @param cls Entity class + * @param type Declared Elide type name + * @param name Declared Entity name + */ + public EntityBinding(EntityDictionary dictionary, + Class cls, + String type, + String name) { + this(dictionary, cls, type, name, NO_VERSION, new HashSet<>()); } - public EntityBinding(EntityDictionary dictionary, Class cls, String type, String name) { + /** + * Constructor + * + * @param dictionary Dictionary to use + * @param cls Entity class + * @param type Declared Elide type name + * @param name Declared Entity name + * @param hiddenAnnotations Annotations for hiding a field in API + */ + public EntityBinding(EntityDictionary dictionary, + Class cls, + String type, + String name, + String apiVersion, + Set> hiddenAnnotations) { + this.dictionary = dictionary; entityClass = cls; jsonApiType = type; + this.apiVersion = apiVersion; entityName = name; inheritedTypes = getInheritedTypes(cls); // Map id's, attributes, and relationships List fieldOrMethodList = getAllFields(); + injected = shouldInject(); if (fieldOrMethodList.stream().anyMatch(field -> field.isAnnotationPresent(Id.class))) { accessType = AccessType.FIELD; @@ -151,20 +188,9 @@ public EntityBinding(EntityDictionary dictionary, Class cls, String type, Str /* Add all public methods that are computed OR life cycle hooks */ fieldOrMethodList.addAll( getInstanceMembers(cls.getMethods(), - (method) -> method.isAnnotationPresent(ComputedAttribute.class) - || method.isAnnotationPresent(ComputedRelationship.class) - || method.isAnnotationPresent(OnReadPreSecurity.class) - || method.isAnnotationPresent(OnReadPreCommit.class) - || method.isAnnotationPresent(OnReadPostCommit.class) - || method.isAnnotationPresent(OnUpdatePreSecurity.class) - || method.isAnnotationPresent(OnUpdatePreCommit.class) - || method.isAnnotationPresent(OnUpdatePostCommit.class) - || method.isAnnotationPresent(OnCreatePreSecurity.class) - || method.isAnnotationPresent(OnCreatePreCommit.class) - || method.isAnnotationPresent(OnCreatePostCommit.class) - || method.isAnnotationPresent(OnDeletePreSecurity.class) - || method.isAnnotationPresent(OnDeletePreCommit.class) - || method.isAnnotationPresent(OnDeletePostCommit.class) + (method) -> method.isAnnotationPresent(LifeCycleHookBinding.class) + || method.isAnnotationPresent(ComputedAttribute.class) + || method.isAnnotationPresent(ComputedRelationship.class) ) ); @@ -183,10 +209,11 @@ public EntityBinding(EntityDictionary dictionary, Class cls, String type, Str fieldOrMethodList.addAll(getInstanceMembers(cls.getMethods())); } - bindEntityFields(cls, type, fieldOrMethodList); + bindEntityFields(cls, type, fieldOrMethodList, hiddenAnnotations); + bindTriggerIfPresent(); - attributes = dequeToList(attributesDeque); - relationships = dequeToList(relationshipsDeque); + apiAttributes = dequeToList(attributesDeque); + apiRelationships = dequeToList(relationshipsDeque); entityPermissions = new EntityPermissions(dictionary, cls, fieldOrMethodList); } @@ -231,27 +258,30 @@ public List getAllFields() { return fields; } + private List getAllMethods() { + List methods = new ArrayList<>(); + + methods.addAll(getInstanceMembers(entityClass.getDeclaredMethods(), (method) -> !method.isSynthetic())); + for (Class type : inheritedTypes) { + methods.addAll(getInstanceMembers(type.getDeclaredMethods(), (method) -> !method.isSynthetic())); + } + + return methods; + } + /** * Bind fields of an entity including the Id field, attributes, and relationships. * * @param cls Class type to bind fields * @param type JSON API type identifier * @param fieldOrMethodList List of fields and methods on entity + * @param hiddenAnnotations Annotations for hiding a field in API */ - private void bindEntityFields(Class cls, String type, Collection fieldOrMethodList) { + private void bindEntityFields(Class cls, String type, + Collection fieldOrMethodList, + Set> hiddenAnnotations) { for (AccessibleObject fieldOrMethod : fieldOrMethodList) { - bindTriggerIfPresent(OnCreatePreSecurity.class, fieldOrMethod); - bindTriggerIfPresent(OnDeletePreSecurity.class, fieldOrMethod); - bindTriggerIfPresent(OnUpdatePreSecurity.class, fieldOrMethod); - bindTriggerIfPresent(OnReadPreSecurity.class, fieldOrMethod); - bindTriggerIfPresent(OnCreatePreCommit.class, fieldOrMethod); - bindTriggerIfPresent(OnDeletePreCommit.class, fieldOrMethod); - bindTriggerIfPresent(OnUpdatePreCommit.class, fieldOrMethod); - bindTriggerIfPresent(OnReadPreCommit.class, fieldOrMethod); - bindTriggerIfPresent(OnCreatePostCommit.class, fieldOrMethod); - bindTriggerIfPresent(OnDeletePostCommit.class, fieldOrMethod); - bindTriggerIfPresent(OnUpdatePostCommit.class, fieldOrMethod); - bindTriggerIfPresent(OnReadPostCommit.class, fieldOrMethod); + bindTriggerIfPresent(fieldOrMethod); if (fieldOrMethod.isAnnotationPresent(Id.class)) { bindEntityId(cls, type, fieldOrMethod); @@ -271,7 +301,9 @@ private void bindEntityFields(Class cls, String type, Collection dequeToList(final Deque deque) { * Bind an attribute or relationship. * * @param fieldOrMethod Field or method to bind + * @param isHidden Whether this field is hidden from API */ - private void bindAttrOrRelation(AccessibleObject fieldOrMethod) { + private void bindAttrOrRelation(AccessibleObject fieldOrMethod, boolean isHidden) { boolean isRelation = RELATIONSHIP_TYPES.stream().anyMatch(fieldOrMethod::isAnnotationPresent); String fieldName = getFieldName(fieldOrMethod); @@ -340,13 +373,21 @@ private void bindAttrOrRelation(AccessibleObject fieldOrMethod) { } if (isRelation) { - bindRelation(fieldOrMethod, fieldName, fieldType); + bindRelation(fieldOrMethod, fieldName, fieldType, isHidden); } else { - bindAttr(fieldOrMethod, fieldName, fieldType); + bindAttr(fieldOrMethod, fieldName, fieldType, isHidden); } } - private void bindRelation(AccessibleObject fieldOrMethod, String fieldName, Class fieldType) { + /** + * Bind a relationship to current class + * + * @param fieldOrMethod Field or method to bind + * @param fieldName Field name + * @param fieldType Field type + * @param isHidden Whether this field is hidden from API + */ + private void bindRelation(AccessibleObject fieldOrMethod, String fieldName, Class fieldType, boolean isHidden) { boolean manyToMany = fieldOrMethod.isAnnotationPresent(ManyToMany.class); boolean manyToOne = fieldOrMethod.isAnnotationPresent(ManyToOne.class); boolean oneToMany = fieldOrMethod.isAnnotationPresent(OneToMany.class); @@ -388,13 +429,25 @@ private void bindRelation(AccessibleObject fieldOrMethod, String fieldName, Clas relationshipToInverse.put(fieldName, mappedBy); relationshipToCascadeTypes.put(fieldName, cascadeTypes); - relationshipsDeque.push(fieldName); + if (!isHidden) { + relationshipsDeque.push(fieldName); + } fieldsToValues.put(fieldName, fieldOrMethod); fieldsToTypes.put(fieldName, fieldType); } - private void bindAttr(AccessibleObject fieldOrMethod, String fieldName, Class fieldType) { - attributesDeque.push(fieldName); + /** + * Bind an attribute to current class + * + * @param fieldOrMethod Field or method to bind + * @param fieldName Field name + * @param fieldType Field type + * @param isHidden Whether this field is hidden from API + */ + private void bindAttr(AccessibleObject fieldOrMethod, String fieldName, Class fieldType, boolean isHidden) { + if (!isHidden) { + attributesDeque.push(fieldName); + } fieldsToValues.put(fieldName, fieldOrMethod); fieldsToTypes.put(fieldName, fieldType); } @@ -484,68 +537,74 @@ public static Class getFieldType(Class parentClass, return TypeUtils.getRawType(type, parentClass); } - private void bindTriggerIfPresent(Class annotationClass, AccessibleObject fieldOrMethod) { - if (fieldOrMethod instanceof Method && fieldOrMethod.isAnnotationPresent(annotationClass)) { - Annotation trigger = fieldOrMethod.getAnnotation(annotationClass); - String value; - try { - value = (String) annotationClass.getMethod("value").invoke(trigger); - } catch (ReflectiveOperationException | IllegalArgumentException | SecurityException e) { - value = ""; - } - - Method method = (Method) fieldOrMethod; - - int paramCount = method.getParameterCount(); - Class[] paramTypes = method.getParameterTypes(); - - LifeCycleHook callback = (entity, scope, changes) -> { - try { - if (changes.isPresent() && paramCount == 2 - && paramTypes[0].isInstance(scope) - && paramTypes[1].isInstance(changes.get())) { - method.invoke(entity, scope, changes.get()); - } else if (paramCount == 1 && paramTypes[0].isInstance(scope)) { - method.invoke(entity, scope); - } else if (paramCount == 0) { - method.invoke(entity); - } else { - throw new IllegalArgumentException(); - } - } catch (ReflectiveOperationException e) { - Throwables.propagateIfPossible(e.getCause()); - throw new IllegalArgumentException(e); - } - }; - - if (value.equals(ALL_FIELDS)) { - bindTrigger(annotationClass, callback); - } else { - bindTrigger(annotationClass, value, callback); - } + private void bindTriggerIfPresent(AccessibleObject fieldOrMethod) { + LifeCycleHookBinding[] triggers = fieldOrMethod.getAnnotationsByType(LifeCycleHookBinding.class); + for (LifeCycleHookBinding trigger : triggers) { + bindTrigger(trigger, getFieldName(fieldOrMethod)); + } + } + private void bindTriggerIfPresent() { + LifeCycleHookBinding[] triggers = entityClass.getAnnotationsByType(LifeCycleHookBinding.class); + for (LifeCycleHookBinding trigger : triggers) { + bindTrigger(trigger); } } - public void bindTrigger(Class annotationClass, + public void bindTrigger(LifeCycleHookBinding.Operation operation, + LifeCycleHookBinding.TransactionPhase phase, String fieldOrMethodName, - LifeCycleHook callback) { - fieldsToTriggers.put(Pair.of(annotationClass, fieldOrMethodName), callback); + LifeCycleHook hook) { + Triple key = + Triple.of(fieldOrMethodName, operation, phase); + + fieldTriggers.put(key, hook); + } + + private void bindTrigger(LifeCycleHookBinding binding, + String fieldOrMethodName) { + Injector injector = dictionary.getInjector(); + LifeCycleHook hook = injector.instantiate(binding.hook()); + injector.inject(hook); + bindTrigger(binding.operation(), binding.phase(), fieldOrMethodName, hook); } - public void bindTrigger(Class annotationClass, - LifeCycleHook callback) { - classToTriggers.put(annotationClass, callback); + public void bindTrigger(LifeCycleHookBinding.Operation operation, + LifeCycleHookBinding.TransactionPhase phase, + LifeCycleHook hook) { + Pair key = + Pair.of(operation, phase); + + classTriggers.put(key, hook); } + private void bindTrigger(LifeCycleHookBinding binding) { + if (binding.oncePerRequest()) { + bindTrigger(binding, PersistentResource.CLASS_NO_FIELD); + return; + } - public Collection getTriggers(Class annotationClass, String fieldName) { - Collection methods = fieldsToTriggers.get(Pair.of(annotationClass, fieldName)); - return methods == null ? Collections.emptyList() : methods; + Injector injector = dictionary.getInjector(); + LifeCycleHook hook = dictionary.getInjector().instantiate(binding.hook()); + injector.inject(hook); + bindTrigger(binding.operation(), binding.phase(), hook); } - public Collection getTriggers(Class annotationClass) { - Collection methods = classToTriggers.get(annotationClass); - return methods == null ? Collections.emptyList() : methods; + public Collection getTriggers(LifeCycleHookBinding.Operation op, + LifeCycleHookBinding.TransactionPhase phase, + String fieldName) { + Triple key = + Triple.of(fieldName, op, phase); + Collection bindings = fieldTriggers.get(key); + return (bindings == null ? Collections.emptyList() : bindings); + } + + public Collection getTriggers(LifeCycleHookBinding.Operation op, + LifeCycleHookBinding.TransactionPhase phase) { + + Pair key = + Pair.of(op, phase); + Collection bindings = classTriggers.get(key); + return (bindings == null ? Collections.emptyList() : bindings); } /** @@ -592,6 +651,27 @@ public A getMethodAnnotation(Class annotationClass, St return annotation == NO_ANNOTATION ? null : annotationClass.cast(annotation); } + private boolean shouldInject() { + boolean hasField = getAllFields().stream() + .anyMatch(accessibleObject -> accessibleObject.isAnnotationPresent(Inject.class)); + + if (hasField) { + return true; + } + + boolean hasMethod = getAllMethods().stream() + .anyMatch(accessibleObject -> accessibleObject.isAnnotationPresent(Inject.class)); + + if (hasMethod) { + return true; + } + + boolean hasConstructor = Arrays.stream(entityClass.getConstructors()) + .anyMatch(ctor -> ctor.getAnnotation(Inject.class) != null); + + return hasConstructor; + } + private List> getInheritedTypes(Class entityClass) { ArrayList> results = new ArrayList<>(); @@ -601,4 +681,35 @@ private List> getInheritedTypes(Class entityClass) { return results; } + + + /** + * Add a collection of arguments to the attributes of this Entity. + * @param attribute attribute name to which argument has to be added + * @param arguments Set of Argument Type for the attribute + */ + public void addArgumentsToAttribute(String attribute, Set arguments) { + AccessibleObject fieldObject = fieldsToValues.get(attribute); + if (fieldObject != null && arguments != null) { + Set existingArgs = attributeArguments.get(fieldObject); + if (existingArgs != null) { + //Replace any argument names with new value + existingArgs.addAll(arguments); + } else { + attributeArguments.put(fieldObject, new HashSet<>(arguments)); + } + } + } + + /** + * Returns the Collection of all attributes of an argument. + * @param attribute Name of the argument for ehich arguments are to be retrieved. + * @return A Set of ArgumentType for the given attribute. + */ + public Set getAttributeArguments(String attribute) { + AccessibleObject fieldObject = fieldsToValues.get(attribute); + return (fieldObject != null) + ? attributeArguments.getOrDefault(fieldObject, EMPTY_ATTRIBUTES_ARGS) + : EMPTY_ATTRIBUTES_ARGS; + } } diff --git a/elide-core/src/main/java/com/yahoo/elide/core/EntityDictionary.java b/elide-core/src/main/java/com/yahoo/elide/core/EntityDictionary.java index bca8faf241..5c4274c016 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/EntityDictionary.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/EntityDictionary.java @@ -9,21 +9,23 @@ import static com.yahoo.elide.core.EntityBinding.EMPTY_BINDING; import com.yahoo.elide.Injector; +import com.yahoo.elide.annotation.ApiVersion; import com.yahoo.elide.annotation.ComputedAttribute; import com.yahoo.elide.annotation.ComputedRelationship; import com.yahoo.elide.annotation.Exclude; import com.yahoo.elide.annotation.Include; +import com.yahoo.elide.annotation.LifeCycleHookBinding; import com.yahoo.elide.annotation.MappedInterface; +import com.yahoo.elide.annotation.NonTransferable; import com.yahoo.elide.annotation.SecurityCheck; -import com.yahoo.elide.annotation.SharePermission; import com.yahoo.elide.core.exceptions.HttpStatusException; import com.yahoo.elide.core.exceptions.InternalServerErrorException; import com.yahoo.elide.core.exceptions.InvalidAttributeException; import com.yahoo.elide.functions.LifeCycleHook; +import com.yahoo.elide.security.FilterExpressionCheck; import com.yahoo.elide.security.checks.Check; import com.yahoo.elide.security.checks.prefab.Collections.AppendOnly; import com.yahoo.elide.security.checks.prefab.Collections.RemoveOnly; -import com.yahoo.elide.security.checks.prefab.Common; import com.yahoo.elide.security.checks.prefab.Role; import com.yahoo.elide.utils.ClassScanner; import com.yahoo.elide.utils.coerce.CoerceUtil; @@ -31,10 +33,12 @@ import com.google.common.collect.BiMap; import com.google.common.collect.HashBiMap; import com.google.common.collect.Maps; - +import com.google.common.collect.Sets; import org.antlr.v4.runtime.tree.ParseTree; import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.tuple.Pair; +import lombok.Getter; import lombok.extern.slf4j.Slf4j; import java.lang.annotation.Annotation; @@ -60,10 +64,11 @@ import java.util.concurrent.CopyOnWriteArrayList; import java.util.function.Function; import java.util.stream.Collectors; - import javax.persistence.AccessType; import javax.persistence.CascadeType; +import javax.persistence.Column; import javax.persistence.Entity; +import javax.persistence.JoinColumn; import javax.persistence.Transient; import javax.ws.rs.WebApplicationException; @@ -76,11 +81,19 @@ @SuppressWarnings("static-method") public class EntityDictionary { - protected final ConcurrentHashMap> bindJsonApiToEntity = new ConcurrentHashMap<>(); + public static final String ELIDE_PACKAGE_PREFIX = "com.yahoo.elide"; + public static final String NO_VERSION = ""; + + protected final ConcurrentHashMap, Class> bindJsonApiToEntity = new ConcurrentHashMap<>(); protected final ConcurrentHashMap, EntityBinding> entityBindings = new ConcurrentHashMap<>(); protected final CopyOnWriteArrayList> bindEntityRoots = new CopyOnWriteArrayList<>(); protected final ConcurrentHashMap, List>> subclassingEntities = new ConcurrentHashMap<>(); protected final BiMap> checkNames; + + @Getter + protected final Set apiVersions; + + @Getter protected final Injector injector; public final static String REGULAR_ID_NAME = "id"; @@ -95,7 +108,25 @@ public class EntityDictionary { * to their implementing classes */ public EntityDictionary(Map> checks) { - this(checks, null); + this.checkNames = Maps.synchronizedBiMap(HashBiMap.create(checks)); + this.apiVersions = new HashSet<>(); + initializeChecks(); + + //Default injector only injects Elide internals. + this.injector = new Injector() { + @Override + public void inject(Object entity) { + if (entity instanceof FilterExpressionCheck) { + try { + Field field = FilterExpressionCheck.class.getDeclaredField("dictionary"); + field.setAccessible(true); + field.set(entity, EntityDictionary.this); + } catch (NoSuchFieldException | IllegalAccessException e) { + throw new IllegalStateException(e); + } + } + } + }; } /** @@ -109,17 +140,20 @@ public EntityDictionary(Map> checks) { * initialize Elide models. */ public EntityDictionary(Map> checks, Injector injector) { - checkNames = Maps.synchronizedBiMap(HashBiMap.create(checks)); + this.checkNames = Maps.synchronizedBiMap(HashBiMap.create(checks)); + this.apiVersions = new HashSet<>(); + initializeChecks(); + this.injector = injector; + } + private void initializeChecks() { addPrefabCheck("Prefab.Role.All", Role.ALL.class); addPrefabCheck("Prefab.Role.None", Role.NONE.class); addPrefabCheck("Prefab.Collections.AppendOnly", AppendOnly.class); addPrefabCheck("Prefab.Collections.RemoveOnly", RemoveOnly.class); - addPrefabCheck("Prefab.Common.UpdateOnCreate", Common.UpdateOnCreate.class); - - this.injector = injector; } + private void addPrefabCheck(String alias, Class checkClass) { if (checkNames.containsKey(alias) || checkNames.inverse().containsKey(checkClass)) { return; @@ -206,8 +240,19 @@ public boolean isMappedInterface(Class interfaceClass) { * @param entityName entity name * @return binding class */ - public Class getEntityClass(String entityName) { - return bindJsonApiToEntity.get(entityName); + public Class getEntityClass(String entityName, String version) { + Class lookup = bindJsonApiToEntity.getOrDefault(Pair.of(entityName, version), null); + + if (lookup == null) { + //Elide standard models transcend API versions. + return entityBindings.values().stream() + .filter(binding -> binding.entityClass.getName().startsWith(ELIDE_PACKAGE_PREFIX)) + .filter(binding -> binding.entityName.equals(entityName)) + .map(EntityBinding::getEntityClass) + .findFirst() + .orElse(null); + } + return lookup; } /** @@ -267,8 +312,8 @@ public ParseTree getPermissionsForClass(Class resourceClass, Class resourceClass, - String field, - Class annotationClass) { + String field, + Class annotationClass) { EntityBinding binding = getEntityBinding(resourceClass); return binding.entityPermissions.getFieldChecksForPermission(field, annotationClass); } @@ -291,35 +336,16 @@ public Class getCheck(String checkIdentifier) { } /** - * Get inherited entity names for a particular entity. - * - * @param entityName Json alias name for entity - * @return List of all inherited entity type names - */ - public List getSubclassingEntityNames(String entityName) { - return getSubclassingEntityNames(getEntityClass(entityName)); - } - - /** - * Get inherited entity names for a particular entity. + * Fetch all entity classes that provided entity inherits from (i.e. all superclass entities down to, + * but excluding Object). * * @param entityClass Entity class - * @return List of all inherited entity type names - */ - public List getSubclassingEntityNames(Class entityClass) { - List> entities = getSubclassingEntities(entityClass); - return entities.stream().map(this::getJsonAliasFor).collect(Collectors.toList()); - } - - /** - * Get a list of inherited entities from a particular entity. - * Namely, the list of entities inheriting from the provided class. - * - * @param entityName Json alias name for entity - * @return List of all inherited entity types + * @return List of all super class entity classes */ - public List> getSubclassingEntities(String entityName) { - return getSubclassingEntities(getEntityClass(entityName)); + public List> getSuperClassEntities(Class entityClass) { + return getEntityBinding(entityClass).inheritedTypes.stream() + .filter(entityBindings::containsKey) + .collect(Collectors.toList()); } /** @@ -329,60 +355,12 @@ public List> getSubclassingEntities(String entityName) { * @param entityClass Entity class * @return List of all inherited entity types */ - public List> getSubclassingEntities(Class entityClass) { - return subclassingEntities.computeIfAbsent(entityClass, unused -> entityBindings - .keySet().stream() - .filter(c -> c != entityClass && entityClass.isAssignableFrom(c)) - .collect(Collectors.toList())); - } - - /** - * Fetch all entity names that the provided entity inherits from (i.e. all superclass entities down to, - * but excluding Object). - * - * @param entityName Json alias name for entity - * @return List of all super class entity json names - */ - public List getSuperClassEntityNames(String entityName) { - return getSuperClassEntityNames(getEntityClass(entityName)); - } - - /** - * Fetch all entity names that the provided entity inherits from (i.e. all superclass entities down to, - * but excluding Object). - * - * @param entityClass Entity class - * @return List of all super class entity json names - */ - public List getSuperClassEntityNames(Class entityClass) { - return getSuperClassEntities(entityClass).stream() - .map(this::getJsonAliasFor) - .collect(Collectors.toList()); - } - - /** - * Fetch all entity classes that the provided entity inherits from (i.e. all superclass entities down to, - * but excluding Object). - * - * @param entityName Json alias name for entity - * @return List of all super class entity classes - */ - public List> getSuperClassEntities(String entityName) { - return getSuperClassEntities(getEntityClass(entityName)); - } - - /** - * Fetch all entity classes that provided entity inherits from (i.e. all superclass entities down to, - * but excluding Object). - * - * @param entityClass Entity class - * @return List of all super class entity classes - */ - public List> getSuperClassEntities(Class entityClass) { - return getEntityBinding(entityClass).inheritedTypes.stream() - .filter(entityBindings::containsKey) - .collect(Collectors.toList()); - } + public List> getSubclassingEntities(Class entityClass) { + return subclassingEntities.computeIfAbsent(entityClass, unused -> entityBindings + .keySet().stream() + .filter(c -> c != entityClass && entityClass.isAssignableFrom(c)) + .collect(Collectors.toList())); + } /** * Returns the friendly named mapped to this given check. @@ -417,13 +395,37 @@ public AccessType getAccessType(Class entityClass) { return getEntityBinding(entityClass).getAccessType(); } + /** + * Get all bound classes. + * + * @return the bound classes + */ + public Set> getBoundClasses() { + return entityBindings.keySet(); + } + + /** + * Get all bound classes for a particular API version. + * + * @return the bound classes + */ + public Set> getBoundClassesByVersion(String apiVersion) { + return entityBindings.values().stream() + .filter(binding -> + binding.getApiVersion().equals(apiVersion) + || binding.entityClass.getName().startsWith(ELIDE_PACKAGE_PREFIX) + ) + .map(EntityBinding::getEntityClass) + .collect(Collectors.toSet()); + } + /** * Get all bindings. * * @return the bindings */ - public Set> getBindings() { - return entityBindings.keySet(); + public Set getBindings() { + return new HashSet<>(entityBindings.values()); } /** @@ -441,7 +443,7 @@ public Map> getCheckMappings() { * @return List of attribute names for entity */ public List getAttributes(Class entityClass) { - return getEntityBinding(entityClass).attributes; + return getEntityBinding(entityClass).apiAttributes; } /** @@ -461,7 +463,7 @@ public List getAttributes(Object entity) { * @return List of relationship names for entity */ public List getRelationships(Class entityClass) { - return getEntityBinding(entityClass).relationships; + return getEntityBinding(entityClass).apiRelationships; } /** @@ -482,7 +484,7 @@ public List getRelationships(Object entity) { */ public List getElideBoundRelationships(Class entityClass) { return getRelationships(entityClass).stream() - .filter(relationName -> getBindings().contains(getParameterizedType(entityClass, relationName))) + .filter(relationName -> getBoundClasses().contains(getParameterizedType(entityClass, relationName))) .collect(Collectors.toList()); } @@ -779,45 +781,42 @@ public String getNameFromAlias(Object entity, String alias) { */ public void initializeEntity(T entity) { if (entity != null) { - @SuppressWarnings("unchecked") - Initializer initializer = getEntityBinding(entity.getClass()).getInitializer(); - if (initializer != null) { - initializer.initialize(entity); - } else if (injector != null) { + EntityBinding binding = getEntityBinding(entity.getClass()); + + if (binding.isInjected()) { injector.inject(entity); } } } /** - * Bind a particular initializer to a class. + * Returns whether or not an entity is shareable. * - * @param the type parameter - * @param initializer Initializer to use for class - * @param cls Class to bind initialization + * @param entityClass the entity type to check for the shareable permissions + * @return true if entityClass is shareable. False otherwise. */ - public void bindInitializer(Initializer initializer, Class cls) { - bindIfUnbound(cls); - getEntityBinding(cls).setInitializer(initializer); + public boolean isTransferable(Class entityClass) { + NonTransferable nonTransferable = getAnnotation(entityClass, NonTransferable.class); + + return (nonTransferable == null || !nonTransferable.enabled()); } /** - * Returns whether or not an entity is shareable. + * Add given Entity bean to dictionary. * - * @param entityClass the entity type to check for the shareable permissions - * @return true if entityClass is shareable. False otherwise. + * @param cls Entity bean class */ - public boolean isShareable(Class entityClass) { - return getAnnotation(entityClass, SharePermission.class) != null - && getAnnotation(entityClass, SharePermission.class).sharable(); + public void bindEntity(Class cls) { + bindEntity(cls, new HashSet<>()); } /** * Add given Entity bean to dictionary. * * @param cls Entity bean class + * @param hiddenAnnotations Annotations for hiding a field in API */ - public void bindEntity(Class cls) { + public void bindEntity(Class cls, Set> hiddenAnnotations) { Class declaredClass = lookupIncludeClass(cls); if (declaredClass == null) { @@ -847,8 +846,35 @@ public void bindEntity(Class cls) { type = include.type(); } - bindJsonApiToEntity.put(type, declaredClass); - entityBindings.put(declaredClass, new EntityBinding(this, declaredClass, type, name)); + String version = getModelVersion(cls); + bindJsonApiToEntity.put(Pair.of(type, version), declaredClass); + apiVersions.add(version); + entityBindings.put(declaredClass, new EntityBinding(this, declaredClass, + type, name, version, hiddenAnnotations)); + if (include.rootLevel()) { + bindEntityRoots.add(declaredClass); + } + } + + /** + * Add an EntityBinding instance to dictionary. + * + * @param entityBinding EntityBinding instance + */ + public void bindEntity(EntityBinding entityBinding) { + Class declaredClass = entityBinding.entityClass; + + if (isClassBound(declaredClass)) { + //Ignore duplicate bindings. + return; + } + + Include include = (Include) getFirstAnnotation(declaredClass, Collections.singletonList(Include.class)); + + String version = getModelVersion(declaredClass); + bindJsonApiToEntity.put(Pair.of(entityBinding.jsonApiType, version), declaredClass); + entityBindings.put(declaredClass, entityBinding); + apiVersions.add(version); if (include.rootLevel()) { bindEntityRoots.add(declaredClass); } @@ -891,14 +917,16 @@ public A getMethodAnnotation(Class recordClass, String } public Collection getTriggers(Class cls, - Class annotationClass, + LifeCycleHookBinding.Operation op, + LifeCycleHookBinding.TransactionPhase phase, String fieldName) { - return getEntityBinding(cls).getTriggers(annotationClass, fieldName); + return getEntityBinding(cls).getTriggers(op, phase, fieldName); } public Collection getTriggers(Class cls, - Class annotationClass) { - return getEntityBinding(cls).getTriggers(annotationClass); + LifeCycleHookBinding.Operation op, + LifeCycleHookBinding.TransactionPhase phase) { + return getEntityBinding(cls).getTriggers(op, phase); } /** @@ -953,10 +981,24 @@ public static Annotation getFirstAnnotation(Class entityClass, for (Class annotationClass : annotationClassList) { annotation = cls.getDeclaredAnnotation(annotationClass); if (annotation != null) { - break; + return annotation; } } } + + return getFirstPackageAnnotation(entityClass, annotationClassList); + } + + /** + * Return first matching annotation from a package or parent package. + * + * @param entityClass Entity class type + * @param annotationClassList List of sought annotations + * @return annotation found + */ + public static Annotation getFirstPackageAnnotation(Class entityClass, + List> annotationClassList) { + Annotation annotation = null; // no class annotation, try packages for (Package pkg = entityClass.getPackage(); annotation == null && pkg != null; pkg = getParentPackage(pkg)) { for (Class annotationClass : annotationClassList) { @@ -1040,7 +1082,7 @@ public Collection getIdAnnotations(Object value) { } /** - * Follow for this class or super-class for Entity annotation. + * Follow for this class or super-class for JPA {@link Entity} annotation. * * @param objClass provided class * @return class with Entity annotation @@ -1062,6 +1104,12 @@ public Class lookupEntityClass(Class objClass) { public Class lookupIncludeClass(Class objClass) { Annotation first = getFirstAnnotation(objClass, Arrays.asList(Exclude.class, Include.class)); if (first instanceof Include) { + Class declaringClass = lookupAnnotationDeclarationClass(objClass, Include.class); + if (declaringClass != null) { + return declaringClass; + } + + //If we didn't find Include declared on a class, it must be declared at the package level. return objClass; } return null; @@ -1082,6 +1130,7 @@ public Class lookupAnnotationDeclarationClass(Class objClass, Class objClass) { return (entityBindings.getOrDefault(objClass, EMPTY_BINDING) != EMPTY_BINDING); } + /** + * Check whether a class is a JPA entity. + * + * @param objClass class + * @return True if it is a JPA entity + */ + public final boolean isJPAEntity(Class objClass) { + try { + lookupEntityClass(objClass); + return true; + } catch (IllegalArgumentException e) { + return false; + } + } /** * Retrieve the accessible object for a field from a target object. @@ -1177,11 +1240,11 @@ public Set getFieldsOfType(Class targetClass, Class targetType) { } public boolean isRelation(Class entityClass, String relationName) { - return getEntityBinding(entityClass).relationships.contains(relationName); + return getEntityBinding(entityClass).apiRelationships.contains(relationName); } public boolean isAttribute(Class entityClass, String attributeName) { - return getEntityBinding(entityClass).attributes.contains(attributeName); + return getEntityBinding(entityClass).apiAttributes.contains(attributeName); } /** @@ -1193,7 +1256,22 @@ public void scanForSecurityChecks() { // /elide-spring-boot-autoconfigure/src/main/java/org/illyasviel/elide // /spring/boot/autoconfigure/ElideAutoConfiguration.java - for (Class cls : ClassScanner.getAnnotatedClasses(SecurityCheck.class)) { + Set> classes = ClassScanner.getAnnotatedClasses(SecurityCheck.class); + + addSecurityChecks(classes); + } + + /** + * Add security checks and bind them to the dictionary. + * @param classes Security check classes. + */ + public void addSecurityChecks(Set> classes) { + + if (classes == null && classes.size() == 0) { + return; + } + + for (Class cls : classes) { if (Check.class.isAssignableFrom(cls)) { SecurityCheck securityCheckMeta = cls.getAnnotation(SecurityCheck.class); log.debug("Register Elide Check [{}] with expression [{}]", @@ -1209,17 +1287,19 @@ public void scanForSecurityChecks() { * Binds a lifecycle hook to a particular field or method in an entity. The hook will be called a * single time per request per field READ, CREATE, or UPDATE. * @param entityClass The entity that triggers the lifecycle hook. - * @param annotationClass (OnReadPostCommit, OnUpdatePreSecurity, etc) - * @param fieldOrMethodName The name of the field or method - * @param callback The callback function to invoke. + * @param fieldOrMethodName The name of the field or method. + * @param operation CREATE, READ, or UPDATE + * @param phase PRESECURITY, PRECOMMIT, or POSTCOMMIT + * @param hook The callback to invoke. */ public void bindTrigger(Class entityClass, - Class annotationClass, String fieldOrMethodName, - LifeCycleHook callback) { - + LifeCycleHookBinding.Operation operation, + LifeCycleHookBinding.TransactionPhase phase, + LifeCycleHook hook) { bindIfUnbound(entityClass); - getEntityBinding(entityClass).bindTrigger(annotationClass, fieldOrMethodName, callback); + + getEntityBinding(entityClass).bindTrigger(operation, phase, fieldOrMethodName, hook); } /** @@ -1229,37 +1309,26 @@ public void bindTrigger(Class entityClass, * * The behavior is determined by the value of the {@code allowMultipleInvocations} flag. * @param entityClass The entity that triggers the lifecycle hook. - * @param annotationClass (OnReadPostCommit, OnUpdatePreSecurity, etc) - * @param callback The callback function to invoke. + * @param operation CREATE, READ, or UPDATE + * @param phase PRESECURITY, PRECOMMIT, or POSTCOMMIT + * @param hook The callback to invoke. * @param allowMultipleInvocations Should the same life cycle hook be invoked multiple times for multiple - * CRUD actions on the same model. + * CRUD actions on the same model. */ public void bindTrigger(Class entityClass, - Class annotationClass, - LifeCycleHook callback, + LifeCycleHookBinding.Operation operation, + LifeCycleHookBinding.TransactionPhase phase, + LifeCycleHook hook, boolean allowMultipleInvocations) { bindIfUnbound(entityClass); + if (allowMultipleInvocations) { - getEntityBinding(entityClass).bindTrigger(annotationClass, callback); + getEntityBinding(entityClass).bindTrigger(operation, phase, hook); } else { - getEntityBinding(entityClass).bindTrigger(annotationClass, PersistentResource.CLASS_NO_FIELD, callback); + getEntityBinding(entityClass).bindTrigger(operation, phase, PersistentResource.CLASS_NO_FIELD, hook); } } - /** - * Binds a lifecycle hook to a particular entity class. The hook will be called a single time per request - * per class READ, CREATE, UPDATE, or DELETE. - * @param entityClass The entity that triggers the lifecycle hook. - * @param annotationClass (OnReadPostCommit, OnUpdatePreSecurity, etc) - * @param callback The callback function to invoke. - */ - public void bindTrigger(Class entityClass, - Class annotationClass, - LifeCycleHook callback) { - bindTrigger(entityClass, annotationClass, callback, false); - } - - /** * Returns true if the relationship cascades deletes and false otherwise. * @param targetClass The class which owns the relationship. @@ -1313,11 +1382,13 @@ public List walkEntityGraph(Set> entities, Function, T /** * Returns whether or not a class is already bound. - * @param cls + * @param cls The class to verify. * @return true if the class is bound. False otherwise. */ public boolean hasBinding(Class cls) { - return bindJsonApiToEntity.contains(cls); + return entityBindings.values().stream() + .filter(binding -> binding.entityClass.equals(cls)) + .findFirst().orElse(null) != null; } /** @@ -1347,8 +1418,18 @@ public Object getValue(Object target, String fieldName, RequestScope scope) { throw new InvalidAttributeException(fieldName, getJsonAliasFor(target.getClass())); } + /** + * Sets the ID field of a target object. + * @param target the object which owns the ID to set. + * @param id the value to set + */ + public void setId(Object target, String id) { + setValue(target, getIdFieldName(lookupBoundClass(target.getClass())), id); + } + /** * Invoke the set[fieldName] method on the target object OR set the field with the corresponding name. + * @param target The object which owns the field to set * @param fieldName the field name to set or invoke equivalent set method * @param value the value to set */ @@ -1464,6 +1545,36 @@ private Map coerceMap(Object target, Map values, String fieldName) { return result; } + /** + * Returns whether or not a specified annotation is present on an entity field or its corresponding method. + * + * @param fieldName The entity field + * @param annotationClass The provided annotation class + * + * @param The type of the {@code annotationClass} + * + * @return {@code true} if the field is annotated by the {@code annotationClass} + */ + public boolean attributeOrRelationAnnotationExists( + Class cls, + String fieldName, + Class annotationClass + ) { + return getAttributeOrRelationAnnotation(cls, annotationClass, fieldName) != null; + } + + /** + * Returns whether or not a specified field exists in an entity. + * + * @param cls The entity + * @param fieldName The provided field to check + * + * @return {@code true} if the field exists in the entity + */ + public boolean isValidField(Class cls, String fieldName) { + return getAllFields(cls).contains(fieldName); + } + private boolean isValidParameterizedMap(Map values, Class keyType, Class valueType) { for (Map.Entry entry : values.entrySet()) { Object key = entry.getKey(); @@ -1481,8 +1592,76 @@ private boolean isValidParameterizedMap(Map values, Class keyType, Clas * @param entityClass the class to bind. */ private void bindIfUnbound(Class entityClass) { + /* This is safe to call with non-proxy objects. Not safe to call with ORM proxy objects. */ + if (! isClassBound(entityClass)) { bindEntity(entityClass); } } + + /** + * Add a collection of argument to the attributes + * @param cls The entity + * @param attributeName attribute name to which argument has to be added + * @param arguments Set of Argument type containing name and type of each argument. + */ + public void addArgumentsToAttribute(Class cls, String attributeName, Set arguments) { + getEntityBinding(cls).addArgumentsToAttribute(attributeName, arguments); + } + + /** + * Add a single argument to the attribute + * @param cls The entity + * @param attributeName attribute name to which argument has to be added + * @param argument A single argument + */ + public void addArgumentToAttribute(Class cls, String attributeName, ArgumentType argument) { + this.addArgumentsToAttribute(cls, attributeName, Sets.newHashSet(argument)); + } + + /** + * Returns the Collection of all attributes of an argument. + * @param cls The entity + * @param attributeName Name of the argument for ehich arguments are to be retrieved. + * @return A Set of ArgumentType for the given attribute. + */ + public Set getAttributeArguments(Class cls, String attributeName) { + return entityBindings.getOrDefault(cls, EMPTY_BINDING).getAttributeArguments(attributeName); + } + + /** + * Get column name using JPA. + * + * @param cls The entity class. + * @param fieldName The entity attribute. + * @return The jpa column name. + */ + public String getAnnotatedColumnName(Class cls, String fieldName) { + Column[] column = getAttributeOrRelationAnnotations(cls, Column.class, fieldName); + + // this would only be valid for dimension columns + JoinColumn[] joinColumn = getAttributeOrRelationAnnotations(cls, JoinColumn.class, fieldName); + + if (column == null || column.length == 0) { + if (joinColumn == null || joinColumn.length == 0) { + return fieldName; + } else { + return joinColumn[0].name(); + } + } else { + return column[0].name(); + } + } + + /** + * Returns the api version bound to a particular model class. + * @param modelClass The model class to lookup. + * @return The api version associated with the model or empty string if there is no association. + */ + public static String getModelVersion(Class modelClass) { + ApiVersion apiVersionAnnotation = + (ApiVersion) getFirstPackageAnnotation(modelClass, Arrays.asList(ApiVersion.class)); + + return (apiVersionAnnotation == null) ? NO_VERSION : apiVersionAnnotation.version(); + } } diff --git a/elide-core/src/main/java/com/yahoo/elide/core/EntityPermissions.java b/elide-core/src/main/java/com/yahoo/elide/core/EntityPermissions.java index 181fab7c03..b9aafcf256 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/EntityPermissions.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/EntityPermissions.java @@ -7,8 +7,8 @@ import com.yahoo.elide.annotation.CreatePermission; import com.yahoo.elide.annotation.DeletePermission; +import com.yahoo.elide.annotation.NonTransferable; import com.yahoo.elide.annotation.ReadPermission; -import com.yahoo.elide.annotation.SharePermission; import com.yahoo.elide.annotation.UpdatePermission; import com.yahoo.elide.generated.parsers.ExpressionLexer; import com.yahoo.elide.generated.parsers.ExpressionParser; @@ -43,7 +43,7 @@ public class EntityPermissions implements CheckInstantiator { ReadPermission.class, CreatePermission.class, DeletePermission.class, - SharePermission.class, + NonTransferable.class, UpdatePermission.class ); @@ -79,7 +79,7 @@ public EntityPermissions(EntityDictionary dictionary, final Map fieldPermissions = new HashMap<>(); fieldOrMethodList.stream() .forEach(member -> bindMemberPermissions(fieldPermissions, member, annotationClass)); - if (annotationClass != SharePermission.class) { + if (annotationClass != NonTransferable.class) { ParseTree classPermission = bindClassPermissions(cls, annotationClass); if (classPermission != null || !fieldPermissions.isEmpty()) { bindings.put(annotationClass, new AnnotationBinding(classPermission, fieldPermissions)); diff --git a/elide-core/src/main/java/com/yahoo/elide/core/HttpStatus.java b/elide-core/src/main/java/com/yahoo/elide/core/HttpStatus.java index 208cc22ab6..44a6bfb312 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/HttpStatus.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/HttpStatus.java @@ -16,6 +16,7 @@ public class HttpStatus { public static final int SC_BAD_REQUEST = 400; public static final int SC_FORBIDDEN = 403; public static final int SC_NOT_FOUND = 404; + public static final int SC_TIMEOUT = 408; public static final int SC_LOCKED = 423; public static final int SC_INTERNAL_SERVER_ERROR = 500; } diff --git a/elide-core/src/main/java/com/yahoo/elide/core/Initializer.java b/elide-core/src/main/java/com/yahoo/elide/core/Initializer.java deleted file mode 100644 index 79aa554139..0000000000 --- a/elide-core/src/main/java/com/yahoo/elide/core/Initializer.java +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Copyright 2017, Yahoo Inc. - * Licensed under the Apache License, Version 2.0 - * See LICENSE file in project root for terms. - */ -package com.yahoo.elide.core; - -/** - * Used to perform any additional initialization required on entity beans which is not - * possible at time of construction. - * @param bean type - */ -@FunctionalInterface -public interface Initializer { - - /** - * Initialize an entity bean. - * - * @param entity Entity bean to initialize - */ - void initialize(T entity); -} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/LifecycleHookInvoker.java b/elide-core/src/main/java/com/yahoo/elide/core/LifecycleHookInvoker.java index e75ce6c3ef..64e35d2a26 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/LifecycleHookInvoker.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/LifecycleHookInvoker.java @@ -5,12 +5,12 @@ */ package com.yahoo.elide.core; +import com.yahoo.elide.annotation.LifeCycleHookBinding; import com.yahoo.elide.functions.LifeCycleHook; import io.reactivex.Observer; import io.reactivex.disposables.Disposable; -import java.lang.annotation.Annotation; import java.util.ArrayList; import java.util.Optional; @@ -20,15 +20,18 @@ public class LifecycleHookInvoker implements Observer { private EntityDictionary dictionary; - private Class annotation; + private LifeCycleHookBinding.Operation op; + private LifeCycleHookBinding.TransactionPhase phase; private Optional exception; private boolean throwsExceptions; public LifecycleHookInvoker(EntityDictionary dictionary, - Class annotation, + LifeCycleHookBinding.Operation op, + LifeCycleHookBinding.TransactionPhase phase, boolean throwExceptions) { this.dictionary = dictionary; - this.annotation = annotation; + this.op = op; + this.phase = phase; this.exception = Optional.empty(); this.throwsExceptions = throwExceptions; } @@ -43,20 +46,18 @@ public void onNext(CRUDEvent event) { ArrayList hooks = new ArrayList<>(); //Collect all the hooks that are keyed on a specific field. - hooks.addAll(dictionary.getTriggers( - event.getResource().getResourceClass(), - this.annotation, - event.getFieldName())); + hooks.addAll(dictionary.getTriggers(event.getResource().getResourceClass(), op, phase, event.getFieldName())); //Collect all the hooks that are keyed on any field. if (!event.getFieldName().isEmpty()) { - hooks.addAll(dictionary.getTriggers(event.getResource().getResourceClass(), this.annotation)); + hooks.addAll(dictionary.getTriggers(event.getResource().getResourceClass(), op, phase)); } try { //Invoke all the hooks hooks.forEach((hook) -> { hook.execute( + this.op, event.getResource().getObject(), event.getResource().getRequestScope(), event.getChanges()); diff --git a/elide-core/src/main/java/com/yahoo/elide/core/Path.java b/elide-core/src/main/java/com/yahoo/elide/core/Path.java index bea0a157f1..75c9d40519 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/Path.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/Path.java @@ -5,6 +5,10 @@ */ package com.yahoo.elide.core; +import static com.yahoo.elide.core.EntityDictionary.getSimpleName; +import static com.yahoo.elide.utils.TypeHelper.appendAlias; +import static com.yahoo.elide.utils.TypeHelper.getTypeAlias; + import com.yahoo.elide.core.exceptions.InvalidValueException; import com.google.common.collect.ImmutableList; @@ -25,7 +29,6 @@ @EqualsAndHashCode public class Path { private static final String PERIOD = "."; - private static final String UNDERSCORE = "_"; @Getter private List pathElements; /** @@ -50,15 +53,29 @@ public Path(List pathElements) { } public Path(Class entityClass, EntityDictionary dictionary, String dotSeparatedPath) { + pathElements = resolvePathElements(entityClass, dictionary, dotSeparatedPath); + } + + /** + * Resolve a dot separated path into list of path elements. + * + * @param entityClass root class e.g. "foo" + * @param dictionary dictionary + * @param dotSeparatedPath path e.g. "bar.baz" + * @return list of path elements e.g. ["foo.bar", "bar.baz"] + */ + private List resolvePathElements(Class entityClass, + EntityDictionary dictionary, + String dotSeparatedPath) { List elements = new ArrayList<>(); String[] fieldNames = dotSeparatedPath.split("\\."); Class currentClass = entityClass; for (String fieldName : fieldNames) { - if (dictionary.isRelation(currentClass, fieldName)) { - Class relationClass = dictionary.getParameterizedType(currentClass, fieldName); - elements.add(new PathElement(currentClass, relationClass, fieldName)); - currentClass = relationClass; + if (needNavigation(currentClass, fieldName, dictionary)) { + Class joinClass = dictionary.getParameterizedType(currentClass, fieldName); + elements.add(new PathElement(currentClass, joinClass, fieldName)); + currentClass = joinClass; } else if (dictionary.isAttribute(currentClass, fieldName) || fieldName.equals(dictionary.getIdFieldName(entityClass))) { Class attributeClass = dictionary.getType(currentClass, fieldName); @@ -67,10 +84,23 @@ public Path(Class entityClass, EntityDictionary dictionary, String dotSeparat elements.add(new PathElement(currentClass, null, fieldName)); } else { String alias = dictionary.getJsonAliasFor(currentClass); - throw new InvalidValueException(alias + " doesn't contain the field " + fieldName); + throw new InvalidValueException(alias + " does not contain the field " + fieldName); } } - pathElements = ImmutableList.copyOf(elements); + + return ImmutableList.copyOf(elements); + } + + /** + * Check whether a field need navigation to another entity. + * + * @param entityClass entity class + * @param fieldName field name + * @param dictionary dictionary + * @return True if the field requires navigation. + */ + protected boolean needNavigation(Class entityClass, String fieldName, EntityDictionary dictionary) { + return dictionary.isRelation(entityClass, fieldName); } public Optional lastElement() { @@ -95,23 +125,14 @@ public String getAlias() { } PathElement previous = pathElements.get(pathElements.size() - 2); - return getTypeAlias(previous.getType()) + UNDERSCORE + previous.getFieldName(); + return appendAlias(getTypeAlias(previous.getType()), previous.getFieldName()); } @Override public String toString() { return pathElements.size() == 0 ? "EMPTY" : pathElements.stream() - .map(e -> '[' + EntityDictionary.getSimpleName(e.getType()) + ']' + PERIOD + e.getFieldName()) + .map(e -> '[' + getSimpleName(e.getType()) + ']' + PERIOD + e.getFieldName()) .collect(Collectors.joining("/")); } - - /** - * Convert a class name into a hibernate friendly name. - * @param type The type to alias - * @return type name alias that will likely not conflict with other types or with reserved keywords. - */ - public static String getTypeAlias(Class type) { - return type.getCanonicalName().replace(PERIOD, UNDERSCORE); - } } diff --git a/elide-core/src/main/java/com/yahoo/elide/core/PersistentResource.java b/elide-core/src/main/java/com/yahoo/elide/core/PersistentResource.java index 888fb4cc4e..66d34cd4a5 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/PersistentResource.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/PersistentResource.java @@ -5,14 +5,21 @@ */ package com.yahoo.elide.core; +import static com.yahoo.elide.annotation.LifeCycleHookBinding.Operation.CREATE; +import static com.yahoo.elide.annotation.LifeCycleHookBinding.Operation.DELETE; +import static com.yahoo.elide.annotation.LifeCycleHookBinding.Operation.READ; +import static com.yahoo.elide.annotation.LifeCycleHookBinding.Operation.UPDATE; + import com.yahoo.elide.annotation.Audit; import com.yahoo.elide.annotation.CreatePermission; import com.yahoo.elide.annotation.DeletePermission; +import com.yahoo.elide.annotation.LifeCycleHookBinding; +import com.yahoo.elide.annotation.NonTransferable; import com.yahoo.elide.annotation.ReadPermission; -import com.yahoo.elide.annotation.SharePermission; import com.yahoo.elide.annotation.UpdatePermission; import com.yahoo.elide.audit.InvalidSyntaxException; import com.yahoo.elide.audit.LogMessage; +import com.yahoo.elide.audit.LogMessageImpl; import com.yahoo.elide.core.exceptions.BadRequestException; import com.yahoo.elide.core.exceptions.ForbiddenAccessException; import com.yahoo.elide.core.exceptions.InternalServerErrorException; @@ -23,14 +30,17 @@ import com.yahoo.elide.core.filter.InPredicate; import com.yahoo.elide.core.filter.expression.AndFilterExpression; import com.yahoo.elide.core.filter.expression.FilterExpression; -import com.yahoo.elide.core.pagination.Pagination; -import com.yahoo.elide.core.sort.Sorting; import com.yahoo.elide.jsonapi.models.Data; import com.yahoo.elide.jsonapi.models.Relationship; import com.yahoo.elide.jsonapi.models.Resource; import com.yahoo.elide.jsonapi.models.ResourceIdentifier; import com.yahoo.elide.jsonapi.models.SingleElementSet; import com.yahoo.elide.parsers.expression.CanPaginateVisitor; +import com.yahoo.elide.request.Argument; +import com.yahoo.elide.request.Attribute; +import com.yahoo.elide.request.EntityProjection; +import com.yahoo.elide.request.Pagination; +import com.yahoo.elide.request.Sorting; import com.yahoo.elide.security.ChangeSpec; import com.yahoo.elide.security.permissions.ExpressionResult; import com.yahoo.elide.utils.coerce.CoerceUtil; @@ -38,13 +48,11 @@ import com.fasterxml.jackson.annotation.JsonIgnore; import com.google.common.base.Preconditions; import com.google.common.collect.Sets; - import org.apache.commons.collections4.CollectionUtils; import org.apache.commons.collections4.IterableUtils; import org.apache.commons.lang3.StringUtils; import lombok.NonNull; - import java.io.Serializable; import java.lang.annotation.Annotation; import java.util.ArrayList; @@ -77,6 +85,7 @@ public class PersistentResource implements com.yahoo.elide.security.Persisten private final DataStoreTransaction transaction; private final RequestScope requestScope; private int hashCode = 0; + static final String CLASS_NO_FIELD = ""; /** @@ -95,6 +104,21 @@ public String toString() { return String.format("PersistentResource{type=%s, id=%s}", type, uuid.orElse(getId())); } + /** + * Create a resource in the database. + * @param entityClass the entity class + * @param requestScope the request scope + * @param uuid the (optional) uuid + * @param object type + * @return persistent resource + */ + public static PersistentResource createObject( + Class entityClass, + RequestScope requestScope, + Optional uuid) { + return createObject(null, entityClass, requestScope, uuid); + } + /** * Create a resource in the database. * @param parent - The immediate ancestor in the lineage or null if this is a root. @@ -110,7 +134,7 @@ public static PersistentResource createObject( RequestScope requestScope, Optional uuid) { - //instead of calling transcation.createObject, create the new object here. + //instead of calling transaction.createObject, create the new object here. T obj = requestScope.getTransaction().createNewObject(entityClass); String id = uuid.orElse(null); @@ -121,16 +145,15 @@ public static PersistentResource createObject( //hashcode and equals are only based on the ID/UUID & type. assignId(newResource, id); - // Keep track of new resources for non shareable resources + // Keep track of new resources for non-transferable resources requestScope.getNewPersistentResources().add(newResource); checkPermission(CreatePermission.class, newResource); newResource.auditClass(Audit.Action.CREATE, new ChangeSpec(newResource, null, null, newResource.getObject())); - requestScope.publishLifecycleEvent(newResource, CRUDEvent.CRUDAction.CREATE); + requestScope.publishLifecycleEvent(newResource, CREATE); - String type = newResource.getType(); - requestScope.setUUIDForObject(type, id, newResource.getObject()); + requestScope.setUUIDForObject(newResource.getResourceClass(), id, newResource.getObject()); // Initialize null ToMany collections requestScope.getDictionary().getRelationships(entityClass).stream() @@ -150,7 +173,12 @@ public static PersistentResource createObject( * @param id the id * @param scope the request scope */ - public PersistentResource(@NonNull T obj, PersistentResource parent, String id, @NonNull RequestScope scope) { + public PersistentResource( + @NonNull T obj, + PersistentResource parent, + String id, + @NonNull RequestScope scope + ) { this.obj = obj; this.uuid = Optional.ofNullable(id); this.lineage = parent != null ? new ResourceLineage(parent.lineage, parent) : new ResourceLineage(); @@ -161,7 +189,7 @@ public PersistentResource(@NonNull T obj, PersistentResource parent, String id, dictionary.initializeEntity(obj); } - /** + /** * Check whether an id matches for this persistent resource. * * @param checkId the check id @@ -183,7 +211,7 @@ public boolean matchesId(String checkId) { /** * Load an single entity from the DB. * - * @param loadClass resource type + * @param projection What to load from the DB. * @param id the id * @param requestScope the request scope * @param type of resource @@ -192,31 +220,43 @@ public boolean matchesId(String checkId) { */ @SuppressWarnings("resource") @NonNull public static PersistentResource loadRecord( - Class loadClass, String id, RequestScope requestScope) - throws InvalidObjectIdentifierException { - Preconditions.checkNotNull(loadClass); + EntityProjection projection, + String id, + RequestScope requestScope + ) throws InvalidObjectIdentifierException { + Preconditions.checkNotNull(projection); Preconditions.checkNotNull(id); Preconditions.checkNotNull(requestScope); DataStoreTransaction tx = requestScope.getTransaction(); EntityDictionary dictionary = requestScope.getDictionary(); + Class loadClass = projection.getType(); // Check the resource cache if exists - Object obj = requestScope.getObjectById(dictionary.getJsonAliasFor(loadClass), id); + Object obj = requestScope.getObjectById(loadClass, id); if (obj == null) { // try to load object Optional permissionFilter = getPermissionFilterExpression(loadClass, requestScope); Class idType = dictionary.getIdType(loadClass); - obj = tx.loadObject(loadClass, (Serializable) CoerceUtil.coerce(id, idType), - permissionFilter, requestScope); + + projection = projection + .copyOf() + .filterExpression(permissionFilter.orElse(null)) + .build(); + + obj = tx.loadObject(projection, (Serializable) CoerceUtil.coerce(id, idType), requestScope); if (obj == null) { throw new InvalidObjectIdentifierException(id, dictionary.getJsonAliasFor(loadClass)); } } PersistentResource resource = new PersistentResource<>( - loadClass.cast(obj), null, requestScope.getUUIDFor(obj), requestScope); + (T) obj, + null, + requestScope.getUUIDFor(obj), + requestScope); + // No need to have read access for a newly created object if (!requestScope.getNewResources().contains(resource)) { resource.checkFieldAwarePermissions(ReadPermission.class); @@ -245,25 +285,26 @@ private static Optional getPermissionFilterExpression(Clas /** * Load a collection from the datastore. * - * @param loadClass the load class + * @param projection the projection to load * @param requestScope the request scope * @param ids a list of object identifiers to optionally load. Can be empty. * @return a filtered collection of resources loaded from the datastore. */ public static Set loadRecords( - Class loadClass, + EntityProjection projection, List ids, - Optional filter, - Optional sorting, - Optional pagination, RequestScope requestScope) { + Class loadClass = projection.getType(); + Pagination pagination = projection.getPagination(); + Sorting sorting = projection.getSorting(); + + FilterExpression filterExpression = projection.getFilterExpression(); + EntityDictionary dictionary = requestScope.getDictionary(); - FilterExpression filterExpression; DataStoreTransaction tx = requestScope.getTransaction(); - if (shouldSkipCollection(loadClass, ReadPermission.class, requestScope)) { if (ids.isEmpty()) { return Collections.emptySet(); @@ -271,8 +312,7 @@ public static Set loadRecords( throw new InvalidObjectIdentifierException(ids.toString(), dictionary.getJsonAliasFor(loadClass)); } - - if (pagination.isPresent() && !pagination.get().isDefaultInstance() + if (pagination != null && !pagination.isDefaultInstance() && !CanPaginateVisitor.canPaginate(loadClass, dictionary, requestScope)) { throw new BadRequestException(String.format("Cannot paginate %s", dictionary.getJsonAliasFor(loadClass))); @@ -289,11 +329,9 @@ public static Set loadRecords( FilterExpression idExpression = buildIdFilterExpression(ids, loadClass, dictionary, requestScope); // Combine filters if necessary - filterExpression = filter + filterExpression = Optional.ofNullable(filterExpression) .map(fe -> (FilterExpression) new AndFilterExpression(idExpression, fe)) .orElse(idExpression); - } else { - filterExpression = filter.orElse(null); } Optional permissionFilter = getPermissionFilterExpression(loadClass, requestScope); @@ -305,9 +343,16 @@ public static Set loadRecords( } } - Set existingResources = filter(ReadPermission.class, filter, - new PersistentResourceSet(tx.loadObjects(loadClass, Optional.ofNullable(filterExpression), sorting, - pagination.map(p -> p.evaluate(loadClass)), requestScope), requestScope)); + EntityProjection modifiedProjection = projection + .copyOf() + .filterExpression(filterExpression) + .sorting(sorting) + .pagination(pagination) + .build(); + + Set existingResources = filter(ReadPermission.class, + Optional.ofNullable(modifiedProjection.getFilterExpression()), + new PersistentResourceSet(tx.loadObjects(modifiedProjection, requestScope), requestScope)); Set allResources = Sets.union(newResources, existingResources); @@ -340,7 +385,14 @@ public boolean updateAttribute(String fieldName, Object newVal) { this.markDirty(); //Hooks for customize logic for setAttribute/Relation if (dictionary.isAttribute(obj.getClass(), fieldName)) { - transaction.setAttribute(obj, fieldName, newVal, requestScope); + transaction.setAttribute(obj, Attribute.builder() + .name(fieldName) + .type(fieldClass) + .parentType(obj.getClass()) + .argument(Argument.builder() + .name("_UNUSED_") + .value(newVal).build()) + .build(), requestScope); } return true; } @@ -432,7 +484,7 @@ protected boolean updateToManyRelation(String fieldName, added = Sets.difference(updated, deleted); - checkSharePermission(added); + checkTransferablePermission(added); Collection collection = (Collection) this.getValueUnchecked(fieldName); @@ -494,11 +546,11 @@ protected boolean updateToOneRelation(String fieldName, if (newValue == null) { return false; } - checkSharePermission(resourceIdentifiers); + checkTransferablePermission(resourceIdentifiers); } else if (oldResource.getObject().equals(newValue)) { return false; } else { - checkSharePermission(resourceIdentifiers); + checkTransferablePermission(resourceIdentifiers); if (hasInverseRelation(fieldName)) { deleteInverseRelation(fieldName, oldResource.getObject()); oldResource.markDirty(); @@ -668,7 +720,7 @@ public void addRelation(String fieldName, PersistentResource newRelation) { if (!newRelation.isNewlyCreated() && relationshipAlreadyExists(fieldName, newRelation)) { return; } - checkSharePermission(Collections.singleton(newRelation)); + checkTransferablePermission(Collections.singleton(newRelation)); Object relation = this.getValueUnchecked(fieldName); if (relation instanceof Collection) { @@ -692,7 +744,7 @@ public void addRelation(String fieldName, PersistentResource newRelation) { * * @param resourceIdentifiers The persistent resources that are being added */ - protected void checkSharePermission(Set resourceIdentifiers) { + protected void checkTransferablePermission(Set resourceIdentifiers) { if (resourceIdentifiers == null) { return; } @@ -702,7 +754,7 @@ protected void checkSharePermission(Set resourceIdentifiers) for (PersistentResource persistentResource : resourceIdentifiers) { if (!newResources.contains(persistentResource) && !lineage.getRecord(persistentResource.getType()).contains(persistentResource)) { - checkPermission(SharePermission.class, persistentResource); + checkPermission(NonTransferable.class, persistentResource); } } } @@ -740,7 +792,7 @@ public void deleteResource() throws ForbiddenAccessException { transaction.delete(getObject(), requestScope); auditClass(Audit.Action.DELETE, new ChangeSpec(this, null, getObject(), null)); - requestScope.publishLifecycleEvent(this, CRUDEvent.CRUDAction.DELETE); + requestScope.publishLifecycleEvent(this, DELETE); requestScope.getDeletedResources().add(this); } @@ -760,7 +812,7 @@ public String getId() { * @param id resource id */ public void setId(String id) { - this.setValue(dictionary.getIdFieldName(getResourceClass()), id); + dictionary.setId(obj, id); } /** @@ -789,49 +841,44 @@ public Optional getUUID() { * * NOTE: Filter expressions for this type are _not_ applied at this level. * - * @param relation relation name + * @param relationship The relationship * @param id single id to lookup * @return The PersistentResource of the sought id or null if does not exist. */ - public PersistentResource getRelation(String relation, String id) { - Set resources = getRelation(relation, Collections.singletonList(id), - Optional.empty(), Optional.empty(), Optional.empty()); - if (resources.isEmpty()) { + public PersistentResource getRelation(com.yahoo.elide.request.Relationship relationship, String id) { + Set resources = getRelation(Collections.singletonList(id), relationship); + + if (resources.isEmpty()) { return null; - } - // If this is an in-memory object (i.e. UUID being created within tx), datastore may not be able to filter. - // If we get multiple results back, make sure we find the right id first. - for (PersistentResource resource : resources) { - if (resource.matchesId(id)) { - return resource; - } - } - return null; + } + // If this is an in-memory object (i.e. UUID being created within tx), datastore may not be able to filter. + // If we get multiple results back, make sure we find the right id first. + for (PersistentResource resource : resources) { + if (resource.matchesId(id)) { + return resource; + } + } + return null; } /** - * Load a single entity relation from the PersistentResource. Example: GET /book/2 + * Load a relation from the PersistentResource. * - * @param relation the relation + * @param relationship the relation * @param ids a list of object identifiers to optionally load. Can be empty. * @return PersistentResource relation */ - public Set getRelation(String relation, - List ids, - Optional filter, - Optional sorting, - Optional pagination) { + public Set getRelation(List ids, com.yahoo.elide.request.Relationship relationship) { - FilterExpression filterExpression; + FilterExpression filterExpression = Optional.ofNullable(relationship.getProjection().getFilterExpression()) + .orElse(null); - Class entityType = dictionary.getParameterizedType(getResourceClass(), relation); + assertRelationshipExists(relationship.getName()); + Class entityType = dictionary.getParameterizedType(getResourceClass(), relationship.getName()); Set newResources = new LinkedHashSet<>(); /* If this is a bulk edit request and the ID we are fetching for is newly created... */ - if (entityType == null) { - throw new InvalidAttributeException(relation, type); - } if (!ids.isEmpty()) { // Fetch our set of new resources that we know about since we can't find them in the datastore newResources = requestScope.getNewPersistentResources().stream() @@ -842,18 +889,22 @@ public Set getRelation(String relation, FilterExpression idExpression = buildIdFilterExpression(ids, entityType, dictionary, requestScope); // Combine filters if necessary - filterExpression = filter + filterExpression = Optional.ofNullable(relationship.getProjection().getFilterExpression()) .map(fe -> (FilterExpression) new AndFilterExpression(idExpression, fe)) .orElse(idExpression); - } else { - filterExpression = filter.orElse(null); } // TODO: Filter on new resources? // TODO: Update pagination to subtract the number of new resources created? - Set existingResources = filter(ReadPermission.class, filter, - getRelation(relation, Optional.ofNullable(filterExpression), sorting, pagination, true)); + Set existingResources = filter( + ReadPermission.class, + Optional.ofNullable(filterExpression), + getRelation(relationship.copyOf() + .projection(relationship.getProjection().copyOf() + .filterExpression(filterExpression) + .build()) + .build(), true)); // TODO: Sort again in memory now that two sets are glommed together? @@ -864,7 +915,7 @@ public Set getRelation(String relation, Set missedIds = Sets.difference(new HashSet<>(ids), allExpectedIds); if (!missedIds.isEmpty()) { - throw new InvalidObjectIdentifierException(missedIds.toString(), relation); + throw new InvalidObjectIdentifierException(missedIds.toString(), relationship.getName()); } return allResources; @@ -883,10 +934,9 @@ private static FilterExpression buildIdFilterExpression(List ids, RequestScope scope) { Class idType = dictionary.getIdType(entityType); String idField = dictionary.getIdFieldName(entityType); - String typeAlias = dictionary.getJsonAliasFor(entityType); List coercedIds = ids.stream() - .filter(id -> scope.getObjectById(typeAlias, id) == null) // these don't exist yet + .filter(id -> scope.getObjectById(entityType, id) == null) // these don't exist yet .map(id -> CoerceUtil.coerce(id, idType)) .collect(Collectors.toList()); @@ -904,60 +954,81 @@ private static FilterExpression buildIdFilterExpression(List ids, /** * Get collection of resources from relation field. * - * @param relationName field + * @param relationship relationship * @return collection relation */ - public Set getRelationCheckedFiltered(String relationName, - Optional filterExpression, - Optional sorting, - Optional pagination) { - - return filter(ReadPermission.class, filterExpression, - getRelation(relationName, filterExpression, sorting, pagination, true)); + public Set getRelationCheckedFiltered(com.yahoo.elide.request.Relationship relationship) { + return filter(ReadPermission.class, + Optional.ofNullable(relationship.getProjection().getFilterExpression()), + getRelation(relationship, true)); } private Set getRelationUncheckedUnfiltered(String relationName) { - return getRelation(relationName, Optional.empty(), Optional.empty(), Optional.empty(), false); + assertRelationshipExists(relationName); + return getRelation(com.yahoo.elide.request.Relationship.builder() + .name(relationName) + .alias(relationName) + .projection(EntityProjection.builder() + .type(dictionary.getParameterizedType(getResourceClass(), relationName)) + .build()) + .build(), false); } private Set getRelationCheckedUnfiltered(String relationName) { - return getRelation(relationName, Optional.empty(), Optional.empty(), Optional.empty(), true); + assertRelationshipExists(relationName); + return getRelation(com.yahoo.elide.request.Relationship.builder() + .name(relationName) + .alias(relationName) + .projection(EntityProjection.builder() + .type(dictionary.getParameterizedType(getResourceClass(), relationName)) + .build()) + .build(), true); + } + + private void assertRelationshipExists(String relationName) { + if (relationName == null || dictionary.getParameterizedType(obj, relationName) == null) { + throw new InvalidAttributeException(relationName, this.getType()); + } } - private Set getRelation(String relationName, - Optional filterExpression, - Optional sorting, - Optional pagination, - boolean checked) { + private Set getRelation(com.yahoo.elide.request.Relationship relationship, + boolean checked) { + if (checked) { + //All getRelation calls funnel to here. We only publish events for actions triggered directly + //by the API client. + requestScope.publishLifecycleEvent(this, READ); + requestScope.publishLifecycleEvent(this, relationship.getName(), READ, Optional.empty()); + } - if (checked && !checkRelation(relationName)) { + if (checked && !checkRelation(relationship)) { return Collections.emptySet(); } - final Class relationClass = dictionary.getParameterizedType(obj, relationName); + final Class relationClass = dictionary.getParameterizedType(obj, relationship.getName()); + + Optional pagination = Optional.ofNullable(relationship.getProjection().getPagination()); + if (pagination.isPresent() && !pagination.get().isDefaultInstance() && !CanPaginateVisitor.canPaginate(relationClass, dictionary, requestScope)) { throw new BadRequestException(String.format("Cannot paginate %s", dictionary.getJsonAliasFor(relationClass))); } - return getRelationUnchecked(relationName, filterExpression, sorting, pagination); + return getRelationUnchecked(relationship); } /** * Check the permissions of the relationship, and return true or false. - * @param relationName The relationship to the entity + * @param relationship The relationship to the entity * @return True if the relationship to the entity has valid permissions for the user */ - protected boolean checkRelation(String relationName) { - List relations = dictionary.getRelationships(obj); + protected boolean checkRelation(com.yahoo.elide.request.Relationship relationship) { + String relationName = relationship.getName(); String realName = dictionary.getNameFromAlias(obj, relationName); relationName = (realName == null) ? relationName : realName; - if (relationName == null || relations == null || !relations.contains(relationName)) { - throw new InvalidAttributeException(relationName, type); - } + assertRelationshipExists(relationName); checkFieldAwareDeferPermissions(ReadPermission.class, relationName, null, null); @@ -970,70 +1041,70 @@ protected boolean checkRelation(String relationName) { /** * Get collection of resources from relation field. * - * @param relationName field - * @param filterExpression An optional filter expression + * @param relationship the relationship to fetch * @return collection relation */ - protected Set getRelationChecked(String relationName, - Optional filterExpression, - Optional sorting, - Optional pagination) { - if (!checkRelation(relationName)) { + protected Set getRelationChecked(com.yahoo.elide.request.Relationship relationship) { + if (!checkRelation(relationship)) { return Collections.emptySet(); } - return getRelationUnchecked(relationName, filterExpression, sorting, pagination); + return getRelationUnchecked(relationship); } /** - * Retrieve an uncheck set of relations. - * - * @param relationName field - * @param filterExpression An optional filter expression - * @param sorting the sorting clause - * @param pagination the pagination params - * @return the resources in the relationship - */ - private Set getRelationUnchecked(String relationName, - Optional filterExpression, - Optional sorting, - Optional pagination) { + * Retrieve an unchecked set of relations. + */ + private Set getRelationUnchecked(com.yahoo.elide.request.Relationship relationship) { + String relationName = relationship.getName(); + FilterExpression filterExpression = relationship.getProjection().getFilterExpression(); + Pagination pagination = relationship.getProjection().getPagination(); + Sorting sorting = relationship.getProjection().getSorting(); + RelationshipType type = getRelationshipType(relationName); final Class relationClass = dictionary.getParameterizedType(obj, relationName); if (relationClass == null) { throw new InvalidAttributeException(relationName, this.getType()); } - Optional computedPagination = pagination.map(p -> p.evaluate(relationClass)); - //Invoke filterExpressionCheck and then merge with filterExpression. Optional permissionFilter = getPermissionFilterExpression(relationClass, requestScope); - Optional computedFilters = filterExpression; + Optional computedFilters = Optional.ofNullable(filterExpression); - if (permissionFilter.isPresent() && filterExpression.isPresent()) { + if (permissionFilter.isPresent() && filterExpression != null) { FilterExpression mergedExpression = - new AndFilterExpression(filterExpression.get(), permissionFilter.get()); + new AndFilterExpression(filterExpression, permissionFilter.get()); computedFilters = Optional.of(mergedExpression); } else if (permissionFilter.isPresent()) { computedFilters = permissionFilter; } - Object val = transaction.getRelation(transaction, obj, relationName, - computedFilters, sorting, computedPagination, requestScope); + com.yahoo.elide.request.Relationship modifiedRelationship = relationship.copyOf() + .projection(relationship.getProjection().copyOf() + .filterExpression(computedFilters.orElse(null)) + .sorting(sorting) + .pagination(pagination) + .build() + ).build(); + + Object val = transaction.getRelation(transaction, obj, modifiedRelationship, requestScope); if (val == null) { return Collections.emptySet(); } Set resources = Sets.newLinkedHashSet(); + if (val instanceof Iterable) { Iterable filteredVal = (Iterable) val; resources = new PersistentResourceSet(this, filteredVal, requestScope); } else if (type.isToOne()) { - resources = new SingleElementSet<>( - new PersistentResource<>(val, this, requestScope.getUUIDFor(val), requestScope)); + resources = new SingleElementSet( + new PersistentResource(val, this, + requestScope.getUUIDFor(val), requestScope)); } else { - resources.add(new PersistentResource<>(val, this, requestScope.getUUIDFor(val), requestScope)); + resources.add(new PersistentResource(val, this, + requestScope.getUUIDFor(val), requestScope)); } return resources; @@ -1072,10 +1143,20 @@ public RelationshipType getRelationshipType(String relation) { * @param attr Attribute name * @return Object value for attribute */ + @Deprecated public Object getAttribute(String attr) { return this.getValueChecked(attr); } + /** + * Get the value for a particular attribute (i.e. non-relational field) + * @param attr the Attribute + * @return Object value for attribute + */ + public Object getAttribute(Attribute attr) { + return this.getValueChecked(attr); + } + /** * Wrapped Entity bean. * @@ -1151,6 +1232,14 @@ public boolean equals(Object obj) { return false; } + /** + * Returns whether or not this resource was created in this transaction. + * @return True if this resource is newly created. + */ + public boolean isNewlyCreated() { + return requestScope.getNewResources().contains(this); + } + /** * Gets lineage. * @return the lineage @@ -1189,8 +1278,8 @@ public Resource toResource() { * Fetch a resource with support for lambda function for getting relationships and attributes. * @return The Resource */ - public Resource toResourceWithSortingAndPagination() { - return toResource(this::getRelationshipsWithSortingAndPagination, this::getAttributes); + public Resource toResource(EntityProjection projection) { + return toResource(() -> { return getRelationships(projection); }, this::getAttributes); } /** @@ -1199,7 +1288,7 @@ public Resource toResourceWithSortingAndPagination() { * @param attributeSupplier The attribute supplier * @return The Resource */ - public Resource toResource(final Supplier> relationshipSupplier, + private Resource toResource(final Supplier> relationshipSupplier, final Supplier> attributeSupplier) { final Resource resource = new Resource(type, (obj == null) ? uuid.orElseThrow( @@ -1217,8 +1306,17 @@ public Resource toResource(final Supplier> relationshi */ protected Map getRelationships() { return getRelationshipsWithRelationshipFunction((relationName) -> { - Optional filterExpression = requestScope.getExpressionForRelation(this, relationName); - return getRelationCheckedFiltered(relationName, filterExpression, Optional.empty(), Optional.empty()); + Optional filterExpression = requestScope.getExpressionForRelation(getResourceClass(), + relationName); + + return getRelationCheckedFiltered(com.yahoo.elide.request.Relationship.builder() + .alias(relationName) + .name(relationName) + .projection(EntityProjection.builder() + .type(dictionary.getParameterizedType(getResourceClass(), relationName)) + .filterExpression(filterExpression.orElse(null)) + .build()) + .build()); }); } @@ -1227,14 +1325,11 @@ protected Map getRelationships() { * * @return Relationship mapping */ - protected Map getRelationshipsWithSortingAndPagination() { - return getRelationshipsWithRelationshipFunction((relationName) -> { - Optional filterExpression = requestScope.getExpressionForRelation(this, relationName); - Optional sorting = Optional.ofNullable(requestScope.getSorting()); - Optional pagination = Optional.ofNullable(requestScope.getPagination()); - return getRelationCheckedFiltered(relationName, - filterExpression, sorting, pagination); - }); + private Map getRelationships(EntityProjection projection) { + return getRelationshipsWithRelationshipFunction( + (relationName) -> getRelationCheckedFiltered(projection.getRelationship(relationName) + .orElseThrow(IllegalStateException::new) + )); } /** @@ -1325,13 +1420,26 @@ protected void nullValue(String fieldName, PersistentResource oldValue) { * @param fieldName the field name * @return value value */ + @Deprecated protected Object getValueChecked(String fieldName) { - requestScope.publishLifecycleEvent(this, CRUDEvent.CRUDAction.READ); - requestScope.publishLifecycleEvent(this, fieldName, CRUDEvent.CRUDAction.READ, Optional.empty()); + requestScope.publishLifecycleEvent(this, READ); + requestScope.publishLifecycleEvent(this, fieldName, READ, Optional.empty()); checkFieldAwareDeferPermissions(ReadPermission.class, fieldName, (Object) null, (Object) null); return getValue(getObject(), fieldName, requestScope); } + /** + * Gets a value from an entity and checks read permissions. + * @param attribute the attribute to fetch. + * @return value value + */ + protected Object getValueChecked(Attribute attribute) { + requestScope.publishLifecycleEvent(this, READ); + requestScope.publishLifecycleEvent(this, attribute.getName(), READ, Optional.empty()); + checkFieldAwareDeferPermissions(ReadPermission.class, attribute.getName(), (Object) null, (Object) null); + return transaction.getAttribute(getObject(), attribute, requestScope); + } + /** * Retrieve an object without checking read permissions (i.e. value is used internally and not sent to others) * @@ -1339,8 +1447,6 @@ protected Object getValueChecked(String fieldName) { * @return Value */ protected Object getValueUnchecked(String fieldName) { - requestScope.publishLifecycleEvent(this, CRUDEvent.CRUDAction.READ); - requestScope.publishLifecycleEvent(this, fieldName, CRUDEvent.CRUDAction.READ, Optional.empty()); return getValue(getObject(), fieldName, requestScope); } @@ -1439,7 +1545,9 @@ protected void delFromCollection( */ protected void setValue(String fieldName, Object value) { final Object original = getValueUnchecked(fieldName); + dictionary.setValue(obj, fieldName, value); + triggerUpdate(fieldName, original, value); } @@ -1451,8 +1559,7 @@ protected void setValue(String fieldName, Object value) { * @return the value */ public static Object getValue(Object target, String fieldName, RequestScope requestScope) { - EntityDictionary dictionary = requestScope.getDictionary(); - return dictionary.getValue(target, fieldName, requestScope); + return requestScope.getDictionary().getValue(target, fieldName, requestScope); } /** @@ -1468,7 +1575,8 @@ protected void deleteInverseRelation(String relationName, Object inverseEntity) Class inverseType = dictionary.getType(inverseEntity.getClass(), inverseField); String uuid = requestScope.getUUIDFor(inverseEntity); - PersistentResource inverseResource = new PersistentResource(inverseEntity, this, uuid, requestScope); + PersistentResource inverseResource = new PersistentResource(inverseEntity, + this, uuid, requestScope); Object inverseRelation = inverseResource.getValueUnchecked(inverseField); if (inverseRelation == null) { @@ -1616,9 +1724,9 @@ protected Set filterFields(Collection fields) { */ private void triggerUpdate(String fieldName, Object original, Object value) { ChangeSpec changeSpec = new ChangeSpec(this, fieldName, original, value); - CRUDEvent.CRUDAction action = isNewlyCreated() - ? CRUDEvent.CRUDAction.CREATE - : CRUDEvent.CRUDAction.UPDATE; + LifeCycleHookBinding.Operation action = isNewlyCreated() + ? CREATE + : UPDATE; requestScope.publishLifecycleEvent(this, fieldName, action, Optional.of(changeSpec)); requestScope.publishLifecycleEvent(this, action); @@ -1686,7 +1794,7 @@ protected void auditField(final ChangeSpec changeSpec) { } for (Audit annotation : annotations) { if (annotation.action().length == 1 && annotation.action()[0] == Audit.Action.UPDATE) { - LogMessage message = new LogMessage(annotation, this, Optional.of(changeSpec)); + LogMessage message = new LogMessageImpl(annotation, this, Optional.of(changeSpec)); getRequestScope().getAuditLogger().log(message); } else { throw new InvalidSyntaxException("Only Audit.Action.UPDATE is allowed on fields."); @@ -1709,7 +1817,7 @@ protected void auditClass(Audit.Action action, ChangeSpec changeSpec) { for (Audit annotation : annotations) { for (Audit.Action auditAction : annotation.action()) { if (auditAction == action) { // compare object reference - LogMessage message = new LogMessage(annotation, this, Optional.ofNullable(changeSpec)); + LogMessage message = new LogMessageImpl(annotation, this, Optional.ofNullable(changeSpec)); getRequestScope().getAuditLogger().log(message); } } diff --git a/elide-core/src/main/java/com/yahoo/elide/core/RequestScope.java b/elide-core/src/main/java/com/yahoo/elide/core/RequestScope.java index b2917930e0..8811e5f31a 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/RequestScope.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/RequestScope.java @@ -6,18 +6,7 @@ package com.yahoo.elide.core; import com.yahoo.elide.ElideSettings; -import com.yahoo.elide.annotation.OnCreatePostCommit; -import com.yahoo.elide.annotation.OnCreatePreCommit; -import com.yahoo.elide.annotation.OnCreatePreSecurity; -import com.yahoo.elide.annotation.OnDeletePostCommit; -import com.yahoo.elide.annotation.OnDeletePreCommit; -import com.yahoo.elide.annotation.OnDeletePreSecurity; -import com.yahoo.elide.annotation.OnReadPostCommit; -import com.yahoo.elide.annotation.OnReadPreCommit; -import com.yahoo.elide.annotation.OnReadPreSecurity; -import com.yahoo.elide.annotation.OnUpdatePostCommit; -import com.yahoo.elide.annotation.OnUpdatePreCommit; -import com.yahoo.elide.annotation.OnUpdatePreSecurity; +import com.yahoo.elide.annotation.LifeCycleHookBinding; import com.yahoo.elide.audit.AuditLogger; import com.yahoo.elide.core.exceptions.BadRequestException; import com.yahoo.elide.core.exceptions.InvalidAttributeException; @@ -25,10 +14,9 @@ import com.yahoo.elide.core.filter.dialect.MultipleFilterDialect; import com.yahoo.elide.core.filter.dialect.ParseException; import com.yahoo.elide.core.filter.expression.FilterExpression; -import com.yahoo.elide.core.pagination.Pagination; -import com.yahoo.elide.core.sort.Sorting; import com.yahoo.elide.jsonapi.JsonApiMapper; import com.yahoo.elide.jsonapi.models.JsonApiDocument; +import com.yahoo.elide.request.EntityProjection; import com.yahoo.elide.security.ChangeSpec; import com.yahoo.elide.security.PermissionExecutor; import com.yahoo.elide.security.User; @@ -38,6 +26,7 @@ import io.reactivex.subjects.PublishSubject; import io.reactivex.subjects.ReplaySubject; import lombok.Getter; +import lombok.Setter; import java.util.Collections; import java.util.HashMap; @@ -47,6 +36,7 @@ import java.util.Map; import java.util.Optional; import java.util.Set; +import java.util.UUID; import java.util.function.Function; import javax.ws.rs.core.MultivaluedHashMap; @@ -59,13 +49,11 @@ public class RequestScope implements com.yahoo.elide.security.RequestScope { @Getter private final JsonApiDocument jsonApiDocument; @Getter private final DataStoreTransaction transaction; @Getter private final User user; - @Getter private final EntityDictionary dictionary; + @Getter protected final EntityDictionary dictionary; @Getter private final JsonApiMapper mapper; @Getter private final AuditLogger auditLogger; @Getter private final Optional> queryParams; @Getter private final Map> sparseFields; - @Getter private final Pagination pagination; - @Getter private final Sorting sorting; @Getter private final PermissionExecutor permissionExecutor; @Getter private final ObjectEntityCache objectEntityCache; @Getter private final Set newPersistentResources; @@ -73,10 +61,13 @@ public class RequestScope implements com.yahoo.elide.security.RequestScope { @Getter private final LinkedHashSet deletedResources; @Getter private final String path; @Getter private final ElideSettings elideSettings; - @Getter private final boolean useFilterExpressions; @Getter private final int updateStatusCode; - @Getter private final MultipleFilterDialect filterDialect; + @Getter private final String apiVersion; + + //TODO - this ought to be read only and set in the constructor. + @Getter @Setter private EntityProjection entityProjection; + private final String requestId; private final Map expressionsByType; private PublishSubject lifecycleEvents; @@ -90,6 +81,7 @@ public class RequestScope implements com.yahoo.elide.security.RequestScope { * Create a new RequestScope with specified update status code. * * @param path the URL path + * @param apiVersion the API version. * @param jsonApiDocument the document for this request * @param transaction the transaction for this request * @param user the user making this request @@ -97,11 +89,13 @@ public class RequestScope implements com.yahoo.elide.security.RequestScope { * @param elideSettings Elide settings object */ public RequestScope(String path, + String apiVersion, JsonApiDocument jsonApiDocument, DataStoreTransaction transaction, User user, MultivaluedMap queryParams, ElideSettings elideSettings) { + this.apiVersion = apiVersion; this.lifecycleEvents = PublishSubject.create(); this.distinctLifecycleEvents = lifecycleEvents.distinct(); this.queuedLifecycleEvents = ReplaySubject.create(); @@ -117,7 +111,6 @@ public RequestScope(String path, this.filterDialect = new MultipleFilterDialect(elideSettings.getJoinFilterDialects(), elideSettings.getSubqueryFilterDialects()); this.elideSettings = elideSettings; - this.useFilterExpressions = elideSettings.isUseFilterExpressions(); this.updateStatusCode = elideSettings.getUpdateStatusCode(); this.globalFilterExpression = null; @@ -126,6 +119,7 @@ public RequestScope(String path, this.newPersistentResources = new LinkedHashSet<>(); this.dirtyResources = new LinkedHashSet<>(); this.deletedResources = new LinkedHashSet<>(); + this.requestId = UUID.randomUUID().toString(); Function permissionExecutorGenerator = elideSettings.getPermissionExecutor(); this.permissionExecutor = (permissionExecutorGenerator == null) @@ -148,14 +142,14 @@ public RequestScope(String path, /* First check to see if there is a global, cross-type filter */ try { - globalFilterExpression = filterDialect.parseGlobalExpression(path, filterParams); + globalFilterExpression = filterDialect.parseGlobalExpression(path, filterParams, apiVersion); } catch (ParseException e) { errorMessage = e.getMessage(); } /* Next check to see if there is are type specific filters */ try { - expressionsByType.putAll(filterDialect.parseTypedExpression(path, filterParams)); + expressionsByType.putAll(filterDialect.parseTypedExpression(path, filterParams, apiVersion)); } catch (ParseException e) { /* If neither dialect parsed, report the last error found */ @@ -175,12 +169,8 @@ public RequestScope(String path, } this.sparseFields = parseSparseFields(queryParams); - this.sorting = Sorting.parseQueryParams(queryParams); - this.pagination = Pagination.parseQueryParams(queryParams, this.getElideSettings()); } else { this.sparseFields = Collections.emptyMap(); - this.sorting = Sorting.getDefaultEmptyInstance(); - this.pagination = Pagination.getDefaultPagination(this.getElideSettings()); } } @@ -188,11 +178,14 @@ public RequestScope(String path, * Special copy constructor for use by PatchRequestScope. * * @param path the URL path + * @param apiVersion the API version * @param jsonApiDocument the json api document * @param outerRequestScope the outer request scope */ - protected RequestScope(String path, JsonApiDocument jsonApiDocument, RequestScope outerRequestScope) { + protected RequestScope(String path, String apiVersion, + JsonApiDocument jsonApiDocument, RequestScope outerRequestScope) { this.jsonApiDocument = jsonApiDocument; + this.apiVersion = apiVersion; this.path = path; this.transaction = outerRequestScope.transaction; this.user = outerRequestScope.user; @@ -201,8 +194,6 @@ protected RequestScope(String path, JsonApiDocument jsonApiDocument, RequestScop this.auditLogger = outerRequestScope.auditLogger; this.queryParams = Optional.empty(); this.sparseFields = Collections.emptyMap(); - this.sorting = Sorting.getDefaultEmptyInstance(); - this.pagination = Pagination.getDefaultPagination(outerRequestScope.getElideSettings()); this.objectEntityCache = outerRequestScope.objectEntityCache; this.newPersistentResources = outerRequestScope.newPersistentResources; this.permissionExecutor = outerRequestScope.getPermissionExecutor(); @@ -211,14 +202,13 @@ protected RequestScope(String path, JsonApiDocument jsonApiDocument, RequestScop this.filterDialect = outerRequestScope.filterDialect; this.expressionsByType = outerRequestScope.expressionsByType; this.elideSettings = outerRequestScope.elideSettings; - this.useFilterExpressions = outerRequestScope.useFilterExpressions; - this.updateStatusCode = outerRequestScope.updateStatusCode; this.lifecycleEvents = outerRequestScope.lifecycleEvents; this.distinctLifecycleEvents = outerRequestScope.distinctLifecycleEvents; + this.updateStatusCode = outerRequestScope.updateStatusCode; this.queuedLifecycleEvents = outerRequestScope.queuedLifecycleEvents; + this.requestId = outerRequestScope.requestId; } - @Override public Set getNewResources() { return (Set) newPersistentResources; } @@ -232,7 +222,7 @@ public boolean isNewResource(Object entity) { * @param queryParams The request query parameters * @return Parsed sparseFields map */ - private static Map> parseSparseFields(MultivaluedMap queryParams) { + public static Map> parseSparseFields(MultivaluedMap queryParams) { Map> result = new HashMap<>(); for (Map.Entry> kv : queryParams.entrySet()) { @@ -263,6 +253,15 @@ public Optional getFilterExpressionByType(String type) { return Optional.ofNullable(expressionsByType.get(type)); } + /** + * Get filter expression for a specific collection type. + * @param entityClass The class to lookup + * @return The filter expression for the given type + */ + public Optional getFilterExpressionByType(Class entityClass) { + return Optional.ofNullable(expressionsByType.get(dictionary.getJsonAliasFor(entityClass))); + } + /** * Get the global/cross-type filter expression. * @param loadClass Entity class @@ -280,15 +279,15 @@ public Optional getLoadFilterExpression(Class loadClass) { } /** - * Get the filter expression for a particular relationship - * @param parent The object which has the relationship + * Get the filter expression for a particular relationship. + * @param parentType The parent type which has the relationship * @param relationName The relationship name * @return A type specific filter expression for the given relationship */ - public Optional getExpressionForRelation(PersistentResource parent, String relationName) { - final Class entityClass = dictionary.getParameterizedType(parent.getObject(), relationName); + public Optional getExpressionForRelation(Class parentType, String relationName) { + final Class entityClass = dictionary.getParameterizedType(parentType, relationName); if (entityClass == null) { - throw new InvalidAttributeException(relationName, parent.getType()); + throw new InvalidAttributeException(relationName, dictionary.getJsonAliasFor(parentType)); } if (dictionary.isMappedInterface(entityClass) && interfaceHasFilterExpression(entityClass)) { throw new InvalidOperationException( @@ -305,7 +304,8 @@ public Optional getExpressionForRelation(PersistentResource pa */ private boolean interfaceHasFilterExpression(Class entityInterface) { for (String filterType : expressionsByType.keySet()) { - Class polyMorphicClass = dictionary.getEntityClass(filterType); + String version = EntityDictionary.getModelVersion(entityInterface); + Class polyMorphicClass = dictionary.getEntityClass(filterType, version); if (entityInterface.isAssignableFrom(polyMorphicClass)) { return true; } @@ -332,62 +332,80 @@ private static MultivaluedMap getFilterParams(MultivaluedMap resource, CRUDEvent.CRUDAction crudAction) { + protected void publishLifecycleEvent(PersistentResource resource, LifeCycleHookBinding.Operation crudAction) { lifecycleEvents.onNext( new CRUDEvent(crudAction, resource, PersistentResource.CLASS_NO_FIELD, Optional.empty()) ); @@ -413,7 +431,7 @@ protected void publishLifecycleEvent(PersistentResource resource, CRUDEvent.C */ protected void publishLifecycleEvent(PersistentResource resource, String fieldName, - CRUDEvent.CRUDAction crudAction, + LifeCycleHookBinding.Operation crudAction, Optional changeSpec) { lifecycleEvents.onNext( new CRUDEvent(crudAction, resource, fieldName, changeSpec) @@ -435,25 +453,29 @@ public String getUUIDFor(Object o) { return objectEntityCache.getUUID(o); } - public Object getObjectById(String type, String id) { - Object result = objectEntityCache.get(type, id); + public Object getObjectById(Class type, String id) { + Class boundType = dictionary.lookupBoundClass(type); + + Object result = objectEntityCache.get(boundType.getName(), id); // Check inheritance too - Iterator it = dictionary.getSubclassingEntityNames(type).iterator(); + Iterator> it = dictionary.getSubclassingEntities(boundType).iterator(); while (result == null && it.hasNext()) { - String newType = getInheritanceKey(it.next(), type); + String newType = getInheritanceKey(it.next().getName(), boundType.getName()); result = objectEntityCache.get(newType, id); } return result; } - public void setUUIDForObject(String type, String id, Object object) { - objectEntityCache.put(type, id, object); + public void setUUIDForObject(Class type, String id, Object object) { + Class boundType = dictionary.lookupBoundClass(type); + + objectEntityCache.put(boundType.getName(), id, object); // Insert for all inherited entities as well - dictionary.getSuperClassEntityNames(type).stream() - .map(i -> getInheritanceKey(type, i)) + dictionary.getSuperClassEntities(type).stream() + .map(i -> getInheritanceKey(boundType.getName(), i.getName())) .forEach((newType) -> objectEntityCache.put(newType, id, object)); } @@ -465,14 +487,20 @@ private void registerPreSecurityObservers() { this.distinctLifecycleEvents .filter(CRUDEvent::isReadEvent) - .subscribeWith(new LifecycleHookInvoker(dictionary, OnReadPreSecurity.class, true)); + .subscribeWith(new LifecycleHookInvoker(dictionary, + LifeCycleHookBinding.Operation.READ, + LifeCycleHookBinding.TransactionPhase.PRESECURITY, true)); this.distinctLifecycleEvents .filter(CRUDEvent::isUpdateEvent) - .subscribeWith(new LifecycleHookInvoker(dictionary, OnUpdatePreSecurity.class, true)); + .subscribeWith(new LifecycleHookInvoker(dictionary, + LifeCycleHookBinding.Operation.UPDATE, + LifeCycleHookBinding.TransactionPhase.PRESECURITY, true)); this.distinctLifecycleEvents .filter(CRUDEvent::isDeleteEvent) - .subscribeWith(new LifecycleHookInvoker(dictionary, OnDeletePreSecurity.class, true)); + .subscribeWith(new LifecycleHookInvoker(dictionary, + LifeCycleHookBinding.Operation.DELETE, + LifeCycleHookBinding.TransactionPhase.PRESECURITY, true)); } } diff --git a/elide-core/src/main/java/com/yahoo/elide/core/TimedFunction.java b/elide-core/src/main/java/com/yahoo/elide/core/TimedFunction.java new file mode 100644 index 0000000000..df5b187b31 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/TimedFunction.java @@ -0,0 +1,40 @@ +/* + * Copyright 2019, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.core; + +import lombok.Data; +import lombok.extern.slf4j.Slf4j; + +import java.util.function.Supplier; + +/** + * Wraps a function and logs how long it took to run (in millis). + * @param The function return type. + */ +@Slf4j +@Data +public class TimedFunction implements Supplier { + + public TimedFunction(Supplier toRun, String logMessage) { + this.toRun = toRun; + this.logMessage = logMessage; + } + + private Supplier toRun; + private String logMessage; + + @Override + public R get() { + long start = System.currentTimeMillis(); + R ret = toRun.get(); + long end = System.currentTimeMillis(); + + log.debug(logMessage + "\tTime spent: {}", end - start); + + return ret; + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/TransactionRegistry.java b/elide-core/src/main/java/com/yahoo/elide/core/TransactionRegistry.java new file mode 100644 index 0000000000..954dbeee71 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/TransactionRegistry.java @@ -0,0 +1,48 @@ +/* + * Copyright 2020, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.core; + +import lombok.Data; + +import java.util.Set; +/** + * Transaction Registry interface to surface transaction details to other parts of Elide. + */ + +public interface TransactionRegistry { + /** + * @see RequestScope + * @see DataStoreTransaction + */ + @Data + public static class TransactionEntry { + public RequestScope request; + public DataStoreTransaction transaction; + } + + /** + * @return all running transactions + */ + Set getRunningTransactions(); + + /** + * @param requestId + * @return matching running transaction + */ + Set getRunningTransaction(String requestId); + + /** + * Adds running transaction + * @param transactionEntry TransactionEntry transactionEntry + */ + void addRunningTransaction(TransactionEntry transactionEntry); + + /** + * Removes running transaction when we call cancel on it + * @param transactionEntry TransactionEntry transactionEntry + */ + void removeRunningTransaction(TransactionEntry transactionEntry); +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/VerifyFieldAccessFilterExpressionVisitor.java b/elide-core/src/main/java/com/yahoo/elide/core/VerifyFieldAccessFilterExpressionVisitor.java index 2f23fed858..f10628b4fb 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/VerifyFieldAccessFilterExpressionVisitor.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/VerifyFieldAccessFilterExpressionVisitor.java @@ -14,12 +14,13 @@ import com.yahoo.elide.core.filter.expression.FilterExpressionVisitor; import com.yahoo.elide.core.filter.expression.NotFilterExpression; import com.yahoo.elide.core.filter.expression.OrFilterExpression; +import com.yahoo.elide.request.EntityProjection; +import com.yahoo.elide.request.Relationship; import com.yahoo.elide.security.PermissionExecutor; import com.yahoo.elide.security.permissions.ExpressionResult; import java.util.Collections; import java.util.Objects; -import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -85,6 +86,9 @@ public Boolean visitPredicate(FilterPredicate filterPredicate) { private Stream getValueChecked(PersistentResource resource, String fieldName, RequestScope requestScope) { + + EntityDictionary dictionary = resource.getDictionary(); + // checkFieldAwareReadPermissions requestScope.getPermissionExecutor().checkSpecificFieldPermissions(resource, null, ReadPermission.class, fieldName); @@ -93,8 +97,16 @@ private Stream getValueChecked(PersistentResource resourc .getRelationshipType(entity.getClass(), fieldName) == RelationshipType.NONE) { return Stream.empty(); } + + Relationship relationship = Relationship.builder() + .name(fieldName) + .alias(fieldName) + .projection(EntityProjection.builder() + .type(dictionary.getParameterizedType(resource.getResourceClass(), fieldName)) + .build()) + .build(); // use no filter to allow the read directly from loaded resource - return resource.getRelationChecked(fieldName, Optional.empty(), Optional.empty(), Optional.empty()).stream(); + return resource.getRelationChecked(relationship).stream(); } /** diff --git a/elide-core/src/main/java/com/yahoo/elide/core/datastore/inmemory/HashMapDataStore.java b/elide-core/src/main/java/com/yahoo/elide/core/datastore/inmemory/HashMapDataStore.java index b20183c4d6..8d2f72fd82 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/datastore/inmemory/HashMapDataStore.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/datastore/inmemory/HashMapDataStore.java @@ -27,8 +27,8 @@ * Simple in-memory only database. */ public class HashMapDataStore implements DataStore, DataStoreTestHarness { - private final Map, Map> dataStore = Collections.synchronizedMap(new HashMap<>()); - @Getter private EntityDictionary dictionary; + protected final Map, Map> dataStore = Collections.synchronizedMap(new HashMap<>()); + @Getter protected EntityDictionary dictionary; @Getter private final Set beanPackages; @Getter private final ConcurrentHashMap, AtomicLong> typeIds = new ConcurrentHashMap<>(); diff --git a/elide-core/src/main/java/com/yahoo/elide/core/datastore/inmemory/HashMapStoreTransaction.java b/elide-core/src/main/java/com/yahoo/elide/core/datastore/inmemory/HashMapStoreTransaction.java index 943ca2d8bd..d4ce2ef85d 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/datastore/inmemory/HashMapStoreTransaction.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/datastore/inmemory/HashMapStoreTransaction.java @@ -10,15 +10,16 @@ import com.yahoo.elide.core.RequestScope; import com.yahoo.elide.core.exceptions.TransactionException; import com.yahoo.elide.core.filter.expression.FilterExpression; -import com.yahoo.elide.core.pagination.Pagination; -import com.yahoo.elide.core.sort.Sorting; + +import com.yahoo.elide.request.EntityProjection; +import com.yahoo.elide.request.Relationship; +import com.yahoo.elide.request.Sorting; import java.io.IOException; import java.io.Serializable; import java.util.ArrayList; import java.util.List; import java.util.Map; -import java.util.Optional; import java.util.concurrent.atomic.AtomicLong; import javax.persistence.GeneratedValue; @@ -131,31 +132,25 @@ public void setId(Object value, String id) { @Override public Object getRelation(DataStoreTransaction relationTx, Object entity, - String relationName, - Optional filterExpression, - Optional sorting, - Optional pagination, + Relationship relationship, RequestScope scope) { - return dictionary.getValue(entity, relationName, scope); + return dictionary.getValue(entity, relationship.getName(), scope); } @Override - public Iterable loadObjects(Class entityClass, Optional filterExpression, - Optional sorting, Optional pagination, + public Iterable loadObjects(EntityProjection projection, RequestScope scope) { synchronized (dataStore) { - Map data = dataStore.get(entityClass); + Map data = dataStore.get(projection.getType()); return data.values(); } } @Override - public Object loadObject(Class entityClass, Serializable id, - Optional filterExpression, - RequestScope scope) { + public Object loadObject(EntityProjection projection, Serializable id, RequestScope scope) { synchronized (dataStore) { - Map data = dataStore.get(entityClass); + Map data = dataStore.get(projection.getType()); if (data == null) { return null; } @@ -179,7 +174,7 @@ public boolean supportsSorting(Class entityClass, Sorting sorting) { } @Override - public boolean supportsPagination(Class entityClass) { + public boolean supportsPagination(Class entityClass, FilterExpression expression) { return false; } diff --git a/elide-core/src/main/java/com/yahoo/elide/core/datastore/inmemory/InMemoryStoreTransaction.java b/elide-core/src/main/java/com/yahoo/elide/core/datastore/inmemory/InMemoryStoreTransaction.java index 0c4475a3c2..aea6969483 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/datastore/inmemory/InMemoryStoreTransaction.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/datastore/inmemory/InMemoryStoreTransaction.java @@ -7,7 +7,6 @@ package com.yahoo.elide.core.datastore.inmemory; import com.yahoo.elide.core.DataStoreTransaction; -import com.yahoo.elide.core.EntityDictionary; import com.yahoo.elide.core.Path; import com.yahoo.elide.core.PersistentResource; import com.yahoo.elide.core.RequestScope; @@ -15,9 +14,12 @@ import com.yahoo.elide.core.filter.expression.FilterPredicatePushdownExtractor; import com.yahoo.elide.core.filter.expression.InMemoryExecutionVerifier; import com.yahoo.elide.core.filter.expression.InMemoryFilterExecutor; -import com.yahoo.elide.core.pagination.Pagination; -import com.yahoo.elide.core.sort.Sorting; -import com.yahoo.elide.security.User; +import com.yahoo.elide.request.Attribute; +import com.yahoo.elide.request.EntityProjection; +import com.yahoo.elide.request.Pagination; +import com.yahoo.elide.request.Relationship; +import com.yahoo.elide.request.Sorting; + import org.apache.commons.lang3.tuple.Pair; import java.io.IOException; @@ -33,12 +35,15 @@ import java.util.stream.Collectors; import java.util.stream.StreamSupport; + /** * Data Store Transaction that wraps another transaction and provides in-memory filtering, soring, and pagination * when the underlying transaction cannot perform the equivalent function. */ public class InMemoryStoreTransaction implements DataStoreTransaction { + private final DataStoreTransaction tx; + private static final Comparator NULL_SAFE_COMPARE = (a, b) -> { if (a == null && b == null) { return 0; @@ -53,8 +58,6 @@ public class InMemoryStoreTransaction implements DataStoreTransaction { } }; - private DataStoreTransaction tx; - /** * Fetches data from the store. */ @@ -71,6 +74,80 @@ public InMemoryStoreTransaction(DataStoreTransaction tx) { this.tx = tx; } + @Override + public Object getRelation(DataStoreTransaction relationTx, + Object entity, + Relationship relationship, + RequestScope scope) { + DataFetcher fetcher = new DataFetcher() { + @Override + public Object fetch(Optional filterExpression, + Optional sorting, + Optional pagination, + RequestScope scope) { + + return tx.getRelation(relationTx, entity, relationship.copyOf() + .projection(relationship.getProjection().copyOf() + .filterExpression(filterExpression.orElse(null)) + .sorting(sorting.orElse(null)) + .pagination(pagination.orElse(null)) + .build() + ).build(), scope); + } + }; + + + /* + * If we are mutating multiple entities, the data store transaction cannot perform filter & pagination directly. + * It must be done in memory by Elide as some newly created entities have not yet been persisted. + */ + boolean filterInMemory = scope.getNewPersistentResources().size() > 0; + return fetchData(fetcher, relationship.getProjection().getType(), + Optional.ofNullable(relationship.getProjection().getFilterExpression()), + Optional.ofNullable(relationship.getProjection().getSorting()), + Optional.ofNullable(relationship.getProjection().getPagination()), + filterInMemory, scope); + } + + @Override + public Object loadObject(EntityProjection projection, + Serializable id, + RequestScope scope) { + + if (projection.getFilterExpression() == null + || tx.supportsFiltering(projection.getType(), + projection.getFilterExpression()) == FeatureSupport.FULL) { + return tx.loadObject(projection, id, scope); + } else { + return DataStoreTransaction.super.loadObject(projection, id, scope); + } + } + + @Override + public Iterable loadObjects(EntityProjection projection, + RequestScope scope) { + + DataFetcher fetcher = new DataFetcher() { + @Override + public Iterable fetch(Optional filterExpression, + Optional sorting, + Optional pagination, + RequestScope scope) { + + return tx.loadObjects(projection.copyOf() + .filterExpression(filterExpression.orElse(null)) + .pagination(pagination.orElse(null)) + .sorting(sorting.orElse(null)) + .build(), scope); + } + }; + + return (Iterable) fetchData(fetcher, projection.getType(), + Optional.ofNullable(projection.getFilterExpression()), + Optional.ofNullable(projection.getSorting()), + Optional.ofNullable(projection.getPagination()), + false, scope); + } @Override public void save(Object entity, RequestScope scope) { @@ -82,11 +159,6 @@ public void delete(Object entity, RequestScope scope) { tx.delete(entity, scope); } - @Override - public User accessUser(Object opaqueUser) { - return tx.accessUser(opaqueUser); - } - @Override public void preCommit() { tx.preCommit(); @@ -98,34 +170,8 @@ public T createNewObject(Class entityClass) { } @Override - public Object getRelation(DataStoreTransaction relationTx, - Object entity, - String relationName, - Optional filterExpression, - Optional sorting, - Optional pagination, - RequestScope scope) { - - Class relationClass = scope.getDictionary().getParameterizedType(entity, relationName); - - DataFetcher fetcher = new DataFetcher() { - @Override - public Object fetch(Optional filterExpression, - Optional sorting, - Optional pagination, - RequestScope scope) { - - return tx.getRelation(relationTx, entity, relationName, filterExpression, sorting, pagination, scope); - } - }; - - - /* - * If we are mutating multiple entities, the data store transaction cannot perform filter & pagination directly. - * It must be done in memory by Elide as some newly created entities have not yet been persisted. - */ - boolean filterInMemory = scope.getNewPersistentResources().size() > 0; - return fetchData(fetcher, relationClass, filterExpression, sorting, pagination, filterInMemory, scope); + public void close() throws IOException { + tx.close(); } @Override @@ -148,14 +194,13 @@ public void updateToOneRelation(DataStoreTransaction relationTx, } @Override - public Object getAttribute(Object entity, String attributeName, RequestScope scope) { - return tx.getAttribute(entity, attributeName, scope); + public Object getAttribute(Object entity, Attribute attribute, RequestScope scope) { + return tx.getAttribute(entity, attribute, scope); } @Override - public void setAttribute(Object entity, String attributeName, Object attributeValue, RequestScope scope) { - tx.setAttribute(entity, attributeName, attributeValue, scope); - + public void setAttribute(Object entity, Attribute attribute, RequestScope scope) { + tx.setAttribute(entity, attribute, scope); } @Override @@ -173,45 +218,6 @@ public void createObject(Object entity, RequestScope scope) { tx.createObject(entity, scope); } - @Override - public Object loadObject(Class entityClass, - Serializable id, - Optional filterExpression, - RequestScope scope) { - - if (! filterExpression.isPresent() - || tx.supportsFiltering(entityClass, filterExpression.get()) == FeatureSupport.FULL) { - return tx.loadObject(entityClass, id, filterExpression, scope); - } - return DataStoreTransaction.super.loadObject(entityClass, id, filterExpression, scope); - } - - @Override - public Iterable loadObjects(Class entityClass, - Optional filterExpression, - Optional sorting, - Optional pagination, - RequestScope scope) { - - DataFetcher fetcher = new DataFetcher() { - @Override - public Iterable fetch(Optional filterExpression, - Optional sorting, - Optional pagination, - RequestScope scope) { - return tx.loadObjects(entityClass, filterExpression, sorting, pagination, scope); - } - }; - - return (Iterable) fetchData(fetcher, entityClass, - filterExpression, sorting, pagination, false, scope); - } - - @Override - public void close() throws IOException { - tx.close(); - } - private Iterable filterLoadedData(Iterable loadedRecords, Optional filterExpression, RequestScope scope) { @@ -247,8 +253,7 @@ private Object fetchData(DataFetcher fetcher, Optional inMemorySort = sortSplit.getRight(); Pair, Optional> paginationSplit = splitPagination(entityClass, - pagination, inMemoryFilter.isPresent(), inMemorySort.isPresent()); - + filterExpression.orElse(null), pagination, inMemoryFilter.isPresent(), inMemorySort.isPresent()); Optional dataStorePagination = paginationSplit.getLeft(); Optional inMemoryPagination = paginationSplit.getRight(); @@ -268,7 +273,6 @@ private Object fetchData(DataFetcher fetcher, return sortAndPaginateLoadedData( loadedRecords, - entityClass, inMemorySort, inMemoryPagination, scope); @@ -276,7 +280,6 @@ private Object fetchData(DataFetcher fetcher, private Iterable sortAndPaginateLoadedData(Iterable loadedRecords, - Class entityClass, Optional sorting, Optional pagination, RequestScope scope) { @@ -286,10 +289,8 @@ private Iterable sortAndPaginateLoadedData(Iterable loadedRecord return loadedRecords; } - EntityDictionary dictionary = scope.getDictionary(); - Map sortRules = sorting - .map((s) -> s.getValidSortingRules(entityClass, dictionary)) + .map((s) -> s.getSortingPaths()) .orElse(new HashMap<>()); // No sorting required for this type & no pagination. @@ -322,8 +323,8 @@ private List paginateInMemory(List records, Pagination paginatio endIdx = records.size(); } - if (pagination.isGenerateTotals()) { - pagination.setPageTotals(records.size()); + if (pagination.returnPageTotals()) { + pagination.setPageTotals((long) records.size()); } return records.subList(offset, endIdx); } @@ -446,11 +447,12 @@ private Pair, Optional> splitSorting( */ private Pair, Optional> splitPagination( Class entityClass, + FilterExpression expression, Optional pagination, boolean filteredInMemory, boolean sortedInMemory ) { - if (!tx.supportsPagination(entityClass) + if (!tx.supportsPagination(entityClass, expression) || filteredInMemory || sortedInMemory) { return Pair.of(Optional.empty(), pagination); diff --git a/elide-core/src/main/java/com/yahoo/elide/core/datastore/wrapped/TransactionWrapper.java b/elide-core/src/main/java/com/yahoo/elide/core/datastore/wrapped/TransactionWrapper.java index f27e4e0219..9ee3631d61 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/datastore/wrapped/TransactionWrapper.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/datastore/wrapped/TransactionWrapper.java @@ -9,15 +9,15 @@ import com.yahoo.elide.core.DataStoreTransaction; import com.yahoo.elide.core.RequestScope; import com.yahoo.elide.core.filter.expression.FilterExpression; -import com.yahoo.elide.core.pagination.Pagination; -import com.yahoo.elide.core.sort.Sorting; -import com.yahoo.elide.security.User; +import com.yahoo.elide.request.Attribute; +import com.yahoo.elide.request.EntityProjection; +import com.yahoo.elide.request.Relationship; +import com.yahoo.elide.request.Sorting; import lombok.AllArgsConstructor; import lombok.Data; import java.io.IOException; import java.io.Serializable; -import java.util.Optional; import java.util.Set; /** @@ -28,11 +28,6 @@ public abstract class TransactionWrapper implements DataStoreTransaction { protected DataStoreTransaction tx; - @Override - public User accessUser(Object opaqueUser) { - return tx.accessUser(opaqueUser); - } - @Override public void preCommit() { tx.preCommit(); @@ -44,16 +39,15 @@ public T createNewObject(Class entityClass) { } @Override - public Object loadObject(Class entityClass, Serializable id, Optional filterExpression, + public Object loadObject(EntityProjection projection, Serializable id, RequestScope scope) { - return tx.loadObject(entityClass, id, filterExpression, scope); + return tx.loadObject(projection, id, scope); } @Override - public Object getRelation(DataStoreTransaction relationTx, Object entity, String relationName, - Optional filterExpression, Optional sorting, - Optional pagination, RequestScope scope) { - return tx.getRelation(relationTx, entity, relationName, filterExpression, sorting, pagination, scope); + public Object getRelation(DataStoreTransaction relationTx, Object entity, + Relationship relationship, RequestScope scope) { + return tx.getRelation(relationTx, entity, relationship, scope); } @Override @@ -71,13 +65,13 @@ public void updateToOneRelation(DataStoreTransaction relationTx, Object entity, } @Override - public Object getAttribute(Object entity, String attributeName, RequestScope scope) { - return tx.getAttribute(entity, attributeName, scope); + public Object getAttribute(Object entity, Attribute attribute, RequestScope scope) { + return tx.getAttribute(entity, attribute, scope); } @Override - public void setAttribute(Object entity, String attributeName, Object attributeValue, RequestScope scope) { - tx.setAttribute(entity, attributeName, attributeValue, scope); + public void setAttribute(Object entity, Attribute attribute, RequestScope scope) { + tx.setAttribute(entity, attribute, scope); } @Override @@ -91,8 +85,8 @@ public boolean supportsSorting(Class entityClass, Sorting sorting) { } @Override - public boolean supportsPagination(Class entityClass) { - return tx.supportsPagination(entityClass); + public boolean supportsPagination(Class entityClass, FilterExpression expression) { + return tx.supportsPagination(entityClass, expression); } @Override @@ -119,16 +113,11 @@ public void commit(RequestScope requestScope) { @Override public void createObject(Object o, RequestScope requestScope) { tx.createObject(o, requestScope); - } @Override - public Iterable loadObjects(Class entityClass, - Optional filterExpression, - Optional sorting, - Optional pagination, - RequestScope requestScope) { - return tx.loadObjects(entityClass, filterExpression, sorting, pagination, requestScope); + public Iterable loadObjects(EntityProjection projection, RequestScope scope) { + return tx.loadObjects(projection, scope); } @Override diff --git a/elide-core/src/main/java/com/yahoo/elide/core/exceptions/CustomErrorException.java b/elide-core/src/main/java/com/yahoo/elide/core/exceptions/CustomErrorException.java index 9ddf36c375..d44fe4bec2 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/exceptions/CustomErrorException.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/exceptions/CustomErrorException.java @@ -44,12 +44,12 @@ public CustomErrorException(int status, String message, Throwable cause, ErrorOb } @Override - public Pair getErrorResponse(boolean encodeResponse) { + public Pair getErrorResponse() { return buildCustomResponse(); } @Override - public Pair getVerboseErrorResponse(boolean encodeResponse) { + public Pair getVerboseErrorResponse() { return buildCustomResponse(); } diff --git a/elide-core/src/main/java/com/yahoo/elide/core/exceptions/ForbiddenAccessException.java b/elide-core/src/main/java/com/yahoo/elide/core/exceptions/ForbiddenAccessException.java index afa7ea325f..51db5de06f 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/exceptions/ForbiddenAccessException.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/exceptions/ForbiddenAccessException.java @@ -5,11 +5,13 @@ */ package com.yahoo.elide.core.exceptions; +import com.yahoo.elide.core.EntityDictionary; import com.yahoo.elide.core.HttpStatus; import com.yahoo.elide.security.permissions.expressions.Expression; import lombok.Getter; +import java.lang.annotation.Annotation; import java.util.Optional; /** @@ -23,12 +25,14 @@ public class ForbiddenAccessException extends HttpStatusException { @Getter private final Optional expression; @Getter private final Optional evaluationMode; - public ForbiddenAccessException(String message) { - this(message, null, null); + public ForbiddenAccessException(Class permission) { + this(permission, null, null); } - public ForbiddenAccessException(String message, Expression expression, Expression.EvaluationMode mode) { - super(HttpStatus.SC_FORBIDDEN, null, null, () -> message + ": " + expression); + public ForbiddenAccessException(Class permission, + Expression expression, Expression.EvaluationMode mode) { + super(HttpStatus.SC_FORBIDDEN, getMessage(permission), null, () -> getMessage(permission) + ": " + expression); + this.expression = Optional.ofNullable(expression); this.evaluationMode = Optional.ofNullable(mode); } @@ -37,4 +41,8 @@ public String getLoggedMessage() { return String.format("ForbiddenAccessException: Message=%s\tMode=%s\tExpression=[%s]", getVerboseMessage(), getEvaluationMode(), getExpression()); } + + private static String getMessage(Class permission) { + return EntityDictionary.getSimpleName(permission) + " Denied"; + } } diff --git a/elide-core/src/main/java/com/yahoo/elide/core/exceptions/HttpStatusException.java b/elide-core/src/main/java/com/yahoo/elide/core/exceptions/HttpStatusException.java index 057d7cf663..a2f0e476cd 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/exceptions/HttpStatusException.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/exceptions/HttpStatusException.java @@ -6,6 +6,7 @@ package com.yahoo.elide.core.exceptions; import com.yahoo.elide.core.EntityDictionary; +import com.yahoo.elide.core.ErrorObjects; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -16,9 +17,6 @@ import lombok.extern.slf4j.Slf4j; -import java.util.Collections; -import java.util.List; -import java.util.Map; import java.util.Optional; import java.util.function.Supplier; @@ -54,56 +52,35 @@ protected static String formatExceptionCause(Throwable e) { /** * Get a response detailing the error that occurred. + * Encode the error message to be safe for HTML. * @return Pair containing status code and a JsonNode containing error details */ public Pair getErrorResponse() { - return getErrorResponse(false); - } - - /** - * Get a response detailing the error that occurred. - * Optionally, encode the error message to be safe for HTML. - * @param encodeResponse true if the message should be encoded for html - * @return Pair containing status code and a JsonNode containing error details - */ - public Pair getErrorResponse(boolean encodeResponse) { - String message = encodeResponse ? Encode.forHtml(toString()) : toString(); - Map> errors = Collections.singletonMap( - "errors", Collections.singletonList(message) - ); - return buildResponse(errors); + return buildResponse(getMessage()); } /** * Get a verbose response detailing the error that occurred. + * Encode the error message to be safe for HTML. * @return Pair containing status code and a JsonNode containing error details */ public Pair getVerboseErrorResponse() { - return getVerboseErrorResponse(false); + return buildResponse(getVerboseMessage()); } - /** - * Get a verbose response detailing the error that occurred. - * Optionally, encode the error message to be safe for HTML. - * @param encodeResponse true if the message should be encoded for html - * @return Pair containing status code and a JsonNode containing error details - */ - public Pair getVerboseErrorResponse(boolean encodeResponse) { - String message = encodeResponse ? Encode.forHtml(getVerboseMessage()) : getVerboseMessage(); - Map> errors = Collections.singletonMap( - "errors", Collections.singletonList(message) - ); - return buildResponse(errors); - } + private Pair buildResponse(String message) { + String errorDetail = message; + errorDetail = Encode.forHtml(errorDetail); - private Pair buildResponse(Map> errors) { + ErrorObjects errors = ErrorObjects.builder().addError().withDetail(errorDetail).build(); JsonNode responseBody = OBJECT_MAPPER.convertValue(errors, JsonNode.class); + return Pair.of(getStatus(), responseBody); } public String getVerboseMessage() { return verboseMessageSupplier.map(Supplier::get) - .orElse(toString()); + .orElse(getMessage()); } public int getStatus() { diff --git a/elide-core/src/main/java/com/yahoo/elide/core/exceptions/InvalidAttributeException.java b/elide-core/src/main/java/com/yahoo/elide/core/exceptions/InvalidAttributeException.java index 51420a5019..2fe38d4918 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/exceptions/InvalidAttributeException.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/exceptions/InvalidAttributeException.java @@ -13,7 +13,7 @@ */ public class InvalidAttributeException extends HttpStatusException { public InvalidAttributeException(String attributeName, String type, Throwable cause) { - super(HttpStatus.SC_NOT_FOUND, "Unknown attribute '" + attributeName + "' in '" + type + "'", cause, null); + super(HttpStatus.SC_NOT_FOUND, "Unknown attribute " + attributeName + " in " + type + "", cause, null); } public InvalidAttributeException(String attributeName, String type) { diff --git a/elide-core/src/main/java/com/yahoo/elide/core/exceptions/InvalidCollectionException.java b/elide-core/src/main/java/com/yahoo/elide/core/exceptions/InvalidCollectionException.java index 1c4ebb3100..9264e524fd 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/exceptions/InvalidCollectionException.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/exceptions/InvalidCollectionException.java @@ -13,7 +13,7 @@ */ public class InvalidCollectionException extends HttpStatusException { public InvalidCollectionException(String collection) { - this("Unknown collection '%s'", collection); + this("Unknown collection %s", collection); } public InvalidCollectionException(String format, String collection) { diff --git a/elide-core/src/main/java/com/yahoo/elide/core/exceptions/InvalidObjectIdentifierException.java b/elide-core/src/main/java/com/yahoo/elide/core/exceptions/InvalidObjectIdentifierException.java index a9fed4d1ee..0f8bba017f 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/exceptions/InvalidObjectIdentifierException.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/exceptions/InvalidObjectIdentifierException.java @@ -15,6 +15,6 @@ public class InvalidObjectIdentifierException extends HttpStatusException { private static final long serialVersionUID = 1L; public InvalidObjectIdentifierException(String id, String objectOrFieldName) { - super(HttpStatus.SC_NOT_FOUND, "Unknown identifier '" + id + "' for " + objectOrFieldName); + super(HttpStatus.SC_NOT_FOUND, "Unknown identifier " + id + " for " + objectOrFieldName); } } diff --git a/elide-core/src/main/java/com/yahoo/elide/core/exceptions/InvalidOperationException.java b/elide-core/src/main/java/com/yahoo/elide/core/exceptions/InvalidOperationException.java index 854d38315e..55db006f12 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/exceptions/InvalidOperationException.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/exceptions/InvalidOperationException.java @@ -14,6 +14,6 @@ public class InvalidOperationException extends HttpStatusException { private static final long serialVersionUID = 1L; public InvalidOperationException(String body) { - super(HttpStatus.SC_BAD_REQUEST, "Invalid operation: '" + body + "'"); + super(HttpStatus.SC_BAD_REQUEST, "Invalid operation: " + body); } } diff --git a/elide-core/src/main/java/com/yahoo/elide/core/exceptions/InvalidPredicateException.java b/elide-core/src/main/java/com/yahoo/elide/core/exceptions/InvalidPredicateException.java deleted file mode 100644 index f5402babbc..0000000000 --- a/elide-core/src/main/java/com/yahoo/elide/core/exceptions/InvalidPredicateException.java +++ /dev/null @@ -1,16 +0,0 @@ -/* - * Copyright 2016, Yahoo Inc. - * Licensed under the Apache License, Version 2.0 - * See LICENSE file in project root for terms. - */ -package com.yahoo.elide.core.exceptions; - -/** - * Invalid predicate exception. - */ -@Deprecated -public class InvalidPredicateException extends BadRequestException { - public InvalidPredicateException(String message) { - super(message); - } -} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/exceptions/JsonPatchExtensionException.java b/elide-core/src/main/java/com/yahoo/elide/core/exceptions/JsonPatchExtensionException.java index 456004c539..385a3df96b 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/exceptions/JsonPatchExtensionException.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/exceptions/JsonPatchExtensionException.java @@ -6,14 +6,8 @@ package com.yahoo.elide.core.exceptions; import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.node.ArrayNode; -import com.fasterxml.jackson.databind.node.IntNode; -import com.fasterxml.jackson.databind.node.JsonNodeFactory; -import com.fasterxml.jackson.databind.node.ObjectNode; -import com.fasterxml.jackson.databind.node.TextNode; import org.apache.commons.lang3.tuple.Pair; -import org.owasp.encoder.Encode; /** * Exception describing error caused from Json Patch Extension request. @@ -22,62 +16,17 @@ public class JsonPatchExtensionException extends HttpStatusException { private final Pair response; public JsonPatchExtensionException(int status, final JsonNode errorNode) { - super(status, null); + super(status, ""); response = Pair.of(status, errorNode); } - /** - * @deprecated use {@link #getErrorResponse(boolean encodeResponse)} - */ - @Deprecated - public Pair getResponse() { - return response; - } - @Override public Pair getErrorResponse() { - return getErrorResponse(false); - } - - @Override - public Pair getErrorResponse(boolean encodeResponse) { - if (!encodeResponse) { - return response; - } - - return encodeResponse(); + return response; } @Override public Pair getVerboseErrorResponse() { - return getVerboseErrorResponse(false); - } - - @Override - public Pair getVerboseErrorResponse(boolean encodeResponse) { - if (!encodeResponse) { - return response; - } - - return encodeResponse(); - } - - private Pair encodeResponse() { - // response is final, so construct a new response with encoded values - ArrayNode encodedArray = JsonNodeFactory.instance.arrayNode(); - ArrayNode errors = (ArrayNode) response.getRight().get("errors"); - for (JsonNode node : errors) { - ObjectNode objectNode = (ObjectNode) node; - - TextNode text = (TextNode) objectNode.get("detail"); - IntNode status = (IntNode) objectNode.get("status"); - - ObjectNode encodedObjectNode = JsonNodeFactory.instance.objectNode(); - TextNode encodedTextNode = JsonNodeFactory.instance.textNode(Encode.forHtml(text.asText())); - encodedObjectNode.set("detail", encodedTextNode); - encodedObjectNode.set("status", status); - encodedArray.add(encodedObjectNode); - } - return Pair.of(response.getLeft(), encodedArray); + return response; } } diff --git a/elide-core/src/main/java/com/yahoo/elide/core/exceptions/TimeoutException.java b/elide-core/src/main/java/com/yahoo/elide/core/exceptions/TimeoutException.java new file mode 100644 index 0000000000..e5fab2f747 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/exceptions/TimeoutException.java @@ -0,0 +1,21 @@ +/* + * Copyright 2020, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.core.exceptions; + +import com.yahoo.elide.core.HttpStatus; + +/** + * Thrown for request timeouts. + * + * {@link HttpStatus#SC_TIMEOUT} + */ +public class TimeoutException extends HttpStatusException { + private static final long serialVersionUID = 1L; + + public TimeoutException(Throwable e) { + super(HttpStatus.SC_TIMEOUT, "Request Timeout", e, null); + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/exceptions/UnknownEntityException.java b/elide-core/src/main/java/com/yahoo/elide/core/exceptions/UnknownEntityException.java index 8d6e9680b3..0ababf2145 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/exceptions/UnknownEntityException.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/exceptions/UnknownEntityException.java @@ -12,6 +12,6 @@ */ public class UnknownEntityException extends HttpStatusException { public UnknownEntityException(String entityType) { - super(HttpStatus.SC_BAD_REQUEST, "Unknown entity type: '" + entityType + "'"); + super(HttpStatus.SC_BAD_REQUEST, "Unknown entity type: " + entityType); } } diff --git a/elide-core/src/main/java/com/yahoo/elide/core/filter/FilterPredicate.java b/elide-core/src/main/java/com/yahoo/elide/core/filter/FilterPredicate.java index 26c5bdfc7e..ae35c69561 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/filter/FilterPredicate.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/filter/FilterPredicate.java @@ -113,34 +113,6 @@ public FilterPredicate scopedBy(PathElement scope) { return new FilterPredicate(new Path(pathElements), operator, values); } - /** - * Returns an alias that uniquely identifies the last collection of entities in the path. - * @return An alias for the path. - */ - public String getAlias() { - List elements = path.getPathElements(); - - PathElement last = elements.get(elements.size() - 1); - - if (elements.size() == 1) { - return getTypeAlias(last.getType()); - } - - PathElement previous = elements.get(elements.size() - 2); - - return getTypeAlias(previous.getType()) + UNDERSCORE + previous.getFieldName(); - } - - /** - * Build an HQL friendly alias for a class. - * - * @param type The type to alias - * @return type name alias that will likely not conflict with other types or with reserved keywords. - */ - public static String getTypeAlias(Class type) { - return type.getCanonicalName().replace(PERIOD, UNDERSCORE); - } - public Class getEntityType() { List elements = path.getPathElements(); PathElement first = elements.get(0); @@ -176,8 +148,8 @@ public String toString() { for (PathElement element : elements) { formattedPath.append(PERIOD).append(element.getFieldName()); - } + return formattedPath.append(' ').append(operator).append(' ').append(values).toString(); } diff --git a/elide-core/src/main/java/com/yahoo/elide/core/filter/dialect/DefaultFilterDialect.java b/elide-core/src/main/java/com/yahoo/elide/core/filter/dialect/DefaultFilterDialect.java index 667d82f83b..3fd96f6606 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/filter/dialect/DefaultFilterDialect.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/filter/dialect/DefaultFilterDialect.java @@ -41,7 +41,8 @@ public DefaultFilterDialect(EntityDictionary dictionary) { * @return a list of the predicates from the query params * @throws ParseException when a filter parameter cannot be parsed */ - private List extractPredicates(MultivaluedMap queryParams) throws ParseException { + private List extractPredicates(MultivaluedMap queryParams, + String apiVersion) throws ParseException { List filterPredicates = new ArrayList<>(); Pattern pattern = Pattern.compile("filter\\[([^\\]]+)\\](\\[([^\\]]+)\\])?"); @@ -65,7 +66,7 @@ private List extractPredicates(MultivaluedMap q final Operator operator = (matcher.group(3) == null) ? Operator.IN : Operator.fromString(matcher.group(3)); - Path path = getPath(keyParts); + Path path = getPath(keyParts, apiVersion); List elements = path.getPathElements(); Path.PathElement last = elements.get(elements.size() - 1); @@ -87,10 +88,11 @@ private List extractPredicates(MultivaluedMap q } @Override - public FilterExpression parseGlobalExpression(String path, MultivaluedMap filterParams) + public FilterExpression parseGlobalExpression(String path, MultivaluedMap filterParams, + String apiVersion) throws ParseException { List filterPredicates; - filterPredicates = extractPredicates(filterParams); + filterPredicates = extractPredicates(filterParams, apiVersion); /* Extract the first collection in the URL */ String normalizedPath = JsonApiParser.normalizePath(path); @@ -130,10 +132,11 @@ public FilterExpression parseGlobalExpression(String path, MultivaluedMap parseTypedExpression(String path, MultivaluedMap filterParams) + public Map parseTypedExpression(String path, MultivaluedMap filterParams, + String apiVersion) throws ParseException { Map expressionMap = new HashMap<>(); - List filterPredicates = extractPredicates(filterParams); + List filterPredicates = extractPredicates(filterParams, apiVersion); for (FilterPredicate filterPredicate : filterPredicates) { validateFilterPredicate(filterPredicate); @@ -153,10 +156,11 @@ public Map parseTypedExpression(String path, Multivalu * Parses [ author, books, publisher, name ] into [(author, books), (book, publisher), (publisher, name)]. * * @param keyParts [ author, books, publisher, name ] + * @param apiVersion The client requested version. * @return [(author, books), (book, publisher), (publisher, name)] * @throws ParseException if the filter cannot be parsed */ - private Path getPath(final String[] keyParts) throws ParseException { + private Path getPath(final String[] keyParts, String apiVersion) throws ParseException { if (keyParts == null || keyParts.length <= 0) { throw new ParseException("Invalid filter expression"); } @@ -165,7 +169,8 @@ private Path getPath(final String[] keyParts) throws ParseException { Class[] types = new Class[keyParts.length]; String type = keyParts[0]; - types[0] = dictionary.getEntityClass(type); + + types[0] = dictionary.getEntityClass(type, apiVersion); if (types[0] == null) { throw new ParseException("Unknown entity in filter: " + type); diff --git a/elide-core/src/main/java/com/yahoo/elide/core/filter/dialect/JoinFilterDialect.java b/elide-core/src/main/java/com/yahoo/elide/core/filter/dialect/JoinFilterDialect.java index 3bd4dcd95a..6cbad8d590 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/filter/dialect/JoinFilterDialect.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/filter/dialect/JoinFilterDialect.java @@ -24,10 +24,12 @@ public interface JoinFilterDialect { * * @param path the URL path * @param filterParams the subset of query parameters that start with 'filter' + * @param apiVersion the version of the API requested. * @return The root of an expression abstract syntax tree parsed from both the path and the query parameters. * @throws ParseException if the expression cannot be parsed. */ public FilterExpression parseGlobalExpression( String path, - MultivaluedMap filterParams) throws ParseException; + MultivaluedMap filterParams, + String apiVersion) throws ParseException; } diff --git a/elide-core/src/main/java/com/yahoo/elide/core/filter/dialect/MultipleFilterDialect.java b/elide-core/src/main/java/com/yahoo/elide/core/filter/dialect/MultipleFilterDialect.java index 0fe013d81a..46d5d3724a 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/filter/dialect/MultipleFilterDialect.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/filter/dialect/MultipleFilterDialect.java @@ -38,24 +38,27 @@ public MultipleFilterDialect(EntityDictionary dictionary) { @Override public FilterExpression parseGlobalExpression(String path, - MultivaluedMap queryParams) throws ParseException { + MultivaluedMap queryParams, + String apiVersion) throws ParseException { if (joinDialects.isEmpty()) { throw new ParseException("Heterogeneous type filtering not supported"); } - return parseExpression(joinDialects, (dialect) -> dialect.parseGlobalExpression(path, queryParams)); + return parseExpression(joinDialects, (dialect) -> dialect.parseGlobalExpression(path, queryParams, apiVersion)); } @Override public Map parseTypedExpression(String path, - MultivaluedMap queryParams) + MultivaluedMap queryParams, + String apiVersion) throws ParseException { if (subqueryDialects.isEmpty()) { throw new ParseException("Type filtering not supported"); } - return parseExpression(subqueryDialects, (dialect) -> dialect.parseTypedExpression(path, queryParams)); + return parseExpression(subqueryDialects, (dialect) -> dialect.parseTypedExpression(path, + queryParams, apiVersion)); } private static R parseExpression(List dialects, ParseFunction parseFunction) throws ParseException { diff --git a/elide-core/src/main/java/com/yahoo/elide/core/filter/dialect/RSQLFilterDialect.java b/elide-core/src/main/java/com/yahoo/elide/core/filter/dialect/RSQLFilterDialect.java index fa1926b476..d7f2c6f5ad 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/filter/dialect/RSQLFilterDialect.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/filter/dialect/RSQLFilterDialect.java @@ -101,7 +101,8 @@ private static Set getDefaultOperatorsWithIsnull() { } @Override - public FilterExpression parseGlobalExpression(String path, MultivaluedMap filterParams) + public FilterExpression parseGlobalExpression(String path, MultivaluedMap filterParams, + String apiVersion) throws ParseException { if (filterParams.size() != 1) { throw new ParseException(SINGLE_PARAMETER_ONLY); @@ -133,7 +134,7 @@ public FilterExpression parseGlobalExpression(String path, MultivaluedMap parseTypedExpression(String path, MultivaluedMap filterParams) + public Map parseTypedExpression(String path, MultivaluedMap filterParams, + String apiVersion) throws ParseException { Map expressionByType = new HashMap<>(); @@ -159,7 +161,7 @@ public Map parseTypedExpression(String path, Multivalu throw new ParseException("Exactly one RSQL expression must be defined for type : " + typeName); } - Class entityType = dictionary.getEntityClass(typeName); + Class entityType = dictionary.getEntityClass(typeName, apiVersion); if (entityType == null) { throw new ParseException(INVALID_QUERY_PARAMETER + paramName); } @@ -175,7 +177,6 @@ public Map parseTypedExpression(String path, Multivalu return expressionByType; } - /** * Parses a RSQL string into an Elide FilterExpression. * @param expressionText the RSQL string diff --git a/elide-core/src/main/java/com/yahoo/elide/core/filter/dialect/SubqueryFilterDialect.java b/elide-core/src/main/java/com/yahoo/elide/core/filter/dialect/SubqueryFilterDialect.java index 6b70404047..8371b1c550 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/filter/dialect/SubqueryFilterDialect.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/filter/dialect/SubqueryFilterDialect.java @@ -33,9 +33,11 @@ public interface SubqueryFilterDialect { * * @param path The URL path * @param filterParams The subset of queryParams that start with 'filter' + * @param apiVersion The version of the API requested. * @return The root of an expression abstract syntax tree parsed from both the path and the query parameters. * @throws ParseException if unable to parse */ - public Map parseTypedExpression(String path, MultivaluedMap filterParams) + public Map parseTypedExpression(String path, MultivaluedMap filterParams, + String apiVersion) throws ParseException; } diff --git a/elide-core/src/main/java/com/yahoo/elide/core/filter/expression/AndFilterExpression.java b/elide-core/src/main/java/com/yahoo/elide/core/filter/expression/AndFilterExpression.java index 9571351ba3..4c87ef7a42 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/filter/expression/AndFilterExpression.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/filter/expression/AndFilterExpression.java @@ -17,6 +17,32 @@ public class AndFilterExpression implements FilterExpression { @Getter private FilterExpression left; @Getter private FilterExpression right; + /** + * Returns a new {@link AndFilterExpression} instance with the specified null-able left and right operands. + *

+ * The publication rules are + *

    + *
  1. If both left and right are not {@code null}, this method produces the same instance as + * {@link #AndFilterExpression(FilterExpression, FilterExpression)} does, + *
  2. If one of them is {@code null}, the other non-null is returned with no modification, + *
  3. If both left and right are {@code null}, this method returns + * {@code null}. + *
+ * + * @param left The provided left {@link FilterExpression} + * @param right The provided right {@link FilterExpression} + * + * @return a new {@link AndFilterExpression} instance or {@code null} + */ + public static FilterExpression fromPair(FilterExpression left, FilterExpression right) { + if (left != null && right != null) { + return new AndFilterExpression(left, right); + } else if (left == null) { + return right; + } + return left; + } + public AndFilterExpression(FilterExpression left, FilterExpression right) { this.left = left; this.right = right; diff --git a/elide-core/src/main/java/com/yahoo/elide/core/filter/expression/OrFilterExpression.java b/elide-core/src/main/java/com/yahoo/elide/core/filter/expression/OrFilterExpression.java index 02cfb62ed8..90f5e628ef 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/filter/expression/OrFilterExpression.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/filter/expression/OrFilterExpression.java @@ -18,6 +18,32 @@ public class OrFilterExpression implements FilterExpression { @Getter private FilterExpression left; @Getter private FilterExpression right; + /** + * Returns a new {@link OrFilterExpression} instance with the specified null-able left and right operands. + *

+ * The publication rules are + *

    + *
  1. If both left and right are not {@code null}, this method produces the same instance as + * {@link #OrFilterExpression(FilterExpression, FilterExpression)} does, + *
  2. If one of them is {@code null}, the other non-null is returned with no modification, + *
  3. If both left and right are {@code null}, this method returns + * {@code null}. + *
+ * + * @param left The provided left {@link FilterExpression} + * @param right The provided right {@link FilterExpression} + * + * @return a new {@link OrFilterExpression} instance or {@code null} + */ + public static FilterExpression fromPair(FilterExpression left, FilterExpression right) { + if (left != null && right != null) { + return new OrFilterExpression(left, right); + } else if (left == null) { + return right; + } + return left; + } + public OrFilterExpression(FilterExpression left, FilterExpression right) { this.left = left; this.right = right; diff --git a/elide-core/src/main/java/com/yahoo/elide/core/pagination/Pagination.java b/elide-core/src/main/java/com/yahoo/elide/core/pagination/Pagination.java deleted file mode 100644 index 61ae0b6241..0000000000 --- a/elide-core/src/main/java/com/yahoo/elide/core/pagination/Pagination.java +++ /dev/null @@ -1,345 +0,0 @@ -/* - * Copyright 2015, Yahoo Inc. - * Licensed under the Apache License, Version 2.0 - * See LICENSE file in project root for terms. - */ -package com.yahoo.elide.core.pagination; - -import com.yahoo.elide.ElideSettings; -import com.yahoo.elide.annotation.Paginate; -import com.yahoo.elide.core.exceptions.InvalidValueException; - -import com.google.common.collect.ImmutableMap; - -import lombok.Getter; -import lombok.ToString; - -import java.util.HashMap; -import java.util.Map; -import java.util.Optional; -import java.util.stream.Collectors; - -import javax.ws.rs.core.MultivaluedMap; - -/** - * Encapsulates the pagination strategy. - */ - -@ToString -public class Pagination { - /** - * Denotes the internal field names for paging. - */ - public enum PaginationKey { offset, number, size, limit, totals } - - public static final int DEFAULT_OFFSET = 0; - public static final int DEFAULT_PAGE_LIMIT = 500; - public static final int MAX_PAGE_LIMIT = 10000; - - // For specifying which page of records is to be returned in the response - public static final String PAGE_NUMBER_KEY = "page[number]"; - - // For specifying the page size - essentially an alias for page[limit] - public static final String PAGE_SIZE_KEY = "page[size]"; - - // For specifying the first row to be returned in the response - public static final String PAGE_OFFSET_KEY = "page[offset]"; - - // For limiting the number of records returned - public static final String PAGE_LIMIT_KEY = "page[limit]"; - - // For requesting total pages/records be included in the response page meta data - public static final String PAGE_TOTALS_KEY = "page[totals]"; - - public static final Map PAGE_KEYS = new HashMap<>(); - static { - PAGE_KEYS.put(PAGE_NUMBER_KEY, PaginationKey.number); - PAGE_KEYS.put(PAGE_SIZE_KEY, PaginationKey.size); - PAGE_KEYS.put(PAGE_OFFSET_KEY, PaginationKey.offset); - PAGE_KEYS.put(PAGE_LIMIT_KEY, PaginationKey.limit); - PAGE_KEYS.put(PAGE_TOTALS_KEY, PaginationKey.totals); - } - - private long pageTotals = 0; - - private static final String PAGE_KEYS_CSV = PAGE_KEYS.keySet().stream().collect(Collectors.joining(", ")); - - // For holding the page query parameters until they can be evaluated - private Map pageData; - - @Getter - private int offset; - - @Getter - private int limit; - - @Getter - private boolean generateTotals; - - private final int defaultMaxPageSize; - private final int defaultPageSize; - - private Pagination(Map pageData, int defaultMaxPageSize, int defaultPageSize) { - this.pageData = pageData; - this.defaultMaxPageSize = defaultMaxPageSize; - this.defaultPageSize = defaultPageSize; - } - - /** - * TODO - Refactor Pagination. - * IMPORTANT - This method should only be used for testing until Pagination is refactored. The - * member field values of this class change depending on evaluation later from the Pagination annotation. - * The existing implementation is too complex because logic resides in the wrong places. - * - * @param limit The page size - * @param offset The page offset - * @param generatePageTotals Whether or not to return page totals - * @return A new pagination object. - */ - public static Pagination fromOffsetAndLimit(int limit, int offset, boolean generatePageTotals) { - - ImmutableMap.Builder pageData = ImmutableMap.builder() - .put(PAGE_KEYS.get(PAGE_OFFSET_KEY), offset) - .put(PAGE_KEYS.get(PAGE_LIMIT_KEY), limit); - - if (generatePageTotals) { - pageData.put(PAGE_KEYS.get(PAGE_TOTALS_KEY), 1); - } - - Pagination result = new Pagination(pageData.build(), MAX_PAGE_LIMIT, DEFAULT_PAGE_LIMIT); - result.offset = offset; - result.limit = limit; - result.generateTotals = generatePageTotals; - return result; - } - - /** - * Given an offset and first parameter from GraphQL, generate page and pageSize values. - * - * @param firstOpt Provided first string - * @param offsetOpt Provided offset string - * @param generatePageTotals True if page totals should be generated, false otherwise - * @param elideSettings Elide settings object containing default pagination values - * @return The new Pagination object. - */ - public static Optional fromOffsetAndFirst(Optional firstOpt, - Optional offsetOpt, - boolean generatePageTotals, - ElideSettings elideSettings) { - return firstOpt.map(firstString -> { - int offset; - int first; - - try { - offset = offsetOpt.map(Integer::parseInt).orElse(0); - first = Integer.parseInt(firstString); - } catch (NumberFormatException e) { - throw new InvalidValueException("Offset and first must be numeric values."); - } - - if (offset < 0) { - throw new InvalidValueException("Offset values must be non-negative."); - } else if (first < 1) { - throw new InvalidValueException("Limit values must be positive."); - } - - ImmutableMap.Builder pageData = ImmutableMap.builder() - .put(PAGE_KEYS.get(PAGE_OFFSET_KEY), offset) - .put(PAGE_KEYS.get(PAGE_LIMIT_KEY), first); - if (generatePageTotals) { - pageData.put(PAGE_KEYS.get(PAGE_TOTALS_KEY), 1); - } - - return Optional.of(getPagination(pageData.build(), elideSettings)); - }).orElseGet(() -> { - if (generatePageTotals) { - Pagination pagination = getDefaultPagination(elideSettings); - pagination.pageData.put(PAGE_KEYS.get(PAGE_TOTALS_KEY), 1); - return Optional.of(pagination); - } - return Optional.empty(); - }); - } - - /** - * Given json-api paging params, generate page and pageSize values from query params. - * - * @param queryParams The page queryParams (ImmuatableMultiValueMap). - * @param elideSettings Elide settings containing pagination default limits - * @return The new Pagination object. - * @throws InvalidValueException invalid query parameter - */ - public static Pagination parseQueryParams(final MultivaluedMap queryParams, - ElideSettings elideSettings) - throws InvalidValueException { - final Map pageData = new HashMap<>(); - queryParams.entrySet() - .forEach(paramEntry -> { - final String queryParamKey = paramEntry.getKey(); - if (PAGE_KEYS.containsKey(queryParamKey)) { - PaginationKey paginationKey = PAGE_KEYS.get(queryParamKey); - if (paginationKey.equals(PaginationKey.totals)) { - // page[totals] is a valueless parameter, use value of 0 just so that its presence can - // be recorded in the map - pageData.put(paginationKey, 0); - } else { - final String value = paramEntry.getValue().get(0); - try { - int intValue = Integer.parseInt(value, 10); - pageData.put(paginationKey, intValue); - } catch (NumberFormatException e) { - throw new InvalidValueException("page values must be integers"); - } - } - } else if (queryParamKey.startsWith("page[")) { - throw new InvalidValueException("Invalid Pagination Parameter. Accepted values are " - + PAGE_KEYS_CSV); - } - }); - return getPagination(pageData, elideSettings); - } - - /** - * Sets the total number of records for the paginated query. - * @param total the total number of records found - */ - public void setPageTotals(long total) { - this.pageTotals = total; - } - - /** - * Fetches the total number of records of the paginated query. - * @return page totals - */ - public long getPageTotals() { - return pageTotals; - } - - /** - * Construct a pagination object from page data and elide settings. - * - * @param pageData Map containing pagination information - * @param elideSettings Settings containing pagination defaults - * @return Pagination object - */ - private static Pagination getPagination(Map pageData, ElideSettings elideSettings) { - // Decidedly default settings until evaluate is called (a call to evaluate from the datastore will update this): - Pagination result = new Pagination(pageData, - elideSettings.getDefaultMaxPageSize(), elideSettings.getDefaultPageSize()); - result.offset = 0; - result.limit = elideSettings.getDefaultPageSize(); - return result; - } - - /** - * Evaluates the pagination variables for default limits. - * - * @param defaultLimit the default page size - * @param maxLimit a hard upper limit on page size - * @return the calculated {@link Pagination} - */ - private Pagination evaluate(int defaultLimit, int maxLimit) { - if (hasInvalidCombination(pageData)) { - throw new InvalidValueException("Invalid usage of pagination parameters."); - } - if (pageData.containsKey(PaginationKey.size) || pageData.containsKey(PaginationKey.number)) { - pageByPages(defaultLimit, maxLimit); - } else if (pageData.containsKey(PaginationKey.limit) || pageData.containsKey(PaginationKey.offset)) { - pageByOffset(defaultLimit, maxLimit); - } else { - limit = defaultLimit; - offset = 0; - } - - generateTotals = pageData.containsKey(PaginationKey.totals); - - return this; - } - - private boolean hasInvalidCombination(Map pageData) { - return (pageData.containsKey(PaginationKey.size) || pageData.containsKey(PaginationKey.number)) - && (pageData.containsKey(PaginationKey.limit) || pageData.containsKey(PaginationKey.offset)); - } - - private void pageByOffset(int defaultLimit, int maxLimit) { - limit = pageData.containsKey(PaginationKey.limit) ? pageData.get(PaginationKey.limit) : defaultLimit; - if (limit > maxLimit) { - throw new InvalidValueException("page[limit] value must be less than or equal to " + maxLimit); - } - if (limit < 0) { - throw new InvalidValueException("page[limit] value must contain a positive value"); - } - - offset = pageData.containsKey(PaginationKey.offset) ? pageData.get(PaginationKey.offset) : 0; - if (offset < 0) { - throw new InvalidValueException("page[offset] must contain a positive values."); - } - } - - private void pageByPages(int defaultLimit, int maxLimit) { - limit = pageData.containsKey(PaginationKey.size) ? pageData.get(PaginationKey.size) : defaultLimit; - if (limit > maxLimit) { - throw new InvalidValueException("page[size] value must be less than or equal to " + maxLimit); - } - if (limit < 0) { - throw new InvalidValueException("page[size] must contain a positive value."); - } - - int pageNumber = pageData.containsKey(PaginationKey.number) ? pageData.get(PaginationKey.number) : 1; - if (pageNumber < 1) { - throw new InvalidValueException("page[number] must contain a positive value."); - } - - offset = (pageNumber - 1) * limit; - } - - /** - * Evaluates the pagination variables. Uses the Paginate annotation if it has been set for the entity to be - * queried. - * - * @param entityClass Entity class to paginate - * @return the calculated {@link Pagination} - */ - public Pagination evaluate(final Class entityClass) { - Paginate paginate = - entityClass != null ? (Paginate) entityClass.getAnnotation(Paginate.class) : null; - - int defaultLimit = paginate != null ? paginate.defaultLimit() : defaultPageSize; - int maxLimit = paginate != null ? paginate.maxLimit() : defaultMaxPageSize; - - evaluate(defaultLimit, maxLimit); - - generateTotals = generateTotals && (paginate == null || paginate.countable()); - - return this; - } - - /** - * Know if this is the default instance. - * @return The default pagination values. - */ - public boolean isDefaultInstance() { - return pageData.isEmpty(); - } - - /** - * Alias for isDefault. - * @return true if there are no pagination rules - */ - public boolean isEmpty() { - return isDefaultInstance(); - } - - /** - * Default Instance. - * @param elideSettings general Elide settings - * @return The default instance. - */ - public static Pagination getDefaultPagination(ElideSettings elideSettings) { - Pagination defaultPagination = new Pagination(new HashMap<>(), - elideSettings.getDefaultMaxPageSize(), elideSettings.getDefaultPageSize()); - defaultPagination.offset = DEFAULT_OFFSET; - defaultPagination.limit = DEFAULT_PAGE_LIMIT; - return defaultPagination; - } -} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/pagination/PaginationImpl.java b/elide-core/src/main/java/com/yahoo/elide/core/pagination/PaginationImpl.java new file mode 100644 index 0000000000..1c6315cb67 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/pagination/PaginationImpl.java @@ -0,0 +1,261 @@ +/* + * Copyright 2019, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.core.pagination; + +import com.yahoo.elide.ElideSettings; +import com.yahoo.elide.annotation.Paginate; +import com.yahoo.elide.core.exceptions.InvalidValueException; +import com.yahoo.elide.request.Pagination; + +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +import javax.ws.rs.core.MultivaluedMap; + +/** + * Holds state associated with pagination. + */ +@ToString +@EqualsAndHashCode +public class PaginationImpl implements Pagination { + /** + * Denotes the internal field names for paging. + */ + public enum PaginationKey { offset, number, size, limit, totals } + + // For specifying which page of records is to be returned in the response + public static final String PAGE_NUMBER_KEY = "page[number]"; + + // For specifying the page size - essentially an alias for page[limit] + public static final String PAGE_SIZE_KEY = "page[size]"; + + // For specifying the first row to be returned in the response + public static final String PAGE_OFFSET_KEY = "page[offset]"; + + // For limiting the number of records returned + public static final String PAGE_LIMIT_KEY = "page[limit]"; + + // For requesting total pages/records be included in the response page meta data + public static final String PAGE_TOTALS_KEY = "page[totals]"; + + public static final Map PAGE_KEYS = new HashMap<>(); + static { + PAGE_KEYS.put(PAGE_NUMBER_KEY, PaginationKey.number); + PAGE_KEYS.put(PAGE_SIZE_KEY, PaginationKey.size); + PAGE_KEYS.put(PAGE_OFFSET_KEY, PaginationKey.offset); + PAGE_KEYS.put(PAGE_LIMIT_KEY, PaginationKey.limit); + PAGE_KEYS.put(PAGE_TOTALS_KEY, PaginationKey.totals); + } + + @Getter + @Setter + private Long pageTotals = 0L; + + private static final String PAGE_KEYS_CSV = PAGE_KEYS.keySet().stream().collect(Collectors.joining(", ")); + + @Getter + private final int offset; + + @Getter + private final int limit; + + private final boolean generateTotals; + + @Getter + private final boolean defaultInstance; + + @Getter + private final Class entityClass; + + /** + * Constructor. + * @param entityClass The type of collection we are paginating. + * @param clientOffset The client requested offset or null if not provided. + * @param clientLimit The client requested limit or null if not provided. + * @param systemDefaultLimit The system default limit (in terms of records). + * @param systemMaxLimit The system max limit (in terms of records). + * @param generateTotals Whether to return the total number of records. + * @param pageByPages Whether to page by pages or records. + */ + public PaginationImpl(Class entityClass, + Integer clientOffset, + Integer clientLimit, + int systemDefaultLimit, + int systemMaxLimit, + Boolean generateTotals, + Boolean pageByPages) { + + this.entityClass = entityClass; + this.defaultInstance = (clientOffset == null && clientLimit == null && generateTotals == null); + + Paginate paginate = entityClass != null ? (Paginate) entityClass.getAnnotation(Paginate.class) : null; + + this.limit = clientLimit != null + ? clientLimit + : (paginate != null ? paginate.defaultLimit() : systemDefaultLimit); + + int maxLimit = paginate != null ? paginate.maxLimit() : systemMaxLimit; + + String pageSizeLabel = pageByPages ? "size" : "limit"; + + if (limit > maxLimit && !defaultInstance) { + throw new InvalidValueException("Pagination " + + pageSizeLabel + " must be less than or equal to " + maxLimit); + } + if (limit < 1) { + throw new InvalidValueException("Pagination " + + pageSizeLabel + " must contain a positive, non-zero value."); + } + + this.generateTotals = generateTotals != null && generateTotals && (paginate == null || paginate.countable()); + + if (pageByPages) { + int pageNumber = clientOffset != null ? clientOffset : 1; + if (pageNumber < 1) { + throw new InvalidValueException("Pagination number must be a positive, non-zero value."); + } + this.offset = (pageNumber - 1) * limit; + } else { + this.offset = clientOffset != null ? clientOffset : 0; + + if (offset < 0) { + throw new InvalidValueException("Pagination offset must contain a positive value."); + } + } + } + + /** + * Whether or not the client requested to return page totals. + * @return true if page totals should be returned. + */ + @Override + public boolean returnPageTotals() { + return generateTotals; + } + + /** + * Given json-api paging params, generate page and pageSize values from query params. + * + * @param entityClass The collection type. + * @param queryParams The page queryParams. + * @param elideSettings Elide settings containing pagination default limits + * @return The new Pagination object. + * @throws InvalidValueException invalid query parameter + */ + public static PaginationImpl parseQueryParams(Class entityClass, + final Optional> queryParams, + ElideSettings elideSettings) + throws InvalidValueException { + + if (! queryParams.isPresent()) { + return getDefaultPagination(entityClass, elideSettings); + } + + final Map pageData = new HashMap<>(); + queryParams.get().entrySet() + .forEach(paramEntry -> { + final String queryParamKey = paramEntry.getKey(); + if (PAGE_KEYS.containsKey(queryParamKey)) { + PaginationKey paginationKey = PAGE_KEYS.get(queryParamKey); + if (paginationKey.equals(PaginationKey.totals)) { + // page[totals] is a valueless parameter, use value of 0 just so that its presence can + // be recorded in the map + pageData.put(paginationKey, 0); + } else { + final String value = paramEntry.getValue().get(0); + try { + int intValue = Integer.parseInt(value, 10); + pageData.put(paginationKey, intValue); + } catch (NumberFormatException e) { + throw new InvalidValueException("page values must be integers"); + } + } + } else if (queryParamKey.startsWith("page[")) { + throw new InvalidValueException("Invalid Pagination Parameter. Accepted values are " + + PAGE_KEYS_CSV); + } + }); + return getPagination(entityClass, pageData, elideSettings); + } + + + /** + * Construct a pagination object from page data and elide settings. + * + * @param entityClass The collection type. + * @param pageData Map containing pagination information + * @param elideSettings Settings containing pagination defaults + * @return Pagination object + */ + private static PaginationImpl getPagination(Class entityClass, Map pageData, + ElideSettings elideSettings) { + if (hasInvalidCombination(pageData)) { + throw new InvalidValueException("Invalid usage of pagination parameters."); + } + + boolean pageByPages = false; + Integer offset = pageData.getOrDefault(PaginationKey.offset, null); + Integer limit = pageData.getOrDefault(PaginationKey.limit, null); + + if (pageData.containsKey(PaginationKey.size) || pageData.containsKey(PaginationKey.number)) { + pageByPages = true; + offset = pageData.getOrDefault(PaginationKey.number, null); + limit = pageData.getOrDefault(PaginationKey.size, null); + } + + return new PaginationImpl(entityClass, + offset, + limit, + elideSettings.getDefaultPageSize(), + elideSettings.getDefaultMaxPageSize(), + pageData.containsKey(PaginationKey.totals) ? true : null, + pageByPages); + } + + private static boolean hasInvalidCombination(Map pageData) { + return (pageData.containsKey(PaginationKey.size) || pageData.containsKey(PaginationKey.number)) + && (pageData.containsKey(PaginationKey.limit) || pageData.containsKey(PaginationKey.offset)); + } + + + /** + * Default Instance. + * @param elideSettings general Elide settings + * @return The default instance. + */ + public static PaginationImpl getDefaultPagination(Class entityClass, ElideSettings elideSettings) { + return new PaginationImpl( + entityClass, + null, + null, + elideSettings.getDefaultPageSize(), + elideSettings.getDefaultMaxPageSize(), + null, + false); + } + + /** + * Default Instance. + * @return The default instance. + */ + public static PaginationImpl getDefaultPagination(Class entityClass) { + return new PaginationImpl( + entityClass, + null, + null, + DEFAULT_PAGE_LIMIT, + MAX_PAGE_LIMIT, + null, + false); + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/sort/Sorting.java b/elide-core/src/main/java/com/yahoo/elide/core/sort/SortingImpl.java similarity index 80% rename from elide-core/src/main/java/com/yahoo/elide/core/sort/Sorting.java rename to elide-core/src/main/java/com/yahoo/elide/core/sort/SortingImpl.java index 042f80e5d7..624d289bcc 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/sort/Sorting.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/sort/SortingImpl.java @@ -9,12 +9,16 @@ import com.yahoo.elide.core.Path; import com.yahoo.elide.core.exceptions.InvalidValueException; +import com.yahoo.elide.request.Sorting; +import lombok.EqualsAndHashCode; +import lombok.Getter; import lombok.ToString; import java.util.Arrays; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.stream.Collectors; import javax.ws.rs.core.MultivaluedMap; @@ -23,25 +27,30 @@ * Generates a simple wrapper around the sort fields from the JSON-API GET Query. */ @ToString -public class Sorting { - - /** - * Denotes the intended sort type from json-api field. - */ - public enum SortOrder { asc, desc } +@EqualsAndHashCode +public class SortingImpl implements Sorting { private final Map sortRules = new LinkedHashMap<>(); - private static final Sorting DEFAULT_EMPTY_INSTANCE = null; + private static final SortingImpl DEFAULT_EMPTY_INSTANCE = null; private static final String JSONAPI_ID_KEYWORD = "id"; + @Getter + private Class type; + + @Getter + private Map sortingPaths; + /** * Constructs a new Sorting instance. * @param sortingRules The map of sorting rules */ - public Sorting(final Map sortingRules) { + public SortingImpl(final Map sortingRules, Class type, EntityDictionary dictionary) { if (sortingRules != null) { sortRules.putAll(sortingRules); } + + this.type = type; + sortingPaths = getValidSortingRules(type, dictionary); } /** @@ -52,8 +61,8 @@ public Sorting(final Map sortingRules) { * @return The valid sorting rules - validated through the entity dictionary, or empty dictionary * @throws InvalidValueException when sorting values are not valid for the jpa entity */ - public Map getValidSortingRules(final Class entityClass, - final EntityDictionary dictionary) + private Map getValidSortingRules(final Class entityClass, + final EntityDictionary dictionary) throws InvalidValueException { Map returnMap = new LinkedHashMap<>(); for (Map.Entry entry : replaceIdRule(dictionary.getIdFieldName(entityClass)).entrySet()) { @@ -97,6 +106,7 @@ protected static boolean isValidSortRulePath(Path path, EntityDictionary diction * Informs if the structure is default instance. * @return true if this instance is empty - no sorting rules */ + @Override public boolean isDefaultInstance() { return this.sortRules.isEmpty(); } @@ -106,12 +116,18 @@ public boolean isDefaultInstance() { * @param queryParams The query params on the request. * @return The Sorting instance (default or specific). */ - public static Sorting parseQueryParams(final MultivaluedMap queryParams) { - List sortRules = queryParams.entrySet().stream() + public static Sorting parseQueryParams(final Optional> queryParams, + Class type, EntityDictionary dictionary) { + + if (! queryParams.isPresent()) { + return DEFAULT_EMPTY_INSTANCE; + } + + List sortRules = queryParams.get().entrySet().stream() .filter(entry -> entry.getKey().equals("sort")) .map(entry -> entry.getValue().get(0)) .collect(Collectors.toList()); - return parseSortRules(sortRules); + return parseSortRules(sortRules, type, dictionary); } /** @@ -119,8 +135,8 @@ public static Sorting parseQueryParams(final MultivaluedMap quer * @param sortRule Sorting string to parse * @return Sorting object. */ - public static Sorting parseSortRule(String sortRule) { - return parseSortRules(Arrays.asList(sortRule)); + public static Sorting parseSortRule(String sortRule, Class type, EntityDictionary dictionary) { + return parseSortRules(Arrays.asList(sortRule), type, dictionary); } /** @@ -128,7 +144,7 @@ public static Sorting parseSortRule(String sortRule) { * @param sortRules Sorting rules to parse * @return Sorting object containing parsed sort rules */ - private static Sorting parseSortRules(List sortRules) { + private static SortingImpl parseSortRules(List sortRules, Class type, EntityDictionary dictionary) { final Map sortingRules = new LinkedHashMap<>(); for (String sortRule : sortRules) { if (sortRule.contains(",")) { @@ -139,7 +155,7 @@ private static Sorting parseSortRules(List sortRules) { parseSortRule(sortRule, sortingRules); } } - return sortingRules.isEmpty() ? DEFAULT_EMPTY_INSTANCE : new Sorting(sortingRules); + return sortingRules.isEmpty() ? DEFAULT_EMPTY_INSTANCE : new SortingImpl(sortingRules, type, dictionary); } /** diff --git a/elide-core/src/main/java/com/yahoo/elide/extensions/JsonApiPatch.java b/elide-core/src/main/java/com/yahoo/elide/extensions/JsonApiPatch.java index 520c1224d9..2dd05829a3 100644 --- a/elide-core/src/main/java/com/yahoo/elide/extensions/JsonApiPatch.java +++ b/elide-core/src/main/java/com/yahoo/elide/extensions/JsonApiPatch.java @@ -29,6 +29,7 @@ import org.apache.commons.collections4.IterableUtils; import org.apache.commons.lang3.ArrayUtils; import org.apache.commons.lang3.tuple.Pair; +import org.owasp.encoder.Encode; import java.io.IOException; import java.util.Arrays; @@ -288,12 +289,11 @@ private void postProcessRelationships(PatchRequestScope requestScope) { * Turn an exception into a proper error response from patch extension. */ private void throwErrorResponse() { - ObjectNode errorContainer = getErrorContainer(); - ArrayNode errorList = (ArrayNode) errorContainer.get("errors"); + ArrayNode errorContainer = getErrorContainer(); boolean failed = false; for (PatchAction action : actions) { - failed = processAction(errorList, failed, action); + failed = processAction(errorContainer, failed, action); } JsonPatchExtensionException failure = @@ -309,23 +309,27 @@ private void throwErrorResponse() { throw failure; } - private ObjectNode getErrorContainer() { - ObjectNode container = JsonNodeFactory.instance.objectNode(); - container.set("errors", JsonNodeFactory.instance.arrayNode()); + private ArrayNode getErrorContainer() { + ArrayNode container = JsonNodeFactory.instance.arrayNode(); return container; } private boolean processAction(ArrayNode errorList, boolean failed, PatchAction action) { + ObjectNode container = JsonNodeFactory.instance.objectNode(); + ArrayNode errors = JsonNodeFactory.instance.arrayNode(); + container.set("errors", errors); + errorList.add(container); + if (action.cause != null) { // this is the failed operation - errorList.add(toErrorNode(action.cause.getMessage(), action.cause.getStatus())); + errors.add(toErrorNode(action.cause.getMessage(), action.cause.getStatus())); failed = true; } else if (!failed) { // this operation succeeded - errorList.add(ERR_NODE_ERR_IN_SUBSEQUENT_OPERATION); + errors.add(ERR_NODE_ERR_IN_SUBSEQUENT_OPERATION); } else { // this operation never ran - errorList.add(ERR_NODE_OPERATION_NOT_RUN); + errors.add(ERR_NODE_OPERATION_NOT_RUN); } return failed; } @@ -356,9 +360,9 @@ private static void clearAllExceptRelationships(Resource resource) { */ private static JsonNode toErrorNode(String detail, Integer status) { ObjectNode formattedError = JsonNodeFactory.instance.objectNode(); - formattedError.set("detail", JsonNodeFactory.instance.textNode(detail)); + formattedError.set("detail", JsonNodeFactory.instance.textNode(Encode.forHtml(detail))); if (status != null) { - formattedError.set("status", JsonNodeFactory.instance.numberNode(status)); + formattedError.set("status", JsonNodeFactory.instance.textNode(status.toString())); } return formattedError; } diff --git a/elide-core/src/main/java/com/yahoo/elide/extensions/PatchRequestScope.java b/elide-core/src/main/java/com/yahoo/elide/extensions/PatchRequestScope.java index 4e1e993cd2..53b68e596b 100644 --- a/elide-core/src/main/java/com/yahoo/elide/extensions/PatchRequestScope.java +++ b/elide-core/src/main/java/com/yahoo/elide/extensions/PatchRequestScope.java @@ -8,6 +8,7 @@ import com.yahoo.elide.ElideSettings; import com.yahoo.elide.core.DataStoreTransaction; import com.yahoo.elide.core.RequestScope; +import com.yahoo.elide.jsonapi.EntityProjectionMaker; import com.yahoo.elide.jsonapi.models.JsonApiDocument; import com.yahoo.elide.security.User; @@ -22,17 +23,20 @@ public class PatchRequestScope extends RequestScope { * Outer RequestScope constructor for use by Patch Extension. * * @param path the URL path + * @param apiVersion client requested API version * @param transaction current database transaction * @param user request user * @param elideSettings Elide settings object */ public PatchRequestScope( String path, + String apiVersion, DataStoreTransaction transaction, User user, ElideSettings elideSettings) { super( path, + apiVersion, (JsonApiDocument) null, transaction, user, @@ -49,6 +53,7 @@ public PatchRequestScope( * @param scope outer request scope */ public PatchRequestScope(String path, JsonApiDocument jsonApiDocument, PatchRequestScope scope) { - super(path, jsonApiDocument, scope); + super(path, scope.getApiVersion(), jsonApiDocument, scope); + this.setEntityProjection(new EntityProjectionMaker(dictionary, this).parsePath(path)); } } diff --git a/elide-core/src/main/java/com/yahoo/elide/jsonapi/EntityProjectionMaker.java b/elide-core/src/main/java/com/yahoo/elide/jsonapi/EntityProjectionMaker.java new file mode 100644 index 0000000000..f77afb897e --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/jsonapi/EntityProjectionMaker.java @@ -0,0 +1,397 @@ +/* + * Copyright 2019, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.jsonapi; + +import com.yahoo.elide.core.EntityDictionary; +import com.yahoo.elide.core.Path; +import com.yahoo.elide.core.RequestScope; +import com.yahoo.elide.core.exceptions.InvalidCollectionException; +import com.yahoo.elide.core.filter.expression.FilterExpression; +import com.yahoo.elide.core.pagination.PaginationImpl; +import com.yahoo.elide.core.sort.SortingImpl; +import com.yahoo.elide.generated.parsers.CoreBaseVisitor; +import com.yahoo.elide.generated.parsers.CoreParser; +import com.yahoo.elide.parsers.JsonApiParser; +import com.yahoo.elide.request.Attribute; +import com.yahoo.elide.request.EntityProjection; +import com.yahoo.elide.request.Pagination; +import com.yahoo.elide.request.Relationship; +import com.yahoo.elide.request.Sorting; + +import com.google.common.collect.Sets; +import org.apache.commons.lang3.tuple.Pair; + +import lombok.Builder; +import lombok.Data; + +import java.util.Arrays; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import javax.ws.rs.core.MultivaluedHashMap; +import javax.ws.rs.core.MultivaluedMap; + +/** + * Converts a JSON-API request (URL and query parameters) into an EntityProjection. + */ +public class EntityProjectionMaker + extends CoreBaseVisitor, EntityProjectionMaker.NamedEntityProjection>> { + + /** + * An entity projection labeled with the class name or relationship name it is associated with. + */ + @Data + @Builder + public static class NamedEntityProjection { + private String name; + private EntityProjection projection; + } + + private static final String INCLUDE = "include"; + + private EntityDictionary dictionary; + private MultivaluedMap queryParams; + private Map> sparseFields; + private RequestScope scope; + + public EntityProjectionMaker(EntityDictionary dictionary, RequestScope scope) { + this.dictionary = dictionary; + this.queryParams = scope.getQueryParams().orElse(new MultivaluedHashMap<>()); + sparseFields = RequestScope.parseSparseFields(queryParams); + this.scope = scope; + } + + public EntityProjection parsePath(String path) { + return visit(JsonApiParser.parse(path)).apply(null).projection; + } + + public EntityProjection parseInclude(Class entityClass) { + return EntityProjection.builder() + .type(entityClass) + .relationships(toRelationshipSet(getIncludedRelationships(entityClass))) + .build(); + } + + @Override + public Function, NamedEntityProjection> visitRootCollectionLoadEntities( + CoreParser.RootCollectionLoadEntitiesContext ctx) { + return visitTerminalCollection(ctx.term()); + } + + @Override + public Function, NamedEntityProjection> visitSubCollectionReadCollection( + CoreParser.SubCollectionReadCollectionContext ctx) { + return visitTerminalCollection(ctx.term()); + } + + @Override + public Function, NamedEntityProjection> visitRootCollectionSubCollection( + CoreParser.RootCollectionSubCollectionContext ctx) { + return visitEntityWithSubCollection(ctx.entity(), ctx.subCollection()); + } + + @Override + public Function, NamedEntityProjection> visitSubCollectionSubCollection( + CoreParser.SubCollectionSubCollectionContext ctx) { + return visitEntityWithSubCollection(ctx.entity(), ctx.subCollection()); + } + + @Override + public Function, NamedEntityProjection> visitRootCollectionRelationship( + CoreParser.RootCollectionRelationshipContext ctx) { + return visitEntityWithRelationship(ctx.entity(), ctx.relationship()); + } + + @Override + public Function, NamedEntityProjection> visitSubCollectionRelationship( + CoreParser.SubCollectionRelationshipContext ctx) { + return visitEntityWithRelationship(ctx.entity(), ctx.relationship()); + } + + @Override + public Function, NamedEntityProjection> visitRootCollectionLoadEntity( + CoreParser.RootCollectionLoadEntityContext ctx) { + return (unused) -> { + return ctx.entity().accept(this).apply(null); + }; + } + + @Override + public Function, NamedEntityProjection> visitSubCollectionReadEntity( + CoreParser.SubCollectionReadEntityContext ctx) { + return (parentClass) -> { + return ctx.entity().accept(this).apply(parentClass); + }; + } + + @Override + public Function, NamedEntityProjection> visitRelationship(CoreParser.RelationshipContext ctx) { + return (parentClass) -> { + String entityName = ctx.term().getText(); + + Class entityClass = getEntityClass(parentClass, entityName); + FilterExpression filter = scope.getExpressionForRelation(parentClass, entityName).orElse(null); + + Sorting sorting = SortingImpl.parseQueryParams(scope.getQueryParams(), entityClass, dictionary); + Pagination pagination = PaginationImpl.parseQueryParams(entityClass, + scope.getQueryParams(), scope.getElideSettings()); + + return NamedEntityProjection.builder() + .name(entityName) + .projection(EntityProjection.builder() + .filterExpression(filter) + .sorting(sorting) + .pagination(pagination) + .type(entityClass) + .build() + ).build(); + }; + } + + @Override + public Function, NamedEntityProjection> visitEntity(CoreParser.EntityContext ctx) { + return (parentClass) -> { + String entityName = ctx.term().getText(); + + Class entityClass = getEntityClass(parentClass, entityName); + + return NamedEntityProjection.builder() + .name(entityName) + .projection(EntityProjection.builder() + .type(entityClass) + .attributes(getSparseAttributes(entityClass)) + .relationships(toRelationshipSet(getRequiredRelationships(entityClass))) + .build() + ).build(); + }; + } + + @Override + protected Function, NamedEntityProjection> aggregateResult( + Function, NamedEntityProjection> aggregate, + Function, NamedEntityProjection> nextResult) { + + if (aggregate == null) { + return nextResult; + } else { + return aggregate; + } + } + + public EntityProjection visitIncludePath(Path path) { + Path.PathElement pathElement = path.getPathElements().get(0); + int size = path.getPathElements().size(); + + Class entityClass = pathElement.getFieldType(); + + if (size > 1) { + Path nextPath = new Path(path.getPathElements().subList(1, size)); + EntityProjection relationshipProjection = visitIncludePath(nextPath); + + return EntityProjection.builder() + .relationships(toRelationshipSet(getSparseRelationships(entityClass))) + .relationship(nextPath.getPathElements().get(0).getFieldName(), relationshipProjection) + .attributes(getSparseAttributes(entityClass)) + .filterExpression(scope.getFilterExpressionByType(entityClass).orElse(null)) + .type(entityClass) + .build(); + } + + return EntityProjection.builder() + .relationships(toRelationshipSet(getSparseRelationships(entityClass))) + .attributes(getSparseAttributes(entityClass)) + .type(entityClass) + .filterExpression(scope.getFilterExpressionByType(entityClass).orElse(null)) + .build(); + } + + private Function, NamedEntityProjection> visitEntityWithSubCollection(CoreParser.EntityContext entity, + CoreParser.SubCollectionContext subCollection) { + return (parentClass) -> { + String entityName = entity.term().getText(); + + Class entityClass = getEntityClass(parentClass, entityName); + + NamedEntityProjection projection = subCollection.accept(this).apply(entityClass); + + return NamedEntityProjection.builder() + .name(entityName) + .projection(EntityProjection.builder() + .type(entityClass) + .relationship(projection.name, projection.projection) + .build() + ).build(); + }; + } + + private Function, NamedEntityProjection> visitEntityWithRelationship(CoreParser.EntityContext entity, + CoreParser.RelationshipContext relationship) { + return (parentClass) -> { + String entityName = entity.term().getText(); + + Class entityClass = getEntityClass(parentClass, entityName); + + String relationshipName = relationship.term().getText(); + NamedEntityProjection relationshipProjection = relationship.accept(this).apply(entityClass); + + FilterExpression filter = scope.getFilterExpressionByType(entityClass).orElse(null); + + return NamedEntityProjection.builder() + .name(entityName) + .projection(EntityProjection.builder() + .type(entityClass) + .filterExpression(filter) + .relationships(toRelationshipSet(getRequiredRelationships(entityClass))) + .relationship(relationshipName, relationshipProjection.projection) + .build() + ).build(); + }; + } + + private Function, NamedEntityProjection> visitTerminalCollection(CoreParser.TermContext collectionName) { + return (parentClass) -> { + String collectionNameText = collectionName.getText(); + + Class entityClass = getEntityClass(parentClass, collectionNameText); + + FilterExpression filter; + if (parentClass == null) { + filter = scope.getLoadFilterExpression(entityClass).orElse(null); + } else { + filter = scope.getExpressionForRelation(parentClass, collectionNameText).orElse(null); + } + + Sorting sorting = SortingImpl.parseQueryParams(scope.getQueryParams(), entityClass, dictionary); + Pagination pagination = PaginationImpl.parseQueryParams(entityClass, + scope.getQueryParams(), scope.getElideSettings()); + + return NamedEntityProjection.builder() + .name(collectionNameText) + .projection(EntityProjection.builder() + .filterExpression(filter) + .sorting(sorting) + .pagination(pagination) + .relationships(toRelationshipSet(getRequiredRelationships(entityClass))) + .attributes(getSparseAttributes(entityClass)) + .type(entityClass) + .build() + ).build(); + }; + } + + private Class getEntityClass(Class parentClass, String entityLabel) { + + //entityLabel represents a root collection. + if (parentClass == null) { + + Class entityClass = dictionary.getEntityClass(entityLabel, scope.getApiVersion()); + + if (entityClass != null) { + return entityClass; + } + + + //entityLabel represents a relationship. + } else if (dictionary.isRelation(parentClass, entityLabel)) { + return dictionary.getParameterizedType(parentClass, entityLabel); + } + + throw new InvalidCollectionException(entityLabel); + } + + private Map getIncludedRelationships(Class entityClass) { + Set includePaths = getIncludePaths(entityClass); + + Map relationships = includePaths.stream() + .map((path) -> Pair.of(path.getPathElements().get(0).getFieldName(), visitIncludePath(path))) + .collect(Collectors.toMap( + Pair::getKey, + Pair::getValue, + EntityProjection::merge + )); + + return relationships; + } + + private Set getSparseAttributes(Class entityClass) { + Set allAttributes = new LinkedHashSet<>(dictionary.getAttributes(entityClass)); + + Set sparseFieldsForEntity = sparseFields.get(dictionary.getJsonAliasFor(entityClass)); + if (sparseFieldsForEntity == null || sparseFieldsForEntity.isEmpty()) { + sparseFieldsForEntity = allAttributes; + } + + return Sets.intersection(allAttributes, sparseFieldsForEntity).stream() + .map(attributeName -> Attribute.builder() + .parentType(entityClass) + .name(attributeName) + .type(dictionary.getType(entityClass, attributeName)) + .build()) + .collect(Collectors.toSet()); + } + + private Map getSparseRelationships(Class entityClass) { + Set allRelationships = new LinkedHashSet<>(dictionary.getRelationships(entityClass)); + Set sparseFieldsForEntity = sparseFields.get(dictionary.getJsonAliasFor(entityClass)); + + if (sparseFieldsForEntity == null || sparseFieldsForEntity.isEmpty()) { + sparseFieldsForEntity = allRelationships; + } + + sparseFieldsForEntity = Sets.intersection(allRelationships, sparseFieldsForEntity); + + return sparseFieldsForEntity.stream() + .collect(Collectors.toMap( + Function.identity(), + (relationshipName) -> { + FilterExpression filter = scope.getExpressionForRelation(entityClass, relationshipName) + .orElse(null); + + return EntityProjection.builder() + .type(dictionary.getParameterizedType(entityClass, relationshipName)) + .filterExpression(filter) + .build(); + } + )); + } + + private Map getRequiredRelationships(Class entityClass) { + return Stream.concat( + getIncludedRelationships(entityClass).entrySet().stream(), + getSparseRelationships(entityClass).entrySet().stream() + ).collect(Collectors.toMap( + Map.Entry::getKey, + Map.Entry::getValue, + EntityProjection::merge + )); + } + + private Set getIncludePaths(Class entityClass) { + if (queryParams.get(INCLUDE) != null) { + return queryParams.get(INCLUDE).stream() + .flatMap(param -> Arrays.stream(param.split(","))) + .map(pathString -> new Path(entityClass, dictionary, pathString)) + .collect(Collectors.toSet()); + } + + return new LinkedHashSet<>(); + } + + private Set toRelationshipSet(Map relationships) { + return relationships.entrySet().stream() + .map(entry -> Relationship.builder() + .name(entry.getKey()) + .alias(entry.getKey()) + .projection(entry.getValue()) + .build()) + .collect(Collectors.toSet()); + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/jsonapi/document/processors/IncludedProcessor.java b/elide-core/src/main/java/com/yahoo/elide/jsonapi/document/processors/IncludedProcessor.java index 40056b0c11..a7805da4bb 100644 --- a/elide-core/src/main/java/com/yahoo/elide/jsonapi/document/processors/IncludedProcessor.java +++ b/elide-core/src/main/java/com/yahoo/elide/jsonapi/document/processors/IncludedProcessor.java @@ -7,8 +7,10 @@ import com.yahoo.elide.core.PersistentResource; import com.yahoo.elide.core.exceptions.ForbiddenAccessException; -import com.yahoo.elide.core.filter.expression.FilterExpression; +import com.yahoo.elide.jsonapi.EntityProjectionMaker; import com.yahoo.elide.jsonapi.models.JsonApiDocument; +import com.yahoo.elide.request.EntityProjection; +import com.yahoo.elide.request.Relationship; import com.google.common.collect.Lists; @@ -60,13 +62,16 @@ public void execute(JsonApiDocument jsonApiDocument, Set res */ private void addIncludedResources(JsonApiDocument jsonApiDocument, PersistentResource rec, List requestedRelationPaths) { + + EntityProjectionMaker maker = new EntityProjectionMaker(rec.getDictionary(), rec.getRequestScope()); + EntityProjection projection = maker.parseInclude(rec.getResourceClass()); // Process each include relation path requestedRelationPaths.forEach(pathParam -> { List pathList = Arrays.asList(pathParam.split(RELATION_PATH_SEPARATOR)); pathList.forEach(requestedRelationPath -> { List relationPath = Lists.newArrayList(requestedRelationPath.split(RELATION_PATH_DELIMITER)); - addResourcesForPath(jsonApiDocument, rec, relationPath); + addResourcesForPath(jsonApiDocument, rec, relationPath, projection); }); }); } @@ -76,15 +81,17 @@ private void addIncludedResources(JsonApiDocument jsonApiDocument, PersistentRes * JsonApiDocument. */ private void addResourcesForPath(JsonApiDocument jsonApiDocument, PersistentResource rec, - List relationPath) { + List relationPath, + EntityProjection projection) { //Pop off a relation of relation path String relation = relationPath.remove(0); - Optional filterExpression = rec.getRequestScope().getExpressionForRelation(rec, relation); Set collection; + Relationship relationship = projection.getRelationship(relation).orElseThrow(IllegalStateException::new); try { - collection = rec.getRelationCheckedFiltered(relation, filterExpression, Optional.empty(), Optional.empty()); + collection = rec.getRelationCheckedFiltered(relationship); + } catch (ForbiddenAccessException e) { return; } @@ -95,7 +102,8 @@ private void addResourcesForPath(JsonApiDocument jsonApiDocument, PersistentReso //If more relations left in the path, process a level deeper if (!relationPath.isEmpty()) { //Use a copy of the relationPath to preserve the path for remaining branches of the relationship tree - addResourcesForPath(jsonApiDocument, resource, new ArrayList<>(relationPath)); + addResourcesForPath(jsonApiDocument, resource, new ArrayList<>(relationPath), + relationship.getProjection()); } }); } diff --git a/elide-core/src/main/java/com/yahoo/elide/jsonapi/models/Resource.java b/elide-core/src/main/java/com/yahoo/elide/jsonapi/models/Resource.java index 9b71f59c6d..42ae14852a 100644 --- a/elide-core/src/main/java/com/yahoo/elide/jsonapi/models/Resource.java +++ b/elide-core/src/main/java/com/yahoo/elide/jsonapi/models/Resource.java @@ -5,12 +5,14 @@ */ package com.yahoo.elide.jsonapi.models; +import com.yahoo.elide.core.EntityDictionary; import com.yahoo.elide.core.PersistentResource; import com.yahoo.elide.core.RequestScope; import com.yahoo.elide.core.exceptions.ForbiddenAccessException; import com.yahoo.elide.core.exceptions.InvalidObjectIdentifierException; import com.yahoo.elide.core.exceptions.UnknownEntityException; import com.yahoo.elide.jsonapi.serialization.KeySerializer; +import com.yahoo.elide.request.EntityProjection; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; @@ -147,13 +149,21 @@ public boolean equals(Object obj) { public PersistentResource toPersistentResource(RequestScope requestScope) throws ForbiddenAccessException, InvalidObjectIdentifierException { - Class cls = requestScope.getDictionary().getEntityClass(type); + EntityDictionary dictionary = requestScope.getDictionary(); + + Class cls = dictionary.getEntityClass(type, requestScope.getApiVersion()); + if (cls == null) { throw new UnknownEntityException(type); } if (id == null) { throw new InvalidObjectIdentifierException(id, type); } - return PersistentResource.loadRecord(cls, id, requestScope); + + EntityProjection projection = EntityProjection.builder() + .type(cls) + .build(); + + return PersistentResource.loadRecord(projection, id, requestScope); } } diff --git a/elide-core/src/main/java/com/yahoo/elide/jsonapi/models/ResourceIdentifier.java b/elide-core/src/main/java/com/yahoo/elide/jsonapi/models/ResourceIdentifier.java index 5cf97420b5..63757b9242 100644 --- a/elide-core/src/main/java/com/yahoo/elide/jsonapi/models/ResourceIdentifier.java +++ b/elide-core/src/main/java/com/yahoo/elide/jsonapi/models/ResourceIdentifier.java @@ -9,6 +9,7 @@ import com.yahoo.elide.core.RequestScope; import com.yahoo.elide.core.exceptions.ForbiddenAccessException; import com.yahoo.elide.core.exceptions.InvalidObjectIdentifierException; +import com.yahoo.elide.request.EntityProjection; import com.fasterxml.jackson.annotation.JsonProperty; @@ -37,8 +38,11 @@ public String getId() { public PersistentResource toPersistentResource(RequestScope requestScope) throws ForbiddenAccessException, InvalidObjectIdentifierException { - Class cls = requestScope.getDictionary().getEntityClass(type); - return PersistentResource.loadRecord(cls, id, requestScope); + + Class cls = requestScope.getDictionary().getEntityClass(type, requestScope.getApiVersion()); + return PersistentResource.loadRecord(EntityProjection.builder() + .type(cls) + .build(), id, requestScope); } public Resource castToResource() { diff --git a/elide-core/src/main/java/com/yahoo/elide/parsers/expression/CanPaginateVisitor.java b/elide-core/src/main/java/com/yahoo/elide/parsers/expression/CanPaginateVisitor.java index 4cc0615419..449abf86bd 100644 --- a/elide-core/src/main/java/com/yahoo/elide/parsers/expression/CanPaginateVisitor.java +++ b/elide-core/src/main/java/com/yahoo/elide/parsers/expression/CanPaginateVisitor.java @@ -208,14 +208,14 @@ public PaginationStatus visitPAREN(ExpressionParser.PARENContext ctx) { @Override public PaginationStatus visitPermissionClass(ExpressionParser.PermissionClassContext ctx) { - Check check = getCheck(dictionary, ctx.getText()); + Check check = getCheck(dictionary, ctx.getText()); //Filter expression checks can always be pushed to the DataStore so pagination is possible if (check instanceof FilterExpressionCheck) { return PaginationStatus.CAN_PAGINATE; } if (check instanceof UserCheck) { - if (check.ok(scope.getUser())) { + if (((UserCheck) check).ok(scope.getUser())) { return PaginationStatus.USER_CHECK_TRUE; } return PaginationStatus.USER_CHECK_FALSE; diff --git a/elide-core/src/main/java/com/yahoo/elide/parsers/expression/PermissionToFilterExpressionVisitor.java b/elide-core/src/main/java/com/yahoo/elide/parsers/expression/PermissionToFilterExpressionVisitor.java index a8622f1bbc..5413b8cd35 100644 --- a/elide-core/src/main/java/com/yahoo/elide/parsers/expression/PermissionToFilterExpressionVisitor.java +++ b/elide-core/src/main/java/com/yahoo/elide/parsers/expression/PermissionToFilterExpressionVisitor.java @@ -177,7 +177,7 @@ public FilterExpression visitPermissionClass(ExpressionParser.PermissionClassCon } if (check instanceof UserCheck) { - boolean userCheckResult = check.ok(requestScope.getUser()); + boolean userCheckResult = ((UserCheck) check).ok(requestScope.getUser()); return userCheckResult ? TRUE_USER_CHECK_EXPRESSION : FALSE_USER_CHECK_EXPRESSION; } diff --git a/elide-core/src/main/java/com/yahoo/elide/parsers/state/CollectionTerminalState.java b/elide-core/src/main/java/com/yahoo/elide/parsers/state/CollectionTerminalState.java index 7673900c80..5c30c5b559 100644 --- a/elide-core/src/main/java/com/yahoo/elide/parsers/state/CollectionTerminalState.java +++ b/elide-core/src/main/java/com/yahoo/elide/parsers/state/CollectionTerminalState.java @@ -14,9 +14,6 @@ import com.yahoo.elide.core.exceptions.InvalidObjectIdentifierException; import com.yahoo.elide.core.exceptions.InvalidValueException; import com.yahoo.elide.core.exceptions.UnknownEntityException; -import com.yahoo.elide.core.filter.expression.FilterExpression; -import com.yahoo.elide.core.pagination.Pagination; -import com.yahoo.elide.core.sort.Sorting; import com.yahoo.elide.jsonapi.JsonApiMapper; import com.yahoo.elide.jsonapi.document.processors.DocumentProcessor; import com.yahoo.elide.jsonapi.document.processors.IncludedProcessor; @@ -25,11 +22,12 @@ import com.yahoo.elide.jsonapi.models.Meta; import com.yahoo.elide.jsonapi.models.Relationship; import com.yahoo.elide.jsonapi.models.Resource; +import com.yahoo.elide.request.EntityProjection; +import com.yahoo.elide.request.Pagination; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.google.common.base.Preconditions; - import org.apache.commons.collections4.IterableUtils; import org.apache.commons.lang3.tuple.Pair; @@ -56,9 +54,11 @@ public class CollectionTerminalState extends BaseState { private final Optional relationName; private final Class entityClass; private PersistentResource newObject; + private final EntityProjection parentProjection; public CollectionTerminalState(Class entityClass, Optional parent, - Optional relationName) { + Optional relationName, EntityProjection projection) { + this.parentProjection = projection; this.parent = parent; this.relationName = relationName; this.entityClass = entityClass; @@ -78,16 +78,20 @@ public Supplier> handleGet(StateContext state) { DocumentProcessor includedProcessor = new IncludedProcessor(); includedProcessor.execute(jsonApiDocument, collection, queryParams); + Pagination pagination = parentProjection.getPagination(); + if (parent.isPresent()) { + pagination = parentProjection.getRelationship(relationName.get()).get().getProjection().getPagination(); + } + // Add pagination meta data - Pagination pagination = requestScope.getPagination(); - if (!pagination.isEmpty()) { + if (!pagination.isDefaultInstance()) { Map pageMetaData = new HashMap<>(); pageMetaData.put("number", (pagination.getOffset() / pagination.getLimit()) + 1); pageMetaData.put("limit", pagination.getLimit()); // Get total records if it has been requested and add to the page meta data - if (pagination.isGenerateTotals()) { + if (pagination.returnPageTotals()) { Long totalRecords = pagination.getPageTotals(); pageMetaData.put("totalPages", totalRecords / pagination.getLimit() + ((totalRecords % pagination.getLimit()) > 0 ? 1 : 0)); @@ -126,27 +130,13 @@ private Set getResourceCollection(RequestScope requestScope) // TODO: In case of join filters, apply pagination after getting records // instead of passing it to the datastore - Optional pagination = Optional.ofNullable(requestScope.getPagination()); - Optional sorting = Optional.ofNullable(requestScope.getSorting()); - if (parent.isPresent()) { - Optional filterExpression = - requestScope.getExpressionForRelation(parent.get(), relationName.get()); - collection = parent.get().getRelationCheckedFiltered( - relationName.get(), - filterExpression, - sorting, - pagination); + parentProjection.getRelationship(relationName.get()).orElseThrow(IllegalStateException::new)); } else { - Optional filterExpression = requestScope.getLoadFilterExpression(entityClass); - collection = PersistentResource.loadRecords( - entityClass, + parentProjection, new ArrayList<>(), //Empty list of IDs - filterExpression, - sorting, - pagination, requestScope); } @@ -177,7 +167,9 @@ private PersistentResource createObject(RequestScope requestScope) } String id = resource.getId(); - Class newObjectClass = requestScope.getDictionary().getEntityClass(resource.getType()); + + Class newObjectClass = requestScope.getDictionary().getEntityClass(resource.getType(), + requestScope.getApiVersion()); if (newObjectClass == null) { throw new UnknownEntityException("Entity " + resource.getType() + " not found"); @@ -187,8 +179,8 @@ private PersistentResource createObject(RequestScope requestScope) + " to type: " + entityClass); } - PersistentResource pResource = PersistentResource.createObject( - parent.orElse(null), newObjectClass, requestScope, Optional.ofNullable(id)); + PersistentResource pResource = PersistentResource.createObject(parent.orElse(null), newObjectClass, + requestScope, Optional.ofNullable(id)); Map attributes = resource.getAttributes(); if (attributes != null) { diff --git a/elide-core/src/main/java/com/yahoo/elide/parsers/state/RecordState.java b/elide-core/src/main/java/com/yahoo/elide/parsers/state/RecordState.java index f1c759fc22..3f450bb8d4 100644 --- a/elide-core/src/main/java/com/yahoo/elide/parsers/state/RecordState.java +++ b/elide-core/src/main/java/com/yahoo/elide/parsers/state/RecordState.java @@ -8,14 +8,13 @@ import com.yahoo.elide.core.EntityDictionary; import com.yahoo.elide.core.PersistentResource; import com.yahoo.elide.core.RelationshipType; -import com.yahoo.elide.core.exceptions.InvalidAttributeException; -import com.yahoo.elide.core.exceptions.InvalidCollectionException; -import com.yahoo.elide.core.filter.expression.FilterExpression; import com.yahoo.elide.generated.parsers.CoreParser.SubCollectionReadCollectionContext; import com.yahoo.elide.generated.parsers.CoreParser.SubCollectionReadEntityContext; import com.yahoo.elide.generated.parsers.CoreParser.SubCollectionRelationshipContext; import com.yahoo.elide.generated.parsers.CoreParser.SubCollectionSubCollectionContext; import com.yahoo.elide.jsonapi.models.SingleElementSet; +import com.yahoo.elide.request.EntityProjection; +import com.yahoo.elide.request.Relationship; import com.google.common.base.Preconditions; @@ -28,54 +27,53 @@ public class RecordState extends BaseState { private final PersistentResource resource; - public RecordState(PersistentResource resource) { + /* The projection which loaded this record */ + private final EntityProjection projection; + + public RecordState(PersistentResource resource, EntityProjection projection) { Preconditions.checkNotNull(resource); this.resource = resource; + this.projection = projection; } @Override public void handle(StateContext state, SubCollectionReadCollectionContext ctx) { String subCollection = ctx.term().getText(); EntityDictionary dictionary = state.getRequestScope().getDictionary(); + Class entityClass; String entityName; - try { - RelationshipType type = dictionary.getRelationshipType(resource.getObject(), subCollection); - if (type == RelationshipType.NONE) { - throw new InvalidCollectionException(subCollection); - } - Class paramType = dictionary.getParameterizedType(resource.getObject(), subCollection); - if (dictionary.isMappedInterface(paramType)) { - entityName = EntityDictionary.getSimpleName(paramType); - entityClass = paramType; - } else { - entityName = dictionary.getJsonAliasFor(paramType); - entityClass = dictionary.getEntityClass(entityName); - - } - if (entityClass == null) { - throw new IllegalArgumentException("Unknown type " + entityName); - } - final BaseState nextState; - final CollectionTerminalState collectionTerminalState = - new CollectionTerminalState(entityClass, Optional.of(resource), Optional.of(subCollection)); - Set collection = null; - if (type.isToOne()) { - Optional filterExpression = - state.getRequestScope().getExpressionForRelation(resource, subCollection); - collection = resource.getRelationCheckedFiltered(subCollection, - filterExpression, Optional.empty(), Optional.empty()); - } - if (collection instanceof SingleElementSet) { - PersistentResource record = ((SingleElementSet) collection).getValue(); - nextState = new RecordTerminalState(record, collectionTerminalState); - } else { - nextState = collectionTerminalState; - } - state.setState(nextState); - } catch (InvalidAttributeException e) { - throw new InvalidCollectionException(subCollection); + + RelationshipType type = dictionary.getRelationshipType(resource.getObject(), subCollection); + + Class paramType = dictionary.getParameterizedType(resource.getObject(), subCollection); + if (dictionary.isMappedInterface(paramType)) { + entityName = EntityDictionary.getSimpleName(paramType); + entityClass = paramType; + } else { + entityName = dictionary.getJsonAliasFor(paramType); + + entityClass = dictionary.getEntityClass(entityName, state.getRequestScope().getApiVersion()); } + if (entityClass == null) { + throw new IllegalArgumentException("Unknown type " + entityName); + } + final BaseState nextState; + final CollectionTerminalState collectionTerminalState = + new CollectionTerminalState(entityClass, Optional.of(resource), + Optional.of(subCollection), projection); + Set collection = null; + if (type.isToOne()) { + collection = resource.getRelationCheckedFiltered(projection.getRelationship(subCollection) + .orElseThrow(IllegalStateException::new)); + } + if (collection instanceof SingleElementSet) { + PersistentResource record = ((SingleElementSet) collection).getValue(); + nextState = new RecordTerminalState(record, collectionTerminalState); + } else { + nextState = collectionTerminalState; + } + state.setState(nextState); } @Override @@ -83,46 +81,35 @@ public void handle(StateContext state, SubCollectionReadEntityContext ctx) { String id = ctx.entity().id().getText(); String subCollection = ctx.entity().term().getText(); - try { - PersistentResource nextRecord = resource.getRelation(subCollection, id); - state.setState(new RecordTerminalState(nextRecord)); - } catch (InvalidAttributeException e) { - throw new InvalidCollectionException(subCollection); - } + PersistentResource nextRecord = resource.getRelation( + projection.getRelationship(subCollection).orElseThrow(IllegalStateException::new), id); + state.setState(new RecordTerminalState(nextRecord)); } @Override public void handle(StateContext state, SubCollectionSubCollectionContext ctx) { String id = ctx.entity().id().getText(); String subCollection = ctx.entity().term().getText(); - try { - state.setState(new RecordState(resource.getRelation(subCollection, id))); - } catch (InvalidAttributeException e) { - throw new InvalidCollectionException(subCollection); - } + + Relationship relationship = projection.getRelationship(subCollection) + .orElseThrow(IllegalStateException::new); + + state.setState(new RecordState(resource.getRelation(relationship, id), relationship.getProjection())); } @Override public void handle(StateContext state, SubCollectionRelationshipContext ctx) { String id = ctx.entity().id().getText(); String subCollection = ctx.entity().term().getText(); + String relationName = ctx.relationship().term().getText(); PersistentResource childRecord; - try { - childRecord = resource.getRelation(subCollection, id); - } catch (InvalidAttributeException e) { - throw new InvalidCollectionException(subCollection); - } - String relationName = ctx.relationship().term().getText(); - try { - Optional filterExpression = - state.getRequestScope().getExpressionForRelation(resource, subCollection); - childRecord.getRelationCheckedFiltered(relationName, filterExpression, Optional.empty(), Optional.empty()); - } catch (InvalidAttributeException e) { - throw new InvalidCollectionException(relationName); - } + Relationship childRelationship = projection.getRelationship(subCollection) + .orElseThrow(IllegalStateException::new); + + childRecord = resource.getRelation(childRelationship , id); - state.setState(new RelationshipTerminalState(childRecord, relationName)); + state.setState(new RelationshipTerminalState(childRecord, relationName, childRelationship.getProjection())); } } diff --git a/elide-core/src/main/java/com/yahoo/elide/parsers/state/RelationshipTerminalState.java b/elide-core/src/main/java/com/yahoo/elide/parsers/state/RelationshipTerminalState.java index 0c0ed93346..ff1f466bf3 100644 --- a/elide-core/src/main/java/com/yahoo/elide/parsers/state/RelationshipTerminalState.java +++ b/elide-core/src/main/java/com/yahoo/elide/parsers/state/RelationshipTerminalState.java @@ -5,6 +5,7 @@ */ package com.yahoo.elide.parsers.state; +import com.yahoo.elide.annotation.UpdatePermission; import com.yahoo.elide.core.HttpStatus; import com.yahoo.elide.core.PersistentResource; import com.yahoo.elide.core.RelationshipType; @@ -18,6 +19,7 @@ import com.yahoo.elide.jsonapi.models.JsonApiDocument; import com.yahoo.elide.jsonapi.models.Relationship; import com.yahoo.elide.jsonapi.models.Resource; +import com.yahoo.elide.request.EntityProjection; import com.fasterxml.jackson.databind.JsonNode; @@ -41,8 +43,13 @@ public class RelationshipTerminalState extends BaseState { private final RelationshipType relationshipType; private final String relationshipName; - public RelationshipTerminalState(PersistentResource record, String relationshipName) { + /* The projection which loaded the resource which owns the relationship */ + private final EntityProjection parentProjection; + + public RelationshipTerminalState(PersistentResource record, String relationshipName, + EntityProjection parentProjection) { this.record = record; + this.parentProjection = parentProjection; this.relationshipType = record.getRelationshipType(relationshipName); this.relationshipName = relationshipName; @@ -55,9 +62,10 @@ public Supplier> handleGet(StateContext state) { JsonApiMapper mapper = requestScope.getMapper(); Optional> queryParams = requestScope.getQueryParams(); - Map relationships = record.toResourceWithSortingAndPagination().getRelationships(); + Map relationships = record.toResource(parentProjection).getRelationships(); + Relationship relationship = null; if (relationships != null) { - Relationship relationship = relationships.get(relationshipName); + relationship = relationships.get(relationshipName); // Handle valid relationship @@ -168,7 +176,7 @@ private boolean delete(Data data, RequestScope requestScope) { Collection resources = data.get(); if (CollectionUtils.isEmpty(resources)) { // As per: http://jsonapi.org/format/#crud-updating-relationship-responses-403 - throw new ForbiddenAccessException("Unknown update"); + throw new ForbiddenAccessException(UpdatePermission.class); } resources.stream().forEachOrdered(resource -> record.removeRelation(relationshipName, resource.toPersistentResource(requestScope))); diff --git a/elide-core/src/main/java/com/yahoo/elide/parsers/state/StartState.java b/elide-core/src/main/java/com/yahoo/elide/parsers/state/StartState.java index dfdba1827c..cff08982b4 100644 --- a/elide-core/src/main/java/com/yahoo/elide/parsers/state/StartState.java +++ b/elide-core/src/main/java/com/yahoo/elide/parsers/state/StartState.java @@ -7,14 +7,12 @@ import com.yahoo.elide.core.EntityDictionary; import com.yahoo.elide.core.PersistentResource; -import com.yahoo.elide.core.exceptions.InvalidAttributeException; -import com.yahoo.elide.core.exceptions.InvalidCollectionException; -import com.yahoo.elide.core.filter.expression.FilterExpression; import com.yahoo.elide.generated.parsers.CoreParser.EntityContext; import com.yahoo.elide.generated.parsers.CoreParser.RootCollectionLoadEntitiesContext; import com.yahoo.elide.generated.parsers.CoreParser.RootCollectionLoadEntityContext; import com.yahoo.elide.generated.parsers.CoreParser.RootCollectionRelationshipContext; import com.yahoo.elide.generated.parsers.CoreParser.RootCollectionSubCollectionContext; +import com.yahoo.elide.request.EntityProjection; import java.util.Optional; @@ -26,11 +24,11 @@ public class StartState extends BaseState { public void handle(StateContext state, RootCollectionLoadEntitiesContext ctx) { String entityName = ctx.term().getText(); EntityDictionary dictionary = state.getRequestScope().getDictionary(); - Class entityClass = dictionary.getEntityClass(entityName); - if (entityClass == null || !dictionary.isRoot(entityClass)) { - throw new InvalidCollectionException(entityName); - } - state.setState(new CollectionTerminalState(entityClass, Optional.empty(), Optional.empty())); + + Class entityClass = dictionary.getEntityClass(entityName, state.getRequestScope().getApiVersion()); + + state.setState(new CollectionTerminalState(entityClass, Optional.empty(), Optional.empty(), + state.getRequestScope().getEntityProjection())); } @Override @@ -42,23 +40,21 @@ public void handle(StateContext state, RootCollectionLoadEntityContext ctx) { @Override public void handle(StateContext state, RootCollectionSubCollectionContext ctx) { PersistentResource record = entityRecord(state, ctx.entity()); - state.setState(new RecordState(record)); + + state.setState(new RecordState(record, state.getRequestScope().getEntityProjection())); } @Override public void handle(StateContext state, RootCollectionRelationshipContext ctx) { PersistentResource record = entityRecord(state, ctx.entity()); + EntityProjection projection = state.getRequestScope().getEntityProjection(); String relationName = ctx.relationship().term().getText(); - try { - Optional filterExpression = - state.getRequestScope().getExpressionForRelation(record, relationName); - record.getRelationCheckedFiltered(relationName, filterExpression, Optional.empty(), Optional.empty()); - } catch (InvalidAttributeException e) { - throw new InvalidCollectionException(relationName); - } - state.setState(new RelationshipTerminalState(record, relationName)); + record.getRelationCheckedFiltered(projection.getRelationship(relationName) + .orElseThrow(IllegalStateException::new)); + + state.setState(new RelationshipTerminalState(record, relationName, projection)); } @Override @@ -67,14 +63,9 @@ public String toString() { } private PersistentResource entityRecord(StateContext state, EntityContext entity) { - String entityName = entity.term().getText(); String id = entity.id().getText(); - EntityDictionary dictionary = state.getRequestScope().getDictionary(); - Class entityClass = dictionary.getEntityClass(entityName); - if (entityClass == null || !dictionary.isRoot(entityClass)) { - throw new InvalidCollectionException(entityName); - } - return PersistentResource.loadRecord(entityClass, id, state.getRequestScope()); + return PersistentResource.loadRecord(state.getRequestScope().getEntityProjection(), + id, state.getRequestScope()); } } diff --git a/elide-core/src/main/java/com/yahoo/elide/request/Argument.java b/elide-core/src/main/java/com/yahoo/elide/request/Argument.java new file mode 100644 index 0000000000..fb8ba1798a --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/request/Argument.java @@ -0,0 +1,32 @@ +/* + * Copyright 2019, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.request; + +import lombok.Builder; +import lombok.NonNull; +import lombok.Value; + +/** + * Represents an argument passed to an attribute. + */ +@Value +@Builder +public class Argument { + + @NonNull + String name; + + Object value; + + /** + * Returns the argument type. + * @return the argument type. + */ + public Class getType() { + return value.getClass(); + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/request/Attribute.java b/elide-core/src/main/java/com/yahoo/elide/request/Attribute.java new file mode 100644 index 0000000000..7af57e2442 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/request/Attribute.java @@ -0,0 +1,58 @@ +/* + * Copyright 2019, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.request; + +import lombok.Builder; +import lombok.Data; +import lombok.NonNull; +import lombok.Singular; +import lombok.ToString; + +import java.util.Set; + +/** + * Represents an attribute on an Elide entity. Attributes can take arguments. + */ +@Data +@Builder +public class Attribute { + @NonNull + @ToString.Exclude + private Class type; + + @NonNull + private String name; + + @ToString.Exclude + private String alias; + + @ToString.Exclude + //If null, the parentType is the same as the entity projection to which this attribute belongs. + //If not null, this represents the model type where this attribute can be found. + private Class parentType; + + @Singular + @ToString.Exclude + private Set arguments; + + private Attribute(@NonNull Class type, @NonNull String name, String alias, Class parentType, + Set arguments) { + this.type = type; + this.parentType = parentType; + this.name = name; + this.alias = alias == null ? name : alias; + this.arguments = arguments; + } + + private Attribute(@NonNull Class type, @NonNull String name, String alias, Set arguments) { + this.type = type; + this.parentType = null; + this.name = name; + this.alias = alias == null ? name : alias; + this.arguments = arguments; + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/request/EntityProjection.java b/elide-core/src/main/java/com/yahoo/elide/request/EntityProjection.java new file mode 100644 index 0000000000..080ecaf91a --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/request/EntityProjection.java @@ -0,0 +1,273 @@ +/* + * Copyright 2019, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.request; + +import com.yahoo.elide.core.exceptions.BadRequestException; +import com.yahoo.elide.core.filter.expression.FilterExpression; +import com.google.common.collect.Sets; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.Getter; +import lombok.NonNull; + +import java.util.LinkedHashSet; +import java.util.Optional; +import java.util.Set; + +/** + * Represents a client data request against a subgraph of the entity relationship graph. + */ +@Data +@Builder +@AllArgsConstructor +public class EntityProjection { + @NonNull + private Class type; + + private Set attributes; + + private Set relationships; + + private FilterExpression filterExpression; + + private Sorting sorting; + + private Pagination pagination; + + /** + * Creates a builder initialized as a copy of this collection + * @return The new builder + */ + public EntityProjectionBuilder copyOf() { + return EntityProjection.builder() + .type(this.type) + .attributes(new LinkedHashSet<>(attributes)) + .relationships(new LinkedHashSet<>(this.relationships)) + .filterExpression(this.filterExpression) + .sorting(this.sorting) + .pagination(this.pagination); + } + + /** + * Returns a relationship subgraph by name. + * @param name The name of the relationship. + * @return + */ + public Optional getRelationship(String name) { + return relationships.stream() + .filter((relationship) -> relationship.getName().equalsIgnoreCase(name)) + .findFirst(); + } + + /** + * Returns a relationship subgraph by name. + * @param name The name of the relationship. + * @param name The alias of the relationship. + * @return + */ + public Optional getRelationship(String name, String alias) { + return relationships.stream() + .filter((relationship) -> relationship.getName().equalsIgnoreCase(name)) + .filter((relationship) -> relationship.getAlias().equalsIgnoreCase(alias)) + .findFirst(); + } + + /** + * Recursively merges two EntityProjections. + * @param toMerge The projection to merge + * @return A newly created and merged EntityProjection. + */ + public EntityProjection merge(EntityProjection toMerge) { + EntityProjectionBuilder merged = copyOf(); + + for (Relationship relationship: toMerge.getRelationships()) { + EntityProjection theirs = relationship.getProjection(); + + Relationship ourRelationship = getRelationship(relationship.getName(), + relationship.getAlias()).orElse(null); + + if (ourRelationship != null) { + merged.relationships.remove(ourRelationship); + merged.relationships.add((Relationship.builder() + .name(relationship.getName()) + .alias(relationship.getAlias()) + .projection(ourRelationship.getProjection().merge(theirs)) + .build())); + } else { + merged.relationships.add((relationship)); + } + } + if (toMerge.getPagination() != null) { + merged.pagination = toMerge.getPagination(); + } + + if (toMerge.getSorting() != null) { + merged.sorting = toMerge.getSorting(); + } + + if (toMerge.getFilterExpression() != null) { + merged.filterExpression = toMerge.getFilterExpression(); + } + + merged.attributes.addAll(toMerge.attributes); + + return merged.build(); + } + + /** + * Customizes the lombok builder to our needs. + */ + public static class EntityProjectionBuilder { + @Getter + private Class type; + + private Set relationships = new LinkedHashSet<>(); + + private Set attributes = new LinkedHashSet<>(); + + @Getter + private FilterExpression filterExpression; + + @Getter + private Sorting sorting; + + @Getter + private Pagination pagination; + + public EntityProjectionBuilder relationships(Set relationships) { + this.relationships = relationships; + return this; + } + + public EntityProjectionBuilder attributes(Set attributes) { + this.attributes = attributes; + return this; + } + + public EntityProjectionBuilder relationship(String name, EntityProjection projection) { + return relationship(Relationship.builder() + .alias(name) + .parentType(projection.type) + .name(name) + .projection(projection) + .build()); + } + + /** + * Add a new relationship into this project or merge an existing relationship that has same field name + * and alias as this relationship. If there exists another attribute/relationship of different field that is + * using the same alias, it would throw exception because that's ambiguous. + * + * @param relationship new relationship to add + * @return this builder after adding the relationship + */ + public EntityProjectionBuilder relationship(Relationship relationship) { + String relationshipName = relationship.getName(); + String relationshipAlias = relationship.getAlias(); + + Relationship existing = relationships.stream() + .filter(r -> r.getName().equals(relationshipName) && r.getAlias().equals(relationshipAlias)) + .findFirst().orElse(null); + + if (existing != null) { + relationships.remove(existing); + relationships.add(Relationship.builder() + .parentType(relationship.getParentType()) + .name(relationshipName) + .alias(relationshipAlias) + .projection(existing.getProjection().merge(relationship.getProjection())) + .build()); + } else { + if (isAmbiguous(relationshipName, relationshipAlias)) { + throw new BadRequestException( + String.format("Alias {%s}.{%s} is ambiguous.", type, relationshipAlias) + ); + } + relationships.add(relationship); + } + + return this; + } + + /** + * Add a new attribute into this project or merge an existing attribute that has same field name + * and alias as this attribute. If there exists another attribute/relationship of different field that is + * using the same alias, it would throw exception because that's ambiguous. + * + * @param attribute new attribute to add + * @return this builder after adding the attribute + */ + public EntityProjectionBuilder attribute(Attribute attribute) { + String attributeName = attribute.getName(); + String attributeAlias = attribute.getAlias(); + + Attribute existing = attributes.stream() + .filter(a -> a.getName().equals(attributeName) && a.getAlias().equals(attributeAlias)) + .findFirst().orElse(null); + + if (existing != null) { + attributes.remove(existing); + attributes.add(Attribute.builder() + .type(attribute.getType()) + .parentType(attribute.getParentType()) + .name(attributeName) + .alias(attributeAlias) + .arguments(Sets.union(attribute.getArguments(), existing.getArguments())) + .build()); + } else { + if (isAmbiguous(attributeName, attributeAlias)) { + throw new BadRequestException( + String.format("Alias {%s}.{%s} is ambiguous.", type, attributeAlias) + ); + } + attributes.add(attribute); + } + + return this; + } + + /** + * Get an attribute by alias. + * + * @param attributeAlias alias to refer to an attribute field + * @return found attribute or null + */ + public Attribute getAttributeByAlias(String attributeAlias) { + return attributes.stream() + .filter(attribute -> attribute.getAlias().equals(attributeAlias)) + .findAny() + .orElse(null); + } + + /** + * Get an relationship by alias. + * + * @param relationshipAlias alias to refer to a relationship field + * @return found attribute or null + */ + public Relationship getRelationshipByAlias(String relationshipAlias) { + return relationships.stream() + .filter(relationship -> relationship.getAlias().equals(relationshipAlias)) + .findAny() + .orElse(null); + } + + /** + * Check whether a field alias is ambiguous. + * + * @param fieldName field that the alias is bound to + * @param alias an field alias + * @return whether new alias would cause ambiguous + */ + private boolean isAmbiguous(String fieldName, String alias) { + return attributes.stream().anyMatch(a -> !fieldName.equals(a.getName()) && alias.equals(a.getAlias())) + || relationships.stream().anyMatch( + r -> !fieldName.equals(r.getName()) && alias.equals(r.getAlias())); + } + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/request/Pagination.java b/elide-core/src/main/java/com/yahoo/elide/request/Pagination.java new file mode 100644 index 0000000000..49cd6836ec --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/request/Pagination.java @@ -0,0 +1,64 @@ +/* + * Copyright 2020, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.request; + +/** + * Represents a client request to paginate a collection. + */ +public interface Pagination { + + /** + * Default offset (in records) it client does not provide one. + */ + int DEFAULT_OFFSET = 0; + + /** + * Default page limit (in records) it client does not provide one. + */ + int DEFAULT_PAGE_LIMIT = 500; + + /** + * Maximum allowable page limit (in records). + */ + int MAX_PAGE_LIMIT = 10000; + + /** + * Get the page offset. + * @return record offset. + */ + int getOffset(); + + /** + * Get the page limit. + * @return record limit. + */ + int getLimit(); + + /** + * Whether or not to fetch the collection size or not. + * @return true if the client wants the total size of the collection. + */ + boolean returnPageTotals(); + + /** + * Get the total size of the collection + * @return total record count. + */ + Long getPageTotals(); + + /** + * Set the total size of the collection. + * @param pageTotals the total size. + */ + void setPageTotals(Long pageTotals); + + /** + * Is this the default instance (not present). + * @return true if pagination wasn't requested. False otherwise. + */ + boolean isDefaultInstance(); +} diff --git a/elide-core/src/main/java/com/yahoo/elide/request/Relationship.java b/elide-core/src/main/java/com/yahoo/elide/request/Relationship.java new file mode 100644 index 0000000000..973b65c235 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/request/Relationship.java @@ -0,0 +1,61 @@ +/* + * Copyright 2019, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.request; + +import lombok.Builder; +import lombok.Data; +import lombok.NonNull; + +/** + * Represents a relationship on an Elide entity. + */ +@Data +@Builder +public class Relationship { + + @NonNull + private String name; + + private String alias; + + //If null, the parentType is the same as the entity projection to which this relationship belongs. + //If not null, this represents the model type where this relationship can be found. + private Class parentType; + + @NonNull + private EntityProjection projection; + + private Relationship(@NonNull String name, String alias, @NonNull EntityProjection projection) { + this.name = name; + this.parentType = null; + this.alias = alias == null ? name : alias; + this.projection = projection; + } + + private Relationship(@NonNull String name, String alias, Class parentType, + @NonNull EntityProjection projection) { + this.name = name; + this.parentType = parentType; + this.alias = alias == null ? name : alias; + this.projection = projection; + } + + public RelationshipBuilder copyOf() { + return Relationship.builder() + .alias(alias) + .name(name) + .projection(projection); + } + + public Relationship merge(Relationship toMerge) { + return Relationship.builder() + .name(name) + .alias(alias) + .projection(projection.merge(toMerge.projection)) + .build(); + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/request/Sorting.java b/elide-core/src/main/java/com/yahoo/elide/request/Sorting.java new file mode 100644 index 0000000000..67a25bcfcb --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/request/Sorting.java @@ -0,0 +1,41 @@ +/* + * Copyright 2019, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.request; + +import com.yahoo.elide.core.Path; + +import java.util.Map; + +/** + * Represents a client request to sort a collection. + */ +public interface Sorting { + + /** + * Denotes the intended sort direction (ascending or descending). + */ + public enum SortOrder { asc, desc } + + /** + * Return an ordered map of paths and their sort order. + * @param The type to sort. + * @return An ordered map of paths and their sort order. + */ + public Map getSortingPaths(); + + /** + * Get the type of the collection to sort. + * @return the collection type. + */ + public Class getType(); + + /** + * Is this sorting the default instance (not present). + * @return true if sorting wasn't requested. False otherwise. + */ + public boolean isDefaultInstance(); +} diff --git a/elide-core/src/main/java/com/yahoo/elide/resources/DefaultOpaqueUserFunction.java b/elide-core/src/main/java/com/yahoo/elide/resources/DefaultOpaqueUserFunction.java deleted file mode 100644 index 9cc95e4484..0000000000 --- a/elide-core/src/main/java/com/yahoo/elide/resources/DefaultOpaqueUserFunction.java +++ /dev/null @@ -1,18 +0,0 @@ -/* - * Copyright 2017, Yahoo Inc. - * Licensed under the Apache License, Version 2.0 - * See LICENSE file in project root for terms. - */ -package com.yahoo.elide.resources; - -import java.util.function.Function; - -import javax.ws.rs.core.SecurityContext; - -/** - * Placeholder for injection frameworks. - */ -@FunctionalInterface -public interface DefaultOpaqueUserFunction extends Function { - // Empty -} diff --git a/elide-core/src/main/java/com/yahoo/elide/resources/JsonApiEndpoint.java b/elide-core/src/main/java/com/yahoo/elide/resources/JsonApiEndpoint.java index 3e05d6ea3d..96d3a4fd98 100644 --- a/elide-core/src/main/java/com/yahoo/elide/resources/JsonApiEndpoint.java +++ b/elide-core/src/main/java/com/yahoo/elide/resources/JsonApiEndpoint.java @@ -6,12 +6,12 @@ package com.yahoo.elide.resources; import static com.yahoo.elide.Elide.JSONAPI_CONTENT_TYPE; +import static com.yahoo.elide.core.EntityDictionary.NO_VERSION; import com.yahoo.elide.Elide; import com.yahoo.elide.ElideResponse; import com.yahoo.elide.annotation.PATCH; - -import java.util.function.Function; +import com.yahoo.elide.security.User; import javax.inject.Inject; import javax.inject.Named; @@ -38,22 +38,18 @@ @Path("/") public class JsonApiEndpoint { protected final Elide elide; - protected final Function getUser; - - public static final DefaultOpaqueUserFunction DEFAULT_GET_USER = securityContext -> securityContext; @Inject public JsonApiEndpoint( - @Named("elide") Elide elide, - @Named("elideUserExtractionFunction") DefaultOpaqueUserFunction getUser) { + @Named("elide") Elide elide) { this.elide = elide; - this.getUser = getUser == null ? DEFAULT_GET_USER : getUser; } /** * Create handler. * * @param path request path + * @param apiVersion The api version * @param securityContext security context * @param jsonapiDocument post data as jsonapi document * @return response @@ -63,15 +59,19 @@ public JsonApiEndpoint( @Consumes(JSONAPI_CONTENT_TYPE) public Response post( @PathParam("path") String path, + @HeaderParam("ApiVersion") String apiVersion, @Context SecurityContext securityContext, String jsonapiDocument) { - return build(elide.post(path, jsonapiDocument, getUser.apply(securityContext))); + String safeApiVersion = apiVersion == null ? NO_VERSION : apiVersion; + User user = new SecurityContextUser(securityContext); + return build(elide.post(path, jsonapiDocument, user, safeApiVersion)); } /** * Read handler. * * @param path request path + * @param apiVersion The API version * @param uriInfo URI info * @param securityContext security context * @return response @@ -80,16 +80,20 @@ public Response post( @Path("{path:.*}") public Response get( @PathParam("path") String path, + @HeaderParam("ApiVersion") String apiVersion, @Context UriInfo uriInfo, @Context SecurityContext securityContext) { + String safeApiVersion = apiVersion == null ? NO_VERSION : apiVersion; MultivaluedMap queryParams = uriInfo.getQueryParameters(); - return build(elide.get(path, queryParams, getUser.apply(securityContext))); + User user = new SecurityContextUser(securityContext); + return build(elide.get(path, queryParams, user, safeApiVersion)); } /** * Update handler. * * @param contentType document MIME type + * @param apiVersion the API version * @param accept response MIME type * @param path request path * @param securityContext security context @@ -101,17 +105,21 @@ public Response get( @Consumes(JSONAPI_CONTENT_TYPE) public Response patch( @HeaderParam("Content-Type") String contentType, + @HeaderParam("ApiVersion") String apiVersion, @HeaderParam("accept") String accept, @PathParam("path") String path, @Context SecurityContext securityContext, String jsonapiDocument) { - return build(elide.patch(contentType, accept, path, jsonapiDocument, getUser.apply(securityContext))); + String safeApiVersion = apiVersion == null ? NO_VERSION : apiVersion; + User user = new SecurityContextUser(securityContext); + return build(elide.patch(contentType, accept, path, jsonapiDocument, user, safeApiVersion)); } /** * Delete relationship handler (expects body with resource ids and types). * * @param path request path + * @param apiVersion the API version. * @param securityContext security context * @param jsonApiDocument DELETE document * @return response @@ -121,9 +129,12 @@ public Response patch( @Consumes(JSONAPI_CONTENT_TYPE) public Response delete( @PathParam("path") String path, + @HeaderParam("ApiVersion") String apiVersion, @Context SecurityContext securityContext, String jsonApiDocument) { - return build(elide.delete(path, jsonApiDocument, getUser.apply(securityContext))); + String safeApiVersion = apiVersion == null ? NO_VERSION : apiVersion; + User user = new SecurityContextUser(securityContext); + return build(elide.delete(path, jsonApiDocument, user, safeApiVersion)); } private static Response build(ElideResponse response) { diff --git a/elide-core/src/main/java/com/yahoo/elide/resources/SecurityContextUser.java b/elide-core/src/main/java/com/yahoo/elide/resources/SecurityContextUser.java new file mode 100644 index 0000000000..275a4fe241 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/resources/SecurityContextUser.java @@ -0,0 +1,28 @@ +/* + * Copyright 2020, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.resources; + +import com.yahoo.elide.security.User; + +import javax.ws.rs.core.SecurityContext; + +/** + * Elide User for JAXRS. + */ +public class SecurityContextUser extends User { + private SecurityContext ctx; + + public SecurityContextUser(SecurityContext ctx) { + super(ctx.getUserPrincipal()); + this.ctx = ctx; + } + + @Override + public boolean isInRole(String role) { + return ctx.isUserInRole(role); + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/security/FilterExpressionCheck.java b/elide-core/src/main/java/com/yahoo/elide/security/FilterExpressionCheck.java index d6695ff1dc..9c0bfba13d 100644 --- a/elide-core/src/main/java/com/yahoo/elide/security/FilterExpressionCheck.java +++ b/elide-core/src/main/java/com/yahoo/elide/security/FilterExpressionCheck.java @@ -12,12 +12,13 @@ import com.yahoo.elide.core.filter.FilterPredicate; import com.yahoo.elide.core.filter.expression.FilterExpression; import com.yahoo.elide.parsers.expression.FilterExpressionCheckEvaluationVisitor; -import com.yahoo.elide.security.checks.InlineCheck; +import com.yahoo.elide.security.checks.OperationCheck; import lombok.extern.slf4j.Slf4j; import java.util.Optional; import java.util.function.Predicate; +import javax.inject.Inject; /** * Check for FilterExpression. This is a super class for user defined FilterExpression check. The subclass should @@ -26,7 +27,10 @@ * @param Type of class */ @Slf4j -public abstract class FilterExpressionCheck extends InlineCheck { +public abstract class FilterExpressionCheck extends OperationCheck { + + @Inject + protected EntityDictionary dictionary; /** * Returns a FilterExpression from FilterExpressionCheck. @@ -37,13 +41,6 @@ public abstract class FilterExpressionCheck extends InlineCheck { */ public abstract FilterExpression getFilterExpression(Class entityClass, RequestScope requestScope); - /* NOTE: Filter Expression checks and user checks are intended to be _distinct_ */ - @Override - public final boolean ok(User user) { - throw new UnsupportedOperationException(); - } - - /** * The filter expression is evaluated in memory if it cannot be pushed to the data store by elide for any reason. * @@ -54,7 +51,7 @@ public final boolean ok(User user) { */ @Override public final boolean ok(T object, RequestScope requestScope, Optional changeSpec) { - Class entityClass = coreScope(requestScope).getDictionary().lookupBoundClass(object.getClass()); + Class entityClass = dictionary.lookupBoundClass(object.getClass()); FilterExpression filterExpression = getFilterExpression(entityClass, requestScope); return filterExpression.accept(new FilterExpressionCheckEvaluationVisitor(object, this, requestScope)); } @@ -88,10 +85,23 @@ public boolean applyPredicateToObject(T object, FilterPredicate filterPredicate, * @param defaultPath path to use if no FieldExpressionPath defined * @return Predicates */ - protected static Path getFieldPath(Class type, RequestScope requestScope, String method, String defaultPath) { - EntityDictionary dictionary = coreScope(requestScope).getDictionary(); - FilterExpressionPath fep = dictionary.getMethodAnnotation(type, method, FilterExpressionPath.class); - return new Path(type, dictionary, fep == null ? defaultPath : fep.value()); + protected Path getFieldPath(Class type, RequestScope requestScope, String method, String defaultPath) { + try { + FilterExpressionPath fep = getFilterExpressionPath(type, method, dictionary); + return new Path(type, dictionary, fep == null ? defaultPath : fep.value()); + } catch (NoSuchMethodException | SecurityException e) { + throw new IllegalStateException(e); + } + } + + private static FilterExpressionPath getFilterExpressionPath( + Class type, + String method, + EntityDictionary dictionary) throws NoSuchMethodException { + FilterExpressionPath path = dictionary.lookupBoundClass(type) + .getMethod(method) + .getAnnotation(FilterExpressionPath.class); + return path; } protected static com.yahoo.elide.core.RequestScope coreScope(RequestScope requestScope) { diff --git a/elide-core/src/main/java/com/yahoo/elide/security/PermissionExecutor.java b/elide-core/src/main/java/com/yahoo/elide/security/PermissionExecutor.java index 5775e87937..d36ae24aa6 100644 --- a/elide-core/src/main/java/com/yahoo/elide/security/PermissionExecutor.java +++ b/elide-core/src/main/java/com/yahoo/elide/security/PermissionExecutor.java @@ -6,6 +6,7 @@ package com.yahoo.elide.security; import com.yahoo.elide.core.Path.PathElement; +import com.yahoo.elide.core.PersistentResource; import com.yahoo.elide.core.exceptions.ForbiddenAccessException; import com.yahoo.elide.core.filter.FilterPredicate; import com.yahoo.elide.core.filter.expression.FilterExpression; diff --git a/elide-core/src/main/java/com/yahoo/elide/security/executors/ActivePermissionExecutor.java b/elide-core/src/main/java/com/yahoo/elide/security/executors/ActivePermissionExecutor.java index 7d1f3a3916..12df709d0c 100644 --- a/elide-core/src/main/java/com/yahoo/elide/security/executors/ActivePermissionExecutor.java +++ b/elide-core/src/main/java/com/yahoo/elide/security/executors/ActivePermissionExecutor.java @@ -11,16 +11,15 @@ import com.yahoo.elide.annotation.CreatePermission; import com.yahoo.elide.annotation.DeletePermission; +import com.yahoo.elide.annotation.NonTransferable; import com.yahoo.elide.annotation.ReadPermission; -import com.yahoo.elide.annotation.SharePermission; import com.yahoo.elide.annotation.UpdatePermission; -import com.yahoo.elide.core.EntityDictionary; +import com.yahoo.elide.core.PersistentResource; import com.yahoo.elide.core.RequestScope; import com.yahoo.elide.core.exceptions.ForbiddenAccessException; import com.yahoo.elide.core.filter.expression.FilterExpression; import com.yahoo.elide.security.ChangeSpec; import com.yahoo.elide.security.PermissionExecutor; -import com.yahoo.elide.security.PersistentResource; import com.yahoo.elide.security.permissions.ExpressionResult; import com.yahoo.elide.security.permissions.ExpressionResultCache; import com.yahoo.elide.security.permissions.PermissionExpressionBuilder; @@ -98,7 +97,7 @@ public ExpressionResult checkPermission( } /** - * Check permission on class. Checking on SharePermission falls to check ReadPermission. + * Check permission on class. Checking on Transferable falls to check ReadPermission. * * @param annotationClass annotation class * @param resource resource @@ -114,8 +113,8 @@ public ExpressionResult checkPermission(Class annotati PersistentResource resource, ChangeSpec changeSpec) { Supplier expressionSupplier = () -> { - if (SharePermission.class == annotationClass) { - if (requestScope.getDictionary().isShareable(resource.getResourceClass())) { + if (NonTransferable.class == annotationClass) { + if (requestScope.getDictionary().isTransferable(resource.getResourceClass())) { return expressionBuilder.buildAnyFieldExpressions(resource, ReadPermission.class, changeSpec); } return PermissionExpressionBuilder.FAIL_EXPRESSION; @@ -375,8 +374,7 @@ public void executeCommitChecks() { ExpressionResult result = expression.evaluate(Expression.EvaluationMode.ALL_CHECKS); if (result == FAIL) { ForbiddenAccessException e = new ForbiddenAccessException( - EntityDictionary.getSimpleName(expr.getAnnotationClass()), - expression, Expression.EvaluationMode.ALL_CHECKS); + expr.getAnnotationClass(), expression, Expression.EvaluationMode.ALL_CHECKS); if (log.isTraceEnabled()) { log.trace("{}", e.getLoggedMessage()); } @@ -423,7 +421,7 @@ private ExpressionResult executeExpressions(final Expression expression, result = expression.evaluate(Expression.EvaluationMode.ALL_CHECKS); if (result == FAIL) { ForbiddenAccessException e = new ForbiddenAccessException( - EntityDictionary.getSimpleName(annotationClass), + annotationClass, expression, Expression.EvaluationMode.ALL_CHECKS); if (log.isTraceEnabled()) { @@ -437,8 +435,7 @@ private ExpressionResult executeExpressions(final Expression expression, return DEFERRED; } if (result == FAIL) { - ForbiddenAccessException e = new ForbiddenAccessException( - EntityDictionary.getSimpleName(annotationClass), expression, mode); + ForbiddenAccessException e = new ForbiddenAccessException(annotationClass, expression, mode); if (log.isTraceEnabled()) { log.trace("{}", e.getLoggedMessage()); } diff --git a/elide-core/src/main/java/com/yahoo/elide/security/executors/BypassPermissionExecutor.java b/elide-core/src/main/java/com/yahoo/elide/security/executors/BypassPermissionExecutor.java index f66d1b76b0..42bf636e17 100644 --- a/elide-core/src/main/java/com/yahoo/elide/security/executors/BypassPermissionExecutor.java +++ b/elide-core/src/main/java/com/yahoo/elide/security/executors/BypassPermissionExecutor.java @@ -5,10 +5,10 @@ */ package com.yahoo.elide.security.executors; +import com.yahoo.elide.core.PersistentResource; import com.yahoo.elide.core.filter.expression.FilterExpression; import com.yahoo.elide.security.ChangeSpec; import com.yahoo.elide.security.PermissionExecutor; -import com.yahoo.elide.security.PersistentResource; import com.yahoo.elide.security.permissions.ExpressionResult; import java.lang.annotation.Annotation; diff --git a/elide-core/src/main/java/com/yahoo/elide/security/permissions/PermissionCondition.java b/elide-core/src/main/java/com/yahoo/elide/security/permissions/PermissionCondition.java index 35602b4c7e..7791ebe8bd 100644 --- a/elide-core/src/main/java/com/yahoo/elide/security/permissions/PermissionCondition.java +++ b/elide-core/src/main/java/com/yahoo/elide/security/permissions/PermissionCondition.java @@ -8,8 +8,8 @@ import com.yahoo.elide.annotation.CreatePermission; import com.yahoo.elide.annotation.DeletePermission; +import com.yahoo.elide.annotation.NonTransferable; import com.yahoo.elide.annotation.ReadPermission; -import com.yahoo.elide.annotation.SharePermission; import com.yahoo.elide.annotation.UpdatePermission; import com.yahoo.elide.security.ChangeSpec; import com.yahoo.elide.security.PersistentResource; @@ -37,7 +37,7 @@ public class PermissionCondition { .put(UpdatePermission.class, "UPDATE") .put(DeletePermission.class, "DELETE") .put(CreatePermission.class, "CREATE") - .put(SharePermission.class, "SHARE") + .put(NonTransferable.class, "NO TRANSFER") .build(); /** diff --git a/elide-core/src/main/java/com/yahoo/elide/security/permissions/PermissionExpressionBuilder.java b/elide-core/src/main/java/com/yahoo/elide/security/permissions/PermissionExpressionBuilder.java index 428298c9f6..0cd2c96e16 100644 --- a/elide-core/src/main/java/com/yahoo/elide/security/permissions/PermissionExpressionBuilder.java +++ b/elide-core/src/main/java/com/yahoo/elide/security/permissions/PermissionExpressionBuilder.java @@ -13,6 +13,7 @@ import com.yahoo.elide.annotation.ReadPermission; import com.yahoo.elide.core.CheckInstantiator; import com.yahoo.elide.core.EntityDictionary; +import com.yahoo.elide.core.PersistentResource; import com.yahoo.elide.core.RequestScope; import com.yahoo.elide.core.filter.expression.FilterExpression; import com.yahoo.elide.core.filter.expression.OrFilterExpression; @@ -20,7 +21,6 @@ import com.yahoo.elide.parsers.expression.PermissionExpressionVisitor; import com.yahoo.elide.parsers.expression.PermissionToFilterExpressionVisitor; import com.yahoo.elide.security.ChangeSpec; -import com.yahoo.elide.security.PersistentResource; import com.yahoo.elide.security.checks.Check; import com.yahoo.elide.security.permissions.expressions.AnyFieldExpression; import com.yahoo.elide.security.permissions.expressions.CheckExpression; diff --git a/elide-core/src/main/java/com/yahoo/elide/security/permissions/expressions/CheckExpression.java b/elide-core/src/main/java/com/yahoo/elide/security/permissions/expressions/CheckExpression.java index 2593a50458..2394f2dc1e 100644 --- a/elide-core/src/main/java/com/yahoo/elide/security/permissions/expressions/CheckExpression.java +++ b/elide-core/src/main/java/com/yahoo/elide/security/permissions/expressions/CheckExpression.java @@ -11,11 +11,11 @@ import static com.yahoo.elide.security.permissions.ExpressionResult.UNEVALUATED; import com.yahoo.elide.core.EntityDictionary; +import com.yahoo.elide.core.PersistentResource; import com.yahoo.elide.security.ChangeSpec; -import com.yahoo.elide.security.PersistentResource; import com.yahoo.elide.security.RequestScope; import com.yahoo.elide.security.checks.Check; -import com.yahoo.elide.security.checks.InlineCheck; +import com.yahoo.elide.security.checks.OperationCheck; import com.yahoo.elide.security.checks.UserCheck; import com.yahoo.elide.security.permissions.ExpressionResult; import com.yahoo.elide.security.permissions.ExpressionResultCache; @@ -76,7 +76,7 @@ public ExpressionResult evaluate(EvaluationMode mode) { return result; } - if (mode == EvaluationMode.INLINE_CHECKS_ONLY && ! (check instanceof InlineCheck)) { + if (mode == EvaluationMode.INLINE_CHECKS_ONLY && (resource != null && resource.isNewlyCreated())) { result = DEFERRED; return result; } @@ -112,7 +112,12 @@ public ExpressionResult evaluate(EvaluationMode mode) { */ private ExpressionResult computeCheck() { Object entity = (resource == null) ? null : resource.getObject(); - result = check.ok(entity, requestScope, changeSpec) ? PASS : FAIL; + + if (check instanceof UserCheck) { + result = ((UserCheck) check).ok(requestScope.getUser()) ? PASS : FAIL; + } else { + result = ((OperationCheck) check).ok(entity, requestScope, changeSpec) ? PASS : FAIL; + } return result; } diff --git a/elide-core/src/main/java/com/yahoo/elide/utils/ClassScanner.java b/elide-core/src/main/java/com/yahoo/elide/utils/ClassScanner.java index 767156a92b..90dcf8716e 100644 --- a/elide-core/src/main/java/com/yahoo/elide/utils/ClassScanner.java +++ b/elide-core/src/main/java/com/yahoo/elide/utils/ClassScanner.java @@ -10,6 +10,9 @@ import io.github.classgraph.ScanResult; import java.lang.annotation.Annotation; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; import java.util.Set; import java.util.stream.Collectors; @@ -47,16 +50,24 @@ static public Set> getAnnotatedClasses(String packageName, Class> getAnnotatedClasses(Class annotation) { - try (ScanResult scanResult = new ClassGraph() - .enableClassInfo().enableAnnotationInfo().scan()) { - return scanResult.getClassesWithAnnotation(annotation.getCanonicalName()).stream() - .map((ClassInfo::loadClass)) - .collect(Collectors.toSet()); + static public Set> getAnnotatedClasses(List> annotations) { + Set> result = new HashSet<>(); + try (ScanResult scanResult = new ClassGraph().enableClassInfo().enableAnnotationInfo().scan()) { + for (Class annotation : annotations) { + result.addAll(scanResult.getClassesWithAnnotation(annotation.getCanonicalName()).stream() + .map((ClassInfo::loadClass)) + .collect(Collectors.toSet())); + } } + return result; + } + + @SafeVarargs + static public Set> getAnnotatedClasses(Class ...annotations) { + return getAnnotatedClasses(Arrays.asList(annotations)); } /** diff --git a/elide-core/src/main/java/com/yahoo/elide/utils/TypeHelper.java b/elide-core/src/main/java/com/yahoo/elide/utils/TypeHelper.java index 478fa0f334..70091c6c71 100644 --- a/elide-core/src/main/java/com/yahoo/elide/utils/TypeHelper.java +++ b/elide-core/src/main/java/com/yahoo/elide/utils/TypeHelper.java @@ -6,16 +6,24 @@ package com.yahoo.elide.utils; +import com.yahoo.elide.core.Path; + import com.google.common.collect.Sets; +import java.util.List; import java.util.Set; /** - * Utilities for checking classes and primitive types. + * Utilities for handling types and aliases. */ public class TypeHelper { + private static final String UNDERSCORE = "_"; + private static final String PERIOD = "."; private static final Set> PRIMITIVE_NUMBER_TYPES = Sets .newHashSet(short.class, int.class, long.class, float.class, double.class); + private static final Set> NUMBER_TYPES = Sets + .newHashSet(short.class, int.class, long.class, float.class, double.class, + Short.class, Integer.class, Long.class, Float.class, Double.class); /** * Determine whether a type is primitive number type @@ -26,4 +34,102 @@ public class TypeHelper { public static boolean isPrimitiveNumberType(Class type) { return PRIMITIVE_NUMBER_TYPES.contains(type); } + + /** + * Determine whether a type is number type + * + * @param type type to check + * @return True is the type is number type + */ + public static boolean isNumberType(Class type) { + return NUMBER_TYPES.contains(type); + } + + /** + * Extend an type alias to the final type of an extension path + * + * @param alias type alias to be extended, e.g. a_b + * @param extension path extension from aliased type, e.g. [b.c]/[c.d] + * @return extended type alias, e.g. a_b_c + */ + public static String extendTypeAlias(String alias, Path extension) { + String result = alias; + List elements = extension.getPathElements(); + + for (int i = 0; i < elements.size() - 1; i++) { + result = appendAlias(result, elements.get(i).getFieldName()); + } + + return result; + } + + /** + * Generate alias for representing a relationship path which dose not include the last field name. + * The path would start with the class alias of the first element, and then each field would append "_fieldName" to + * the result. + * The last field would not be included as that's not a part of the relationship path. + * + * @param path path that represents a relationship chain + * @return relationship path alias, i.e. foo.bar.baz would be foo_bar + */ + public static String getPathAlias(Path path) { + return extendTypeAlias(getTypeAlias(path.getPathElements().get(0).getType()), path); + } + + /** + * Append a new field to a parent alias to get new alias. + * + * @param parentAlias parent path alias + * @param fieldName field name + * @return alias for the field + */ + public static String appendAlias(String parentAlias, String fieldName) { + return nullOrEmpty(parentAlias) + ? fieldName + : nullOrEmpty(fieldName) + ? parentAlias + : parentAlias + UNDERSCORE + fieldName; + } + + /** + * Build an query friendly alias for a class. + * + * @param type The type to alias + * @return type name alias that will likely not conflict with other types or with reserved keywords. + */ + public static String getTypeAlias(Class type) { + return type.getCanonicalName().replace(PERIOD, UNDERSCORE); + } + + /** + * Get alias for the final field of a path. + * + * @param path path to the field + * @param fieldName physical field name + * @return combined alias + */ + public static String getFieldAlias(Path path, String fieldName) { + return getFieldAlias(getPathAlias(path), fieldName); + } + + /** + * Get alias for the final field of a path. + * + * @param tableAlias alias for table that contains the field + * @param fieldName physical field name + * @return combined alias + */ + public static String getFieldAlias(String tableAlias, String fieldName) { + return nullOrEmpty(tableAlias) ? fieldName : tableAlias + PERIOD + fieldName; + } + + /** + * Check whether an alias is null or empty string + * + * @param alias alias + * @return True if is null or empty + */ + private static boolean nullOrEmpty(String alias) { + return alias == null || alias.equals(""); + } } diff --git a/elide-core/src/main/java/com/yahoo/elide/utils/coerce/CoerceUtil.java b/elide-core/src/main/java/com/yahoo/elide/utils/coerce/CoerceUtil.java index c9d9e8ba41..31df500f7b 100644 --- a/elide-core/src/main/java/com/yahoo/elide/utils/coerce/CoerceUtil.java +++ b/elide-core/src/main/java/com/yahoo/elide/utils/coerce/CoerceUtil.java @@ -5,6 +5,8 @@ */ package com.yahoo.elide.utils.coerce; +import static com.yahoo.elide.utils.TypeHelper.isNumberType; + import com.yahoo.elide.core.exceptions.InvalidAttributeException; import com.yahoo.elide.core.exceptions.InvalidValueException; import com.yahoo.elide.utils.coerce.converters.FromMapConverter; @@ -13,12 +15,12 @@ import com.yahoo.elide.utils.coerce.converters.ToUUIDConverter; import com.google.common.collect.MapMaker; - import org.apache.commons.beanutils.BeanUtilsBean; import org.apache.commons.beanutils.ConversionException; import org.apache.commons.beanutils.ConvertUtils; import org.apache.commons.beanutils.Converter; +import java.lang.reflect.Array; import java.util.Collections; import java.util.HashMap; import java.util.Map; @@ -49,6 +51,12 @@ public class CoerceUtil { public static T coerce(Object value, Class cls) { initializeCurrentClassLoaderIfNecessary(); + // null value of number type would be converted to 0, as 'null' would cause exception for primitive + // number classes + if (value == null && isNumberType(cls)) { + return (T) Array.get(Array.newInstance(cls, 1), 0); + } + if (value == null || cls == null || cls.isInstance(value)) { return (T) value; } diff --git a/elide-core/src/test/java/com/yahoo/elide/ElideCustomSerdeRegistrationTest.java b/elide-core/src/test/java/com/yahoo/elide/ElideCustomSerdeRegistrationTest.java index b7ab6bd5b6..eabd20bc4e 100644 --- a/elide-core/src/test/java/com/yahoo/elide/ElideCustomSerdeRegistrationTest.java +++ b/elide-core/src/test/java/com/yahoo/elide/ElideCustomSerdeRegistrationTest.java @@ -41,7 +41,9 @@ public String serialize(Dummy val) { @Test public void testRegisterCustomSerde() { - HashMapDataStore wrapped = new HashMapDataStore(Dummy.class.getPackage()); + + //Create a fake Elide. Don't actually bind any entities. + HashMapDataStore wrapped = new HashMapDataStore(String.class.getPackage()); InMemoryDataStore store = new InMemoryDataStore(wrapped); ElideSettings elideSettings = new ElideSettingsBuilder(store).build(); new Elide(elideSettings); diff --git a/elide-core/src/test/java/com/yahoo/elide/audit/LogMessageTest.java b/elide-core/src/test/java/com/yahoo/elide/audit/LogMessageImplTest.java similarity index 82% rename from elide-core/src/test/java/com/yahoo/elide/audit/LogMessageTest.java rename to elide-core/src/test/java/com/yahoo/elide/audit/LogMessageImplTest.java index db3a4b3c34..428e64ebde 100644 --- a/elide-core/src/test/java/com/yahoo/elide/audit/LogMessageTest.java +++ b/elide-core/src/test/java/com/yahoo/elide/audit/LogMessageImplTest.java @@ -5,6 +5,7 @@ */ package com.yahoo.elide.audit; +import static com.yahoo.elide.core.EntityDictionary.NO_VERSION; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -15,7 +16,7 @@ import com.yahoo.elide.core.EntityDictionary; import com.yahoo.elide.core.PersistentResource; import com.yahoo.elide.core.RequestScope; -import com.yahoo.elide.security.User; +import com.yahoo.elide.security.TestUser; import com.google.common.collect.Sets; import example.Child; @@ -24,7 +25,6 @@ import org.junit.jupiter.api.Test; import java.io.IOException; -import java.security.Principal; import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -33,7 +33,7 @@ import java.util.concurrent.Executors; import java.util.concurrent.ThreadLocalRandom; -public class LogMessageTest { +public class LogMessageImplTest { private static transient PersistentResource childRecord; private static transient PersistentResource friendRecord; @@ -53,13 +53,8 @@ public static void init() { friend.setId(9); child.setFriends(Sets.newHashSet(friend)); - final RequestScope requestScope = new RequestScope(null, null, null, new User( - new Principal() { - @Override - public String getName() { - return "aaron"; - } - }), null, + final RequestScope requestScope = new RequestScope(null, NO_VERSION, null, null, + new TestUser("aaron"), null, new ElideSettingsBuilder(null) .withAuditLogger(new TestAuditLogger()) .withEntityDictionary(dictionary) @@ -73,7 +68,7 @@ public String getName() { @Test public void verifyOpaqueUserExpressions() { final String[] expressions = { "${opaqueUser.name}", "${opaqueUser.name}" }; - final LogMessage message = new LogMessage("{0} {1}", childRecord, expressions, 1, Optional.empty()); + final LogMessageImpl message = new LogMessageImpl("{0} {1}", childRecord, expressions, 1, Optional.empty()); assertEquals("aaron aaron", message.getMessage(), "JEXL substitution evaluates correctly."); assertEquals(Optional.empty(), message.getChangeSpec()); } @@ -81,7 +76,7 @@ public void verifyOpaqueUserExpressions() { @Test public void verifyObjectExpressions() { final String[] expressions = { "${child.id}", "${parent.getId()}" }; - final LogMessage message = new LogMessage("{0} {1}", childRecord, expressions, 1, Optional.empty()); + final LogMessageImpl message = new LogMessageImpl("{0} {1}", childRecord, expressions, 1, Optional.empty()); assertEquals("5 7", message.getMessage(), "JEXL substitution evaluates correctly."); assertEquals(Optional.empty(), message.getChangeSpec()); } @@ -90,8 +85,8 @@ public void verifyObjectExpressions() { public void verifyListExpressions() { final String[] expressions = { "${child[0].id}", "${child[1].id}", "${parent.getId()}" }; final String[] expressionForDefault = { "${child.id}" }; - final LogMessage message = new LogMessage("{0} {1} {2}", friendRecord, expressions, 1, Optional.empty()); - final LogMessage defaultMessage = new LogMessage("{0}", friendRecord, expressionForDefault, 1, Optional.empty()); + final LogMessageImpl message = new LogMessageImpl("{0} {1} {2}", friendRecord, expressions, 1, Optional.empty()); + final LogMessageImpl defaultMessage = new LogMessageImpl("{0}", friendRecord, expressionForDefault, 1, Optional.empty()); assertEquals("5 9 7", message.getMessage(), "JEXL substitution evaluates correctly."); assertEquals("9", defaultMessage.getMessage(), "JEXL substitution evaluates correctly."); assertEquals(Optional.empty(), message.getChangeSpec()); @@ -103,7 +98,7 @@ public void invalidExpression() { final String[] expressions = { "${child.id}, ${%%%}" }; assertThrows( InvalidSyntaxException.class, - () -> new LogMessage("{0} {1}", childRecord, expressions, 1, Optional.empty()).getMessage()); + () -> new LogMessageImpl("{0} {1}", childRecord, expressions, 1, Optional.empty()).getMessage()); } @Test @@ -111,7 +106,7 @@ public void invalidTemplate() { final String[] expressions = { "${child.id}" }; assertThrows( InvalidSyntaxException.class, - () -> new LogMessage("{}", childRecord, expressions, 1, Optional.empty()).getMessage()); + () -> new LogMessageImpl("{}", childRecord, expressions, 1, Optional.empty()).getMessage()); } public static class TestLoggerException extends RuntimeException { @@ -141,7 +136,7 @@ public void threadSafetyTest() { public void threadSafeLogger() throws IOException, InterruptedException { TestLoggerException testException = new TestLoggerException(); - LogMessage failMessage = new LogMessage("test", 0) { + LogMessageImpl failMessage = new LogMessageImpl("test", 0) { @Override public String getMessage() { throw testException; @@ -150,7 +145,7 @@ public String getMessage() { try { testAuditLogger.log(failMessage); Thread.sleep(Math.floorMod(ThreadLocalRandom.current().nextInt(), 100)); - testAuditLogger.commit(null); + testAuditLogger.commit(); fail("Exception expected"); } catch (TestLoggerException e) { assertSame(e, testException); @@ -158,7 +153,7 @@ public String getMessage() { // should not cause another exception try { - testAuditLogger.commit(null); + testAuditLogger.commit(); } catch (TestLoggerException e) { fail("Exception not cleared from previous logger commit"); } diff --git a/elide-core/src/test/java/com/yahoo/elide/audit/TestAuditLogger.java b/elide-core/src/test/java/com/yahoo/elide/audit/TestAuditLogger.java index 167433aa8d..0f007f8434 100644 --- a/elide-core/src/test/java/com/yahoo/elide/audit/TestAuditLogger.java +++ b/elide-core/src/test/java/com/yahoo/elide/audit/TestAuditLogger.java @@ -5,15 +5,14 @@ */ package com.yahoo.elide.audit; -import com.yahoo.elide.core.RequestScope; - import java.io.IOException; import java.util.ArrayList; import java.util.List; public class TestAuditLogger extends AuditLogger { @Override - public void commit(RequestScope requestScope) throws IOException { + public void commit() throws IOException { + //NOOP } public List getMessages() { diff --git a/elide-core/src/test/java/com/yahoo/elide/core/DataStoreTransactionTest.java b/elide-core/src/test/java/com/yahoo/elide/core/DataStoreTransactionTest.java index 09f24a0acc..0988a56e2a 100644 --- a/elide-core/src/test/java/com/yahoo/elide/core/DataStoreTransactionTest.java +++ b/elide-core/src/test/java/com/yahoo/elide/core/DataStoreTransactionTest.java @@ -14,20 +14,20 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -import com.yahoo.elide.core.filter.expression.FilterExpression; -import com.yahoo.elide.core.pagination.Pagination; -import com.yahoo.elide.core.sort.Sorting; -import com.yahoo.elide.security.User; +import com.yahoo.elide.request.Attribute; +import com.yahoo.elide.request.EntityProjection; +import com.yahoo.elide.request.Relationship; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import java.io.IOException; +import java.io.Serializable; import java.util.Arrays; -import java.util.Optional; public class DataStoreTransactionTest implements DataStoreTransaction { private static final String NAME = "name"; + private static final Attribute NAME_ATTRIBUTE = Attribute.builder().name(NAME).type(String.class).build(); private static final String ENTITY = "entity"; private RequestScope scope; @@ -42,12 +42,6 @@ public void setupMocks() { when(dictionary.getValue(ENTITY, NAME, scope)).thenReturn(3L); } - @Test - public void testAccessUser() { - User actualUser = accessUser(2L); - assertEquals(2L, actualUser.getOpaqueUser()); - } - @Test public void testPreCommit() { preCommit(); @@ -68,7 +62,7 @@ public void testSupportsSorting() { @Test public void testSupportsPagination() { - boolean actual = supportsPagination(null); + boolean actual = supportsPagination(null, null); assertTrue(actual); } @@ -80,14 +74,14 @@ public void testSupportsFiltering() { @Test public void testGetAttribute() { - Object actual = getAttribute(ENTITY, NAME, scope); + Object actual = getAttribute(ENTITY, NAME_ATTRIBUTE, scope); assertEquals(3L, actual); verify(scope, times(1)).getDictionary(); } @Test public void testSetAttribute() { - setAttribute(ENTITY, NAME, null, scope); + setAttribute(ENTITY, NAME_ATTRIBUTE, scope); verify(scope, never()).getDictionary(); } @@ -105,24 +99,29 @@ public void testUpdateToManyRelation() { @Test public void testGetRelation() { - Object actual = getRelation(this, ENTITY, NAME, Optional.empty(), Optional.empty(), Optional.empty(), scope); + Object actual = getRelation(this, ENTITY, Relationship.builder() + .name(NAME) + .projection(EntityProjection.builder() + .type(String.class) + .build()) + .build(), scope); assertEquals(3L, actual); - verify(scope, times(1)).getDictionary(); } @Test public void testLoadObject() { - String string = (String) loadObject(String.class, 2L, Optional.empty(), scope); + String string = (String) loadObject(EntityProjection.builder().type(String.class).build(), 2L, scope); assertEquals(ENTITY, string); - verify(scope, times(1)).getDictionary(); } /** Implemented to support the interface only. No need to test these. **/ + @Override + public Object loadObject(EntityProjection entityProjection, Serializable id, RequestScope scope) { + return ENTITY; + } @Override - @Deprecated - public Iterable loadObjects(Class entityClass, Optional filterExpression, - Optional sorting, Optional pagination, RequestScope scope) { + public Iterable loadObjects(EntityProjection entityProjection, RequestScope scope) { return Arrays.asList(ENTITY); } diff --git a/elide-core/src/test/java/com/yahoo/elide/core/EntityDictionaryTest.java b/elide-core/src/test/java/com/yahoo/elide/core/EntityDictionaryTest.java index 70e87f4ad9..c5894c6642 100644 --- a/elide-core/src/test/java/com/yahoo/elide/core/EntityDictionaryTest.java +++ b/elide-core/src/test/java/com/yahoo/elide/core/EntityDictionaryTest.java @@ -5,6 +5,7 @@ */ package com.yahoo.elide.core; +import static com.yahoo.elide.annotation.LifeCycleHookBinding.Operation.UPDATE; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotEquals; @@ -12,25 +13,23 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; import com.yahoo.elide.Injector; import com.yahoo.elide.annotation.ComputedAttribute; import com.yahoo.elide.annotation.Exclude; import com.yahoo.elide.annotation.FilterExpressionPath; import com.yahoo.elide.annotation.Include; +import com.yahoo.elide.annotation.LifeCycleHookBinding; import com.yahoo.elide.annotation.MappedInterface; -import com.yahoo.elide.annotation.OnUpdatePreSecurity; import com.yahoo.elide.annotation.ReadPermission; import com.yahoo.elide.annotation.SecurityCheck; import com.yahoo.elide.core.exceptions.InvalidAttributeException; +import com.yahoo.elide.core.filter.expression.FilterExpression; import com.yahoo.elide.functions.LifeCycleHook; -import com.yahoo.elide.models.generics.Employee; -import com.yahoo.elide.models.generics.Manager; +import com.yahoo.elide.security.FilterExpressionCheck; import com.yahoo.elide.security.checks.UserCheck; import com.yahoo.elide.security.checks.prefab.Collections.AppendOnly; import com.yahoo.elide.security.checks.prefab.Collections.RemoveOnly; -import com.yahoo.elide.security.checks.prefab.Common.UpdateOnCreate; import com.yahoo.elide.security.checks.prefab.Role; import com.google.common.collect.ImmutableList; @@ -49,6 +48,9 @@ import example.Right; import example.StringId; import example.User; +import example.models.generics.Employee; +import example.models.generics.Manager; +import example.models.versioned.BookV2; import org.junit.jupiter.api.Test; @@ -61,18 +63,20 @@ import java.util.Map; import java.util.Set; import java.util.stream.Collectors; - +import javax.inject.Inject; import javax.persistence.AccessType; import javax.persistence.Entity; import javax.persistence.GeneratedValue; import javax.persistence.Id; +import javax.persistence.OneToOne; import javax.persistence.Transient; public class EntityDictionaryTest extends EntityDictionary { //Test class to validate inheritance logic @Include(rootLevel = true, type = "friend") - private class Friend extends Child { } + private class Friend extends Child { + } public EntityDictionaryTest() { super(Collections.EMPTY_MAP, mock(Injector.class)); @@ -93,21 +97,29 @@ private void init() { bindEntity(Employee.class); bindEntity(Job.class); bindEntity(NoId.class); + bindEntity(BookV2.class); + bindEntity(Book.class); checkNames.forcePut("user has all access", Role.ALL.class); } + @Test + public void testSetId() { + Parent parent = new Parent(); + setId(parent, "123"); + assertEquals(parent.getId(), 123); + } + @Test public void testFindCheckByExpression() { assertEquals("user has all access", getCheckIdentifier(Role.ALL.class)); assertEquals("Prefab.Role.None", getCheckIdentifier(Role.NONE.class)); assertEquals("Prefab.Collections.AppendOnly", getCheckIdentifier(AppendOnly.class)); assertEquals("Prefab.Collections.RemoveOnly", getCheckIdentifier(RemoveOnly.class)); - assertEquals("Prefab.Common.UpdateOnCreate", getCheckIdentifier(UpdateOnCreate.class)); } @SecurityCheck("User is Admin") - public class Foo extends UserCheck { + public class Bar extends UserCheck { @Override public boolean ok(com.yahoo.elide.security.User user) { @@ -121,12 +133,39 @@ public void testCheckScan() { EntityDictionary testDictionary = new EntityDictionary(new HashMap<>()); testDictionary.scanForSecurityChecks(); - assertEquals("User is Admin", testDictionary.getCheckIdentifier(Foo.class)); + assertEquals("User is Admin", testDictionary.getCheckIdentifier(Bar.class)); + } + + @SecurityCheck("Filter Expression Injection Test") + public class Foo extends FilterExpressionCheck { + + @Inject + Long testLong; + + @Override + public FilterExpression getFilterExpression(Class entityClass, + com.yahoo.elide.security.RequestScope requestScope) { + assertEquals(testLong, 123L); + return null; + } + } + + @Test + public void testCheckInjection() { + EntityDictionary testDictionary = new EntityDictionary(new HashMap<>(), new Injector() { + @Override + public void inject(Object entity) { + ((Foo) entity).testLong = 123L; + } + }); + testDictionary.scanForSecurityChecks(); + + assertEquals("Filter Expression Injection Test", testDictionary.getCheckIdentifier(Foo.class)); } @Test public void testGetAttributeOrRelationAnnotation() { - String[] fields = { "field1", "field2", "field3", "relation1", "relation2" }; + String[] fields = {"field1", "field2", "field3", "relation1", "relation2"}; Annotation annotation; for (String field : fields) { annotation = getAttributeOrRelationAnnotation(FunWithPermissions.class, ReadPermission.class, field); @@ -134,28 +173,6 @@ public void testGetAttributeOrRelationAnnotation() { } } - @Test - public void testBindingInitializerPriorToBindingEntityClass() { - @Entity - @Include - class Foo { - @Id - private long id; - - private int bar; - } - - Initializer initializer = mock(Initializer.class); - bindInitializer(initializer, Foo.class); - - assertEquals(1, getAllFields(Foo.class).size()); - - Foo foo = new Foo(); - initializeEntity(foo); - - verify(initializer).initialize(foo); - } - @Test public void testBindingTriggerPriorToBindingEntityClass1() { @Entity @@ -169,7 +186,7 @@ class Foo2 { LifeCycleHook trigger = mock(LifeCycleHook.class); - bindTrigger(Foo2.class, OnUpdatePreSecurity.class, "bar", trigger); + bindTrigger(Foo2.class, "bar", UPDATE, LifeCycleHookBinding.TransactionPhase.PRESECURITY, trigger); assertEquals(1, getAllFields(Foo2.class).size()); } @@ -186,7 +203,7 @@ class Foo3 { LifeCycleHook trigger = mock(LifeCycleHook.class); - bindTrigger(Foo3.class, OnUpdatePreSecurity.class, trigger, true); + bindTrigger(Foo3.class, UPDATE, LifeCycleHookBinding.TransactionPhase.PRESECURITY, trigger, true); assertEquals(1, getAllFields(Foo3.class).size()); } @@ -203,7 +220,7 @@ class Foo4 { LifeCycleHook trigger = mock(LifeCycleHook.class); - bindTrigger(Foo4.class, OnUpdatePreSecurity.class, trigger); + bindTrigger(Foo4.class, UPDATE, LifeCycleHookBinding.TransactionPhase.PRESECURITY, trigger, false); assertEquals(1, getAllFields(Foo4.class).size()); } @@ -358,7 +375,7 @@ public void testDetectCascadeRelations() { @Test public void testGetIdAnnotations() throws Exception { - Collection expectedAnnotationClasses = Arrays.asList(new Class[] { Id.class, GeneratedValue.class }); + Collection expectedAnnotationClasses = Arrays.asList(new Class[]{Id.class, GeneratedValue.class}); Collection actualAnnotationsClasses = getIdAnnotations(new Parent()).stream() .map(Annotation::annotationType) .collect(Collectors.toList()); @@ -385,7 +402,7 @@ class NoId { @Test public void testGetIdAnnotationsSubClass() throws Exception { - Collection expectedAnnotationClasses = Arrays.asList(new Class[] { Id.class, GeneratedValue.class }); + Collection expectedAnnotationClasses = Arrays.asList(new Class[]{Id.class, GeneratedValue.class}); Collection actualAnnotationsClasses = getIdAnnotations(new Friend()).stream() .map(Annotation::annotationType) .collect(Collectors.toList()); @@ -396,12 +413,12 @@ public void testGetIdAnnotationsSubClass() throws Exception { @Test public void testIsSharableTrue() throws Exception { - assertTrue(isShareable(Right.class)); + assertTrue(isTransferable(Right.class)); } @Test public void testIsSharableFalse() throws Exception { - assertFalse(isShareable(Left.class)); + assertFalse(isTransferable(Left.class)); } @Test @@ -489,15 +506,17 @@ public void testNoExcludedFieldsReturned() { List attrs = getAttributes(Child.class); List rels = getRelationships(Child.class); assertTrue(!attrs.contains("excludedEntity") && !attrs.contains("excludedRelationship") - && !attrs.contains("excludedEntityList")); + && !attrs.contains("excludedEntityList")); assertTrue(!rels.contains("excludedEntity") && !rels.contains("excludedRelationship") - && !rels.contains("excludedEntityList")); + && !rels.contains("excludedEntityList")); } @MappedInterface - public interface SuitableInterface { } + public interface SuitableInterface { + } - public interface BadInterface { } + public interface BadInterface { + } @Test public void testMappedInterface() { @@ -528,20 +547,20 @@ class SubsubclassBinding extends SubclassBinding { bindEntity(SubclassBinding.class); bindEntity(SubsubclassBinding.class); - assertEquals(SubclassBinding.class, getEntityBinding(SubclassBinding.class).entityClass); + assertEquals(SuperclassBinding.class, getEntityBinding(SubclassBinding.class).entityClass); assertEquals(SuperclassBinding.class, getEntityBinding(SuperclassBinding.class).entityClass); assertEquals(SuperclassBinding.class, lookupEntityClass(SuperclassBinding.class)); assertEquals(SuperclassBinding.class, lookupEntityClass(SubclassBinding.class)); assertEquals(SuperclassBinding.class, lookupEntityClass(SubsubclassBinding.class)); - assertEquals("subclassBinding", getEntityFor(SubclassBinding.class)); + assertEquals("superclassBinding", getEntityFor(SubclassBinding.class)); assertEquals("superclassBinding", getEntityFor(SuperclassBinding.class)); - assertEquals(SubclassBinding.class, getEntityClass("subclassBinding")); - assertEquals(SuperclassBinding.class, getEntityClass("superclassBinding")); + assertNull(getEntityClass("subclassBinding", NO_VERSION)); + assertEquals(SuperclassBinding.class, getEntityClass("superclassBinding", NO_VERSION)); - assertEquals("subclassBinding", getJsonAliasFor(SubclassBinding.class)); + assertEquals("superclassBinding", getJsonAliasFor(SubclassBinding.class)); assertEquals("superclassBinding", getJsonAliasFor(SuperclassBinding.class)); } @@ -565,11 +584,10 @@ class SubsubclassBinding extends SubclassBinding { } bindEntity(SuperclassBinding.class); - bindEntity(SubclassBinding.class); bindEntity(SubsubclassBinding.class); assertEquals(SuperclassBinding.class, getEntityBinding(SuperclassBinding.class).entityClass); - assertEquals(SubclassBinding.class, getEntityBinding(SubclassBinding.class).entityClass); + assertEquals(SuperclassBinding.class, getEntityBinding(SubclassBinding.class).entityClass); assertEquals(SubsubclassBinding.class, getEntityBinding(SubsubclassBinding.class).entityClass); assertEquals(SuperclassBinding.class, lookupEntityClass(SuperclassBinding.class)); @@ -606,15 +624,13 @@ class SubsubclassBinding extends SubclassBinding { } bindEntity(SuperclassBinding.class); - bindEntity(SubclassBinding.class); - bindEntity(SubsubclassBinding.class); - assertEquals(SubclassBinding.class, getEntityBinding(SubclassBinding.class).entityClass); + assertEquals(SuperclassBinding.class, getEntityBinding(SubclassBinding.class).entityClass); assertEquals(SuperclassBinding.class, getEntityBinding(SuperclassBinding.class).entityClass); assertEquals(SuperclassBinding.class, lookupIncludeClass(SuperclassBinding.class)); - assertEquals(SubclassBinding.class, lookupIncludeClass(SubclassBinding.class)); - assertEquals(SubsubclassBinding.class, lookupIncludeClass(SubsubclassBinding.class)); + assertEquals(SuperclassBinding.class, lookupIncludeClass(SubclassBinding.class)); + assertEquals(SuperclassBinding.class, lookupIncludeClass(SubsubclassBinding.class)); } @Test @@ -635,15 +651,14 @@ class SubsubclassBinding extends SubclassBinding { } bindEntity(SuperclassBinding.class); - bindEntity(SubclassBinding.class); bindEntity(SubsubclassBinding.class); - assertEquals(SubclassBinding.class, getEntityBinding(SubclassBinding.class).entityClass); + assertEquals(SuperclassBinding.class, getEntityBinding(SubclassBinding.class).entityClass); assertEquals(SuperclassBinding.class, getEntityBinding(SuperclassBinding.class).entityClass); assertEquals(SubsubclassBinding.class, getEntityBinding(SubsubclassBinding.class).entityClass); assertEquals(SuperclassBinding.class, lookupIncludeClass(SuperclassBinding.class)); - assertEquals(SubclassBinding.class, lookupIncludeClass(SubclassBinding.class)); + assertEquals(SuperclassBinding.class, lookupIncludeClass(SubclassBinding.class)); assertEquals(SubsubclassBinding.class, lookupIncludeClass(SubsubclassBinding.class)); } @@ -663,24 +678,24 @@ class SubsubclassBinding extends SubclassBinding { } bindEntity(SuperclassBinding.class); - bindEntity(SubclassBinding.class); bindEntity(SubsubclassBinding.class); - assertEquals(SubclassBinding.class, getEntityBinding(SubclassBinding.class).entityClass); + assertEquals(SuperclassBinding.class, getEntityBinding(SubclassBinding.class).entityClass); assertEquals(SuperclassBinding.class, getEntityBinding(SuperclassBinding.class).entityClass); assertThrows(IllegalArgumentException.class, () -> { getEntityBinding(SubsubclassBinding.class); }); assertEquals(SuperclassBinding.class, lookupIncludeClass(SuperclassBinding.class)); - assertEquals(SubclassBinding.class, lookupIncludeClass(SubclassBinding.class)); + assertEquals(SuperclassBinding.class, lookupIncludeClass(SubclassBinding.class)); assertEquals(null, lookupIncludeClass(SubsubclassBinding.class)); } @Test public void testGetFirstAnnotation() { @Exclude - class Foo { } + class Foo { + } @Include class Bar extends Foo { @@ -699,7 +714,8 @@ class Baz extends Bar { public void testGetFirstAnnotationConflict() { @Exclude @Include - class Foo { } + class Foo { + } Annotation first = getFirstAnnotation(Foo.class, Arrays.asList(Exclude.class, Include.class)); assertTrue(first instanceof Exclude); @@ -727,6 +743,93 @@ public void testBadLookupEntityClass() { assertThrows(IllegalArgumentException.class, () -> lookupEntityClass(Object.class)); } + @Test + public void testFieldIsInjected() { + EntityDictionary testDictionary = new EntityDictionary(new HashMap<>()); + + @Include + class FieldInject { + @Inject + private String field; + } + + testDictionary.bindEntity(FieldInject.class); + + assertTrue(testDictionary.getEntityBinding(FieldInject.class).isInjected()); + } + + @Test + public void testInheritedFieldIsInjected() { + EntityDictionary testDictionary = new EntityDictionary(new HashMap<>()); + class BaseClass { + @Inject + private String field; + } + + @Include + class SubClass extends BaseClass { + private String anotherField; + } + + testDictionary.bindEntity(SubClass.class); + + assertTrue(testDictionary.getEntityBinding(SubClass.class).isInjected()); + } + + @Test + public void testMethodIsInjected() { + EntityDictionary testDictionary = new EntityDictionary(new HashMap<>()); + + @Include + class MethodInject { + @Inject + private void setField(String field) { + //NOOP + } + } + + testDictionary.bindEntity(MethodInject.class); + + assertTrue(testDictionary.getEntityBinding(MethodInject.class).isInjected()); + } + + @Test + public void testInhertedMethodIsInjected() { + EntityDictionary testDictionary = new EntityDictionary(new HashMap<>()); + class BaseClass { + @Inject + private void setField(String field) { + //NOOP + } + } + + @Include + class SubClass extends BaseClass { + private String anotherField; + } + + testDictionary.bindEntity(SubClass.class); + + assertTrue(testDictionary.getEntityBinding(SubClass.class).isInjected()); + } + + @Test + public void testConstructorIsInjected() { + EntityDictionary testDictionary = new EntityDictionary(new HashMap<>()); + + @Include + class ConstructorInject { + @Inject + public ConstructorInject(String field) { + //NOOP + } + } + + testDictionary.bindEntity(ConstructorInject.class); + + assertTrue(testDictionary.getEntityBinding(ConstructorInject.class).isInjected()); + } + @Test public void testFieldLookup() throws Exception { bindEntity(Book.class); @@ -831,4 +934,61 @@ public void testCheckLookup() throws Exception { assertThrows(IllegalArgumentException.class, () -> this.getCheck(String.class.getName())); } + + @Test + public void testAttributeOrRelationAnnotationExists() { + assertTrue(attributeOrRelationAnnotationExists(Job.class, "jobId", Id.class)); + assertFalse(attributeOrRelationAnnotationExists(Job.class, "title", OneToOne.class)); + } + + @Test + public void testIsValidField() { + assertTrue(isValidField(Job.class, "title")); + assertFalse(isValidField(Job.class, "foo")); + } + + @Test + public void testGetBoundByVersion() { + Set> models = getBoundClassesByVersion("1.0"); + assertEquals(3, models.size()); //Also includes com.yahoo.elide inner classes from this file. + assertTrue(models.contains(BookV2.class)); + + + models = getBoundClassesByVersion(NO_VERSION); + assertEquals(14, models.size()); + } + + @Test + public void testGetEntityClassByVersion() { + Class model = getEntityClass("book", NO_VERSION); + assertEquals(Book.class, model); + + model = getEntityClass("book", "1.0"); + assertEquals(BookV2.class, model); + } + + @Test + public void testGetModelVersion() { + assertEquals("1.0", getModelVersion(BookV2.class)); + assertEquals(NO_VERSION, getModelVersion(Book.class)); + } + + @Test + public void testHasBinding() { + assertTrue(hasBinding(FunWithPermissions.class)); + assertTrue(hasBinding(Parent.class)); + assertTrue(hasBinding(Child.class)); + assertTrue(hasBinding(User.class)); + assertTrue(hasBinding(Left.class)); + assertTrue(hasBinding(Right.class)); + assertTrue(hasBinding(StringId.class)); + assertTrue(hasBinding(Friend.class)); + assertTrue(hasBinding(FieldAnnotations.class)); + assertTrue(hasBinding(Manager.class)); + assertTrue(hasBinding(Employee.class)); + assertTrue(hasBinding(Job.class)); + assertTrue(hasBinding(NoId.class)); + assertTrue(hasBinding(BookV2.class)); + assertTrue(hasBinding(Book.class)); + } } diff --git a/elide-core/src/test/java/com/yahoo/elide/core/LifeCycleTest.java b/elide-core/src/test/java/com/yahoo/elide/core/LifeCycleTest.java index 2d811cd562..0f46cd8ab5 100644 --- a/elide-core/src/test/java/com/yahoo/elide/core/LifeCycleTest.java +++ b/elide-core/src/test/java/com/yahoo/elide/core/LifeCycleTest.java @@ -1,26 +1,32 @@ /* - * Copyright 2016, Yahoo Inc. + * Copyright 2020, Yahoo Inc. * Licensed under the Apache License, Version 2.0 * See LICENSE file in project root for terms. */ package com.yahoo.elide.core; import static com.yahoo.elide.Elide.JSONAPI_CONTENT_TYPE; -import static com.yahoo.elide.Elide.JSONAPI_CONTENT_TYPE_WITH_JSON_PATCH_EXTENSION; +import static com.yahoo.elide.annotation.LifeCycleHookBinding.Operation.CREATE; +import static com.yahoo.elide.annotation.LifeCycleHookBinding.Operation.DELETE; +import static com.yahoo.elide.annotation.LifeCycleHookBinding.Operation.READ; +import static com.yahoo.elide.annotation.LifeCycleHookBinding.Operation.UPDATE; +import static com.yahoo.elide.annotation.LifeCycleHookBinding.TransactionPhase.POSTCOMMIT; +import static com.yahoo.elide.annotation.LifeCycleHookBinding.TransactionPhase.PRECOMMIT; +import static com.yahoo.elide.annotation.LifeCycleHookBinding.TransactionPhase.PRESECURITY; +import static com.yahoo.elide.core.EntityDictionary.NO_VERSION; + import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.isA; -import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.ArgumentMatchers.notNull; import static org.mockito.Mockito.clearInvocations; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.reset; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -29,150 +35,324 @@ import com.yahoo.elide.ElideResponse; import com.yahoo.elide.ElideSettings; import com.yahoo.elide.ElideSettingsBuilder; -import com.yahoo.elide.annotation.Exclude; import com.yahoo.elide.annotation.Include; -import com.yahoo.elide.annotation.OnCreatePostCommit; -import com.yahoo.elide.annotation.OnCreatePreCommit; -import com.yahoo.elide.annotation.OnCreatePreSecurity; -import com.yahoo.elide.annotation.OnDeletePostCommit; -import com.yahoo.elide.annotation.OnDeletePreCommit; -import com.yahoo.elide.annotation.OnDeletePreSecurity; -import com.yahoo.elide.annotation.OnReadPostCommit; -import com.yahoo.elide.annotation.OnReadPreCommit; -import com.yahoo.elide.annotation.OnReadPreSecurity; -import com.yahoo.elide.annotation.OnUpdatePostCommit; -import com.yahoo.elide.annotation.OnUpdatePreCommit; -import com.yahoo.elide.annotation.OnUpdatePreSecurity; +import com.yahoo.elide.annotation.LifeCycleHookBinding; import com.yahoo.elide.audit.AuditLogger; -import com.yahoo.elide.core.datastore.inmemory.HashMapDataStore; -import com.yahoo.elide.core.datastore.inmemory.InMemoryDataStore; import com.yahoo.elide.functions.LifeCycleHook; +import com.yahoo.elide.request.Attribute; +import com.yahoo.elide.request.EntityProjection; +import com.yahoo.elide.request.Relationship; import com.yahoo.elide.security.ChangeSpec; +import com.yahoo.elide.security.TestUser; import com.yahoo.elide.security.User; -import com.yahoo.elide.security.checks.Check; -import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableSet; -import com.google.common.collect.Sets; - -import example.Author; -import example.Book; -import example.Editor; -import example.Publisher; -import example.TestCheckMappings; - -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import lombok.Getter; +import lombok.Setter; + import java.util.Arrays; -import java.util.HashMap; import java.util.HashSet; -import java.util.Map; import java.util.Optional; +import java.util.Set; -import javax.persistence.Entity; import javax.persistence.Id; -import javax.persistence.Transient; -import javax.validation.ConstraintViolationException; +import javax.persistence.ManyToMany; +import javax.persistence.OneToMany; import javax.ws.rs.core.MultivaluedHashMap; import javax.ws.rs.core.MultivaluedMap; /** * Tests the invocation & sequencing of DataStoreTransaction method invocations and life cycle events. + * Model used to mock different lifecycle test scenarios. This model uses fields instead of properties. */ -public class LifeCycleTest { +@Include(type = "testModel") +@LifeCycleHookBinding(hook = FieldTestModel.ClassPreSecurityHook.class, operation = CREATE, phase = PRESECURITY) +@LifeCycleHookBinding(hook = FieldTestModel.ClassPreCommitHook.class, operation = CREATE, phase = PRECOMMIT) +@LifeCycleHookBinding(hook = FieldTestModel.ClassPostCommitHook.class, operation = CREATE, phase = POSTCOMMIT) +@LifeCycleHookBinding(hook = FieldTestModel.ClassPreSecurityHook.class, operation = DELETE, phase = PRESECURITY) +@LifeCycleHookBinding(hook = FieldTestModel.ClassPreCommitHookEverything.class, operation = CREATE, + phase = PRECOMMIT, oncePerRequest = false) +@LifeCycleHookBinding(hook = FieldTestModel.ClassPreCommitHook.class, operation = DELETE, phase = PRECOMMIT) +@LifeCycleHookBinding(hook = FieldTestModel.ClassPostCommitHook.class, operation = DELETE, phase = POSTCOMMIT) +@LifeCycleHookBinding(hook = FieldTestModel.ClassPreSecurityHook.class, operation = UPDATE, phase = PRESECURITY) +@LifeCycleHookBinding(hook = FieldTestModel.ClassPreCommitHook.class, operation = UPDATE, phase = PRECOMMIT) +@LifeCycleHookBinding(hook = FieldTestModel.ClassPostCommitHook.class, operation = UPDATE, phase = POSTCOMMIT) +@LifeCycleHookBinding(hook = FieldTestModel.ClassPreSecurityHook.class, operation = READ, phase = PRESECURITY) +@LifeCycleHookBinding(hook = FieldTestModel.ClassPreCommitHook.class, operation = READ, phase = PRECOMMIT) +@LifeCycleHookBinding(hook = FieldTestModel.ClassPostCommitHook.class, operation = READ, phase = POSTCOMMIT) +class FieldTestModel { + + @Id + private String id; + + @Getter + @Setter + @LifeCycleHookBinding(hook = FieldTestModel.AttributePreSecurityHook.class, operation = CREATE, phase = PRESECURITY) + @LifeCycleHookBinding(hook = FieldTestModel.AttributePreCommitHook.class, operation = CREATE, phase = PRECOMMIT) + @LifeCycleHookBinding(hook = FieldTestModel.AttributePostCommitHook.class, operation = CREATE, phase = POSTCOMMIT) + @LifeCycleHookBinding(hook = FieldTestModel.AttributePreSecurityHook.class, operation = DELETE, phase = PRESECURITY) + @LifeCycleHookBinding(hook = FieldTestModel.AttributePreCommitHook.class, operation = DELETE, phase = PRECOMMIT) + @LifeCycleHookBinding(hook = FieldTestModel.AttributePostCommitHook.class, operation = DELETE, phase = POSTCOMMIT) + @LifeCycleHookBinding(hook = FieldTestModel.AttributePreSecurityHook.class, operation = UPDATE, phase = PRESECURITY) + @LifeCycleHookBinding(hook = FieldTestModel.AttributePreCommitHook.class, operation = UPDATE, phase = PRECOMMIT) + @LifeCycleHookBinding(hook = FieldTestModel.AttributePostCommitHook.class, operation = UPDATE, phase = POSTCOMMIT) + @LifeCycleHookBinding(hook = FieldTestModel.AttributePreSecurityHook.class, operation = READ, phase = PRESECURITY) + @LifeCycleHookBinding(hook = FieldTestModel.AttributePreCommitHook.class, operation = READ, phase = PRECOMMIT) + @LifeCycleHookBinding(hook = FieldTestModel.AttributePostCommitHook.class, operation = READ, phase = POSTCOMMIT) + private String field; + + @Getter + @Setter + @OneToMany + @LifeCycleHookBinding(hook = FieldTestModel.RelationPreSecurityHook.class, operation = CREATE, phase = PRESECURITY) + @LifeCycleHookBinding(hook = FieldTestModel.RelationPreCommitHook.class, operation = CREATE, phase = PRECOMMIT) + @LifeCycleHookBinding(hook = FieldTestModel.RelationPostCommitHook.class, operation = CREATE, phase = POSTCOMMIT) + @LifeCycleHookBinding(hook = FieldTestModel.RelationPreSecurityHook.class, operation = DELETE, phase = PRESECURITY) + @LifeCycleHookBinding(hook = FieldTestModel.RelationPreCommitHook.class, operation = DELETE, phase = PRECOMMIT) + @LifeCycleHookBinding(hook = FieldTestModel.RelationPostCommitHook.class, operation = DELETE, phase = POSTCOMMIT) + @LifeCycleHookBinding(hook = FieldTestModel.RelationPreSecurityHook.class, operation = UPDATE, phase = PRESECURITY) + @LifeCycleHookBinding(hook = FieldTestModel.RelationPreCommitHook.class, operation = UPDATE, phase = PRECOMMIT) + @LifeCycleHookBinding(hook = FieldTestModel.RelationPostCommitHook.class, operation = UPDATE, phase = POSTCOMMIT) + @LifeCycleHookBinding(hook = FieldTestModel.RelationPreSecurityHook.class, operation = READ, phase = PRESECURITY) + @LifeCycleHookBinding(hook = FieldTestModel.RelationPreCommitHook.class, operation = READ, phase = PRECOMMIT) + @LifeCycleHookBinding(hook = FieldTestModel.RelationPostCommitHook.class, operation = READ, phase = POSTCOMMIT) + private Set models = new HashSet<>(); + + static class ClassPreSecurityHook implements LifeCycleHook { + @Override + public void execute(LifeCycleHookBinding.Operation operation, + FieldTestModel elideEntity, + com.yahoo.elide.security.RequestScope requestScope, + Optional changes) { + elideEntity.classCallback(operation, PRESECURITY); + } + } - private static final AuditLogger MOCK_AUDIT_LOGGER = mock(AuditLogger.class); - private EntityDictionary dictionary; - private MockCallback callback; - private MockCallback onUpdateDeferredCallback; - private MockCallback onUpdateImmediateCallback; - private MockCallback onUpdatePostCommitCallback; - private MockCallback onUpdatePostCommitAuthor; + static class ClassPreCommitHook implements LifeCycleHook { + @Override + public void execute(LifeCycleHookBinding.Operation operation, + FieldTestModel elideEntity, + com.yahoo.elide.security.RequestScope requestScope, + Optional changes) { + elideEntity.classCallback(operation, PRECOMMIT); + } + } + + static class ClassPreCommitHookEverything implements LifeCycleHook { + @Override + public void execute(LifeCycleHookBinding.Operation operation, + FieldTestModel elideEntity, + com.yahoo.elide.security.RequestScope requestScope, + Optional changes) { + elideEntity.classAllFieldsCallback(operation, PRECOMMIT); + } + } + + static class ClassPostCommitHook implements LifeCycleHook { + @Override + public void execute(LifeCycleHookBinding.Operation operation, + FieldTestModel elideEntity, + com.yahoo.elide.security.RequestScope requestScope, + Optional changes) { + elideEntity.classCallback(operation, POSTCOMMIT); + } + } + + static class AttributePreSecurityHook implements LifeCycleHook { + @Override + public void execute(LifeCycleHookBinding.Operation operation, + FieldTestModel elideEntity, + com.yahoo.elide.security.RequestScope requestScope, + Optional changes) { + elideEntity.attributeCallback(operation, PRESECURITY, changes.orElse(null)); + } + } + + static class AttributePreCommitHook implements LifeCycleHook { + @Override + public void execute(LifeCycleHookBinding.Operation operation, + FieldTestModel elideEntity, + com.yahoo.elide.security.RequestScope requestScope, + Optional changes) { + elideEntity.attributeCallback(operation, PRECOMMIT, changes.orElse(null)); + } + } + static class AttributePostCommitHook implements LifeCycleHook { + @Override + public void execute(LifeCycleHookBinding.Operation operation, + FieldTestModel elideEntity, + com.yahoo.elide.security.RequestScope requestScope, + Optional changes) { + elideEntity.attributeCallback(operation, POSTCOMMIT, changes.orElse(null)); + } + } - public class MockCallback implements LifeCycleHook { + static class RelationPreSecurityHook implements LifeCycleHook { @Override - public void execute(T object, com.yahoo.elide.security.RequestScope scope, Optional changes) { - //NOOP + public void execute(LifeCycleHookBinding.Operation operation, + FieldTestModel elideEntity, + com.yahoo.elide.security.RequestScope requestScope, + Optional changes) { + elideEntity.relationCallback(operation, PRESECURITY, changes.orElse(null)); } } - public class TestEntityDictionary extends EntityDictionary { - public TestEntityDictionary(Map> checks) { - super(checks); + static class RelationPreCommitHook implements LifeCycleHook { + @Override + public void execute(LifeCycleHookBinding.Operation operation, + FieldTestModel elideEntity, + com.yahoo.elide.security.RequestScope requestScope, + Optional changes) { + elideEntity.relationCallback(operation, PRECOMMIT, changes.orElse(null)); } + } + static class RelationPostCommitHook implements LifeCycleHook { @Override - public Class lookupBoundClass(Class objClass) { - // Special handling for mocked Book class which has Entity annotation - if (objClass.getName().contains("$MockitoMock$")) { - objClass = objClass.getSuperclass(); - } - return super.lookupBoundClass(objClass); + public void execute(LifeCycleHookBinding.Operation operation, + FieldTestModel elideEntity, + com.yahoo.elide.security.RequestScope requestScope, + Optional changes) { + elideEntity.relationCallback(operation, POSTCOMMIT, changes.orElse(null)); } + } + public void classCallback(LifeCycleHookBinding.Operation operation, + LifeCycleHookBinding.TransactionPhase phase) { + //NOOP - this will be mocked to verify hook invocation. } - LifeCycleTest() throws Exception { - callback = mock(MockCallback.class); - onUpdateDeferredCallback = mock(MockCallback.class); - onUpdateImmediateCallback = mock(MockCallback.class); - onUpdatePostCommitCallback = mock(MockCallback.class); - onUpdatePostCommitAuthor = mock(MockCallback.class); - dictionary = new TestEntityDictionary(TestCheckMappings.MAPPINGS); - dictionary.bindEntity(Book.class); - dictionary.bindEntity(Author.class); - dictionary.bindEntity(Publisher.class); - dictionary.bindEntity(Editor.class); - ImmutableList.of( - OnCreatePostCommit.class, OnCreatePreCommit.class, OnCreatePreSecurity.class, - OnReadPostCommit.class, OnReadPreCommit.class, OnReadPreSecurity.class, - OnDeletePostCommit.class, OnDeletePreCommit.class, OnDeletePreSecurity.class) - .stream().forEach(cls -> dictionary.bindTrigger(Book.class, cls, callback)); - ImmutableList.of( - OnUpdatePostCommit.class, OnUpdatePreCommit.class, OnUpdatePreSecurity.class) - .stream().forEach(cls -> dictionary.bindTrigger(Book.class, cls, "title", callback)); - dictionary.bindTrigger(Book.class, OnUpdatePreCommit.class, onUpdateDeferredCallback, true); - dictionary.bindTrigger(Book.class, OnUpdatePreSecurity.class, onUpdateImmediateCallback, true); - dictionary.bindTrigger(Book.class, OnUpdatePostCommit.class, onUpdatePostCommitCallback, true); - dictionary.bindTrigger(Author.class, OnUpdatePostCommit.class, onUpdatePostCommitAuthor, true); + public void attributeCallback(LifeCycleHookBinding.Operation operation, + LifeCycleHookBinding.TransactionPhase phase, + ChangeSpec changes) { + //NOOP - this will be mocked to verify hook invocation. } - @BeforeEach - public void clearMocks() { - clearInvocations(onUpdatePostCommitAuthor, onUpdateImmediateCallback, onUpdatePostCommitCallback, onUpdatePostCommitAuthor); + public void relationCallback(LifeCycleHookBinding.Operation operation, + LifeCycleHookBinding.TransactionPhase phase, + ChangeSpec changes) { + //NOOP - this will be mocked to verify hook invocation. + } + + public void classAllFieldsCallback(LifeCycleHookBinding.Operation operation, + LifeCycleHookBinding.TransactionPhase phase) { + //NOOP - this will be mocked to verify hook invocation. + } +} + +/** + * Model used to mock different lifecycle test scenarios. This model uses properties instead of fields. + */ +@Include +class PropertyTestModel { + private String id; + + private Set models = new HashSet<>(); + + static class RelationPostCommitHook implements LifeCycleHook { + @Override + public void execute(LifeCycleHookBinding.Operation operation, + PropertyTestModel elideEntity, + com.yahoo.elide.security.RequestScope requestScope, + Optional changes) { + elideEntity.relationCallback(operation, POSTCOMMIT, changes.orElse(null)); + } + } + + @Id + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + @ManyToMany + @LifeCycleHookBinding(hook = PropertyTestModel.RelationPostCommitHook.class, + operation = CREATE, phase = POSTCOMMIT) + @LifeCycleHookBinding(hook = PropertyTestModel.RelationPostCommitHook.class, + operation = UPDATE, phase = POSTCOMMIT) + public Set getModels() { + return models; + } + + public void setModels(Set models) { + this.models = models; + } + + public void relationCallback(LifeCycleHookBinding.Operation operation, + LifeCycleHookBinding.TransactionPhase phase, + ChangeSpec changes) { + //NOOP - this will be mocked to verify hook invocation. + } +} + +/** + * Tests the invocation & sequencing of DataStoreTransaction method invocations and life cycle events. + */ +public class LifeCycleTest { + + private static final AuditLogger MOCK_AUDIT_LOGGER = mock(AuditLogger.class); + private EntityDictionary dictionary; + + LifeCycleTest() throws Exception { + dictionary = TestDictionary.getTestDictionary(); + dictionary.bindEntity(FieldTestModel.class); + dictionary.bindEntity(PropertyTestModel.class); } @Test public void testElideCreate() throws Exception { DataStore store = mock(DataStore.class); DataStoreTransaction tx = mock(DataStoreTransaction.class); - Book book = mock(Book.class); + FieldTestModel mockModel = mock(FieldTestModel.class); Elide elide = getElide(store, dictionary, MOCK_AUDIT_LOGGER); - String bookBody = "{\"data\": {\"type\":\"book\",\"attributes\": {\"title\":\"Grapes of Wrath\"}}}"; + String body = "{\"data\": {\"type\":\"testModel\",\"id\":\"1\",\"attributes\": {\"field\":\"Foo\"}}}"; when(store.beginTransaction()).thenReturn(tx); - when(tx.createNewObject(Book.class)).thenReturn(book); + when(tx.createNewObject(FieldTestModel.class)).thenReturn(mockModel); - ElideResponse response = elide.post("/book", bookBody, null); + ElideResponse response = elide.post("/testModel", body, null, NO_VERSION); assertEquals(HttpStatus.SC_CREATED, response.getResponseCode()); - /* - * This gets called for : - * - read pre-security for the book - * - create pre-security for the book - * - read pre-commit for the book - * - create pre-commit for the book - * - read post-commit for the book - * - create post-commit for the book - */ - verify(callback, times(6)).execute(eq(book), isA(RequestScope.class), any()); - verify(tx).accessUser(any()); + verify(mockModel, times(1)).classCallback(eq(READ), eq(PRESECURITY)); + verify(mockModel, times(1)).classCallback(eq(READ), eq(PRECOMMIT)); + verify(mockModel, times(1)).classCallback(eq(READ), eq(POSTCOMMIT)); + verify(mockModel, times(1)).classCallback(eq(CREATE), eq(PRESECURITY)); + verify(mockModel, times(1)).classCallback(eq(CREATE), eq(PRECOMMIT)); + verify(mockModel, times(1)).classCallback(eq(CREATE), eq(POSTCOMMIT)); + verify(mockModel, never()).classCallback(eq(UPDATE), any()); + verify(mockModel, never()).classCallback(eq(DELETE), any()); + + verify(mockModel, times(2)).classAllFieldsCallback(any(), any()); + verify(mockModel, times(2)).classAllFieldsCallback(eq(CREATE), eq(PRECOMMIT)); + + verify(mockModel, times(1)).attributeCallback(eq(READ), eq(PRESECURITY), any()); + verify(mockModel, times(1)).attributeCallback(eq(READ), eq(PRECOMMIT), any()); + verify(mockModel, times(1)).attributeCallback(eq(READ), eq(POSTCOMMIT), any()); + verify(mockModel, times(1)).attributeCallback(eq(CREATE), eq(PRESECURITY), any()); + verify(mockModel, times(1)).attributeCallback(eq(CREATE), eq(PRECOMMIT), any()); + verify(mockModel, times(1)).attributeCallback(eq(CREATE), eq(POSTCOMMIT), any()); + verify(mockModel, never()).attributeCallback(eq(UPDATE), any(), any()); + verify(mockModel, never()).attributeCallback(eq(DELETE), any(), any()); + + verify(mockModel, times(1)).relationCallback(eq(READ), eq(PRESECURITY), any()); + verify(mockModel, times(1)).relationCallback(eq(READ), eq(PRECOMMIT), any()); + verify(mockModel, times(1)).relationCallback(eq(READ), eq(POSTCOMMIT), any()); + verify(mockModel, times(1)).relationCallback(eq(CREATE), eq(PRESECURITY), any()); + verify(mockModel, times(1)).relationCallback(eq(CREATE), eq(PRECOMMIT), any()); + verify(mockModel, times(1)).relationCallback(eq(CREATE), eq(POSTCOMMIT), any()); + verify(mockModel, never()).relationCallback(eq(UPDATE), any(), any()); + verify(mockModel, never()).relationCallback(eq(DELETE), any(), any()); + verify(tx).preCommit(); - verify(tx, times(1)).createObject(eq(book), isA(RequestScope.class)); + verify(tx, times(1)).createObject(eq(mockModel), isA(RequestScope.class)); verify(tx).flush(isA(RequestScope.class)); verify(tx).commit(isA(RequestScope.class)); verify(tx).close(); @@ -182,66 +362,72 @@ public void testElideCreate() throws Exception { public void testElideCreateFailure() throws Exception { DataStore store = mock(DataStore.class); DataStoreTransaction tx = mock(DataStoreTransaction.class); - Book book = mock(Book.class); - doThrow(RuntimeException.class).when(book).setTitle(anyString()); + FieldTestModel mockModel = mock(FieldTestModel.class); + doThrow(RuntimeException.class).when(mockModel).setField(anyString()); Elide elide = getElide(store, dictionary, MOCK_AUDIT_LOGGER); - String bookBody = "{\"data\": {\"type\":\"book\",\"attributes\": {\"title\":\"Grapes of Wrath\"}}}"; + String body = "{\"data\": {\"type\":\"testModel\",\"id\":\"1\",\"attributes\": {\"field\":\"Foo\"}}}"; when(store.beginTransaction()).thenReturn(tx); - when(tx.createNewObject(Book.class)).thenReturn(book); + when(tx.createNewObject(FieldTestModel.class)).thenReturn(mockModel); - ElideResponse response = elide.post("/book", bookBody, null); + ElideResponse response = elide.post("/testModel", body, null, NO_VERSION); assertEquals(HttpStatus.SC_INTERNAL_SERVER_ERROR, response.getResponseCode()); assertEquals( - "{\"errors\":[{\"detail\":\"InternalServerErrorException: Unexpected exception caught\"}]}", + "{\"errors\":[{\"detail\":\"Unexpected exception caught\"}]}", response.getBody()); - /* - * This gets called for : - * - read pre-security for the book - * - create pre-security for the book - * - read pre-commit for the book - * - create pre-commit for the book - * - read post-commit for the book - * - create post-commit for the book - */ - verify(callback, times(1)).execute(eq(book), isA(RequestScope.class), any()); - verify(tx).accessUser(any()); + verify(mockModel, never()).classCallback(any(), any()); + verify(mockModel, never()).attributeCallback(any(), any(), any()); + verify(mockModel, never()).relationCallback(any(), any(), any()); + verify(mockModel, never()).classAllFieldsCallback(any(), any()); + verify(tx, never()).preCommit(); - verify(tx, never()).createObject(eq(book), isA(RequestScope.class)); + verify(tx, never()).createObject(eq(mockModel), isA(RequestScope.class)); verify(tx, never()).flush(isA(RequestScope.class)); verify(tx, never()).commit(isA(RequestScope.class)); verify(tx).close(); } - @Test public void testElideGet() throws Exception { DataStore store = mock(DataStore.class); DataStoreTransaction tx = mock(DataStoreTransaction.class); - Book book = mock(Book.class); - when(book.getId()).thenReturn(1L); + FieldTestModel mockModel = mock(FieldTestModel.class); Elide elide = getElide(store, dictionary, MOCK_AUDIT_LOGGER); when(store.beginReadTransaction()).thenCallRealMethod(); when(store.beginTransaction()).thenReturn(tx); - when(tx.loadObject(eq(Book.class), any(), any(), isA(RequestScope.class))).thenReturn(book); + when(tx.loadObject(isA(EntityProjection.class), any(), isA(RequestScope.class))).thenReturn(mockModel); MultivaluedMap headers = new MultivaluedHashMap<>(); - ElideResponse response = elide.get("/book/1", headers, null); + ElideResponse response = elide.get("/testModel/1", headers, null, NO_VERSION); assertEquals(HttpStatus.SC_OK, response.getResponseCode()); - /* - * This gets called for : - * - read pre-security for the book - * - read pre-commit for the book - * - read post-commit for the book - */ - verify(callback, times(3)).execute(eq(book), isA(RequestScope.class), any()); - verify(tx).accessUser(any()); + verify(mockModel, never()).classAllFieldsCallback(any(), any()); + + verify(mockModel, times(1)).classCallback(eq(READ), eq(PRESECURITY)); + verify(mockModel, times(1)).classCallback(eq(READ), eq(PRECOMMIT)); + verify(mockModel, times(1)).classCallback(eq(READ), eq(POSTCOMMIT)); + verify(mockModel, never()).classCallback(eq(CREATE), any()); + verify(mockModel, never()).classCallback(eq(UPDATE), any()); + verify(mockModel, never()).classCallback(eq(DELETE), any()); + + verify(mockModel, times(1)).attributeCallback(eq(READ), eq(PRESECURITY), any()); + verify(mockModel, times(1)).attributeCallback(eq(READ), eq(PRECOMMIT), any()); + verify(mockModel, times(1)).attributeCallback(eq(READ), eq(POSTCOMMIT), any()); + verify(mockModel, never()).attributeCallback(eq(CREATE), any(), any()); + verify(mockModel, never()).attributeCallback(eq(UPDATE), any(), any()); + verify(mockModel, never()).attributeCallback(eq(DELETE), any(), any()); + + verify(mockModel, times(1)).relationCallback(eq(READ), eq(PRESECURITY), any()); + verify(mockModel, times(1)).relationCallback(eq(READ), eq(PRECOMMIT), any()); + verify(mockModel, times(1)).relationCallback(eq(READ), eq(POSTCOMMIT), any()); + verify(mockModel, never()).relationCallback(eq(CREATE), any(), any()); + verify(mockModel, never()).relationCallback(eq(UPDATE), any(), any()); + verify(mockModel, never()).relationCallback(eq(DELETE), any(), any()); verify(tx).preCommit(); verify(tx).flush(any()); verify(tx).commit(any()); @@ -249,1034 +435,946 @@ public void testElideGet() throws Exception { } @Test - public void testElideGetRelationship() throws Exception { + public void testElideGetSparse() throws Exception { DataStore store = mock(DataStore.class); DataStoreTransaction tx = mock(DataStoreTransaction.class); - Book book = mock(Book.class); - Author author = mock(Author.class); - when(book.getId()).thenReturn(1L); - when(author.getId()).thenReturn(2L); - when(book.getAuthors()).thenReturn(ImmutableSet.of(author)); + FieldTestModel mockModel = mock(FieldTestModel.class); Elide elide = getElide(store, dictionary, MOCK_AUDIT_LOGGER); when(store.beginReadTransaction()).thenCallRealMethod(); when(store.beginTransaction()).thenReturn(tx); - when(tx.loadObject(eq(Book.class), any(), any(), isA(RequestScope.class))).thenReturn(book); + when(tx.loadObject(isA(EntityProjection.class), any(), isA(RequestScope.class))).thenReturn(mockModel); MultivaluedMap headers = new MultivaluedHashMap<>(); - ElideResponse response = elide.get("/book/1/relationships/authors", headers, null); + headers.putSingle("fields[testModel]", "field"); + ElideResponse response = elide.get("/testModel/1", headers, null, NO_VERSION); assertEquals(HttpStatus.SC_OK, response.getResponseCode()); - /* - * This gets called for : - * - read pre-security for the book - * - read pre-commit for the book - * - read post-commit for the book - */ - verify(callback, times(3)).execute(eq(book), isA(RequestScope.class), any()); - verify(tx).accessUser(any()); - verify(tx).preCommit(); - verify(tx).flush(any()); - verify(tx).commit(any()); - verify(tx).close(); - } - - @Test - public void testElidePatch() throws Exception { - DataStore store = mock(DataStore.class); - DataStoreTransaction tx = mock(DataStoreTransaction.class); - Book book = mock(Book.class); - - Elide elide = getElide(store, dictionary, MOCK_AUDIT_LOGGER); - - when(book.getId()).thenReturn(1L); - when(store.beginTransaction()).thenReturn(tx); - when(tx.loadObject(eq(Book.class), any(), any(), isA(RequestScope.class))).thenReturn(book); - - String bookBody = "{\"data\":{\"type\":\"book\",\"id\":1,\"attributes\": {\"title\":\"Grapes of Wrath\"}}}"; - - String contentType = JSONAPI_CONTENT_TYPE; - ElideResponse response = elide.patch(contentType, contentType, "/book/1", bookBody, null); - assertEquals(HttpStatus.SC_NO_CONTENT, response.getResponseCode()); - - /* - * This gets called for : - * - read pre-security for the book - * - update pre-security for the book.title - * - read pre-commit for the book - * - update pre-commit for the book.title - * - read post-commit for the book - * - update post-commit for the book.title - */ - verify(callback, times(6)).execute(eq(book), isA(RequestScope.class), any()); - verify(onUpdateImmediateCallback, times(1)).execute(eq(book), isA(RequestScope.class), any()); - verify(onUpdateDeferredCallback, times(1)).execute(eq(book), isA(RequestScope.class), any()); - verify(onUpdateImmediateCallback, never()).execute(eq(book), isA(RequestScope.class), eq(Optional.empty())); - verify(onUpdateDeferredCallback, never()).execute(eq(book), isA(RequestScope.class), eq(Optional.empty())); - verify(tx).accessUser(any()); - verify(tx).preCommit(); - - verify(tx).save(eq(book), isA(RequestScope.class)); - verify(tx).flush(isA(RequestScope.class)); - verify(tx).commit(isA(RequestScope.class)); - verify(tx).close(); - } - - @Test - public void testElidePatchFailure() throws Exception { - DataStore store = mock(DataStore.class); - DataStoreTransaction tx = mock(DataStoreTransaction.class); - Book book = mock(Book.class); - - Elide elide = getElide(store, dictionary, MOCK_AUDIT_LOGGER); - - when(book.getId()).thenReturn(1L); - when(store.beginTransaction()).thenReturn(tx); - when(tx.loadObject(eq(Book.class), any(), any(), isA(RequestScope.class))).thenReturn(book); - doThrow(ConstraintViolationException.class).when(tx).flush(any()); - - String bookBody = "{\"data\":{\"type\":\"book\",\"id\":1,\"attributes\": {\"title\":\"Grapes of Wrath\"}}}"; + verify(mockModel, never()).classAllFieldsCallback(any(), any()); - String contentType = JSONAPI_CONTENT_TYPE; - ElideResponse response = elide.patch(contentType, contentType, "/book/1", bookBody, null); - assertEquals(HttpStatus.SC_BAD_REQUEST, response.getResponseCode()); - assertEquals( - "{\"errors\":[{\"detail\":\"Constraint violation\"}]}", - response.getBody()); - - /* - * This gets called for : - * - read pre-security for the book - * - update pre-security for the book.title - * - read pre-commit for the book - * - update pre-commit for the book.title - * - read post-commit for the book - * - update post-commit for the book.title - */ - verify(callback, times(2)).execute(eq(book), isA(RequestScope.class), any()); - verify(onUpdateImmediateCallback, times(1)).execute(eq(book), isA(RequestScope.class), any()); - verify(onUpdateDeferredCallback, never()).execute(eq(book), isA(RequestScope.class), any()); - verify(onUpdateImmediateCallback, never()).execute(eq(book), isA(RequestScope.class), eq(Optional.empty())); - verify(onUpdateDeferredCallback, never()).execute(eq(book), isA(RequestScope.class), eq(Optional.empty())); - verify(tx).accessUser(any()); - verify(tx, times(1)).preCommit(); - - verify(tx, times(1)).save(eq(book), isA(RequestScope.class)); - verify(tx, times(1)).flush(isA(RequestScope.class)); - verify(tx, never()).commit(isA(RequestScope.class)); - verify(tx).close(); - } - - @Test - public void testElideDelete() throws Exception { - DataStore store = mock(DataStore.class); - DataStoreTransaction tx = mock(DataStoreTransaction.class); - Book book = mock(Book.class); + verify(mockModel, times(1)).classCallback(eq(READ), eq(PRESECURITY)); + verify(mockModel, times(1)).classCallback(eq(READ), eq(PRECOMMIT)); + verify(mockModel, times(1)).classCallback(eq(READ), eq(POSTCOMMIT)); + verify(mockModel, never()).classCallback(eq(CREATE), any()); + verify(mockModel, never()).classCallback(eq(UPDATE), any()); + verify(mockModel, never()).classCallback(eq(DELETE), any()); - Elide elide = getElide(store, dictionary, MOCK_AUDIT_LOGGER); + verify(mockModel, times(1)).attributeCallback(eq(READ), eq(PRESECURITY), any()); + verify(mockModel, times(1)).attributeCallback(eq(READ), eq(PRECOMMIT), any()); + verify(mockModel, times(1)).attributeCallback(eq(READ), eq(POSTCOMMIT), any()); + verify(mockModel, never()).attributeCallback(eq(CREATE), any(), any()); + verify(mockModel, never()).attributeCallback(eq(UPDATE), any(), any()); + verify(mockModel, never()).attributeCallback(eq(DELETE), any(), any()); - when(book.getId()).thenReturn(1L); - when(store.beginTransaction()).thenReturn(tx); - when(tx.loadObject(eq(Book.class), any(), any(), isA(RequestScope.class))).thenReturn(book); + verify(mockModel, never()).relationCallback(any(), any(), any()); - ElideResponse response = elide.delete("/book/1", "", null); - assertEquals(HttpStatus.SC_NO_CONTENT, response.getResponseCode()); - - /* - * This gets called for : - * - delete pre-security for the book - * - delete pre-commit for the book - * - delete post-commit for the book - */ - verify(callback, times(3)).execute(eq(book), isA(RequestScope.class), any()); - verify(tx).accessUser(any()); verify(tx).preCommit(); - - verify(tx).delete(eq(book), isA(RequestScope.class)); - verify(tx).flush(isA(RequestScope.class)); - verify(tx).commit(isA(RequestScope.class)); + verify(tx).flush(any()); + verify(tx).commit(any()); verify(tx).close(); } @Test - public void testElidePatchExtensionCreate() throws Exception { + public void testElideGetRelationship() throws Exception { DataStore store = mock(DataStore.class); DataStoreTransaction tx = mock(DataStoreTransaction.class); - Book book = mock(Book.class); + FieldTestModel mockModel = mock(FieldTestModel.class); + FieldTestModel child = mock(FieldTestModel.class); + when(mockModel.getModels()).thenReturn(ImmutableSet.of(child)); Elide elide = getElide(store, dictionary, MOCK_AUDIT_LOGGER); - String bookBody = "[{\"op\": \"add\",\"path\": \"/book\",\"value\":{" - + "\"type\":\"book\",\"id\": \"A\",\"attributes\": {\"title\":\"Grapes of Wrath\"}}}]"; - + when(store.beginReadTransaction()).thenCallRealMethod(); when(store.beginTransaction()).thenReturn(tx); - when(tx.createNewObject(Book.class)).thenReturn(book); + when(tx.loadObject(isA(EntityProjection.class), any(), isA(RequestScope.class))).thenReturn(mockModel); - String contentType = JSONAPI_CONTENT_TYPE_WITH_JSON_PATCH_EXTENSION; - ElideResponse response = elide.patch(contentType, contentType, "/", bookBody, null); + MultivaluedMap headers = new MultivaluedHashMap<>(); + ElideResponse response = elide.get("/testModel/1/relationships/models", headers, null, NO_VERSION); assertEquals(HttpStatus.SC_OK, response.getResponseCode()); - /* - * This gets called for : - * - read pre-security for the book - * - create pre-security for the book - * - read pre-commit for the book - * - create pre-commit for the book - * - read post-commit for the book - * - create post-commit for the book - */ - verify(callback, times(6)).execute(eq(book), isA(RequestScope.class), any()); - verify(tx).accessUser(any()); + verify(mockModel, never()).classAllFieldsCallback(any(), any()); + + verify(mockModel, times(1)).classCallback(eq(READ), eq(PRESECURITY)); + verify(mockModel, times(1)).classCallback(eq(READ), eq(PRECOMMIT)); + verify(mockModel, times(1)).classCallback(eq(READ), eq(POSTCOMMIT)); + verify(mockModel, never()).classCallback(eq(CREATE), any()); + verify(mockModel, never()).classCallback(eq(UPDATE), any()); + verify(mockModel, never()).classCallback(eq(DELETE), any()); + + verify(mockModel, times(1)).attributeCallback(eq(READ), eq(PRESECURITY), any()); + verify(mockModel, times(1)).attributeCallback(eq(READ), eq(PRECOMMIT), any()); + verify(mockModel, times(1)).attributeCallback(eq(READ), eq(POSTCOMMIT), any()); + verify(mockModel, never()).attributeCallback(eq(CREATE), any(), any()); + verify(mockModel, never()).attributeCallback(eq(UPDATE), any(), any()); + verify(mockModel, never()).attributeCallback(eq(DELETE), any(), any()); + + verify(mockModel, times(1)).relationCallback(eq(READ), eq(PRESECURITY), any()); + verify(mockModel, times(1)).relationCallback(eq(READ), eq(PRECOMMIT), any()); + verify(mockModel, times(1)).relationCallback(eq(READ), eq(POSTCOMMIT), any()); + verify(mockModel, never()).relationCallback(eq(CREATE), any(), any()); + verify(mockModel, never()).relationCallback(eq(UPDATE), any(), any()); + verify(mockModel, never()).relationCallback(eq(DELETE), any(), any()); + verify(tx).preCommit(); - verify(tx, times(1)).createObject(eq(book), isA(RequestScope.class)); - verify(tx).flush(isA(RequestScope.class)); - verify(tx).commit(isA(RequestScope.class)); + verify(tx).flush(any()); + verify(tx).commit(any()); verify(tx).close(); } @Test - public void failElidePatchExtensionCreate() throws Exception { + public void testElidePatch() throws Exception { DataStore store = mock(DataStore.class); DataStoreTransaction tx = mock(DataStoreTransaction.class); - Book book = mock(Book.class); + FieldTestModel mockModel = mock(FieldTestModel.class); Elide elide = getElide(store, dictionary, MOCK_AUDIT_LOGGER); - String bookBody = "[{\"op\": \"add\",\"path\": \"/book\",\"value\":{" - + "\"type\":\"book\",\"attributes\": {\"title\":\"Grapes of Wrath\"}}}]"; + String body = "{\"data\": {\"type\":\"testModel\",\"id\":\"1\",\"attributes\": {\"field\":\"Foo\"}}}"; + dictionary.setValue(mockModel, "id", "1"); when(store.beginTransaction()).thenReturn(tx); - when(tx.createNewObject(Book.class)).thenReturn(book); + when(tx.loadObject(isA(EntityProjection.class), any(), isA(RequestScope.class))).thenReturn(mockModel); - String contentType = JSONAPI_CONTENT_TYPE_WITH_JSON_PATCH_EXTENSION; - ElideResponse response = elide.patch(contentType, contentType, "/", bookBody, null); - assertEquals(HttpStatus.SC_BAD_REQUEST, response.getResponseCode()); - assertEquals( - "{\"errors\":[{\"detail\":\"JsonPatchExtensionException\"}]}", - response.getBody()); - - verify(callback, never()).execute(eq(book), isA(RequestScope.class), any()); - verify(tx).accessUser(any()); - verify(tx, never()).preCommit(); - verify(tx, never()).flush(isA(RequestScope.class)); - verify(tx, never()).commit(isA(RequestScope.class)); - verify(tx).close(); - } + String contentType = JSONAPI_CONTENT_TYPE; + ElideResponse response = elide.patch(contentType, contentType, "/testModel/1", body, null, NO_VERSION); + assertEquals(HttpStatus.SC_NO_CONTENT, response.getResponseCode()); - @Test - public void testElidePatchExtensionUpdate() throws Exception { - DataStore store = mock(DataStore.class); - DataStoreTransaction tx = mock(DataStoreTransaction.class); - Book book = mock(Book.class); + verify(mockModel, never()).classAllFieldsCallback(any(), any()); - Elide elide = getElide(store, dictionary, MOCK_AUDIT_LOGGER); + verify(mockModel, never()).classCallback(eq(READ), any()); + verify(mockModel, never()).classCallback(eq(CREATE), any()); + verify(mockModel, never()).classCallback(eq(DELETE), any()); + verify(mockModel, times(1)).classCallback(eq(UPDATE), eq(PRESECURITY)); + verify(mockModel, times(1)).classCallback(eq(UPDATE), eq(PRECOMMIT)); + verify(mockModel, times(1)).classCallback(eq(UPDATE), eq(POSTCOMMIT)); - when(book.getId()).thenReturn(1L); - when(store.beginTransaction()).thenReturn(tx); - when(tx.loadObject(eq(Book.class), any(), any(), isA(RequestScope.class))).thenReturn(book); + verify(mockModel, never()).attributeCallback(eq(READ), any(), any()); + verify(mockModel, never()).attributeCallback(eq(CREATE), any(), any()); + verify(mockModel, never()).attributeCallback(eq(DELETE), any(), any()); + verify(mockModel, times(1)).attributeCallback(eq(UPDATE), eq(PRESECURITY), any()); + verify(mockModel, times(1)).attributeCallback(eq(UPDATE), eq(PRECOMMIT), any()); + verify(mockModel, times(1)).attributeCallback(eq(UPDATE), eq(POSTCOMMIT), any()); - String bookBody = "[{\"op\": \"replace\",\"path\": \"/book/1\",\"value\":{" - + "\"type\":\"book\",\"id\":1,\"attributes\": {\"title\":\"Grapes of Wrath\"}}}]"; + verify(mockModel, never()).relationCallback(eq(READ), any(), any()); + verify(mockModel, never()).relationCallback(eq(CREATE), any(), any()); + verify(mockModel, never()).relationCallback(eq(DELETE), any(), any()); + verify(mockModel, never()).relationCallback(eq(UPDATE), any(), any()); - String contentType = JSONAPI_CONTENT_TYPE_WITH_JSON_PATCH_EXTENSION; - ElideResponse response = elide.patch(contentType, contentType, "/", bookBody, null); - assertEquals(HttpStatus.SC_OK, response.getResponseCode()); - assertEquals("[{\"data\":null}]", response.getBody()); - - /* - * This gets called for : - * - read pre-security for the book - * - update pre-security for the book.title - * - read pre-commit for the book - * - update pre-commit for the book.title - * - read post-commit for the book - * - update post-commit for the book.title - */ - verify(callback, times(6)).execute(eq(book), isA(RequestScope.class), any()); - verify(onUpdateImmediateCallback, times(1)).execute(eq(book), isA(RequestScope.class), any()); - verify(onUpdateDeferredCallback, times(1)).execute(eq(book), isA(RequestScope.class), any()); - verify(onUpdateImmediateCallback, never()).execute(eq(book), isA(RequestScope.class), eq(Optional.empty())); - verify(onUpdateDeferredCallback, never()).execute(eq(book), isA(RequestScope.class), eq(Optional.empty())); - verify(tx).accessUser(any()); verify(tx).preCommit(); - - verify(tx).save(eq(book), isA(RequestScope.class)); + verify(tx).save(eq(mockModel), isA(RequestScope.class)); verify(tx).flush(isA(RequestScope.class)); verify(tx).commit(isA(RequestScope.class)); verify(tx).close(); } @Test - public void testElidePatchExtensionDelete() throws Exception { + public void testElideDelete() throws Exception { DataStore store = mock(DataStore.class); DataStoreTransaction tx = mock(DataStoreTransaction.class); - Book book = mock(Book.class); + FieldTestModel mockModel = mock(FieldTestModel.class); Elide elide = getElide(store, dictionary, MOCK_AUDIT_LOGGER); - when(book.getId()).thenReturn(1L); + dictionary.setValue(mockModel, "id", "1"); when(store.beginTransaction()).thenReturn(tx); - when(tx.loadObject(eq(Book.class), any(), any(), isA(RequestScope.class))).thenReturn(book); + when(tx.loadObject(isA(EntityProjection.class), any(), isA(RequestScope.class))).thenReturn(mockModel); - String bookBody = "[{\"op\": \"remove\",\"path\": \"/book\",\"value\":{" - + "\"type\":\"book\",\"id\": \"1\"}}]"; + ElideResponse response = elide.delete("/testModel/1", "", null, NO_VERSION); + assertEquals(HttpStatus.SC_NO_CONTENT, response.getResponseCode()); - String contentType = JSONAPI_CONTENT_TYPE_WITH_JSON_PATCH_EXTENSION; - ElideResponse response = elide.patch(contentType, contentType, "/", bookBody, null); - assertEquals(HttpStatus.SC_OK, response.getResponseCode()); + verify(mockModel, never()).classAllFieldsCallback(any(), any()); + + verify(mockModel, never()).classCallback(eq(UPDATE), any()); + verify(mockModel, never()).classCallback(eq(CREATE), any()); + verify(mockModel, times(1)).classCallback(eq(READ), eq(PRESECURITY)); + verify(mockModel, times(1)).classCallback(eq(READ), eq(PRECOMMIT)); + verify(mockModel, times(1)).classCallback(eq(READ), eq(POSTCOMMIT)); + verify(mockModel, times(1)).classCallback(eq(DELETE), eq(PRESECURITY)); + verify(mockModel, times(1)).classCallback(eq(DELETE), eq(PRECOMMIT)); + verify(mockModel, times(1)).classCallback(eq(DELETE), eq(POSTCOMMIT)); + + verify(mockModel, never()).attributeCallback(eq(UPDATE), any(), any()); + verify(mockModel, never()).attributeCallback(eq(CREATE), any(), any()); + verify(mockModel, never()).attributeCallback(eq(READ), any(), any()); + verify(mockModel, never()).attributeCallback(eq(DELETE), any(), any()); + + //TODO - Read should not be called for a delete. + verify(mockModel, never()).relationCallback(eq(UPDATE), any(), any()); + verify(mockModel, never()).relationCallback(eq(CREATE), any(), any()); + verify(mockModel, never()).relationCallback(eq(DELETE), any(), any()); + verify(mockModel, times(1)).relationCallback(eq(READ), eq(PRESECURITY), any()); + verify(mockModel, times(1)).relationCallback(eq(READ), eq(PRECOMMIT), any()); + verify(mockModel, times(1)).relationCallback(eq(READ), eq(POSTCOMMIT), any()); - /* - * This gets called for : - * - delete pre-security for the book - * - delete pre-commit for the book - * - delete post-commit for the book - */ - verify(callback, times(3)).execute(eq(book), isA(RequestScope.class), any()); - verify(tx).accessUser(any()); verify(tx).preCommit(); - - verify(tx).delete(eq(book), isA(RequestScope.class)); + verify(tx).delete(eq(mockModel), isA(RequestScope.class)); verify(tx).flush(isA(RequestScope.class)); verify(tx).commit(isA(RequestScope.class)); verify(tx).close(); } +//TODO - these need to be rewritten for Elide 5. +// @Test +// public void testElidePatchExtensionCreate() throws Exception { +// DataStore store = mock(DataStore.class); +// DataStoreTransaction tx = mock(DataStoreTransaction.class); +// FieldTestModel mockModel = mock(FieldTestModel.class); +// +// Elide elide = getElide(store, dictionary, MOCK_AUDIT_LOGGER); +// +// String bookBody = "[{\"op\": \"add\",\"path\": \"/book\",\"value\":{" +// + "\"type\":\"book\",\"id\": \"A\",\"attributes\": {\"title\":\"Grapes of Wrath\"}}}]"; +// +// when(store.beginTransaction()).thenReturn(tx); +// when(tx.createNewObject(Book.class)).thenReturn(book); +// +// String contentType = JSONAPI_CONTENT_TYPE_WITH_JSON_PATCH_EXTENSION; +// ElideResponse response = elide.patch(contentType, contentType, "/", bookBody, null); +// assertEquals(HttpStatus.SC_OK, response.getResponseCode()); +// +// /* +// * This gets called for : +// * - read pre-security for the book +// * - create pre-security for the book +// * - read pre-commit for the book +// * - create pre-commit for the book +// * - read post-commit for the book +// * - create post-commit for the book +// */ +// verify(callback, times(6)).execute(eq(book), isA(RequestScope.class), any()); +// verify(tx).accessUser(any()); +// verify(tx).preCommit(); +// verify(tx, times(1)).createObject(eq(book), isA(RequestScope.class)); +// verify(tx).flush(isA(RequestScope.class)); +// verify(tx).commit(isA(RequestScope.class)); +// verify(tx).close(); +// } +// +// @Test +// public void failElidePatchExtensionCreate() throws Exception { +// DataStore store = mock(DataStore.class); +// DataStoreTransaction tx = mock(DataStoreTransaction.class); +// Book book = mock(Book.class); +// +// Elide elide = getElide(store, dictionary, MOCK_AUDIT_LOGGER); +// +// String bookBody = "[{\"op\": \"add\",\"path\": \"/book\",\"value\":{" +// + "\"type\":\"book\",\"attributes\": {\"title\":\"Grapes of Wrath\"}}}]"; +// +// when(store.beginTransaction()).thenReturn(tx); +// when(tx.createNewObject(Book.class)).thenReturn(book); +// +// String contentType = JSONAPI_CONTENT_TYPE_WITH_JSON_PATCH_EXTENSION; +// ElideResponse response = elide.patch(contentType, contentType, "/", bookBody, null); +// assertEquals(HttpStatus.SC_BAD_REQUEST, response.getResponseCode()); +// assertEquals( +// "{\"errors\":[{\"detail\":\"JsonPatchExtensionException\"}]}", +// response.getBody()); +// +// verify(callback, never()).execute(eq(book), isA(RequestScope.class), any()); +// verify(tx).accessUser(any()); +// verify(tx, never()).preCommit(); +// verify(tx, never()).flush(isA(RequestScope.class)); +// verify(tx, never()).commit(isA(RequestScope.class)); +// verify(tx).close(); +// } +// +// @Test +// public void testElidePatchExtensionUpdate() throws Exception { +// DataStore store = mock(DataStore.class); +// DataStoreTransaction tx = mock(DataStoreTransaction.class); +// Book book = mock(Book.class); +// +// Elide elide = getElide(store, dictionary, MOCK_AUDIT_LOGGER); +// +// when(book.getId()).thenReturn(1L); +// when(store.beginTransaction()).thenReturn(tx); +// when(tx.loadObject(eq(Book.class), any(), any(), isA(RequestScope.class))).thenReturn(book); +// +// String bookBody = "[{\"op\": \"replace\",\"path\": \"/book/1\",\"value\":{" +// + "\"type\":\"book\",\"id\":1,\"attributes\": {\"title\":\"Grapes of Wrath\"}}}]"; +// +// String contentType = JSONAPI_CONTENT_TYPE_WITH_JSON_PATCH_EXTENSION; +// ElideResponse response = elide.patch(contentType, contentType, "/", bookBody, null); +// assertEquals(HttpStatus.SC_OK, response.getResponseCode()); +// assertEquals("[{\"data\":null}]", response.getBody()); +// +// /* +// * This gets called for : +// * - read pre-security for the book +// * - update pre-security for the book.title +// * - read pre-commit for the book +// * - update pre-commit for the book.title +// * - read post-commit for the book +// * - update post-commit for the book.title +// */ +// verify(callback, times(6)).execute(eq(book), isA(RequestScope.class), any()); +// verify(onUpdateImmediateCallback, times(1)).execute(eq(book), isA(RequestScope.class), any()); +// verify(onUpdateDeferredCallback, times(1)).execute(eq(book), isA(RequestScope.class), any()); +// verify(onUpdateImmediateCallback, never()).execute(eq(book), isA(RequestScope.class), eq(Optional.empty())); +// verify(onUpdateDeferredCallback, never()).execute(eq(book), isA(RequestScope.class), eq(Optional.empty())); +// verify(tx).accessUser(any()); +// verify(tx).preCommit(); +// +// verify(tx).save(eq(book), isA(RequestScope.class)); +// verify(tx).flush(isA(RequestScope.class)); +// verify(tx).commit(isA(RequestScope.class)); +// verify(tx).close(); +// } +// +// @Test +// public void testElidePatchExtensionDelete() throws Exception { +// DataStore store = mock(DataStore.class); +// DataStoreTransaction tx = mock(DataStoreTransaction.class); +// Book book = mock(Book.class); +// +// Elide elide = getElide(store, dictionary, MOCK_AUDIT_LOGGER); +// +// when(book.getId()).thenReturn(1L); +// when(store.beginTransaction()).thenReturn(tx); +// when(tx.loadObject(eq(Book.class), any(), any(), isA(RequestScope.class))).thenReturn(book); +// +// String bookBody = "[{\"op\": \"remove\",\"path\": \"/book\",\"value\":{" +// + "\"type\":\"book\",\"id\": \"1\"}}]"; +// +// String contentType = JSONAPI_CONTENT_TYPE_WITH_JSON_PATCH_EXTENSION; +// ElideResponse response = elide.patch(contentType, contentType, "/", bookBody, null); +// assertEquals(HttpStatus.SC_OK, response.getResponseCode()); +// +// /* +// * This gets called for : +// * - delete pre-security for the book +// * - delete pre-commit for the book +// * - delete post-commit for the book +// */ +// verify(callback, times(3)).execute(eq(book), isA(RequestScope.class), any()); +// verify(tx).accessUser(any()); +// verify(tx).preCommit(); +// +// verify(tx).delete(eq(book), isA(RequestScope.class)); +// verify(tx).flush(isA(RequestScope.class)); +// verify(tx).commit(isA(RequestScope.class)); +// verify(tx).close(); +// } +// +// public void testElidePatchFailure() throws Exception { +// DataStore store = mock(DataStore.class); +// DataStoreTransaction tx = mock(DataStoreTransaction.class); +// FieldTestModel mockModel = mock(FieldTestModel.class); +// +// Elide elide = getElide(store, dictionary, MOCK_AUDIT_LOGGER); +// +// String body = "{\"data\": {\"type\":\"testModel\",\"id\":\"1\",\"attributes\": {\"field\":\"Foo\"}}}"; +// +// dictionary.setValue(mockModel, "id", "1"); +// when(store.beginTransaction()).thenReturn(tx); +// when(tx.loadObject(isA(EntityProjection.class), any(), isA(RequestScope.class))).thenReturn(mockModel); +// doThrow(ConstraintViolationException.class).when(tx).flush(any()); +// +// String contentType = JSONAPI_CONTENT_TYPE; +// ElideResponse response = elide.patch(contentType, contentType, "/testModel/1", body, null, NO_VERSION); +// assertEquals(HttpStatus.SC_BAD_REQUEST, response.getResponseCode()); +// assertEquals( +// "{\"errors\":[{\"detail\":\"Constraint violation\"}]}", +// response.getBody()); +// +// verify(mockModel, never()).classAllFieldsCallback(any(), any()); +// +// verify(mockModel, never()).classCallback(eq(READ), any()); +// verify(mockModel, never()).classCallback(eq(CREATE), any()); +// verify(mockModel, never()).classCallback(eq(DELETE), any()); +// +// verify(mockModel, times(1)).classCallback(eq(UPDATE), eq(PRESECURITY)); +// verify(mockModel, times(0)).classCallback(eq(UPDATE), eq(PRECOMMIT)); +// verify(mockModel, times(0)).classCallback(eq(UPDATE), eq(POSTCOMMIT)); +// +// verify(mockModel, never()).attributeCallback(eq(READ), any(), any()); +// verify(mockModel, never()).attributeCallback(eq(CREATE), any(), any()); +// verify(mockModel, never()).attributeCallback(eq(DELETE), any(), any()); +// verify(mockModel, times(1)).attributeCallback(eq(UPDATE), eq(PRESECURITY), any()); +// verify(mockModel, times(0)).attributeCallback(eq(UPDATE), eq(PRECOMMIT), any()); +// verify(mockModel, times(0)).attributeCallback(eq(UPDATE), eq(POSTCOMMIT), any()); +// +// verify(mockModel, never()).relationCallback(eq(READ), any(), any()); +// verify(mockModel, never()).relationCallback(eq(UPDATE), any(), any()); +// verify(mockModel, never()).relationCallback(eq(CREATE), any(), any()); +// verify(mockModel, never()).relationCallback(eq(DELETE), any(), any()); +// +// verify(tx).preCommit(); +// verify(tx).save(eq(mockModel), isA(RequestScope.class)); +// verify(tx).flush(isA(RequestScope.class)); +// verify(tx, never()).commit(isA(RequestScope.class)); +// verify(tx).close(); +// } +// +// @Test +// public void testElidePatchExtensionCreate() throws Exception { +// DataStore store = mock(DataStore.class); +// DataStoreTransaction tx = mock(DataStoreTransaction.class); +// FieldTestModel mockModel = mock(FieldTestModel.class); +// +// Elide elide = getElide(store, dictionary, MOCK_AUDIT_LOGGER); +// +// String body = "[{\"op\": \"add\",\"path\": \"/testModel\",\"value\":{" +// + "\"type\":\"testModel\",\"id\":\"1\",\"attributes\": {\"field\":\"Foo\"}}}]"; +// +// when(store.beginTransaction()).thenReturn(tx); +// when(tx.createNewObject(FieldTestModel.class)).thenReturn(mockModel); +// +// String contentType = JSONAPI_CONTENT_TYPE_WITH_JSON_PATCH_EXTENSION; +// ElideResponse response = elide.patch(contentType, contentType, "/", body, null, NO_VERSION); +// assertEquals(HttpStatus.SC_OK, response.getResponseCode()); +// +// verify(mockModel, times(1)).classCallback(eq(READ), eq(PRESECURITY)); +// verify(mockModel, times(1)).classCallback(eq(READ), eq(PRECOMMIT)); +// verify(mockModel, times(1)).classCallback(eq(READ), eq(POSTCOMMIT)); +// verify(mockModel, times(1)).classCallback(eq(CREATE), eq(PRESECURITY)); +// verify(mockModel, times(1)).classCallback(eq(CREATE), eq(PRECOMMIT)); +// verify(mockModel, times(1)).classCallback(eq(CREATE), eq(POSTCOMMIT)); +// verify(mockModel, never()).classCallback(eq(UPDATE), any()); +// verify(mockModel, never()).classCallback(eq(DELETE), any()); +// +// verify(mockModel, times(2)).classAllFieldsCallback(any(), any()); +// verify(mockModel, times(2)).classAllFieldsCallback(eq(CREATE), eq(PRECOMMIT)); +// +// verify(mockModel, times(1)).attributeCallback(eq(READ), eq(PRESECURITY), any()); +// verify(mockModel, times(1)).attributeCallback(eq(READ), eq(PRECOMMIT), any()); +// verify(mockModel, times(1)).attributeCallback(eq(READ), eq(POSTCOMMIT), any()); +// verify(mockModel, times(1)).attributeCallback(eq(CREATE), eq(PRESECURITY), any()); +// verify(mockModel, times(1)).attributeCallback(eq(CREATE), eq(PRECOMMIT), any()); +// verify(mockModel, times(1)).attributeCallback(eq(CREATE), eq(POSTCOMMIT), any()); +// verify(mockModel, never()).attributeCallback(eq(UPDATE), any(), any()); +// verify(mockModel, never()).attributeCallback(eq(DELETE), any(), any()); +// +// verify(mockModel, times(1)).relationCallback(eq(READ), eq(PRESECURITY), any()); +// verify(mockModel, times(1)).relationCallback(eq(READ), eq(PRECOMMIT), any()); +// verify(mockModel, times(1)).relationCallback(eq(READ), eq(POSTCOMMIT), any()); +// verify(mockModel, times(1)).relationCallback(eq(CREATE), eq(PRESECURITY), any()); +// verify(mockModel, times(1)).relationCallback(eq(CREATE), eq(PRECOMMIT), any()); +// verify(mockModel, times(1)).relationCallback(eq(CREATE), eq(POSTCOMMIT), any()); +// verify(mockModel, never()).relationCallback(eq(UPDATE), any(), any()); +// verify(mockModel, never()).relationCallback(eq(DELETE), any(), any()); +// +// verify(tx).preCommit(); +// verify(tx, times(1)).createObject(eq(mockModel), isA(RequestScope.class)); +// verify(tx).flush(isA(RequestScope.class)); +// verify(tx).commit(isA(RequestScope.class)); +// verify(tx).close(); +// } +// +// @Test +// public void failElidePatchExtensionCreate() throws Exception { +// DataStore store = mock(DataStore.class); +// DataStoreTransaction tx = mock(DataStoreTransaction.class); +// FieldTestModel mockModel = mock(FieldTestModel.class); +// +// Elide elide = getElide(store, dictionary, MOCK_AUDIT_LOGGER); +// +// String body = "[{\"op\": \"add\",\"path\": \"/testModel\",\"value\":{" +// + "\"type\":\"testModel\",\"attributes\": {\"field\":\"Foo\"}}}]"; +// +// when(store.beginTransaction()).thenReturn(tx); +// when(tx.createNewObject(FieldTestModel.class)).thenReturn(mockModel); +// +// String contentType = JSONAPI_CONTENT_TYPE_WITH_JSON_PATCH_EXTENSION; +// ElideResponse response = elide.patch(contentType, contentType, "/", body, null, NO_VERSION); +// assertEquals(HttpStatus.SC_BAD_REQUEST, response.getResponseCode()); +// assertEquals( +// "[{\"errors\":[{\"detail\":\"Bad Request Body'Patch extension requires all objects to have an assigned ID (temporary or permanent) when assigning relationships.'\",\"status\":\"400\"}]}]", +// response.getBody()); +// +// verify(tx, never()).preCommit(); +// verify(tx, never()).flush(isA(RequestScope.class)); +// verify(tx, never()).commit(isA(RequestScope.class)); +// verify(tx).close(); +// } +// +// @Test +// public void testElidePatchExtensionUpdate() throws Exception { +// DataStore store = mock(DataStore.class); +// DataStoreTransaction tx = mock(DataStoreTransaction.class); +// FieldTestModel mockModel = mock(FieldTestModel.class); +// +// Elide elide = getElide(store, dictionary, MOCK_AUDIT_LOGGER); +// +// String body = "[{\"op\": \"replace\",\"path\": \"/testModel/1\",\"value\":{" +// + "\"type\":\"testModel\",\"id\":\"1\",\"attributes\": {\"field\":\"Foo\"}}}]"; +// +// dictionary.setValue(mockModel, "id", "1"); +// when(store.beginTransaction()).thenReturn(tx); +// when(tx.loadObject(isA(EntityProjection.class), any(), isA(RequestScope.class))).thenReturn(mockModel); +// +// String contentType = JSONAPI_CONTENT_TYPE_WITH_JSON_PATCH_EXTENSION; +// ElideResponse response = elide.patch(contentType, contentType, "/", body, null, NO_VERSION); +// assertEquals(HttpStatus.SC_OK, response.getResponseCode()); +// +// verify(mockModel, never()).classAllFieldsCallback(any(), any()); +// +// verify(mockModel, never()).classCallback(eq(READ), any()); +// verify(mockModel, never()).classCallback(eq(CREATE), any()); +// verify(mockModel, never()).classCallback(eq(DELETE), any()); +// verify(mockModel, times(1)).classCallback(eq(UPDATE), eq(PRESECURITY)); +// verify(mockModel, times(1)).classCallback(eq(UPDATE), eq(PRECOMMIT)); +// verify(mockModel, times(1)).classCallback(eq(UPDATE), eq(POSTCOMMIT)); +// +// verify(mockModel, never()).attributeCallback(eq(READ), any(), any()); +// verify(mockModel, never()).attributeCallback(eq(CREATE), any(), any()); +// verify(mockModel, never()).attributeCallback(eq(DELETE), any(), any()); +// verify(mockModel, times(1)).attributeCallback(eq(UPDATE), eq(PRESECURITY), any()); +// verify(mockModel, times(1)).attributeCallback(eq(UPDATE), eq(PRECOMMIT), any()); +// verify(mockModel, times(1)).attributeCallback(eq(UPDATE), eq(POSTCOMMIT), any()); +// +// verify(mockModel, never()).relationCallback(eq(READ), any(), any()); +// verify(mockModel, never()).relationCallback(eq(CREATE), any(), any()); +// verify(mockModel, never()).relationCallback(eq(DELETE), any(), any()); +// verify(mockModel, never()).relationCallback(eq(UPDATE), any(), any()); +// +// verify(tx).preCommit(); +// verify(tx).save(eq(mockModel), isA(RequestScope.class)); +// verify(tx).flush(isA(RequestScope.class)); +// verify(tx).commit(isA(RequestScope.class)); +// verify(tx).close(); +// } +// +// @Test +// public void testElidePatchExtensionDelete() throws Exception { +// DataStore store = mock(DataStore.class); +// DataStoreTransaction tx = mock(DataStoreTransaction.class); +// FieldTestModel mockModel = mock(FieldTestModel.class); +// +// Elide elide = getElide(store, dictionary, MOCK_AUDIT_LOGGER); +// +// dictionary.setValue(mockModel, "id", "1"); +// when(store.beginTransaction()).thenReturn(tx); +// when(tx.loadObject(isA(EntityProjection.class), any(), isA(RequestScope.class))).thenReturn(mockModel); +// +// String body = "[{\"op\": \"remove\",\"path\": \"/testModel\",\"value\":{" +// + "\"type\":\"testModel\",\"id\":\"1\"}}]"; +// +// String contentType = JSONAPI_CONTENT_TYPE_WITH_JSON_PATCH_EXTENSION; +// ElideResponse response = elide.patch(contentType, contentType, "/", body, null, NO_VERSION); +// assertEquals(HttpStatus.SC_OK, response.getResponseCode()); +// +// verify(mockModel, never()).classAllFieldsCallback(any(), any()); +// +// verify(mockModel, never()).classCallback(eq(UPDATE), any()); +// verify(mockModel, never()).classCallback(eq(CREATE), any()); +// verify(mockModel, times(1)).classCallback(eq(READ), eq(PRESECURITY)); +// verify(mockModel, times(1)).classCallback(eq(READ), eq(PRECOMMIT)); +// verify(mockModel, times(1)).classCallback(eq(READ), eq(POSTCOMMIT)); +// verify(mockModel, times(1)).classCallback(eq(DELETE), eq(PRESECURITY)); +// verify(mockModel, times(1)).classCallback(eq(DELETE), eq(PRECOMMIT)); +// verify(mockModel, times(1)).classCallback(eq(DELETE), eq(POSTCOMMIT)); +// +// verify(mockModel, never()).attributeCallback(eq(UPDATE), any(), any()); +// verify(mockModel, never()).attributeCallback(eq(CREATE), any(), any()); +// verify(mockModel, never()).attributeCallback(eq(READ), any(), any()); +// verify(mockModel, never()).attributeCallback(eq(DELETE), any(), any()); +// +// //TODO - Read should not be called for a delete. +// verify(mockModel, never()).relationCallback(eq(UPDATE), any(), any()); +// verify(mockModel, never()).relationCallback(eq(CREATE), any(), any()); +// verify(mockModel, never()).relationCallback(eq(DELETE), any(), any()); +// verify(mockModel, times(1)).relationCallback(eq(READ), eq(PRESECURITY), any()); +// verify(mockModel, times(1)).relationCallback(eq(READ), eq(PRECOMMIT), any()); +// verify(mockModel, times(1)).relationCallback(eq(READ), eq(POSTCOMMIT), any()); +// +// verify(tx).preCommit(); +// verify(tx).delete(eq(mockModel), isA(RequestScope.class)); +// verify(tx).flush(isA(RequestScope.class)); +// verify(tx).commit(isA(RequestScope.class)); +// verify(tx).close(); +// } + @Test public void testCreate() { - Book book = mock(Book.class); + FieldTestModel mockModel = mock(FieldTestModel.class); DataStoreTransaction tx = mock(DataStoreTransaction.class); - when(tx.createNewObject(Book.class)).thenReturn(book); + when(tx.createNewObject(FieldTestModel.class)).thenReturn(mockModel); RequestScope scope = buildRequestScope(dictionary, tx); - PersistentResource resource = PersistentResource.createObject(null, Book.class, scope, Optional.of("uuid")); - resource.setValueChecked("title", "should not affect calls since this is create!"); - resource.setValueChecked("genre", "boring books"); - assertNotNull(resource); - verify(book, never()).onCreatePreSecurity(scope); - verify(book, never()).checkPermission(scope); + PersistentResource resource = PersistentResource.createObject(FieldTestModel.class, scope, Optional.of("1")); + resource.setValueChecked("field", "should not affect calls since this is create!"); + + verify(mockModel, never()).classCallback(any(), any()); + verify(mockModel, never()).attributeCallback(any(), any(), any()); + verify(mockModel, never()).relationCallback(any(), any(), any()); + verify(mockModel, never()).classAllFieldsCallback(any(), any()); scope.runQueuedPreSecurityTriggers(); - verify(book, times(1)).onCreatePreSecurity(scope); - verify(book, never()).onDeletePreSecurity(scope); - verify(book, never()).onUpdatePreSecurityTitle(scope); - verify(book, never()).onCreatePreCommitStar(eq(scope), any()); - verify(book, times(1)).onReadPreSecurity(scope); - verify(book, never()).checkPermission(scope); + verify(mockModel, times(1)).classCallback(any(), any()); + verify(mockModel, times(1)).classCallback(eq(CREATE), eq(PRESECURITY)); + verify(mockModel, times(1)).attributeCallback(any(), any(), any()); + verify(mockModel, times(1)).attributeCallback(eq(CREATE), eq(PRESECURITY), any()); + verify(mockModel, times(1)).relationCallback(any(), any(), any()); + verify(mockModel, times(1)).relationCallback(eq(CREATE), eq(PRESECURITY), any()); + verify(mockModel, never()).classAllFieldsCallback(any(), any()); + + clearInvocations(mockModel); scope.runQueuedPreCommitTriggers(); - verify(book, times(1)).onCreatePreCommit(scope); - verify(book, never()).onDeletePreCommit(scope); - verify(book, never()).onUpdatePreCommitTitle(scope); - verify(book, times(2)).onCreatePreCommitStar(eq(scope), any()); - verify(book, times(1)).onReadPreCommitTitle(scope); - verify(book, never()).checkPermission(scope); - scope.getPermissionExecutor().executeCommitChecks(); - verify(book, times(3)).checkPermission(scope); + verify(mockModel, times(1)).classCallback(any(), any()); + verify(mockModel, times(1)).classCallback(eq(CREATE), eq(PRECOMMIT)); + verify(mockModel, times(1)).attributeCallback(any(), any(), any()); + verify(mockModel, times(1)).attributeCallback(eq(CREATE), eq(PRECOMMIT), any()); + verify(mockModel, times(1)).relationCallback(any(), any(), any()); + verify(mockModel, times(1)).relationCallback(eq(CREATE), eq(PRECOMMIT), any()); + verify(mockModel, times(2)).classAllFieldsCallback(any(), any()); + verify(mockModel, times(2)).classAllFieldsCallback(eq(CREATE), eq(PRECOMMIT)); + clearInvocations(mockModel); + scope.getPermissionExecutor().executeCommitChecks(); scope.runQueuedPostCommitTriggers(); - verify(book, times(1)).onCreatePostCommit(scope); - verify(book, never()).onDeletePostCommit(scope); - verify(book, never()).onUpdatePostCommitTitle(scope); - verify(book, times(2)).onCreatePreCommitStar(eq(scope), any()); - verify(book, times(1)).onReadPostCommit(scope); - verify(book, times(3)).checkPermission(scope); + + verify(mockModel, never()).classAllFieldsCallback(any(), any()); + verify(mockModel, times(1)).classCallback(any(), any()); + verify(mockModel, times(1)).classCallback(eq(CREATE), eq(POSTCOMMIT)); + verify(mockModel, times(1)).attributeCallback(any(), any(), any()); + verify(mockModel, times(1)).attributeCallback(eq(CREATE), eq(POSTCOMMIT), any()); + verify(mockModel, times(1)).relationCallback(any(), any(), any()); + verify(mockModel, times(1)).relationCallback(eq(CREATE), eq(POSTCOMMIT), any()); } @Test - public void testUpdate() { - Book book = mock(Book.class); + public void testRead() { + FieldTestModel mockModel = mock(FieldTestModel.class); DataStoreTransaction tx = mock(DataStoreTransaction.class); - + when(tx.createNewObject(FieldTestModel.class)).thenReturn(mockModel); RequestScope scope = buildRequestScope(dictionary, tx); - PersistentResource resource = new PersistentResource(book, null, scope.getUUIDFor(book), scope); - resource.setValueChecked("title", "new title"); - verify(book, never()).onCreatePreSecurity(scope); - verify(book, never()).onDeletePreSecurity(scope); - verify(book, times(1)).onUpdatePreSecurityTitle(scope); - verify(book, times(1)).onReadPreSecurity(scope); - verify(book, never()).onUpdatePreCommit(); - verify(onUpdateDeferredCallback, never()).execute(eq(book), isA(RequestScope.class), any()); - verify(onUpdateImmediateCallback, times(1)).execute(eq(book), isA(RequestScope.class), any()); - verify(book, times(1)).checkPermission(scope); + PersistentResource resource = new PersistentResource(mockModel, null, "1", scope); + + resource.getValueChecked(Attribute.builder().type(String.class).name("field").build()); + verify(mockModel, times(1)).classCallback(any(), any()); + verify(mockModel, times(1)).classCallback(eq(READ), eq(PRESECURITY)); + verify(mockModel, times(1)).attributeCallback(any(), any(), any()); + verify(mockModel, times(1)).attributeCallback(eq(READ), eq(PRESECURITY), any()); + verify(mockModel, never()).classAllFieldsCallback(any(), any()); + verify(mockModel, never()).relationCallback(any(), any(), any()); + + clearInvocations(mockModel); scope.runQueuedPreSecurityTriggers(); - verify(book, never()).onCreatePreSecurity(scope); - verify(book, never()).onDeletePreSecurity(scope); - verify(book, times(1)).onUpdatePreSecurityTitle(scope); - verify(book, times(1)).onReadPreSecurity(scope); - verify(book, never()).onUpdatePreCommit(); - verify(onUpdateDeferredCallback, never()).execute(eq(book), isA(RequestScope.class), any()); - verify(onUpdateImmediateCallback, times(1)).execute(eq(book), isA(RequestScope.class), any()); - verify(book, times(1)).checkPermission(scope); + verify(mockModel, never()).classCallback(any(), any()); + verify(mockModel, never()).classAllFieldsCallback(any(), any()); + verify(mockModel, never()).relationCallback(any(), any(), any()); + verify(mockModel, never()).attributeCallback(any(), any(), any()); + + clearInvocations(mockModel); scope.runQueuedPreCommitTriggers(); - verify(book, never()).onCreatePreCommit(scope); - verify(book, never()).onDeletePreCommit(scope); - verify(book, times(1)).onUpdatePreCommitTitle(scope); - verify(book, times(1)).onReadPreCommitTitle(scope); - verify(book, times(1)).onUpdatePreCommit(); - verify(onUpdateDeferredCallback, times(1)).execute(eq(book), isA(RequestScope.class), any()); - verify(onUpdateImmediateCallback, times(1)).execute(eq(book), isA(RequestScope.class), any()); - verify(book, times(1)).checkPermission(scope); - scope.getPermissionExecutor().executeCommitChecks(); - verify(book, times(1)).checkPermission(scope); + verify(mockModel, times(1)).classCallback(any(), any()); + verify(mockModel, times(1)).classCallback(eq(READ), eq(PRECOMMIT)); + verify(mockModel, times(1)).attributeCallback(any(), any(), any()); + verify(mockModel, times(1)).attributeCallback(eq(READ), eq(PRECOMMIT), any()); + verify(mockModel, never()).classAllFieldsCallback(any(), any()); + verify(mockModel, never()).relationCallback(any(), any(), any()); + clearInvocations(mockModel); scope.runQueuedPostCommitTriggers(); - verify(book, never()).onCreatePostCommit(scope); - verify(book, never()).onDeletePostCommit(scope); - verify(book, times(1)).onUpdatePostCommitTitle(scope); - verify(book, times(1)).onReadPostCommit(scope); - verify(book, times(1)).onUpdatePreCommit(); - verify(onUpdateDeferredCallback, times(1)).execute(eq(book), isA(RequestScope.class), any()); - verify(onUpdateImmediateCallback, times(1)).execute(eq(book), isA(RequestScope.class), any()); - verify(onUpdatePostCommitAuthor, never()).execute(any(), isA(RequestScope.class), any()); - - // verify no empty callbacks - verifyNoEmptyCallbacks(); + + verify(mockModel, times(1)).classCallback(any(), any()); + verify(mockModel, times(1)).classCallback(eq(READ), eq(POSTCOMMIT)); + verify(mockModel, times(1)).attributeCallback(any(), any(), any()); + verify(mockModel, times(1)).attributeCallback(eq(READ), eq(POSTCOMMIT), any()); + verify(mockModel, never()).classAllFieldsCallback(any(), any()); + verify(mockModel, never()).relationCallback(any(), any(), any()); } @Test - public void testUpdateWithChangeSpec() { - Book book = mock(Book.class); + public void testDelete() { + FieldTestModel mockModel = mock(FieldTestModel.class); DataStoreTransaction tx = mock(DataStoreTransaction.class); - + when(tx.createNewObject(FieldTestModel.class)).thenReturn(mockModel); RequestScope scope = buildRequestScope(dictionary, tx); - PersistentResource resource = new PersistentResource(book, null, scope.getUUIDFor(book), scope); - - verify(book, never()).onCreatePreSecurity(scope); - verify(book, never()).onDeletePreSecurity(scope); - verify(book, never()).onUpdatePreSecurityGenre(any(RequestScope.class), any(ChangeSpec.class)); - verify(book, never()).onUpdatePreSecurityGenre(any(RequestScope.class), isNull()); - verify(book, never()).onReadPreSecurity(scope); - verify(book, never()).onUpdatePreCommit(); - verify(onUpdateDeferredCallback, never()).execute(eq(book), isA(RequestScope.class), any()); - verify(onUpdateImmediateCallback, never()).execute(eq(book), isA(RequestScope.class), any()); - verify(onUpdatePostCommitCallback, never()).execute(eq(book), isA(RequestScope.class), any()); - verify(book, never()).checkPermission(scope); - - //Verify changeSpec is passed to hooks - resource.setValueChecked("genre", "new genre"); - - verify(book, never()).onCreatePreSecurity(scope); - verify(book, never()).onDeletePreSecurity(scope); - verify(book, times(1)).onUpdatePreSecurityGenre(any(RequestScope.class), any(ChangeSpec.class)); - verify(book, times(1)).onReadPreSecurity(scope); - verify(book, never()).onUpdatePreCommit(); - verify(onUpdateDeferredCallback, never()).execute(eq(book), isA(RequestScope.class), any()); - verify(onUpdateImmediateCallback, times(1)).execute(eq(book), isA(RequestScope.class), any()); - verify(book, times(1)).checkPermission(scope); + PersistentResource resource = new PersistentResource(mockModel, null, "1", scope); - scope.runQueuedPreSecurityTriggers(); - verify(book, never()).onCreatePreSecurity(scope); - verify(book, never()).onDeletePreSecurity(scope); - verify(book, times(1)).onUpdatePreSecurityGenre(any(RequestScope.class), any(ChangeSpec.class)); - verify(book, times(1)).onReadPreSecurity(scope); - verify(book, never()).onUpdatePreCommit(); - verify(onUpdateDeferredCallback, never()).execute(eq(book), isA(RequestScope.class), any()); - verify(onUpdateImmediateCallback, times(1)).execute(eq(book), isA(RequestScope.class), any()); - verify(book, times(1)).checkPermission(scope); + resource.deleteResource(); - scope.runQueuedPreCommitTriggers(); - verify(book, never()).onCreatePreCommit(scope); - verify(book, never()).onDeletePreCommit(scope); - verify(book, times(1)).onUpdatePreCommitGenre(any(RequestScope.class), any(ChangeSpec.class)); - verify(book, times(1)).onUpdatePreCommit(); - verify(onUpdateDeferredCallback, times(1)).execute(eq(book), isA(RequestScope.class), any()); - verify(onUpdateImmediateCallback, times(1)).execute(eq(book), isA(RequestScope.class), any()); - verify(book, times(1)).checkPermission(scope); + verify(mockModel, times(2)).classCallback(any(), any()); + verify(mockModel, times(1)).classCallback(eq(DELETE), eq(PRESECURITY)); - scope.getPermissionExecutor().executeCommitChecks(); - verify(book, times(1)).checkPermission(scope); + //TODO - DELETE should not invoke READ. + verify(mockModel, times(1)).classCallback(eq(READ), eq(PRESECURITY)); + verify(mockModel, times(1)).relationCallback(any(), any(), any()); + verify(mockModel, times(1)).relationCallback(eq(READ), eq(PRESECURITY), any()); + verify(mockModel, never()).classAllFieldsCallback(any(), any()); + verify(mockModel, never()).attributeCallback(any(), any(), any()); - scope.runQueuedPostCommitTriggers(); - verify(book, never()).onCreatePostCommit(scope); - verify(book, never()).onDeletePostCommit(scope); - verify(book, times(1)).onUpdatePostCommitGenre(any(RequestScope.class), any(ChangeSpec.class)); - verify(book, times(1)).onReadPostCommit(scope); - verify(book, times(1)).onUpdatePreCommit(); - verify(onUpdateDeferredCallback, times(1)).execute(eq(book), isA(RequestScope.class), any()); - verify(onUpdateImmediateCallback, times(1)).execute(eq(book), isA(RequestScope.class), any()); - verify(onUpdatePostCommitCallback, times(1)).execute(eq(book), isA(RequestScope.class), any()); - verify(onUpdatePostCommitAuthor, never()).execute(isA(Author.class), isA(RequestScope.class), any()); - - // verify no empty callbacks - verifyNoEmptyCallbacks(); - } + clearInvocations(mockModel); + scope.runQueuedPreSecurityTriggers(); - @Test - public void testMultipleUpdateWithChangeSpec() { - Book book = mock(Book.class); + verify(mockModel, never()).classAllFieldsCallback(any(), any()); + verify(mockModel, never()).classCallback(any(), any()); + verify(mockModel, never()).attributeCallback(any(), any(), any()); + verify(mockModel, never()).relationCallback(any(), any(), any()); - DataStoreTransaction tx = mock(DataStoreTransaction.class); - - RequestScope scope = buildRequestScope(dictionary, tx); - PersistentResource resource = new PersistentResource(book, null, scope.getUUIDFor(book), scope); - - verify(book, never()).onCreatePreSecurity(scope); - verify(book, never()).onDeletePreSecurity(scope); - verify(book, never()).onUpdatePreSecurityGenre(any(RequestScope.class), any(ChangeSpec.class)); - verify(book, never()).onUpdatePreSecurityGenre(any(RequestScope.class), isNull()); - verify(book, never()).onReadPreSecurity(scope); - verify(book, never()).onUpdatePreCommit(); - verify(onUpdateDeferredCallback, never()).execute(eq(book), isA(RequestScope.class), any()); - verify(onUpdateImmediateCallback, never()).execute(eq(book), isA(RequestScope.class), any()); - verify(onUpdatePostCommitCallback, never()).execute(eq(book), isA(RequestScope.class), any()); - verify(book, never()).checkPermission(scope); - - // Verify changeSpec is passed to hooks - resource.setValueChecked("genre", "new genre"); - resource.setValueChecked("title", "new title"); - - verify(book, never()).onCreatePreSecurity(scope); - verify(book, never()).onDeletePreSecurity(scope); - verify(book, times(1)).onUpdatePreSecurityGenre(any(RequestScope.class), any(ChangeSpec.class)); - verify(book, never()).onUpdatePreSecurityGenre(any(RequestScope.class), isNull()); - verify(book, times(1)).onReadPreSecurity(scope); - verify(book, never()).onUpdatePreCommit(); - verify(onUpdateDeferredCallback, never()).execute(eq(book), isA(RequestScope.class), any()); - verify(onUpdateImmediateCallback, times(2)).execute(eq(book), isA(RequestScope.class), any()); - verify(onUpdatePostCommitCallback, never()).execute(eq(book), isA(RequestScope.class), any()); - verify(book, times(2)).checkPermission(scope); + scope.runQueuedPreCommitTriggers(); - scope.runQueuedPreSecurityTriggers(); - verify(book, never()).onCreatePreSecurity(scope); - verify(book, never()).onDeletePreSecurity(scope); - verify(book, times(1)).onUpdatePreSecurityGenre(any(RequestScope.class), any(ChangeSpec.class)); - verify(book, times(1)).onReadPreSecurity(scope); - verify(book, never()).onUpdatePreCommit(); - verify(onUpdateDeferredCallback, never()).execute(eq(book), isA(RequestScope.class), any()); - verify(onUpdateImmediateCallback, times(2)).execute(eq(book), isA(RequestScope.class), any()); - verify(onUpdatePostCommitCallback, never()).execute(eq(book), isA(RequestScope.class), any()); - verify(book, times(2)).checkPermission(scope); + verify(mockModel, times(2)).classCallback(any(), any()); + verify(mockModel, times(1)).classCallback(eq(DELETE), eq(PRECOMMIT)); - scope.runQueuedPreCommitTriggers(); - verify(book, never()).onCreatePreCommit(scope); - verify(book, never()).onDeletePreCommit(scope); - verify(book, times(1)).onUpdatePreCommitGenre(any(RequestScope.class), any(ChangeSpec.class)); - verify(book, times(1)).onUpdatePreCommit(); - verify(onUpdateDeferredCallback, times(2)).execute(eq(book), isA(RequestScope.class), any()); - verify(onUpdateImmediateCallback, times(2)).execute(eq(book), isA(RequestScope.class), any()); - verify(onUpdatePostCommitCallback, never()).execute(eq(book), isA(RequestScope.class), any()); - verify(book, times(2)).checkPermission(scope); + //TODO - DELETE should not invoke READ. + verify(mockModel, times(1)).classCallback(eq(READ), eq(PRECOMMIT)); + verify(mockModel, times(1)).relationCallback(any(), any(), any()); + verify(mockModel, times(1)).relationCallback(eq(READ), eq(PRECOMMIT), any()); + verify(mockModel, never()).classAllFieldsCallback(any(), any()); + verify(mockModel, never()).attributeCallback(any(), any(), any()); + clearInvocations(mockModel); scope.getPermissionExecutor().executeCommitChecks(); - verify(book, times(2)).checkPermission(scope); - scope.runQueuedPostCommitTriggers(); - verify(book, never()).onCreatePostCommit(scope); - verify(book, never()).onDeletePostCommit(scope); - verify(book, times(1)).onUpdatePostCommitGenre(any(RequestScope.class), any(ChangeSpec.class)); - verify(book, times(1)).onReadPostCommit(scope); - verify(book, times(1)).onUpdatePreCommit(); - verify(onUpdateDeferredCallback, times(2)).execute(eq(book), isA(RequestScope.class), any()); - verify(onUpdateImmediateCallback, times(2)).execute(eq(book), isA(RequestScope.class), any()); - verify(onUpdatePostCommitCallback, times(2)).execute(eq(book), isA(RequestScope.class), any()); - verify(onUpdatePostCommitAuthor, never()).execute(any(), isA(RequestScope.class), any()); - - verifyNoEmptyCallbacks(); + + verify(mockModel, times(2)).classCallback(any(), any()); + verify(mockModel, times(1)).classCallback(eq(DELETE), eq(POSTCOMMIT)); + + //TODO - DELETE should not invoke READ. + verify(mockModel, times(1)).classCallback(eq(READ), eq(POSTCOMMIT)); + verify(mockModel, times(1)).relationCallback(any(), any(), any()); + verify(mockModel, times(1)).relationCallback(eq(READ), eq(POSTCOMMIT), any()); + verify(mockModel, never()).classAllFieldsCallback(any(), any()); + verify(mockModel, never()).attributeCallback(any(), any(), any()); } @Test - public void testUpdateRelationshipWithChangeSpec() { - Book book = new Book(); - Author author = new Author(); - book.setAuthors(Sets.newHashSet(author)); - author.setBooks(Sets.newHashSet(book)); + public void testAttributeUpdate() { + FieldTestModel mockModel = mock(FieldTestModel.class); DataStoreTransaction tx = mock(DataStoreTransaction.class); - when(tx.getRelation(any(), eq(author), eq("books"), any(), any(), any(), any())).then((i) -> author.getBooks()); - when(tx.getRelation(any(), eq(book), eq("authors"), any(), any(), any(), any())).then((i) -> book.getAuthors()); - + when(tx.createNewObject(FieldTestModel.class)).thenReturn(mockModel); RequestScope scope = buildRequestScope(dictionary, tx); - PersistentResource resourceBook = new PersistentResource(book, null, scope.getUUIDFor(book), scope); - PersistentResource resourceAuthor = new PersistentResource(author, null, scope.getUUIDFor(book), scope); - verify(onUpdateDeferredCallback, never()).execute(eq(book), isA(RequestScope.class), any()); - verify(onUpdateImmediateCallback, never()).execute(eq(book), isA(RequestScope.class), any()); - verify(onUpdatePostCommitCallback, never()).execute(eq(book), isA(RequestScope.class), any()); - verify(onUpdatePostCommitAuthor, never()).execute(eq(book), isA(RequestScope.class), any()); + PersistentResource resource = new PersistentResource(mockModel, null, scope.getUUIDFor(mockModel), scope); + resource.setValueChecked("field", "new value"); - //Verify changeSpec is passed to hooks - resourceAuthor.removeRelation("books", resourceBook); + verify(mockModel, times(1)).classCallback(any(), any()); + verify(mockModel, times(1)).classCallback(eq(UPDATE), eq(PRESECURITY)); + verify(mockModel, times(1)).attributeCallback(any(), any(), any()); + verify(mockModel, times(1)).attributeCallback(eq(UPDATE), eq(PRESECURITY), notNull()); + verify(mockModel, never()).relationCallback(any(), any(), any()); + verify(mockModel, never()).classAllFieldsCallback(any(), any()); + + clearInvocations(mockModel); scope.runQueuedPreSecurityTriggers(); - verify(onUpdateDeferredCallback, never()).execute(eq(book), isA(RequestScope.class), any()); - verify(onUpdateImmediateCallback, times(1)).execute(eq(book), isA(RequestScope.class), any()); + verify(mockModel, never()).classAllFieldsCallback(any(), any()); + verify(mockModel, never()).relationCallback(any(), any(), any()); + verify(mockModel, never()).attributeCallback(any(), any(), any()); + + clearInvocations(mockModel); scope.runQueuedPreCommitTriggers(); - verify(onUpdateDeferredCallback, times(1)).execute(eq(book), isA(RequestScope.class), any()); - scope.getPermissionExecutor().executeCommitChecks(); - verify(onUpdatePostCommitCallback, never()).execute(eq(book), isA(RequestScope.class), any()); - verify(onUpdatePostCommitAuthor, never()).execute(eq(book), isA(RequestScope.class), any()); + verify(mockModel, never()).relationCallback(any(), any(), any()); + verify(mockModel, never()).classAllFieldsCallback(any(), any()); + + verify(mockModel, times(1)).classCallback(any(), any()); + verify(mockModel, times(1)).classCallback(eq(UPDATE), eq(PRECOMMIT)); + verify(mockModel, times(1)).attributeCallback(any(), any(), any()); + verify(mockModel, times(1)).attributeCallback(eq(UPDATE), eq(PRECOMMIT), notNull()); + clearInvocations(mockModel); + scope.getPermissionExecutor().executeCommitChecks(); scope.runQueuedPostCommitTriggers(); - verify(onUpdateDeferredCallback, times(1)).execute(eq(book), isA(RequestScope.class), any()); - verify(onUpdateImmediateCallback, times(1)).execute(eq(book), isA(RequestScope.class), any()); - verify(onUpdatePostCommitCallback, times(1)).execute(eq(book), isA(RequestScope.class), any()); - verify(onUpdatePostCommitAuthor, times(1)).execute(eq(author), isA(RequestScope.class), any()); - verifyNoEmptyCallbacks(); + verify(mockModel, never()).relationCallback(any(), any(), any()); + verify(mockModel, never()).classAllFieldsCallback(any(), any()); + + verify(mockModel, times(1)).classCallback(any(), any()); + verify(mockModel, times(1)).classCallback(eq(UPDATE), eq(POSTCOMMIT)); + verify(mockModel, times(1)).attributeCallback(any(), any(), any()); + verify(mockModel, times(1)).attributeCallback(eq(UPDATE), eq(POSTCOMMIT), notNull()); } @Test - public void testOnDelete() { - Book book = mock(Book.class); + public void testRelationshipUpdate() { + FieldTestModel mockModel = mock(FieldTestModel.class); DataStoreTransaction tx = mock(DataStoreTransaction.class); - + when(tx.createNewObject(FieldTestModel.class)).thenReturn(mockModel); RequestScope scope = buildRequestScope(dictionary, tx); - PersistentResource resource = new PersistentResource(book, null, scope.getUUIDFor(book), scope); - verify(book, never()).onCreatePreSecurity(scope); - verify(book, never()).onDeletePreSecurity(scope); - verify(book, never()).onUpdatePreSecurityTitle(scope); - verify(book, never()).onReadPreSecurity(scope); - verify(book, never()).checkPermission(scope); - resource.deleteResource(); - verify(book, never()).onCreatePreSecurity(scope); - verify(book, times(1)).onDeletePreSecurity(scope); - verify(book, never()).onUpdatePreSecurityTitle(scope); - verify(book, never()).onReadPreSecurity(scope); - verify(book, times(1)).checkPermission(scope); + FieldTestModel modelToAdd = mock(FieldTestModel.class); - scope.runQueuedPreSecurityTriggers(); - verify(book, never()).onCreatePreSecurity(scope); - verify(book, times(1)).onDeletePreSecurity(scope); - verify(book, never()).onUpdatePreSecurityTitle(scope); - verify(book, never()).onReadPreSecurity(scope); + PersistentResource resource = new PersistentResource(mockModel, null, scope.getUUIDFor(mockModel), scope); + PersistentResource resourceToAdd = new PersistentResource(modelToAdd, null, scope.getUUIDFor(mockModel), scope); - scope.runQueuedPreCommitTriggers(); - verify(book, never()).onCreatePreCommit(scope); - verify(book, times(1)).onDeletePreCommit(scope); - verify(book, never()).onUpdatePreCommitTitle(scope); - verify(book, never()).onReadPreCommitTitle(scope); + resource.addRelation("models", resourceToAdd); - scope.getPermissionExecutor().executeCommitChecks(); - verify(book, times(1)).checkPermission(scope); + verify(mockModel, times(1)).classCallback(any(), any()); + verify(mockModel, times(1)).classCallback(eq(UPDATE), eq(PRESECURITY)); - scope.runQueuedPostCommitTriggers(); - verify(book, never()).onCreatePostCommit(scope); - verify(book, times(1)).onDeletePostCommit(scope); - verify(book, never()).onUpdatePostCommitTitle(scope); - verify(book, never()).onReadPostCommit(scope); - } + //TODO - this should be only called once. THis is called twice because the mock has a null collection. + verify(mockModel, times(2)).relationCallback(any(), any(), any()); + verify(mockModel, times(2)).relationCallback(eq(UPDATE), eq(PRESECURITY), notNull()); - @Test - public void testOnRead() { - Book book = mock(Book.class); - DataStoreTransaction tx = mock(DataStoreTransaction.class); - RequestScope scope = buildRequestScope(dictionary, tx); - PersistentResource resource = new PersistentResource(book, null, scope.getUUIDFor(book), scope); - - resource.getValueChecked("title"); - verify(book, never()).onCreatePreSecurity(scope); - verify(book, never()).onDeletePreSecurity(scope); - verify(book, never()).onUpdatePreSecurityTitle(scope); - verify(book, times(1)).onReadPreSecurity(scope); + verify(mockModel, never()).attributeCallback(any(), any(), any()); + verify(mockModel, never()).classAllFieldsCallback(any(), any()); + clearInvocations(mockModel); scope.runQueuedPreSecurityTriggers(); - verify(book, never()).onCreatePreSecurity(scope); - verify(book, never()).onDeletePreSecurity(scope); - verify(book, never()).onUpdatePreSecurityTitle(scope); - verify(book, times(1)).onReadPreSecurity(scope); + verify(mockModel, never()).classAllFieldsCallback(any(), any()); + verify(mockModel, never()).relationCallback(any(), any(), any()); + verify(mockModel, never()).attributeCallback(any(), any(), any()); + + clearInvocations(mockModel); scope.runQueuedPreCommitTriggers(); - verify(book, never()).onCreatePreCommit(scope); - verify(book, never()).onDeletePreCommit(scope); - verify(book, never()).onUpdatePreCommitTitle(scope); - verify(book, times(1)).onReadPreCommitTitle(scope); - scope.getPermissionExecutor().executeCommitChecks(); + verify(mockModel, never()).attributeCallback(any(), any(), any()); + verify(mockModel, never()).classAllFieldsCallback(any(), any()); - scope.runQueuedPostCommitTriggers(); - verify(book, never()).onCreatePostCommit(scope); - verify(book, never()).onDeletePostCommit(scope); - verify(book, never()).onUpdatePostCommitTitle(scope); - verify(book, times(1)).onReadPostCommit(scope); - } + verify(mockModel, times(1)).classCallback(any(), any()); + verify(mockModel, times(1)).classCallback(eq(UPDATE), eq(PRECOMMIT)); + //TODO - this should be only called once. + verify(mockModel, times(2)).relationCallback(any(), any(), any()); + verify(mockModel, times(2)).relationCallback(eq(UPDATE), eq(PRECOMMIT), notNull()); - @Test - public void testPreSecurityLifecycleHookException() { - @Entity - @Include - class Book { - public String title; - - @OnUpdatePreSecurity(value = "title") - public void blowUp(RequestScope scope) { - throw new IllegalStateException(); - } - } + clearInvocations(mockModel); + scope.getPermissionExecutor().executeCommitChecks(); + scope.runQueuedPostCommitTriggers(); - EntityDictionary dictionary = new EntityDictionary(new HashMap<>()); - DataStoreTransaction tx = mock(DataStoreTransaction.class); - dictionary.bindEntity(Book.class); + verify(mockModel, never()).attributeCallback(any(), any(), any()); + verify(mockModel, never()).classAllFieldsCallback(any(), any()); - Book book = new Book(); - RequestScope scope = buildRequestScope(dictionary, tx); - PersistentResource resource = new PersistentResource(book, null, "1", scope); - - assertThrows(IllegalStateException.class, () -> resource.updateAttribute("title", "New value")); + verify(mockModel, times(1)).classCallback(any(), any()); + verify(mockModel, times(1)).classCallback(eq(UPDATE), eq(POSTCOMMIT)); + //TODO - this should be only called once. + verify(mockModel, times(2)).relationCallback(any(), any(), any()); + verify(mockModel, times(2)).relationCallback(eq(UPDATE), eq(POSTCOMMIT), notNull()); } @Test - public void testPreCommitLifeCycleHookException() { - @Entity - @Include - class Book { - public String title; - - @OnUpdatePreCommit(value = "title") - public void blowUp(RequestScope scope) { - throw new IllegalStateException(); - } - } - - EntityDictionary dictionary = new EntityDictionary(new HashMap<>()); + public void testAddToCollectionTrigger() { + PropertyTestModel mockModel = mock(PropertyTestModel.class); DataStoreTransaction tx = mock(DataStoreTransaction.class); - dictionary.bindEntity(Book.class); - - Book book = new Book(); + when(tx.createNewObject(PropertyTestModel.class)).thenReturn(mockModel); RequestScope scope = buildRequestScope(dictionary, tx); - PersistentResource resource = new PersistentResource(book, null, "1", scope); - resource.updateAttribute("title", "New value"); + PropertyTestModel modelToAdd = mock(PropertyTestModel.class); - assertThrows(IllegalStateException.class, () -> scope.runQueuedPreCommitTriggers()); - } + //First we test adding to a newly created object. + PersistentResource resource = PersistentResource.createObject(PropertyTestModel.class, scope, Optional.of("1")); + PersistentResource resourceToAdd = new PersistentResource(modelToAdd, null, scope.getUUIDFor(mockModel), scope); - /** - * Tests that Entities that use field level access (as opposed to properties) - * can register read hooks on the entity class. - */ - @Test - public void testReadHookOnEntityFields() { - @Entity - @Include - class Book { - @Id - private String id; - private String title; - - @Exclude - @Transient - private int readPreSecurityInvoked = 0; - - @Exclude - @Transient - private int readPreCommitInvoked = 0; - - @Exclude - @Transient - private int readPostCommitInvoked = 0; - - @OnReadPreSecurity("title") - public void readPreSecurity(RequestScope scope) { - readPreSecurityInvoked++; - } - - @OnReadPreCommit("title") - public void readPreCommit(RequestScope scope) { - readPreCommitInvoked++; - } - - @OnReadPostCommit("title") - public void readPostCommit(RequestScope scope) { - readPostCommitInvoked++; - } - } + resource.updateRelation("models", new HashSet<>(Arrays.asList(resourceToAdd))); - EntityDictionary dictionary = new EntityDictionary(new HashMap<>()); - DataStoreTransaction tx = mock(DataStoreTransaction.class); - dictionary.bindEntity(Book.class); + scope.runQueuedPreSecurityTriggers(); + scope.runQueuedPreCommitTriggers(); + scope.runQueuedPostCommitTriggers(); - Book book = new Book(); - RequestScope scope = buildRequestScope(dictionary, tx); - PersistentResource resource = new PersistentResource(book, null, "1", scope); + verify(mockModel, never()).relationCallback(eq(UPDATE), any(), any()); + verify(mockModel, times(1)).relationCallback(eq(CREATE), eq(POSTCOMMIT), notNull()); + + //Build another resource, scope & reset the mock to do a pure update (no create): + scope = buildRequestScope(dictionary, tx); + resource = new PersistentResource(mockModel, null, scope.getUUIDFor(mockModel), scope); + reset(mockModel); - resource.getAttribute("title"); + resource.updateRelation("models", new HashSet<>(Arrays.asList(resourceToAdd))); - assertEquals(1, book.readPreSecurityInvoked); - assertEquals(0, book.readPreCommitInvoked); + scope.runQueuedPreSecurityTriggers(); scope.runQueuedPreCommitTriggers(); - assertEquals(1, book.readPreCommitInvoked); - assertEquals(0, book.readPostCommitInvoked); scope.runQueuedPostCommitTriggers(); - assertEquals(1, book.readPreSecurityInvoked); - assertEquals(1, book.readPreCommitInvoked); - assertEquals(1, book.readPostCommitInvoked); + + verify(mockModel, never()).relationCallback(eq(CREATE), any(), any()); + verify(mockModel, times(1)).relationCallback(eq(UPDATE), eq(POSTCOMMIT), notNull()); } - /** - * Tests that Entities that use field level access (as opposed to properties) - * can register update hooks on the entity class. - */ @Test - public void testUpdateHookOnEntityFields() { - @Entity - @Include - class Book { - @Id - private String id; - private String title; - - @Exclude - @Transient - private int updatePreSecurityInvoked = 0; - - @Exclude - @Transient - private int updatePreCommitInvoked = 0; - - @Exclude - @Transient - private int updatePostCommitInvoked = 0; - - @OnUpdatePreSecurity("title") - public void updatePreSecurity(RequestScope scope) { - updatePreSecurityInvoked++; - } - - @OnUpdatePreCommit("title") - public void updatePreCommit(RequestScope scope) { - updatePreCommitInvoked++; - } - - @OnUpdatePostCommit("title") - public void updatePostCommit(RequestScope scope) { - updatePostCommitInvoked++; - } - } - - EntityDictionary dictionary = new EntityDictionary(new HashMap<>()); + public void testRemoveFromCollectionTrigger() { + PropertyTestModel mockModel = mock(PropertyTestModel.class); DataStoreTransaction tx = mock(DataStoreTransaction.class); - dictionary.bindEntity(Book.class); - - Book book = new Book(); + when(tx.createNewObject(PropertyTestModel.class)).thenReturn(mockModel); RequestScope scope = buildRequestScope(dictionary, tx); - PersistentResource resource = new PersistentResource(book, null, "1", scope); - resource.updateAttribute("title", "foo"); + PropertyTestModel childModel1 = mock(PropertyTestModel.class); + PropertyTestModel childModel2 = mock(PropertyTestModel.class); + when(childModel1.getId()).thenReturn("2"); + when(childModel2.getId()).thenReturn("3"); + + //First we test removing from a newly created object. + PersistentResource resource = PersistentResource.createObject(PropertyTestModel.class, scope, Optional.of("1")); + PersistentResource childResource1 = new PersistentResource(childModel1, null, "2", scope); + PersistentResource childResource2 = new PersistentResource(childModel2, null, "3", scope); - assertEquals(1, book.updatePreSecurityInvoked); - assertEquals(0, book.updatePreCommitInvoked); + resource.updateRelation("models", new HashSet<>(Arrays.asList(childResource1, childResource2))); + + scope.runQueuedPreSecurityTriggers(); scope.runQueuedPreCommitTriggers(); - assertEquals(1, book.updatePreCommitInvoked); - assertEquals(0, book.updatePostCommitInvoked); scope.runQueuedPostCommitTriggers(); - assertEquals(1, book.updatePreSecurityInvoked); - assertEquals(1, book.updatePreCommitInvoked); - assertEquals(1, book.updatePostCommitInvoked); - } - /** - * Tests that Entities that use field level access (as opposed to properties) - * can register create hooks on the entity class. - */ - @Test - public void testCreateHookOnEntityFields() { - @Entity - @Include - class Book { - @Id - private String id; - private String title; - - @Exclude - @Transient - private int createPreCommitInvoked = 0; - - @Exclude - @Transient - private int createPostCommitInvoked = 0; - - @Exclude - @Transient - private int createPreSecurityInvoked = 0; - - @OnCreatePreSecurity - public void createPreSecurity(RequestScope scope) { - createPreSecurityInvoked++; - } - - @OnCreatePreCommit("title") - public void createPreCommit(RequestScope scope) { - createPreCommitInvoked++; - } - - @OnCreatePostCommit("title") - public void createPostCommit(RequestScope scope) { - createPostCommitInvoked++; - } - } + verify(mockModel, never()).relationCallback(eq(UPDATE), any(), any()); + verify(mockModel, times(2)).relationCallback(eq(CREATE), eq(POSTCOMMIT), notNull()); + + //Build another resource, scope & reset the mock to do a pure update (no create): + scope = buildRequestScope(dictionary, tx); + resource = new PersistentResource(mockModel, null, scope.getUUIDFor(mockModel), scope); + reset(mockModel); + Relationship relationship = Relationship.builder() + .projection(EntityProjection.builder() + .type(PropertyTestModel.class) + .build()) + .name("models") + .build(); - EntityDictionary dictionary = new EntityDictionary(new HashMap<>()); - DataStoreTransaction tx = mock(DataStoreTransaction.class); - dictionary.bindEntity(Book.class); + when(tx.getRelation(tx, mockModel, relationship, scope)).thenReturn(Arrays.asList(childModel1, childModel2)); - Book book = new Book(); - when(tx.createNewObject(Book.class)).thenReturn(book); - RequestScope scope = buildRequestScope(dictionary, tx); - PersistentResource bookResource = PersistentResource.createObject(null, Book.class, scope, Optional.of("123")); - bookResource.updateAttribute("title", "Foo"); + resource.updateRelation("models", new HashSet<>(Arrays.asList(childResource1))); - assertEquals(0, book.createPreSecurityInvoked); scope.runQueuedPreSecurityTriggers(); - assertEquals(1, book.createPreSecurityInvoked); - assertEquals(0, book.createPreCommitInvoked); scope.runQueuedPreCommitTriggers(); - assertEquals(1, book.createPreCommitInvoked); - assertEquals(0, book.createPostCommitInvoked); scope.runQueuedPostCommitTriggers(); - assertEquals(1, book.createPreSecurityInvoked); - assertEquals(1, book.createPreCommitInvoked); - assertEquals(1, book.createPostCommitInvoked); + + verify(mockModel, never()).relationCallback(eq(CREATE), any(), any()); + verify(mockModel, times(1)).relationCallback(eq(UPDATE), eq(POSTCOMMIT), notNull()); } - /** - * Tests that Entities that use field level access (as opposed to properties) - * can register delete hooks on the entity class. - */ @Test - public void testDeleteHookOnEntityFields() { - @Entity - @Include - class Book { - @Id - private String id; - private String title; - - @Exclude - @Transient - private int deletePreSecurityInvoked = 0; - - @Exclude - @Transient - private int deletePreCommitInvoked = 0; - - @Exclude - @Transient - private int deletePostCommitInvoked = 0; - - @OnDeletePreSecurity - public void deletePreSecurity(RequestScope scope) { - deletePreSecurityInvoked++; - } - - @OnDeletePreCommit - public void deletePreCommit(RequestScope scope) { - deletePreCommitInvoked++; - } - - @OnDeletePostCommit - public void deletePostCommit(RequestScope scope) { - deletePostCommitInvoked++; - } - } - - EntityDictionary dictionary = new EntityDictionary(new HashMap<>()); + public void testPreCommitLifecycleHookException() { DataStoreTransaction tx = mock(DataStoreTransaction.class); - dictionary.bindEntity(Book.class); + FieldTestModel testModel = mock(FieldTestModel.class); - Book book = new Book(); + doThrow(IllegalStateException.class) + .when(testModel) + .attributeCallback(eq(UPDATE), eq(PRECOMMIT), any(ChangeSpec.class)); RequestScope scope = buildRequestScope(dictionary, tx); - PersistentResource resource = new PersistentResource(book, null, "1", scope); - - resource.deleteResource(); - - assertEquals(1, book.deletePreSecurityInvoked); - assertEquals(0, book.deletePreCommitInvoked); - scope.runQueuedPreCommitTriggers(); - assertEquals(1, book.deletePreCommitInvoked); - assertEquals(0, book.deletePostCommitInvoked); - scope.runQueuedPostCommitTriggers(); - assertEquals(1, book.deletePreSecurityInvoked); - assertEquals(1, book.deletePreCommitInvoked); - assertEquals(1, book.deletePostCommitInvoked); + PersistentResource resource = new PersistentResource(testModel, null, "1", scope); + resource.updateAttribute("field", "New value"); + scope.runQueuedPreSecurityTriggers(); + assertThrows(IllegalStateException.class, () -> scope.runQueuedPreCommitTriggers()); } - /** - * Tests that Update lifecycle hooks are triggered when a relationship collection has elements added. - */ @Test - public void testAddToCollectionTrigger() { - HashMapDataStore wrapped = new HashMapDataStore(Book.class.getPackage()); - InMemoryDataStore store = new InMemoryDataStore(wrapped); - HashMap> checkMappings = new HashMap<>(); - checkMappings.put("Book operation check", Book.BookOperationCheck.class); - checkMappings.put("Field path editor check", Editor.FieldPathFilterExpression.class); - store.populateEntityDictionary(new EntityDictionary(checkMappings)); - DataStoreTransaction tx = store.beginTransaction(); - - RequestScope scope = buildRequestScope(wrapped.getDictionary(), tx); - PersistentResource publisherResource = PersistentResource.createObject(null, Publisher.class, scope, Optional.of("1")); - PersistentResource book1Resource = PersistentResource.createObject(publisherResource, Book.class, scope, Optional.of("1")); - publisherResource.updateRelation("books", new HashSet<>(Arrays.asList(book1Resource))); - - scope.runQueuedPreCommitTriggers(); - tx.save(publisherResource.getObject(), scope); - tx.save(book1Resource.getObject(), scope); - tx.commit(scope); - - Publisher publisher = (Publisher) publisherResource.getObject(); - - /* Only the creat hooks should be triggered */ - assertFalse(publisher.isUpdateHookInvoked()); - - scope = buildRequestScope(wrapped.getDictionary(), tx); + public void testPostCommitLifecycleHookException() { + DataStoreTransaction tx = mock(DataStoreTransaction.class); + FieldTestModel testModel = mock(FieldTestModel.class); - PersistentResource book2Resource = PersistentResource.createObject(publisherResource, Book.class, scope, Optional.of("2")); - publisherResource = PersistentResource.loadRecord(Publisher.class, "1", scope); - publisherResource.addRelation("books", book2Resource); + doThrow(IllegalStateException.class) + .when(testModel) + .attributeCallback(eq(UPDATE), eq(POSTCOMMIT), any(ChangeSpec.class)); + RequestScope scope = buildRequestScope(dictionary, tx); + PersistentResource resource = new PersistentResource(testModel, null, "1", scope); + resource.updateAttribute("field", "New value"); + scope.runQueuedPreSecurityTriggers(); scope.runQueuedPreCommitTriggers(); - - publisher = (Publisher) publisherResource.getObject(); - assertTrue(publisher.isUpdateHookInvoked()); + assertThrows(IllegalStateException.class, () -> scope.runQueuedPostCommitTriggers()); } - /** - * Tests that Update lifecycle hooks are triggered when a relationship collection has elements removed. - */ @Test - public void testRemoveFromCollectionTrigger() { - HashMapDataStore wrapped = new HashMapDataStore(Book.class.getPackage()); - InMemoryDataStore store = new InMemoryDataStore(wrapped); - HashMap> checkMappings = new HashMap<>(); - checkMappings.put("Book operation check", Book.BookOperationCheck.class); - checkMappings.put("Field path editor check", Editor.FieldPathFilterExpression.class); - store.populateEntityDictionary(new EntityDictionary(checkMappings)); - DataStoreTransaction tx = store.beginTransaction(); - - RequestScope scope = buildRequestScope(wrapped.getDictionary(), tx); - - PersistentResource publisherResource = PersistentResource.createObject(null, Publisher.class, scope, Optional.of("1")); - PersistentResource book1Resource = PersistentResource.createObject(publisherResource, Book.class, scope, Optional.of("1")); - PersistentResource book2Resource = PersistentResource.createObject(publisherResource, Book.class, scope, Optional.of("2")); - publisherResource.updateRelation("books", new HashSet<>(Arrays.asList(book1Resource, book2Resource))); - - scope.runQueuedPreCommitTriggers(); - tx.save(publisherResource.getObject(), scope); - tx.save(book1Resource.getObject(), scope); - tx.commit(scope); - - Publisher publisher = (Publisher) publisherResource.getObject(); - - /* Only the creat hooks should be triggered */ - assertFalse(publisher.isUpdateHookInvoked()); - - scope = buildRequestScope(wrapped.getDictionary(), tx); + public void testPreSecurityLifecycleHookException() { + DataStoreTransaction tx = mock(DataStoreTransaction.class); + FieldTestModel testModel = mock(FieldTestModel.class); - book2Resource = PersistentResource.createObject(publisherResource, Book.class, scope, Optional.of("2")); - publisherResource = PersistentResource.loadRecord(Publisher.class, "1", scope); - publisherResource.updateRelation("books", new HashSet<>(Arrays.asList(book2Resource))); + doThrow(IllegalStateException.class) + .when(testModel) + .attributeCallback(eq(UPDATE), eq(PRESECURITY), any(ChangeSpec.class)); - scope.runQueuedPreCommitTriggers(); + RequestScope scope = buildRequestScope(dictionary, tx); + PersistentResource resource = new PersistentResource(testModel, null, "1", scope); - publisher = (Publisher) publisherResource.getObject(); - assertTrue(publisher.isUpdateHookInvoked()); + assertThrows(IllegalStateException.class, () -> resource.updateAttribute("field", "New value")); } private Elide getElide(DataStore dataStore, EntityDictionary dictionary, AuditLogger auditLogger) { @@ -1287,18 +1385,14 @@ private ElideSettings getElideSettings(DataStore dataStore, EntityDictionary dic return new ElideSettingsBuilder(dataStore) .withEntityDictionary(dictionary) .withAuditLogger(auditLogger) - .withReturnErrorObjects(true) + .withVerboseErrors() .build(); } - private void verifyNoEmptyCallbacks() { - verify(onUpdateDeferredCallback, never()).execute(any(), isA(RequestScope.class), eq(Optional.empty())); - verify(onUpdateImmediateCallback, never()).execute(any(), isA(RequestScope.class), eq(Optional.empty())); - verify(onUpdatePostCommitCallback, never()).execute(any(), isA(RequestScope.class), eq(Optional.empty())); - verify(onUpdatePostCommitAuthor, never()).execute(any(), isA(RequestScope.class), eq(Optional.empty())); - } - private RequestScope buildRequestScope(EntityDictionary dict, DataStoreTransaction tx) { - return new RequestScope(null, null, tx, new User(1), null, getElideSettings(null, dict, MOCK_AUDIT_LOGGER)); + User user = new TestUser("1"); + + return new RequestScope(null, NO_VERSION, null, tx, user, null, + getElideSettings(null, dict, MOCK_AUDIT_LOGGER)); } } diff --git a/elide-core/src/test/java/com/yahoo/elide/core/PaginationLogicTest.java b/elide-core/src/test/java/com/yahoo/elide/core/PaginationImplTest.java similarity index 52% rename from elide-core/src/test/java/com/yahoo/elide/core/PaginationLogicTest.java rename to elide-core/src/test/java/com/yahoo/elide/core/PaginationImplTest.java index ee5790c0f4..b3f6fdfb69 100644 --- a/elide-core/src/test/java/com/yahoo/elide/core/PaginationLogicTest.java +++ b/elide-core/src/test/java/com/yahoo/elide/core/PaginationImplTest.java @@ -14,18 +14,17 @@ import com.yahoo.elide.ElideSettingsBuilder; import com.yahoo.elide.annotation.Paginate; import com.yahoo.elide.core.exceptions.InvalidValueException; -import com.yahoo.elide.core.pagination.Pagination; +import com.yahoo.elide.core.pagination.PaginationImpl; import org.glassfish.jersey.internal.util.collection.MultivaluedStringMap; import org.junit.jupiter.api.Test; import java.util.Optional; - import javax.ws.rs.core.MultivaluedMap; /** * Tests parsing the page params for json-api pagination. */ -public class PaginationLogicTest { +public class PaginationImplTest { private final ElideSettings elideSettings = new ElideSettingsBuilder(null).build(); @@ -35,8 +34,8 @@ public void shouldParseQueryParamsForCurrentPageAndPageSize() { queryParams.add("page[size]", "10"); queryParams.add("page[number]", "2"); - Pagination pageData = Pagination.parseQueryParams(queryParams, elideSettings); - pageData = pageData.evaluate(PaginationLogicTest.class); + PaginationImpl pageData = PaginationImpl.parseQueryParams(PaginationImplTest.class, + Optional.of(queryParams), elideSettings); // page based strategy uses human readable paging - non-zero index // page 2 becomes (1)*10 so 10 since we shift to zero based index assertEquals(10, pageData.getOffset()); @@ -49,8 +48,8 @@ public void shouldThrowExceptionForNegativePageNumber() { queryParams.add("page[size]", "10"); queryParams.add("page[number]", "-2"); - Pagination pageData = Pagination.parseQueryParams(queryParams, elideSettings); - assertThrows(InvalidValueException.class, () -> pageData.evaluate(PaginationLogicTest.class)); + assertThrows(InvalidValueException.class, () -> PaginationImpl.parseQueryParams(PaginationImplTest.class, + Optional.of(queryParams), elideSettings)); } @Test @@ -58,8 +57,9 @@ public void shouldThrowExceptionForNegativePageSize() { MultivaluedMap queryParams = new MultivaluedStringMap(); queryParams.add("page[size]", "-10"); queryParams.add("page[number]", "2"); - Pagination pageData = Pagination.parseQueryParams(queryParams, elideSettings); - assertThrows(InvalidValueException.class, () -> pageData.evaluate(PaginationLogicTest.class)); + + assertThrows(InvalidValueException.class, () -> PaginationImpl.parseQueryParams(PaginationImplTest.class, + Optional.of(queryParams), elideSettings)); } @Test @@ -68,8 +68,8 @@ public void shouldParseQueryParamsForOffsetAndLimit() { queryParams.add("page[limit]", "10"); queryParams.add("page[offset]", "2"); - Pagination pageData = Pagination.parseQueryParams(queryParams, elideSettings); - pageData = pageData.evaluate(PaginationLogicTest.class); + PaginationImpl pageData = PaginationImpl.parseQueryParams(PaginationImplTest.class, + Optional.of(queryParams), elideSettings); // offset is direct correlation to start field in query assertEquals(2, pageData.getOffset()); assertEquals(10, pageData.getLimit()); @@ -78,76 +78,72 @@ public void shouldParseQueryParamsForOffsetAndLimit() { @Test public void shouldUseDefaultsWhenMissingCurrentPageAndPageSize() { MultivaluedMap queryParams = new MultivaluedStringMap(); - Pagination pageData = Pagination.parseQueryParams(queryParams, elideSettings); - assertEquals(Pagination.DEFAULT_OFFSET, pageData.getOffset()); - assertEquals(Pagination.DEFAULT_PAGE_LIMIT, pageData.getLimit()); + PaginationImpl pageData = PaginationImpl.parseQueryParams(PaginationImplTest.class, + Optional.of(queryParams), elideSettings); + assertEquals(PaginationImpl.DEFAULT_OFFSET, pageData.getOffset()); + assertEquals(PaginationImpl.DEFAULT_PAGE_LIMIT, pageData.getLimit()); } @Test public void checkValidOffsetAndFirstRequest() { - Pagination pageData = Pagination.fromOffsetAndFirst(Optional.of("10"), Optional.of("1"), true, elideSettings).get(); - - // NOTE: This is always set to default until evaluate. Then the appropriate value should be used. - // This is because the particular root entity determines the pagination limits - assertEquals(0, pageData.getOffset()); - assertEquals(500, pageData.getLimit()); - - assertEquals(1, pageData.evaluate(PaginationLogicTest.class).getOffset()); - assertEquals(10, pageData.evaluate(PaginationLogicTest.class).getLimit()); - assertTrue(pageData.evaluate(PaginationLogicTest.class).isGenerateTotals()); + PaginationImpl pageData = new PaginationImpl(PaginationImplTest.class, + 1, + 10, + elideSettings.getDefaultPageSize(), + elideSettings.getDefaultMaxPageSize(), + false, + false); + + assertEquals(1, pageData.getOffset()); + assertEquals(10, pageData.getLimit()); } @Test public void checkErroneousPageLimit() { - Pagination pageData = - Pagination.fromOffsetAndFirst(Optional.of("100000"), Optional.of("1"), false, elideSettings).get(); - - // NOTE: This is always set to default until evaluate. Then the appropriate value should be used. - // This is because the particular root entity determines the pagination limits - assertEquals(0, pageData.getOffset()); - assertEquals(500, pageData.getLimit()); - assertThrows( - InvalidValueException.class, - () -> pageData.evaluate(PaginationLogicTest.class).getOffset()); assertThrows( InvalidValueException.class, - () -> pageData.evaluate(PaginationLogicTest.class).getLimit()); + () -> new PaginationImpl(PaginationImplTest.class, + 10, + 100000, + elideSettings.getDefaultPageSize(), + elideSettings.getDefaultMaxPageSize(), + false, + false)); } @Test public void checkBadOffset() { assertThrows( InvalidValueException.class, - () -> Pagination.fromOffsetAndFirst(Optional.of("-1"), Optional.of("1000"), false, elideSettings)); - } - - @Test - public void checkBadOffsetString() { - assertThrows( - InvalidValueException.class, - () -> Pagination.fromOffsetAndFirst(Optional.of("NaN"), Optional.of("1000"), false, elideSettings)); + () -> new PaginationImpl(PaginationImplTest.class, + -1, + 1000, + elideSettings.getDefaultPageSize(), + elideSettings.getDefaultMaxPageSize(), + false, + false)); } @Test public void checkBadLimit() { assertThrows( InvalidValueException.class, - () -> Pagination.fromOffsetAndFirst(Optional.of("0"), Optional.of("1"), false, elideSettings)); - } - - @Test - public void checkBadLimitString() { - assertThrows( - InvalidValueException.class, - () -> Pagination.fromOffsetAndFirst(Optional.of("1"), Optional.of("NaN"), false, elideSettings)); + () -> new PaginationImpl(PaginationImplTest.class, + 0, + -1, + elideSettings.getDefaultPageSize(), + elideSettings.getDefaultMaxPageSize(), + false, + false)); } @Test public void neverExceedMaxPageSize() { MultivaluedMap queryParams = new MultivaluedStringMap(); queryParams.add("page[size]", "25000"); - Pagination pageData = Pagination.parseQueryParams(queryParams, elideSettings); - assertThrows(InvalidValueException.class, () -> pageData.evaluate(PaginationLogicTest.class)); + assertThrows(InvalidValueException.class, + () -> PaginationImpl.parseQueryParams(PaginationImplTest.class, + Optional.of(queryParams), elideSettings)); } @Test @@ -155,48 +151,54 @@ public void invalidUsageOfPaginationParameters() { MultivaluedMap queryParams = new MultivaluedStringMap(); queryParams.add("page[size]", "10"); queryParams.add("page[offset]", "100"); - Pagination pageData = Pagination.parseQueryParams(queryParams, elideSettings); - assertThrows(InvalidValueException.class, () -> pageData.evaluate(PaginationLogicTest.class)); + assertThrows(InvalidValueException.class, + () -> PaginationImpl.parseQueryParams(PaginationImplTest.class, + Optional.of(queryParams), elideSettings)); } @Test public void pageBasedPaginationWithDefaultSize() { MultivaluedMap queryParams = new MultivaluedStringMap(); queryParams.add("page[number]", "2"); - Pagination pageData = Pagination.parseQueryParams(queryParams, elideSettings); - pageData = pageData.evaluate(PaginationLogicTest.class); - assertEquals(Pagination.DEFAULT_PAGE_LIMIT, pageData.getLimit()); - assertEquals(Pagination.DEFAULT_PAGE_LIMIT, pageData.getOffset()); + PaginationImpl pageData = PaginationImpl.parseQueryParams(PaginationImpl.class, + Optional.of(queryParams), elideSettings); + assertEquals(PaginationImpl.DEFAULT_PAGE_LIMIT, pageData.getLimit()); + assertEquals(PaginationImpl.DEFAULT_PAGE_LIMIT, pageData.getOffset()); } @Test public void shouldThrowExceptionForNonIntPageParamValues() { MultivaluedMap queryParams = new MultivaluedStringMap(); queryParams.add("page[size]", "2.5"); - assertThrows(InvalidValueException.class, () -> Pagination.parseQueryParams(queryParams, elideSettings)); + assertThrows(InvalidValueException.class, + () -> PaginationImpl.parseQueryParams(PaginationImplTest.class, + Optional.of(queryParams), elideSettings)); } @Test public void shouldThrowExceptionForInvalidPageParams() { MultivaluedMap queryParams = new MultivaluedStringMap(); queryParams.add("page[random]", "1"); - assertThrows(InvalidValueException.class, () -> Pagination.parseQueryParams(queryParams, elideSettings)); + assertThrows(InvalidValueException.class, + () -> PaginationImpl.parseQueryParams(PaginationImplTest.class, + Optional.of(queryParams), elideSettings)); } @Test public void shouldSetGenerateTotals() { MultivaluedMap queryParams = new MultivaluedStringMap(); queryParams.add("page[totals]", null); - Pagination pageData = Pagination.parseQueryParams(queryParams, elideSettings); - pageData = pageData.evaluate(PaginationLogicTest.class); - assertTrue(pageData.isGenerateTotals()); + PaginationImpl pageData = PaginationImpl.parseQueryParams(PaginationImplTest.class, + Optional.of(queryParams), elideSettings); + assertTrue(pageData.returnPageTotals()); } @Test public void shouldNotSetGenerateTotals() { MultivaluedMap queryParams = new MultivaluedStringMap(); - Pagination pageData = Pagination.parseQueryParams(queryParams, elideSettings); - assertFalse(pageData.isGenerateTotals()); + PaginationImpl pageData = PaginationImpl.parseQueryParams(PaginationImplTest.class, + Optional.of(queryParams), elideSettings); + assertFalse(pageData.returnPageTotals()); } @@ -204,12 +206,13 @@ public void shouldNotSetGenerateTotals() { public void shouldUseDefaultsWhenNoParams() { MultivaluedMap queryParams = new MultivaluedStringMap(); - Pagination pageData = Pagination.parseQueryParams(queryParams, elideSettings); + PaginationImpl pageData = PaginationImpl.parseQueryParams(PaginationImplTest.class, + Optional.of(queryParams), elideSettings); assertEquals(0, pageData.getOffset()); - assertEquals(Pagination.DEFAULT_PAGE_LIMIT, pageData.getLimit()); + assertEquals(PaginationImpl.DEFAULT_PAGE_LIMIT, pageData.getLimit()); - pageData = Pagination.parseQueryParams(queryParams, - new ElideSettingsBuilder(null) + pageData = PaginationImpl.parseQueryParams(PaginationImplTest.class, + Optional.of(queryParams), new ElideSettingsBuilder(null) .withDefaultPageSize(10) .withDefaultMaxPageSize(10) .build()); @@ -223,16 +226,14 @@ public void testClassLevelOverride() { class PaginationOverrideTest { } MultivaluedMap queryParams = new MultivaluedStringMap(); - Pagination pageData = Pagination.parseQueryParams(queryParams, + PaginationImpl pageData = PaginationImpl.parseQueryParams(PaginationOverrideTest.class, + Optional.of(queryParams), new ElideSettingsBuilder(null) - .withDefaultPageSize(0) - .withDefaultMaxPageSize(0) + .withDefaultPageSize(1) + .withDefaultMaxPageSize(1) .build()); - assertEquals(0, pageData.getOffset()); - assertEquals(0, pageData.getLimit()); - Pagination result = pageData.evaluate(PaginationOverrideTest.class); assertEquals(0, pageData.getOffset()); - assertEquals(10, result.getLimit()); + assertEquals(10, pageData.getLimit()); } } diff --git a/elide-core/src/test/java/com/yahoo/elide/core/PermissionAnnotationTest.java b/elide-core/src/test/java/com/yahoo/elide/core/PermissionAnnotationTest.java index 080eebef53..bba3efc9c3 100644 --- a/elide-core/src/test/java/com/yahoo/elide/core/PermissionAnnotationTest.java +++ b/elide-core/src/test/java/com/yahoo/elide/core/PermissionAnnotationTest.java @@ -5,6 +5,7 @@ */ package com.yahoo.elide.core; +import static com.yahoo.elide.core.EntityDictionary.NO_VERSION; import static org.junit.jupiter.api.Assertions.assertThrows; import com.yahoo.elide.ElideSettings; @@ -17,11 +18,11 @@ import com.yahoo.elide.audit.TestAuditLogger; import com.yahoo.elide.core.exceptions.ForbiddenAccessException; import com.yahoo.elide.security.PermissionExecutor; +import com.yahoo.elide.security.TestUser; import com.yahoo.elide.security.User; import com.yahoo.elide.security.executors.ActivePermissionExecutor; import example.FunWithPermissions; -import example.TestCheckMappings; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; @@ -29,13 +30,13 @@ * Tests audit functions inside RecordDao. */ public class PermissionAnnotationTest { - private static final User GOOD_USER = new User(3); - private static final User BAD_USER = new User(-1); + private static final User GOOD_USER = new TestUser("3"); + private static final User BAD_USER = new TestUser("-1"); private static PersistentResource funRecord; private static PersistentResource badRecord; - private static EntityDictionary dictionary = new EntityDictionary(TestCheckMappings.MAPPINGS); + private static EntityDictionary dictionary = TestDictionary.getTestDictionary(); public PermissionAnnotationTest() { } @@ -55,9 +56,9 @@ public static void setup() { .withEntityDictionary(dictionary) .build(); - RequestScope goodScope = new RequestScope(null, null, null, GOOD_USER, null, elideSettings); + RequestScope goodScope = new RequestScope(null, NO_VERSION, null, null, GOOD_USER, null, elideSettings); funRecord = new PersistentResource<>(fun, null, goodScope.getUUIDFor(fun), goodScope); - RequestScope badScope = new RequestScope(null, null, null, BAD_USER, null, elideSettings); + RequestScope badScope = new RequestScope(null, NO_VERSION, null, null, BAD_USER, null, elideSettings); badRecord = new PersistentResource<>(fun, null, badScope.getUUIDFor(fun), badScope); } diff --git a/elide-core/src/test/java/com/yahoo/elide/core/PersistenceResourceTestSetup.java b/elide-core/src/test/java/com/yahoo/elide/core/PersistenceResourceTestSetup.java index 2692721f95..2a2cd69feb 100644 --- a/elide-core/src/test/java/com/yahoo/elide/core/PersistenceResourceTestSetup.java +++ b/elide-core/src/test/java/com/yahoo/elide/core/PersistenceResourceTestSetup.java @@ -5,6 +5,7 @@ */ package com.yahoo.elide.core; +import static com.yahoo.elide.core.EntityDictionary.NO_VERSION; import static org.mockito.Mockito.mock; import com.yahoo.elide.ElideSettings; @@ -13,12 +14,12 @@ import com.yahoo.elide.annotation.DeletePermission; import com.yahoo.elide.annotation.Include; import com.yahoo.elide.annotation.ReadPermission; -import com.yahoo.elide.annotation.SharePermission; import com.yahoo.elide.annotation.UpdatePermission; import com.yahoo.elide.audit.AuditLogger; -import com.yahoo.elide.core.filter.expression.FilterExpression; import com.yahoo.elide.jsonapi.models.JsonApiDocument; +import com.yahoo.elide.request.EntityProjection; import com.yahoo.elide.security.ChangeSpec; +import com.yahoo.elide.security.TestUser; import com.yahoo.elide.security.User; import com.yahoo.elide.security.checks.OperationCheck; @@ -42,14 +43,14 @@ import example.Parent; import example.Publisher; import example.Right; -import example.TestCheckMappings; import example.UpdateAndCreate; -import example.packageshareable.ContainerWithPackageShare; -import example.packageshareable.ShareableWithPackageShare; -import example.packageshareable.UnshareableWithEntityUnshare; +import example.nontransferable.ContainerWithPackageShare; +import example.nontransferable.ShareableWithPackageShare; +import example.nontransferable.Untransferable; import lombok.AllArgsConstructor; import lombok.EqualsAndHashCode; + import nocreate.NoCreateEntity; import java.util.Collection; @@ -71,7 +72,8 @@ public class PersistenceResourceTestSetup extends PersistentResource { protected final ElideSettings elideSettings; protected static EntityDictionary initDictionary() { - EntityDictionary dictionary = new EntityDictionary(TestCheckMappings.MAPPINGS); + EntityDictionary dictionary = TestDictionary.getTestDictionary(); + dictionary.bindEntity(UpdateAndCreate.class); dictionary.bindEntity(Author.class); dictionary.bindEntity(Book.class); @@ -97,7 +99,7 @@ protected static EntityDictionary initDictionary() { dictionary.bindEntity(ComputedBean.class); dictionary.bindEntity(ContainerWithPackageShare.class); dictionary.bindEntity(ShareableWithPackageShare.class); - dictionary.bindEntity(UnshareableWithEntityUnshare.class); + dictionary.bindEntity(Untransferable.class); return dictionary; } @@ -115,7 +117,7 @@ public PersistenceResourceTestSetup() { new Child(), null, null, // new request scope + new Child == cannot possibly be a UUID for this object - new RequestScope(null, null, null, null, null, + new RequestScope(null, NO_VERSION, null, null, null, null, initSettings() ) ); @@ -142,7 +144,7 @@ protected RequestScope buildRequestScope(DataStoreTransaction tx, User user) { } protected RequestScope buildRequestScope(String path, DataStoreTransaction tx, User user, MultivaluedMap queryParams) { - return new RequestScope(path, null, tx, user, queryParams, elideSettings); + return new RequestScope(path, NO_VERSION, null, tx, user, queryParams, elideSettings); } protected PersistentResource bootstrapPersistentResource(T obj) { @@ -150,13 +152,13 @@ protected PersistentResource bootstrapPersistentResource(T obj) { } protected PersistentResource bootstrapPersistentResource(T obj, DataStoreTransaction tx) { - User goodUser = new User(1); - RequestScope requestScope = new RequestScope(null, null, tx, goodUser, null, elideSettings); + User goodUser = new TestUser("1"); + RequestScope requestScope = new RequestScope(null, NO_VERSION, null, tx, goodUser, null, elideSettings); return new PersistentResource<>(obj, null, requestScope.getUUIDFor(obj), requestScope); } protected RequestScope getUserScope(User user, AuditLogger auditLogger) { - return new RequestScope(null, new JsonApiDocument(), null, user, null, + return new RequestScope(null, NO_VERSION, new JsonApiDocument(), null, user, null, new ElideSettingsBuilder(null) .withEntityDictionary(dictionary) .withAuditLogger(auditLogger) @@ -222,7 +224,6 @@ public ChangeSpecModel(final Function checkFunction) { @ReadPermission(expression = "allow all") @UpdatePermission(expression = "allow all") @DeletePermission(expression = "allow all") - @SharePermission public static final class ChangeSpecChild { @Id public long id; @@ -254,10 +255,17 @@ public boolean ok(Object object, com.yahoo.elide.security.RequestScope requestSc } } - public static Set getRelation(PersistentResource resource, String relation) { - Optional filterExpression = - resource.getRequestScope().getExpressionForRelation(resource, relation); + public Set getRelation(PersistentResource resource, String relation) { + return resource.getRelationCheckedFiltered(getRelationship(resource.getResourceClass(), relation)); + } - return resource.getRelationCheckedFiltered(relation, filterExpression, Optional.empty(), Optional.empty()); + public com.yahoo.elide.request.Relationship getRelationship(Class type, String name) { + return com.yahoo.elide.request.Relationship.builder() + .name(name) + .alias(name) + .projection(EntityProjection.builder() + .type(type) + .build()) + .build(); } } diff --git a/elide-core/src/test/java/com/yahoo/elide/core/PersistentResourceNoopUpdateTest.java b/elide-core/src/test/java/com/yahoo/elide/core/PersistentResourceNoopUpdateTest.java index b744857677..e998756626 100644 --- a/elide-core/src/test/java/com/yahoo/elide/core/PersistentResourceNoopUpdateTest.java +++ b/elide-core/src/test/java/com/yahoo/elide/core/PersistentResourceNoopUpdateTest.java @@ -5,6 +5,7 @@ */ package com.yahoo.elide.core; +import static com.yahoo.elide.core.EntityDictionary.NO_VERSION; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; @@ -13,6 +14,7 @@ import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; +import com.yahoo.elide.security.TestUser; import com.yahoo.elide.security.User; import example.Child; import example.FunWithPermissions; @@ -23,9 +25,11 @@ public class PersistentResourceNoopUpdateTest extends PersistenceResourceTestSetup { private final RequestScope goodUserScope; + private final User goodUser; PersistentResourceNoopUpdateTest() { - goodUserScope = new RequestScope(null, null, mock(DataStoreTransaction.class), - new User(1), null, elideSettings); + goodUser = new TestUser("1"); + goodUserScope = new RequestScope(null, NO_VERSION, null, + mock(DataStoreTransaction.class), goodUser, null, elideSettings); initDictionary(); reset(goodUserScope.getTransaction()); } @@ -35,11 +39,9 @@ public void testNOOPToOneAddRelation() { Child child = newChild(1); fun.setRelation3(child); - User goodUser = new User(1); - DataStoreTransaction tx = mock(DataStoreTransaction.class); - RequestScope goodScope = new RequestScope(null, null, tx, goodUser, null, elideSettings); + RequestScope goodScope = new RequestScope(null, NO_VERSION, null, tx, goodUser, null, elideSettings); PersistentResource funResource = new PersistentResource<>(fun, null, "3", goodScope); PersistentResource childResource = new PersistentResource<>(child, null, "1", goodScope); //We do not want the update to one method to be called when we add the existing entity to the relation @@ -53,11 +55,9 @@ public void testToOneAddRelation() { FunWithPermissions fun = new FunWithPermissions(); Child child = newChild(1); - User goodUser = new User(1); - DataStoreTransaction tx = mock(DataStoreTransaction.class); - RequestScope goodScope = new RequestScope(null, null, tx, goodUser, null, elideSettings); + RequestScope goodScope = new RequestScope(null, NO_VERSION, null, tx, goodUser, null, elideSettings); PersistentResource funResource = new PersistentResource<>(fun, null, "3", goodScope); PersistentResource childResource = new PersistentResource<>(child, null, "1", goodScope); funResource.addRelation("relation3", childResource); @@ -73,11 +73,9 @@ public void testNOOPToManyAddRelation() { children.add(child); fun.setRelation1(children); - User goodUser = new User(1); - DataStoreTransaction tx = mock(DataStoreTransaction.class); - RequestScope goodScope = new RequestScope(null, null, tx, goodUser, null, elideSettings); + RequestScope goodScope = new RequestScope(null, NO_VERSION, null, tx, goodUser, null, elideSettings); PersistentResource funResource = new PersistentResource<>(fun, null, "3", goodScope); PersistentResource childResource = new PersistentResource<>(child, null, null, goodScope); //We do not want the update to one method to be called when we add the existing entity to the relation @@ -90,11 +88,9 @@ public void testToManyAddRelation() { FunWithPermissions fun = new FunWithPermissions(); Child child = newChild(1); - User goodUser = new User(1); - DataStoreTransaction tx = mock(DataStoreTransaction.class); - RequestScope goodScope = new RequestScope(null, null, tx, goodUser, null, elideSettings); + RequestScope goodScope = new RequestScope(null, NO_VERSION, null, tx, goodUser, null, elideSettings); PersistentResource funResource = new PersistentResource<>(fun, null, "3", goodScope); PersistentResource childResource = new PersistentResource<>(child, null, null, goodScope); funResource.addRelation("relation1", childResource); diff --git a/elide-core/src/test/java/com/yahoo/elide/core/PersistentResourceTest.java b/elide-core/src/test/java/com/yahoo/elide/core/PersistentResourceTest.java index 06744d79a7..5937985c13 100644 --- a/elide-core/src/test/java/com/yahoo/elide/core/PersistentResourceTest.java +++ b/elide-core/src/test/java/com/yahoo/elide/core/PersistentResourceTest.java @@ -5,6 +5,7 @@ */ package com.yahoo.elide.core; +import static com.yahoo.elide.core.EntityDictionary.NO_VERSION; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotEquals; @@ -22,6 +23,7 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; + import com.yahoo.elide.annotation.Audit; import com.yahoo.elide.annotation.ReadPermission; import com.yahoo.elide.audit.LogMessage; @@ -38,7 +40,10 @@ import com.yahoo.elide.jsonapi.models.Relationship; import com.yahoo.elide.jsonapi.models.Resource; import com.yahoo.elide.jsonapi.models.ResourceIdentifier; +import com.yahoo.elide.request.Attribute; +import com.yahoo.elide.request.EntityProjection; import com.yahoo.elide.security.ChangeSpec; +import com.yahoo.elide.security.TestUser; import com.yahoo.elide.security.User; import com.google.common.collect.ImmutableMap; @@ -64,14 +69,16 @@ import example.Parent; import example.Right; import example.Shape; -import example.packageshareable.ContainerWithPackageShare; -import example.packageshareable.ShareableWithPackageShare; -import example.packageshareable.UnshareableWithEntityUnshare; +import example.nontransferable.ContainerWithPackageShare; +import example.nontransferable.ShareableWithPackageShare; +import example.nontransferable.Untransferable; import org.apache.commons.collections4.CollectionUtils; import org.apache.commons.collections4.IterableUtils; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.mockito.Answers; +import org.junit.jupiter.api.TestInstance; +import org.mockito.ArgumentCaptor; import nocreate.NoCreateEntity; @@ -92,19 +99,20 @@ import javax.ws.rs.core.MultivaluedHashMap; import javax.ws.rs.core.MultivaluedMap; - /** * Test PersistentResource. */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) public class PersistentResourceTest extends PersistenceResourceTestSetup { - private final RequestScope goodUserScope; - private final RequestScope badUserScope; + private final User goodUser = new TestUser("1"); + private final User badUser = new TestUser("-1"); + + private DataStoreTransaction tx = mock(DataStoreTransaction.class); - public PersistentResourceTest() { - goodUserScope = buildRequestScope(mock(DataStoreTransaction.class), new User(1)); - badUserScope = buildRequestScope(mock(DataStoreTransaction.class), new User(-1)); - reset(goodUserScope.getTransaction()); + @BeforeEach + public void beforeTest() { + reset(tx); } @Test @@ -112,10 +120,6 @@ public void testUpdateToOneRelationHookInAddRelation() { FunWithPermissions fun = new FunWithPermissions(); Child child = newChild(1); - User goodUser = new User(1); - - DataStoreTransaction tx = mock(DataStoreTransaction.class); - RequestScope goodScope = buildRequestScope(tx, goodUser); PersistentResource funResource = new PersistentResource<>(fun, null, "3", goodScope); PersistentResource childResource = new PersistentResource<>(child, null, "1", goodScope); @@ -131,9 +135,6 @@ public void testUpdateToOneRelationHookInUpdateRelation() { Child child1 = newChild(1); Child child2 = newChild(2); fun.setRelation1(Sets.newHashSet(child1)); - User goodUser = new User(1); - - DataStoreTransaction tx = mock(DataStoreTransaction.class); RequestScope goodScope = buildRequestScope(tx, goodUser); PersistentResource funResource = new PersistentResource<>(fun, null, "3", goodScope); @@ -150,9 +151,6 @@ public void testUpdateToOneRelationHookInRemoveRelation() { FunWithPermissions fun = new FunWithPermissions(); Child child = newChild(1); fun.setRelation3(child); - User goodUser = new User(1); - - DataStoreTransaction tx = mock(DataStoreTransaction.class); RequestScope goodScope = buildRequestScope(tx, goodUser); PersistentResource funResource = new PersistentResource<>(fun, null, "3", goodScope); @@ -168,10 +166,8 @@ public void testUpdateToOneRelationHookInClearRelation() { FunWithPermissions fun = new FunWithPermissions(); Child child1 = newChild(1); fun.setRelation3(child1); - User goodUser = new User(1); - DataStoreTransaction tx = mock(DataStoreTransaction.class); - when(tx.getRelation(any(), eq(fun), eq("relation3"), any(), any(), any(), any())).thenReturn(child1); + when(tx.getRelation(any(), eq(fun), any(), any())).thenReturn(child1); RequestScope goodScope = buildRequestScope(tx, goodUser); PersistentResource funResource = new PersistentResource<>(fun, null, "3", goodScope); @@ -186,10 +182,6 @@ public void testUpdateToManyRelationHookInAddRelationBidirection() { Parent parent = new Parent(); Child child = newChild(1); - User goodUser = new User(1); - - DataStoreTransaction tx = mock(DataStoreTransaction.class); - RequestScope goodScope = buildRequestScope(tx, goodUser); PersistentResource parentResource = new PersistentResource<>(parent, null, "3", goodScope); PersistentResource childResource = new PersistentResource<>(child, null, "1", goodScope); @@ -206,9 +198,6 @@ public void testUpdateToManyRelationHookInRemoveRelationBidirection() { Child child = newChild(1); parent.setChildren(Sets.newHashSet(child)); child.setParents(Sets.newHashSet(parent)); - User goodUser = new User(1); - - DataStoreTransaction tx = mock(DataStoreTransaction.class); RequestScope goodScope = buildRequestScope(tx, goodUser); PersistentResource parentResource = new PersistentResource<>(parent, null, "3", goodScope); @@ -230,10 +219,8 @@ public void testUpdateToManyRelationHookInClearRelationBidirection() { parent.setChildren(children); child1.setParents(Sets.newHashSet(parent)); child2.setParents(Sets.newHashSet(parent)); - User goodUser = new User(1); - DataStoreTransaction tx = mock(DataStoreTransaction.class); - when(tx.getRelation(any(), eq(parent), eq("children"), any(), any(), any(), any())).thenReturn(children); + when(tx.getRelation(any(), eq(parent), any(), any())).thenReturn(children); RequestScope goodScope = buildRequestScope(tx, goodUser); PersistentResource parentResource = new PersistentResource<>(parent, null, "3", goodScope); @@ -257,10 +244,8 @@ public void testUpdateToManyRelationHookInUpdateRelationBidirection() { parent.setChildren(children); child1.setParents(Sets.newHashSet(parent)); child2.setParents(Sets.newHashSet(parent)); - User goodUser = new User(1); - DataStoreTransaction tx = mock(DataStoreTransaction.class); - when(tx.getRelation(any(), eq(parent), eq("children"), any(), any(), any(), any())).thenReturn(children); + when(tx.getRelation(any(), eq(parent), any(), any())).thenReturn(children); RequestScope goodScope = buildRequestScope(tx, goodUser); PersistentResource parentResource = new PersistentResource<>(parent, null, "3", goodScope); @@ -281,14 +266,16 @@ public void testUpdateToManyRelationHookInUpdateRelationBidirection() { @Test public void testSetAttributeHookInUpdateAttribute() { Parent parent = newParent(1); - DataStoreTransaction tx = mock(DataStoreTransaction.class); - User goodUser = new User(1); + ArgumentCaptor attributeArgument = ArgumentCaptor.forClass(Attribute.class); RequestScope goodScope = buildRequestScope(tx, goodUser); PersistentResource parentResource = new PersistentResource<>(parent, null, "1", goodScope); parentResource.updateAttribute("firstName", "foobar"); - verify(tx, times(1)).setAttribute(parent, "firstName", "foobar", goodScope); + verify(tx, times(1)).setAttribute(eq(parent), attributeArgument.capture(), eq(goodScope)); + + assertEquals(attributeArgument.getValue().getName(), "firstName"); + assertEquals(attributeArgument.getValue().getArguments().iterator().next().getValue(), "foobar"); } @Test @@ -298,7 +285,9 @@ public void testGetRelationships() { fun.setRelation2(Sets.newHashSet()); fun.setRelation3(null); - PersistentResource funResource = new PersistentResource<>(fun, null, "3", goodUserScope); + RequestScope scope = new TestRequestScope(tx, goodUser, dictionary); + + PersistentResource funResource = new PersistentResource<>(fun, null, "3", scope); Map relationships = funResource.getRelationships(); @@ -309,7 +298,9 @@ public void testGetRelationships() { assertTrue(relationships.containsKey("relation4"), "relation4 should be present"); assertTrue(relationships.containsKey("relation5"), "relation5 should be present"); - PersistentResource funResourceWithBadScope = new PersistentResource<>(fun, null, "3", badUserScope); + scope = new TestRequestScope(tx, badUser, dictionary); + + PersistentResource funResourceWithBadScope = new PersistentResource<>(fun, null, "3", scope); relationships = funResourceWithBadScope.getRelationships(); assertEquals(0, relationships.size(), "All relationships should be filtered out"); @@ -319,8 +310,6 @@ public void testGetRelationships() { public void testNoCreate() { assertNotNull(dictionary); NoCreateEntity noCreate = new NoCreateEntity(); - DataStoreTransaction tx = mock(DataStoreTransaction.class); - User goodUser = new User(1); when(tx.createNewObject(NoCreateEntity.class)).thenReturn(noCreate); @@ -328,7 +317,7 @@ public void testNoCreate() { assertThrows( ForbiddenAccessException.class, () -> PersistentResource.createObject( - null, NoCreateEntity.class, goodScope, Optional.of("1"))); // should throw here + NoCreateEntity.class, goodScope, Optional.of("1"))); // should throw here } @Test @@ -339,7 +328,8 @@ public void testGetAttributes() { fun.setField2(null); fun.setField4("bar"); - PersistentResource funResource = new PersistentResource<>(fun, null, "3", goodUserScope); + RequestScope scope = new TestRequestScope(tx, goodUser, dictionary); + PersistentResource funResource = new PersistentResource<>(fun, null, "3", scope); Map attributes = funResource.getAttributes(); @@ -357,6 +347,7 @@ public void testGetAttributes() { assertEquals(attributes.get("field3"), "Foobar", "field3 should be set to original value."); assertEquals(attributes.get("field4"), "bar", "field4 should be set to original value."); + RequestScope badUserScope = new TestRequestScope(tx, badUser, dictionary); PersistentResource funResourceBad = new PersistentResource<>(fun, null, "3", badUserScope); attributes = funResourceBad.getAttributes(); @@ -377,10 +368,11 @@ public void testFilter() { Child child4 = newChild(-4); { - PersistentResource child1Resource = new PersistentResource<>(child1, null, "1", goodUserScope); - PersistentResource child2Resource = new PersistentResource<>(child2, null, "-2", goodUserScope); - PersistentResource child3Resource = new PersistentResource<>(child3, null, "3", goodUserScope); - PersistentResource child4Resource = new PersistentResource<>(child4, null, "-4", goodUserScope); + RequestScope scope = new TestRequestScope(tx, goodUser, dictionary); + PersistentResource child1Resource = new PersistentResource<>(child1, null, "1", scope); + PersistentResource child2Resource = new PersistentResource<>(child2, null, "-2", scope); + PersistentResource child3Resource = new PersistentResource<>(child3, null, "3", scope); + PersistentResource child4Resource = new PersistentResource<>(child4, null, "-4", scope); Set resources = Sets.newHashSet(child1Resource, child2Resource, child3Resource, child4Resource); @@ -392,10 +384,11 @@ public void testFilter() { } { - PersistentResource child1Resource = new PersistentResource<>(child1, null, "1", badUserScope); - PersistentResource child2Resource = new PersistentResource<>(child2, null, "-2", badUserScope); - PersistentResource child3Resource = new PersistentResource<>(child3, null, "3", badUserScope); - PersistentResource child4Resource = new PersistentResource<>(child4, null, "-4", badUserScope); + RequestScope scope = new TestRequestScope(tx, badUser, dictionary); + PersistentResource child1Resource = new PersistentResource<>(child1, null, "1", scope); + PersistentResource child2Resource = new PersistentResource<>(child2, null, "-2", scope); + PersistentResource child3Resource = new PersistentResource<>(child3, null, "3", scope); + PersistentResource child4Resource = new PersistentResource<>(child4, null, "-4", scope); Set resources = Sets.newHashSet(child1Resource, child2Resource, child3Resource, child4Resource); @@ -512,7 +505,8 @@ public void testDeleteBidirectionalRelation() { left.setOne2one(right); right.setOne2one(left); - PersistentResource leftResource = new PersistentResource<>(left, null, "3", goodUserScope); + RequestScope scope = new TestRequestScope(tx, goodUser, dictionary); + PersistentResource leftResource = new PersistentResource<>(left, null, "3", scope); leftResource.deleteInverseRelation("one2one", right); @@ -525,7 +519,8 @@ public void testDeleteBidirectionalRelation() { parent.setChildren(Sets.newHashSet(child)); parent.setSpouses(Sets.newHashSet()); - PersistentResource childResource = new PersistentResource<>(child, null, "4", goodUserScope); + scope = new TestRequestScope(tx, goodUser, dictionary); + PersistentResource childResource = new PersistentResource<>(child, null, "4", scope); childResource.deleteInverseRelation("parents", parent); @@ -538,7 +533,8 @@ public void testAddBidirectionalRelation() { Left left = new Left(); Right right = new Right(); - PersistentResource leftResource = new PersistentResource<>(left, null, "3", goodUserScope); + RequestScope scope = new TestRequestScope(tx, goodUser, dictionary); + PersistentResource leftResource = new PersistentResource<>(left, null, "3", scope); leftResource.addInverseRelation("one2one", right); @@ -550,7 +546,8 @@ public void testAddBidirectionalRelation() { parent.setChildren(Sets.newHashSet()); parent.setSpouses(Sets.newHashSet()); - PersistentResource childResource = new PersistentResource<>(child, null, "4", goodUserScope); + scope = new TestRequestScope(tx, goodUser, dictionary); + PersistentResource childResource = new PersistentResource<>(child, null, "4", scope); childResource.addInverseRelation("parents", parent); @@ -560,8 +557,6 @@ public void testAddBidirectionalRelation() { @Test public void testSuccessfulOneToOneRelationshipAdd() throws Exception { - User goodUser = new User(1); - DataStoreTransaction tx = mock(DataStoreTransaction.class); Left left = new Left(); Right right = new Right(); left.setId(2); @@ -573,13 +568,13 @@ public void testSuccessfulOneToOneRelationshipAdd() throws Exception { Relationship ids = new Relationship(null, new Data<>(new ResourceIdentifier("right", "3").castToResource())); - when(tx.loadObject(eq(Right.class), eq(3L), any(), any())).thenReturn(right); + when(tx.loadObject(any(), eq(3L), any())).thenReturn(right); boolean updated = leftResource.updateRelation("one2one", ids.toPersistentResources(goodScope)); goodScope.saveOrCreateObjects(); verify(tx, times(1)).save(left, goodScope); verify(tx, times(1)).save(right, goodScope); - verify(tx, times(1)).getRelation(tx, left, "one2one", Optional.empty(), Optional.empty(), Optional.empty(), - goodScope); + verify(tx, times(1)).getRelation(tx, left, getRelationship(Right.class, "one2one"), goodScope); + assertTrue(updated, "The one-2-one relationship should be added."); assertEquals(3, left.getOne2one().getId(), "The correct object was set in the one-2-one relationship"); } @@ -601,8 +596,6 @@ public void testSuccessfulOneToOneRelationshipAdd() throws Exception { */ @Test public void testSuccessfulOneToOneRelationshipAddNull() throws Exception { - User goodUser = new User(1); - DataStoreTransaction tx = mock(DataStoreTransaction.class, Answers.CALLS_REAL_METHODS); Left left = new Left(); left.setId(2); @@ -616,7 +609,7 @@ public void testSuccessfulOneToOneRelationshipAddNull() throws Exception { InvalidObjectIdentifierException.class, () -> leftResource.updateRelation("one2one", ids.toPersistentResources(goodScope))); - assertEquals("Unknown identifier 'null' for right", thrown.getMessage()); + assertEquals("Unknown identifier null for right", thrown.getMessage()); } @Test @@ -636,9 +629,6 @@ public void testSuccessfulOneToOneRelationshipAddNull() throws Exception { * final = (notMine) UNION requested */ public void testSuccessfulManyToManyRelationshipUpdate() throws Exception { - User goodUser = new User(1); - DataStoreTransaction tx = mock(DataStoreTransaction.class); - Parent parent = new Parent(); RequestScope goodScope = buildRequestScope(tx, goodUser); @@ -660,7 +650,7 @@ public void testSuccessfulManyToManyRelationshipUpdate() throws Exception { parent.setChildren(allChildren); parent.setSpouses(Sets.newHashSet()); - when(tx.getRelation(any(), eq(parent), eq("children"), any(), any(), any(), any())).thenReturn(allChildren); + when(tx.getRelation(any(), eq(parent), any(), any())).thenReturn(allChildren); PersistentResource parentResource = new PersistentResource<>(parent, null, "1", goodScope); @@ -670,12 +660,11 @@ public void testSuccessfulManyToManyRelationshipUpdate() throws Exception { idList.add(new ResourceIdentifier("child", "6").castToResource()); Relationship ids = new Relationship(null, new Data<>(idList)); - - when(tx.loadObject(eq(Child.class), eq(2L), any(), any())).thenReturn(child2); - when(tx.loadObject(eq(Child.class), eq(3L), any(), any())).thenReturn(child3); - when(tx.loadObject(eq(Child.class), eq(-4L), any(), any())).thenReturn(child4); - when(tx.loadObject(eq(Child.class), eq(-5L), any(), any())).thenReturn(child5); - when(tx.loadObject(eq(Child.class), eq(6L), any(), any())).thenReturn(child6); + when(tx.loadObject(any(), eq(2L), any())).thenReturn(child2); + when(tx.loadObject(any(), eq(3L), any())).thenReturn(child3); + when(tx.loadObject(any(), eq(-4L), any())).thenReturn(child4); + when(tx.loadObject(any(), eq(-5L), any())).thenReturn(child5); + when(tx.loadObject(any(), eq(6L), any())).thenReturn(child6); //Final set after operation = (3,4,5,6) Set expected = new HashSet<>(); @@ -711,11 +700,12 @@ public void testSuccessfulManyToManyRelationshipUpdate() throws Exception { */ @Test public void testRelationshipMissingData() throws Exception { - User goodUser = new User(1); + User goodUser = new TestUser("1"); + @SuppressWarnings("resource") DataStoreTransaction tx = mock(DataStoreTransaction.class); - RequestScope goodScope = new RequestScope(null, null, tx, goodUser, null, elideSettings); + RequestScope goodScope = new RequestScope(null, NO_VERSION, null, tx, goodUser, null, elideSettings); // null resource in toMany relationship is not valid List idList = new ArrayList<>(); @@ -743,7 +733,9 @@ public void testGetAttributeSuccess() { fun.setField2("blah"); fun.setField3(null); - PersistentResource funResource = new PersistentResource<>(fun, null, "1", goodUserScope); + RequestScope scope = new TestRequestScope(tx, goodUser, dictionary); + + PersistentResource funResource = new PersistentResource<>(fun, null, "1", scope); String result = (String) funResource.getAttribute("field2"); assertEquals("blah", result, "The correct attribute should be returned."); @@ -755,7 +747,9 @@ public void testGetAttributeSuccess() { public void testGetAttributeInvalidField() { FunWithPermissions fun = new FunWithPermissions(); - PersistentResource funResource = new PersistentResource<>(fun, null, "1", goodUserScope); + RequestScope scope = new TestRequestScope(tx, goodUser, dictionary); + + PersistentResource funResource = new PersistentResource<>(fun, null, "1", scope); assertThrows(InvalidAttributeException.class, () -> funResource.getAttribute("invalid")); } @@ -765,7 +759,9 @@ public void testGetAttributeInvalidFieldPermissions() { FunWithPermissions fun = new FunWithPermissions(); fun.setField1("foo"); - PersistentResource funResource = new PersistentResource<>(fun, null, "1", goodUserScope); + RequestScope scope = new TestRequestScope(tx, goodUser, dictionary); + + PersistentResource funResource = new PersistentResource<>(fun, null, "1", scope); assertThrows(ForbiddenAccessException.class, () -> funResource.getAttribute("field1")); } @@ -774,7 +770,9 @@ public void testGetAttributeInvalidFieldPermissions() { public void testGetAttributeInvalidEntityPermissions() { NoReadEntity noread = new NoReadEntity(); - PersistentResource noreadResource = new PersistentResource<>(noread, null, "1", goodUserScope); + RequestScope scope = new TestRequestScope(tx, goodUser, dictionary); + + PersistentResource noreadResource = new PersistentResource<>(noread, null, "1", scope); assertThrows(ForbiddenAccessException.class, () -> noreadResource.getAttribute("field")); } @@ -788,9 +786,10 @@ public void testGetRelationSuccess() { Set children = Sets.newHashSet(child1, child2, child3); fun.setRelation2(children); - PersistentResource funResource = new PersistentResource<>(fun, null, "3", goodUserScope); + RequestScope scope = new TestRequestScope(tx, goodUser, dictionary); + PersistentResource funResource = new PersistentResource<>(fun, null, "3", scope); - when(goodUserScope.getTransaction().getRelation(any(), eq(fun), eq("relation2"), any(), any(), any(), any())).thenReturn(children); + when(scope.getTransaction().getRelation(any(), eq(fun), any(), any())).thenReturn(children); Set results = getRelation(funResource, "relation2"); @@ -807,9 +806,10 @@ public void testGetRelationFilteredSuccess() { Set children = Sets.newHashSet(child1, child2, child3); fun.setRelation2(Sets.newHashSet(child1, child2, child3)); - PersistentResource funResource = new PersistentResource<>(fun, null, "3", goodUserScope); + RequestScope scope = new TestRequestScope(tx, goodUser, dictionary); + PersistentResource funResource = new PersistentResource<>(fun, null, "3", scope); - when(goodUserScope.getTransaction().getRelation(any(), eq(fun), eq("relation2"), any(), any(), any(), any())).thenReturn(children); + when(scope.getTransaction().getRelation(any(), eq(fun), any(), any())).thenReturn(children); Set results = getRelation(funResource, "relation2"); @@ -824,9 +824,7 @@ public void testGetRelationWithPredicateSuccess() { Child child3 = newChild(3, "chris smith"); parent.setChildren(Sets.newHashSet(child1, child2, child3)); - DataStoreTransaction tx = mock(DataStoreTransaction.class); - when(tx.getRelation(eq(tx), any(), any(), any(), any(), any(), any())).thenReturn(Sets.newHashSet(child1)); - User goodUser = new User(1); + when(tx.getRelation(eq(tx), any(), any(), any())).thenReturn(Sets.newHashSet(child1)); MultivaluedMap queryParams = new MultivaluedHashMap<>(); queryParams.add("filter[child.name]", "paul john"); @@ -850,11 +848,12 @@ public void testGetSingleRelationInMemory() { Set children = Sets.newHashSet(child1, child2, child3); parent.setChildren(children); - when(goodUserScope.getTransaction().getRelation(any(), eq(parent), eq("children"), any(), any(), any(), any())).thenReturn(children); + RequestScope scope = new TestRequestScope(tx, goodUser, dictionary); + when(scope.getTransaction().getRelation(any(), eq(parent), any(), any())).thenReturn(children); - PersistentResource parentResource = new PersistentResource<>(parent, null, "1", goodUserScope); + PersistentResource parentResource = new PersistentResource<>(parent, null, "1", scope); - PersistentResource childResource = parentResource.getRelation("children", "2"); + PersistentResource childResource = parentResource.getRelation(getRelationship(Parent.class, "children"), "2"); assertEquals("2", childResource.getId()); assertEquals("john buzzard", ((Child) childResource.getObject()).getName()); @@ -864,7 +863,9 @@ public void testGetSingleRelationInMemory() { public void testGetRelationForbiddenByEntity() { NoReadEntity noread = new NoReadEntity(); - PersistentResource noreadResource = new PersistentResource<>(noread, null, "3", goodUserScope); + RequestScope scope = new TestRequestScope(tx, badUser, dictionary); + + PersistentResource noreadResource = new PersistentResource<>(noread, null, "3", scope); assertThrows(ForbiddenAccessException.class, () -> getRelation(noreadResource, "child")); } @@ -872,7 +873,9 @@ public void testGetRelationForbiddenByEntity() { public void testGetRelationForbiddenByField() { FunWithPermissions fun = new FunWithPermissions(); - PersistentResource funResource = new PersistentResource<>(fun, null, "3", badUserScope); + RequestScope scope = new TestRequestScope(tx, badUser, dictionary); + + PersistentResource funResource = new PersistentResource<>(fun, null, "3", scope); assertThrows(ForbiddenAccessException.class, () -> getRelation(funResource, "relation1")); } @@ -881,6 +884,8 @@ public void testGetRelationForbiddenByField() { public void testGetRelationForbiddenByEntityAllowedByField() { FirstClassFields firstClassFields = new FirstClassFields(); + RequestScope badUserScope = new TestRequestScope(tx, badUser, dictionary); + PersistentResource fcResource = new PersistentResource<>(firstClassFields, null, "3", badUserScope); getRelation(fcResource, "public2"); @@ -890,6 +895,8 @@ public void testGetRelationForbiddenByEntityAllowedByField() { public void testGetAttributeForbiddenByEntityAllowedByField() { FirstClassFields firstClassFields = new FirstClassFields(); + RequestScope badUserScope = new TestRequestScope(tx, badUser, dictionary); + PersistentResource fcResource = new PersistentResource<>(firstClassFields, null, "3", badUserScope); fcResource.getAttribute("public1"); @@ -899,6 +906,8 @@ public void testGetAttributeForbiddenByEntityAllowedByField() { public void testGetRelationForbiddenByEntity2() { FirstClassFields firstClassFields = new FirstClassFields(); + RequestScope badUserScope = new TestRequestScope(tx, badUser, dictionary); + PersistentResource fcResource = new PersistentResource<>(firstClassFields, null, "3", badUserScope); assertThrows(ForbiddenAccessException.class, () -> getRelation(fcResource, "private2")); @@ -908,8 +917,10 @@ public void testGetRelationForbiddenByEntity2() { public void testGetAttributeForbiddenByEntity2() { FirstClassFields firstClassFields = new FirstClassFields(); + RequestScope scope = new TestRequestScope(tx, goodUser, dictionary); + PersistentResource fcResource = new PersistentResource<>(firstClassFields, - null, "3", goodUserScope); + null, "3", scope); assertThrows(ForbiddenAccessException.class, () -> fcResource.getAttribute("private1")); } @@ -918,7 +929,9 @@ public void testGetAttributeForbiddenByEntity2() { public void testGetRelationInvalidRelation() { FunWithPermissions fun = new FunWithPermissions(); - PersistentResource funResource = new PersistentResource<>(fun, null, "3", goodUserScope); + RequestScope scope = new TestRequestScope(tx, goodUser, dictionary); + + PersistentResource funResource = new PersistentResource<>(fun, null, "3", scope); assertThrows(InvalidAttributeException.class, () -> getRelation(funResource, "invalid")); } @@ -931,16 +944,12 @@ public void testGetRelationByIdSuccess() { Child child3 = newChild(3); fun.setRelation2(Sets.newHashSet(child1, child2, child3)); - User goodUser = new User(1); - - DataStoreTransaction tx = mock(DataStoreTransaction.class); - - when(tx.getRelation(eq(tx), any(), any(), any(), any(), any(), any())).thenReturn(Sets.newHashSet(child1)); + when(tx.getRelation(eq(tx), any(), any(), any())).thenReturn(Sets.newHashSet(child1)); RequestScope goodScope = buildRequestScope(tx, goodUser); PersistentResource funResource = new PersistentResource<>(fun, null, "3", goodScope); - PersistentResource result = funResource.getRelation("relation2", "1"); + PersistentResource result = funResource.getRelation(getRelationship(FunWithPermissions.class, "relation2"), "1"); assertEquals(1, ((Child) result.getObject()).getId(), "The correct relationship element should be returned"); } @@ -953,23 +962,22 @@ public void testGetRelationByInvalidId() { Child child3 = newChild(3); fun.setRelation2(Sets.newHashSet(child1, child2, child3)); - User goodUser = new User(1); - - DataStoreTransaction tx = mock(DataStoreTransaction.class); - - when(tx.getRelation(eq(tx), any(), any(), any(), any(), any(), any())).thenReturn(Sets.newHashSet(child1)); + when(tx.getRelation(eq(tx), any(), any(), any())).thenReturn(Sets.newHashSet(child1)); RequestScope goodScope = buildRequestScope(tx, goodUser); PersistentResource funResource = new PersistentResource<>(fun, null, "3", goodScope); - assertThrows(InvalidObjectIdentifierException.class, () -> funResource.getRelation("relation2", "-1000")); + assertThrows(InvalidObjectIdentifierException.class, + () -> funResource.getRelation(getRelationship(FunWithPermissions.class, "relation2"), "-1000")); } @Test public void testGetRelationsNoEntityAccess() { FunWithPermissions fun = new FunWithPermissions(); - PersistentResource funResource = new PersistentResource<>(fun, null, "3", goodUserScope); + RequestScope scope = new TestRequestScope(tx, goodUser, dictionary); + + PersistentResource funResource = new PersistentResource<>(fun, null, "3", scope); Set set = getRelation(funResource, "relation4"); assertEquals(0, set.size()); @@ -979,7 +987,9 @@ public void testGetRelationsNoEntityAccess() { public void testGetRelationsNoEntityAccess2() { FunWithPermissions fun = new FunWithPermissions(); - PersistentResource funResource = new PersistentResource<>(fun, null, "3", goodUserScope); + RequestScope scope = new TestRequestScope(tx, goodUser, dictionary); + + PersistentResource funResource = new PersistentResource<>(fun, null, "3", scope); Set set = getRelation(funResource, "relation5"); assertEquals(0, set.size()); @@ -989,8 +999,6 @@ public void testGetRelationsNoEntityAccess2() { void testDeleteResourceSuccess() { Parent parent = newParent(1); - User goodUser = new User(1); - DataStoreTransaction tx = mock(DataStoreTransaction.class); RequestScope goodScope = buildRequestScope(tx, goodUser); PersistentResource parentResource = new PersistentResource<>(parent, null, "1", goodScope); @@ -1009,8 +1017,6 @@ void testDeleteCascades() { invoice.setItems(Sets.newHashSet(item)); item.setInvoice(invoice); - User goodUser = new User(1); - DataStoreTransaction tx = mock(DataStoreTransaction.class); RequestScope goodScope = buildRequestScope(tx, goodUser); PersistentResource invoiceResource = new PersistentResource<>(invoice, null, "1", goodScope); @@ -1036,9 +1042,7 @@ void testDeleteResourceUpdateRelationshipSuccess() { assertFalse(parent.getChildren().isEmpty()); - User goodUser = new User(1); - DataStoreTransaction tx = mock(DataStoreTransaction.class); - when(tx.getRelation(any(), eq(child), eq("parents"), any(), any(), any(), any())).thenReturn(parents); + when(tx.getRelation(any(), eq(child), any(), any())).thenReturn(parents); RequestScope goodScope = buildRequestScope(tx, goodUser); @@ -1058,11 +1062,7 @@ void testDeleteResourceForbidden() { NoDeleteEntity nodelete = new NoDeleteEntity(); nodelete.setId(1); - DataStoreTransaction tx = mock(DataStoreTransaction.class); - User goodUser = new User(1); - RequestScope goodScope = buildRequestScope(tx, goodUser); - PersistentResource nodeleteResource = new PersistentResource<>(nodelete, null, "1", goodScope); assertThrows(ForbiddenAccessException.class, nodeleteResource::deleteResource); @@ -1077,9 +1077,6 @@ void testAddRelationSuccess() { Child child = newChild(1); - User goodUser = new User(1); - - DataStoreTransaction tx = mock(DataStoreTransaction.class); RequestScope goodScope = buildRequestScope(tx, goodUser); PersistentResource funResource = new PersistentResource<>(fun, null, "3", goodScope); @@ -1100,9 +1097,6 @@ void testAddRelationForbiddenByField() { Child child = newChild(1); - User badUser = new User(-1); - - DataStoreTransaction tx = mock(DataStoreTransaction.class); RequestScope badScope = buildRequestScope(tx, badUser); PersistentResource funResource = new PersistentResource<>(fun, null, "3", badScope); PersistentResource childResource = new PersistentResource<>(child, null, "1", badScope); @@ -1116,9 +1110,6 @@ void testAddRelationForbiddenByEntity() { Child child = newChild(2); noUpdate.setChildren(Sets.newHashSet()); - DataStoreTransaction tx = mock(DataStoreTransaction.class); - User goodUser = new User(1); - RequestScope goodScope = buildRequestScope(tx, goodUser); PersistentResource noUpdateResource = new PersistentResource<>(noUpdate, null, "1", goodScope); PersistentResource childResource = new PersistentResource<>(child, null, "2", goodScope); @@ -1131,9 +1122,6 @@ public void testAddRelationInvalidRelation() { Child child = newChild(1); - User goodUser = new User(1); - - DataStoreTransaction tx = mock(DataStoreTransaction.class); RequestScope goodScope = buildRequestScope(tx, goodUser); PersistentResource funResource = new PersistentResource<>(fun, null, "3", goodScope); PersistentResource childResource = new PersistentResource<>(child, null, "1", goodScope); @@ -1148,8 +1136,6 @@ public void testRemoveToManyRelationSuccess() { Parent parent3 = newParent(3, child); child.setParents(Sets.newHashSet(parent1, parent2, parent3)); - DataStoreTransaction tx = mock(DataStoreTransaction.class); - User goodUser = new User(1); RequestScope goodScope = buildRequestScope(tx, goodUser); PersistentResource childResource = new PersistentResource<>(child, null, "1", goodScope); PersistentResource removeResource = new PersistentResource<>(parent1, null, "1", goodScope); @@ -1173,8 +1159,6 @@ public void testRemoveToOneRelationSuccess() { Child child = newChild(1); fun.setRelation3(child); - DataStoreTransaction tx = mock(DataStoreTransaction.class); - User goodUser = new User(1); RequestScope goodScope = buildRequestScope(tx, goodUser); PersistentResource funResource = new PersistentResource<>(fun, null, "1", goodScope); @@ -1208,19 +1192,47 @@ public void testNoSaveNonModifications() { child.setReadNoAccess(secret); - DataStoreTransaction tx = mock(DataStoreTransaction.class); - when(tx.getRelation(any(), eq(fun), eq("relation3"), any(), any(), any(), any())).thenReturn(child); - when(tx.getRelation(any(), eq(fun), eq("relation1"), any(), any(), any(), any())).thenReturn(children1); - when(tx.getRelation(any(), eq(parent), eq("children"), any(), any(), any(), any())).thenReturn(children2); - when(tx.getRelation(any(), eq(child), eq("readNoAccess"), any(), any(), any(), any())).thenReturn(secret); - - User goodUser = new User(1); - RequestScope goodScope = buildRequestScope(tx, goodUser); - - PersistentResource funResource = new PersistentResource<>(fun, null, "1", goodScope); - PersistentResource childResource = new PersistentResource<>(child, null, "1", goodScope); - PersistentResource secretResource = new PersistentResource<>(secret, null, "1", goodScope); - PersistentResource parentResource = new PersistentResource<>(parent, null, "1", goodScope); + when(tx.getRelation(any(), eq(fun), eq(com.yahoo.elide.request.Relationship.builder() + .name("relation3") + .alias("relation3") + .projection(EntityProjection.builder() + .type(Child.class) + .build()) + .build()), any())).thenReturn(child); + + when(tx.getRelation(any(), eq(fun), eq(com.yahoo.elide.request.Relationship.builder() + .name("relation1") + .alias("relation1") + .projection(EntityProjection.builder() + .type(Child.class) + .build()) + .build()), any())).thenReturn(children1); + + when(tx.getRelation(any(), eq(parent), eq(com.yahoo.elide.request.Relationship.builder() + .name("children") + .alias("children") + .projection(EntityProjection.builder() + .type(Child.class) + .build()) + .build()), any())).thenReturn(children2); + + when(tx.getRelation(any(), eq(child), eq(com.yahoo.elide.request.Relationship.builder() + .name("readNoAccess") + .alias("readNoAccess") + .projection(EntityProjection.builder() + .type(Child.class) + .build()) + .build()), any())).thenReturn(secret); + + RequestScope funScope = new TestRequestScope(tx, goodUser, dictionary); + RequestScope childScope = new TestRequestScope(tx, goodUser, dictionary); + RequestScope parentScope = new TestRequestScope(tx, goodUser, dictionary); + + + PersistentResource funResource = new PersistentResource<>(fun, null, "1", funScope); + PersistentResource childResource = new PersistentResource<>(child, null, "1", childScope); + PersistentResource secretResource = new PersistentResource<>(secret, null, "1", childScope); + PersistentResource parentResource = new PersistentResource<>(parent, null, "1", parentScope); // Add an existing to-one relationship funResource.addRelation("relation3", childResource); @@ -1252,11 +1264,13 @@ public void testNoSaveNonModifications() { // Clear empty to-one relation secretResource.clearRelation("readNoAccess"); - goodScope.saveOrCreateObjects(); - verify(tx, never()).save(fun, goodScope); - verify(tx, never()).save(child, goodScope); - verify(tx, never()).save(parent, goodScope); - verify(tx, never()).save(secret, goodScope); + parentScope.saveOrCreateObjects(); + childScope.saveOrCreateObjects(); + funScope.saveOrCreateObjects(); + verify(tx, never()).save(fun, funScope); + verify(tx, never()).save(child, childScope); + verify(tx, never()).save(parent, parentScope); + verify(tx, never()).save(secret, childScope); } @Test() @@ -1266,8 +1280,6 @@ public void testRemoveNonexistingToOneRelation() { Child unownedChild = newChild(2); fun.setRelation3(ownedChild); - DataStoreTransaction tx = mock(DataStoreTransaction.class); - User goodUser = new User(1); RequestScope goodScope = buildRequestScope(tx, goodUser); PersistentResource funResource = new PersistentResource<>(fun, null, "1", goodScope); @@ -1291,8 +1303,6 @@ public void testRemoveNonexistingToManyRelation() { Parent unownedParent = newParent(4, null); - DataStoreTransaction tx = mock(DataStoreTransaction.class); - User goodUser = new User(1); RequestScope goodScope = buildRequestScope(tx, goodUser); PersistentResource childResource = new PersistentResource<>(child, null, "1", goodScope); PersistentResource removeResource = new PersistentResource<>(unownedParent, null, "1", goodScope); @@ -1318,11 +1328,16 @@ public void testClearToManyRelationSuccess() { Set parents = Sets.newHashSet(parent1, parent2, parent3); child.setParents(parents); - DataStoreTransaction tx = mock(DataStoreTransaction.class); - when(tx.getRelation(any(), eq(child), eq("parents"), any(), any(), any(), any())).thenReturn(parents); + when(tx.getRelation(any(), eq(child), any(), any())).thenReturn(parents); - User goodUser = new User(1); RequestScope goodScope = buildRequestScope(tx, goodUser); + goodScope.setEntityProjection(EntityProjection.builder() + .type(Child.class) + .relationship("parents", + EntityProjection.builder() + .type(Parent.class) + .build()) + .build()); PersistentResource childResource = new PersistentResource<>(child, null, "1", goodScope); @@ -1346,12 +1361,17 @@ public void testClearToOneRelationSuccess() { Child child = newChild(1); fun.setRelation3(child); - DataStoreTransaction tx = mock(DataStoreTransaction.class); - - when(tx.getRelation(any(), eq(fun), eq("relation3"), any(), any(), any(), any())).thenReturn(child); + when(tx.getRelation(any(), eq(fun), any(), any())).thenReturn(child); - User goodUser = new User(1); RequestScope goodScope = buildRequestScope(tx, goodUser); + goodScope.setEntityProjection(EntityProjection.builder() + .type(FunWithPermissions.class) + .relationship("relation3", + EntityProjection.builder() + .type(Child.class) + .build()) + .build()); + PersistentResource funResource = new PersistentResource<>(fun, null, "1", goodScope); funResource.clearRelation("relation3"); @@ -1364,11 +1384,17 @@ public void testClearToOneRelationSuccess() { @Test() public void testClearRelationFilteredByReadAccess() { - User goodUser = new User(1); - DataStoreTransaction tx = mock(DataStoreTransaction.class); Parent parent = new Parent(); RequestScope goodScope = buildRequestScope(tx, goodUser); + goodScope.setEntityProjection(EntityProjection.builder() + .type(Parent.class) + .relationship("children", + EntityProjection.builder() + .type(Child.class) + .build()) + .build()); + Child child1 = newChild(1); Child child2 = newChild(2); Child child3 = newChild(3); @@ -1377,7 +1403,6 @@ public void testClearRelationFilteredByReadAccess() { Child child5 = newChild(-5); child5.setId(-5); //Not accessible to goodUser - //All = (1,2,3,4,5) //Mine = (1,2,3) Set allChildren = new HashSet<>(); @@ -1389,7 +1414,7 @@ public void testClearRelationFilteredByReadAccess() { parent.setChildren(allChildren); parent.setSpouses(Sets.newHashSet()); - when(tx.getRelation(any(), eq(parent), eq("children"), any(), any(), any(), any())).thenReturn(allChildren); + when(tx.getRelation(any(), eq(parent), any(), any())).thenReturn(allChildren); PersistentResource parentResource = new PersistentResource<>(parent, null, "1", goodScope); @@ -1427,9 +1452,15 @@ public void testClearRelationInvalidToOneUpdatePermission() { left.setNoUpdateOne2One(right); right.setNoUpdateOne2One(left); - DataStoreTransaction tx = mock(DataStoreTransaction.class); - User goodUser = new User(1); RequestScope goodScope = buildRequestScope(tx, goodUser); + goodScope.setEntityProjection(EntityProjection.builder() + .type(Left.class) + .relationship("noUpdateOne2One", + EntityProjection.builder() + .type(Right.class) + .build()) + .build()); + PersistentResource leftResource = new PersistentResource<>(left, null, "1", goodScope); assertThrows( @@ -1448,8 +1479,6 @@ public void testNoChangeRelationInvalidToOneUpdatePermission() { left.setNoUpdateOne2One(right); right.setNoUpdateOne2One(left); - DataStoreTransaction tx = mock(DataStoreTransaction.class); - User goodUser = new User(1); RequestScope goodScope = buildRequestScope(tx, goodUser); PersistentResource leftResource = new PersistentResource<>(left, null, "1", goodScope); @@ -1474,12 +1503,17 @@ public void testClearRelationInvalidToManyUpdatePermission() { right1.setNoUpdate(Sets.newHashSet(left)); right2.setNoUpdate(Sets.newHashSet(left)); - DataStoreTransaction tx = mock(DataStoreTransaction.class); + when(tx.getRelation(any(), eq(left), any(), any())).thenReturn(noInverseUpdate); - when(tx.getRelation(any(), eq(left), eq("noInverseUpdate"), any(), any(), any(), any())).thenReturn(noInverseUpdate); - - User goodUser = new User(1); RequestScope goodScope = buildRequestScope(tx, goodUser); + goodScope.setEntityProjection(EntityProjection.builder() + .type(Left.class) + .relationship("noInverseUpdate", + EntityProjection.builder() + .type(Right.class) + .build()) + .build()); + PersistentResource leftResource = new PersistentResource<>(left, null, "1", goodScope); assertThrows( @@ -1497,11 +1531,17 @@ public void testClearRelationInvalidToOneDeletePermission() { noDelete.setId(1); left.setNoDeleteOne2One(noDelete); - DataStoreTransaction tx = mock(DataStoreTransaction.class); + when(tx.getRelation(any(), eq(left), any(), any())).thenReturn(noDelete); - when(tx.getRelation(any(), eq(left), eq("noDeleteOne2One"), any(), any(), any(), any())).thenReturn(noDelete); - User goodUser = new User(1); RequestScope goodScope = buildRequestScope(tx, goodUser); + goodScope.setEntityProjection(EntityProjection.builder() + .type(Left.class) + .relationship("noDeleteOne2One", + EntityProjection.builder() + .type(NoDeleteEntity.class) + .build()) + .build()); + PersistentResource leftResource = new PersistentResource<>(left, null, "1", goodScope); assertTrue(leftResource.clearRelation("noDeleteOne2One")); assertNull(leftResource.getObject().getNoDeleteOne2One()); @@ -1514,8 +1554,6 @@ public void testClearRelationInvalidRelation() { Child child = newChild(1); fun.setRelation3(child); - DataStoreTransaction tx = mock(DataStoreTransaction.class); - User goodUser = new User(1); RequestScope goodScope = buildRequestScope(tx, goodUser); PersistentResource funResource = new PersistentResource<>(fun, null, "1", goodScope); assertThrows(InvalidAttributeException.class, () -> funResource.clearRelation("invalid")); @@ -1525,9 +1563,6 @@ public void testClearRelationInvalidRelation() { public void testUpdateAttributeSuccess() { Parent parent = newParent(1); - DataStoreTransaction tx = mock(DataStoreTransaction.class); - User goodUser = new User(1); - RequestScope goodScope = buildRequestScope(tx, goodUser); PersistentResource parentResource = new PersistentResource<>(parent, null, "1", goodScope); parentResource.updateAttribute("firstName", "foobar"); @@ -1542,9 +1577,6 @@ public void testUpdateAttributeSuccess() { public void testUpdateAttributeInvalidAttribute() { Parent parent = newParent(1); - DataStoreTransaction tx = mock(DataStoreTransaction.class); - User goodUser = new User(1); - RequestScope goodScope = buildRequestScope(tx, goodUser); PersistentResource parentResource = new PersistentResource<>(parent, null, "1", goodScope); assertThrows(InvalidAttributeException.class, () -> parentResource.updateAttribute("invalid", "foobar")); @@ -1556,9 +1588,6 @@ public void testUpdateAttributeInvalidUpdatePermission() { fun.setId(1); - DataStoreTransaction tx = mock(DataStoreTransaction.class); - User badUser = new User(-1); - RequestScope badScope = buildRequestScope(tx, badUser); PersistentResource funResource = new PersistentResource<>(fun, null, "1", badScope); @@ -1575,11 +1604,8 @@ public void testUpdateAttributeInvalidUpdatePermissionNoChange() { FunWithPermissions fun = new FunWithPermissions(); fun.setId(1); - DataStoreTransaction tx = mock(DataStoreTransaction.class); - User badUser = new User(-1); RequestScope badScope = buildRequestScope(tx, badUser); - PersistentResource funResource = new PersistentResource<>(fun, null, "1", badScope); assertThrows( @@ -1597,15 +1623,20 @@ public void testLoadRecords() { Child child4 = newChild(4); Child child5 = newChild(5); - DataStoreTransaction tx = mock(DataStoreTransaction.class); - User goodUser = new User(1); + EntityProjection collection = EntityProjection.builder() + .type(Child.class) + + .build(); - when(tx.loadObjects(eq(Child.class), any(), any(), any(), any(RequestScope.class))) + when(tx.loadObjects(eq(collection), any(RequestScope.class))) .thenReturn(Lists.newArrayList(child1, child2, child3, child4, child5)); RequestScope goodScope = buildRequestScope(tx, goodUser); - Set loaded = PersistentResource.loadRecords(Child.class, new ArrayList<>(), - Optional.empty(), Optional.empty(), Optional.empty(), goodScope); + goodScope.setEntityProjection(collection); + + Set loaded = PersistentResource.loadRecords(EntityProjection.builder() + .type(Child.class) + .build(), new ArrayList<>(), goodScope); Set expected = Sets.newHashSet(child1, child4, child5); @@ -1623,55 +1654,68 @@ public void testLoadRecords() { public void testLoadRecordSuccess() { Child child1 = newChild(1); - DataStoreTransaction tx = mock(DataStoreTransaction.class); - User goodUser = new User(1); + EntityProjection collection = EntityProjection.builder() + .type(Child.class) - when(tx.loadObject(eq(Child.class), eq(1L), any(), any())).thenReturn(child1); + .build(); + + when(tx.loadObject(eq(collection), eq(1L), any())).thenReturn(child1); RequestScope goodScope = buildRequestScope(tx, goodUser); - PersistentResource loaded = PersistentResource.loadRecord(Child.class, "1", goodScope); + goodScope.setEntityProjection(collection); + PersistentResource loaded = PersistentResource.loadRecord(EntityProjection.builder() + .type(Child.class) + .build(), "1", goodScope); assertEquals(child1, loaded.getObject(), "The load function should return the requested child object"); } @Test public void testLoadRecordInvalidId() { - DataStoreTransaction tx = mock(DataStoreTransaction.class); - User goodUser = new User(1); + EntityProjection collection = EntityProjection.builder() + .type(Child.class) - when(tx.loadObject(eq(Child.class), eq("1"), any(), any())).thenReturn(null); + .build(); + + when(tx.loadObject(eq(collection), eq("1"), any())).thenReturn(null); RequestScope goodScope = buildRequestScope(tx, goodUser); + goodScope.setEntityProjection(collection); assertThrows( InvalidObjectIdentifierException.class, - () -> PersistentResource.loadRecord(Child.class, "1", goodScope)); + () -> PersistentResource.loadRecord(EntityProjection.builder() + + .type(Child.class) + .build(), "1", goodScope)); } @Test public void testLoadRecordForbidden() { NoReadEntity noRead = new NoReadEntity(); noRead.setId(1); - DataStoreTransaction tx = mock(DataStoreTransaction.class); - User goodUser = new User(1); + EntityProjection collection = EntityProjection.builder() + .type(NoReadEntity.class) + + .build(); - when(tx.loadObject(eq(NoReadEntity.class), eq(1L), any(), any())).thenReturn(noRead); + when(tx.loadObject(eq(collection), eq(1L), any())).thenReturn(noRead); RequestScope goodScope = buildRequestScope(tx, goodUser); + goodScope.setEntityProjection(collection); + assertThrows( ForbiddenAccessException.class, - () -> PersistentResource.loadRecord(NoReadEntity.class, "1", goodScope)); + () -> PersistentResource.loadRecord(EntityProjection.builder().type(NoReadEntity.class).build(), + "1", goodScope)); } @Test() public void testCreateObjectSuccess() { Parent parent = newParent(1); - DataStoreTransaction tx = mock(DataStoreTransaction.class); - User goodUser = new User(1); - when(tx.createNewObject(Parent.class)).thenReturn(parent); RequestScope goodScope = buildRequestScope(tx, goodUser); - PersistentResource created = PersistentResource.createObject(null, Parent.class, goodScope, Optional.of("uuid")); + PersistentResource created = PersistentResource.createObject(Parent.class, goodScope, Optional.of("uuid")); parent.setChildren(new HashSet<>()); created.getRequestScope().getPermissionExecutor().executeCommitChecks(); @@ -1687,11 +1731,10 @@ public void testCreateMappedIdObjectSuccess() { job.setTitle("day job"); job.setParent(newParent(1)); - final DataStoreTransaction tx = mock(DataStoreTransaction.class); when(tx.createNewObject(Job.class)).thenReturn(job); - final RequestScope goodScope = buildRequestScope(tx, new User(1)); - PersistentResource created = PersistentResource.createObject(null, Job.class, goodScope, Optional.empty()); + final RequestScope goodScope = buildRequestScope(tx, new TestUser("1")); + PersistentResource created = PersistentResource.createObject(Job.class, goodScope, Optional.empty()); created.getRequestScope().getPermissionExecutor().executeCommitChecks(); assertEquals("day job", created.getObject().getTitle(), @@ -1699,7 +1742,7 @@ public void testCreateMappedIdObjectSuccess() { ); assertNull(created.getObject().getJobId(), "The create function should not override the ID"); - created = PersistentResource.createObject(null, Job.class, goodScope, Optional.of("1234")); + created = PersistentResource.createObject(Job.class, goodScope, Optional.of("1234")); created.getRequestScope().getPermissionExecutor().executeCommitChecks(); assertEquals("day job", created.getObject().getTitle(), @@ -1712,8 +1755,6 @@ public void testCreateMappedIdObjectSuccess() { public void testCreateObjectForbidden() { NoCreateEntity noCreate = new NoCreateEntity(); noCreate.setId(1); - DataStoreTransaction tx = mock(DataStoreTransaction.class); - User goodUser = new User(1); when(tx.createNewObject(NoCreateEntity.class)).thenReturn(noCreate); @@ -1722,7 +1763,7 @@ public void testCreateObjectForbidden() { assertThrows( ForbiddenAccessException.class, () -> { - PersistentResource created = PersistentResource.createObject(null, NoCreateEntity.class, goodScope, Optional.of("1")); + PersistentResource created = PersistentResource.createObject(NoCreateEntity.class, goodScope, Optional.of("1")); created.getRequestScope().getPermissionExecutor().executeCommitChecks(); } ); @@ -1740,9 +1781,7 @@ public void testDeletePermissionCheckedOnInverseRelationship() { right.setAllowDeleteAtFieldLevel(Sets.newHashSet(left)); //Bad User triggers the delete permission failure - User badUser = new User(-1); - DataStoreTransaction tx = mock(DataStoreTransaction.class); - when(tx.getRelation(any(), eq(left), eq("fieldLevelDelete"), any(), any(), any(), any())).thenReturn(rights); + when(tx.getRelation(any(), eq(left), any(), any())).thenReturn(rights); RequestScope badScope = buildRequestScope(tx, badUser); PersistentResource leftResource = new PersistentResource<>(left, null, badScope.getUUIDFor(left), badScope); @@ -1765,9 +1804,7 @@ public void testUpdatePermissionCheckedOnInverseRelationship() { List empty = new ArrayList<>(); Relationship ids = new Relationship(null, new Data<>(empty)); - User goodUser = new User(1); - DataStoreTransaction tx = mock(DataStoreTransaction.class); - when(tx.getRelation(any(), eq(left), eq("noInverseUpdate"), any(), any(), any(), any())).thenReturn(rights); + when(tx.getRelation(any(), eq(left), any(), any())).thenReturn(rights); RequestScope goodScope = buildRequestScope(tx, goodUser); PersistentResource leftResource = new PersistentResource<>(left, null, goodScope.getUUIDFor(left), goodScope); @@ -1785,7 +1822,6 @@ public void testFieldLevelAudit() throws Exception { Parent parent = newParent(7); - User goodUser = new User(1); TestAuditLogger logger = new TestAuditLogger(); RequestScope requestScope = getUserScope(goodUser, logger); PersistentResource parentResource = new PersistentResource<>(parent, null, requestScope.getUUIDFor(parent), requestScope); @@ -1806,7 +1842,6 @@ public void testClassLevelAudit() throws Exception { Child child = newChild(5); Parent parent = newParent(7); - User goodUser = new User(1); TestAuditLogger logger = new TestAuditLogger(); RequestScope requestScope = getUserScope(goodUser, logger); PersistentResource parentResource = new PersistentResource<>( @@ -1829,9 +1864,7 @@ public void testOwningRelationshipInverseUpdates() { Parent parent = newParent(1); Child child = newChild(2); - User goodUser = new User(1); - DataStoreTransaction tx = mock(DataStoreTransaction.class); - when(tx.getRelation(any(), eq(parent), eq("children"), any(), any(), any(), any())).thenReturn(parent.getChildren()); + when(tx.getRelation(any(), eq(parent), any(), any())).thenReturn(parent.getChildren()); RequestScope goodScope = buildRequestScope(tx, goodUser); @@ -1852,7 +1885,7 @@ public void testOwningRelationshipInverseUpdates() { assertTrue(child.getParents().contains(parent), "The non-owning relationship should also be updated"); reset(tx); - when(tx.getRelation(any(), eq(parent), eq("children"), any(), any(), any(), any())).thenReturn(parent.getChildren()); + when(tx.getRelation(any(), eq(parent), any(), any())).thenReturn(parent.getChildren()); parentResource.clearRelation("children"); @@ -1866,20 +1899,23 @@ public void testOwningRelationshipInverseUpdates() { @Test public void testIsIdGenerated() { + RequestScope scope = new TestRequestScope(tx, goodUser, dictionary); - PersistentResource generated = new PersistentResource<>(new Child(), null, "1", goodUserScope); + PersistentResource generated = new PersistentResource<>(new Child(), null, "1", scope); assertTrue(generated.isIdGenerated(), "isIdGenerated returns true when ID field has the GeneratedValue annotation"); - PersistentResource notGenerated = new PersistentResource<>(new NoCreateEntity(), null, "1", goodUserScope); + scope = new TestRequestScope(tx, goodUser, dictionary); + + PersistentResource notGenerated = new PersistentResource<>(new NoCreateEntity(), null, "1", scope); assertFalse(notGenerated.isIdGenerated(), "isIdGenerated returns false when ID field does not have the GeneratedValue annotation"); } @Test - public void testSharePermissionErrorOnUpdateSingularRelationship() { + public void testTransferPermissionErrorOnUpdateSingularRelationship() { example.User userModel = new example.User(); userModel.setId(1); @@ -1890,9 +1926,12 @@ public void testSharePermissionErrorOnUpdateSingularRelationship() { idList.add(new ResourceIdentifier("noshare", "1").castToResource()); Relationship ids = new Relationship(null, new Data<>(idList)); - User goodUser = new User(1); - DataStoreTransaction tx = mock(DataStoreTransaction.class); - when(tx.loadObject(eq(NoShareEntity.class), eq(1L), any(), any())).thenReturn(noShare); + EntityProjection collection = EntityProjection.builder() + .type(NoShareEntity.class) + + .build(); + + when(tx.loadObject(eq(collection), eq(1L), any())).thenReturn(noShare); RequestScope goodScope = buildRequestScope(tx, goodUser); PersistentResource userResource = new PersistentResource<>(userModel, null, goodScope.getUUIDFor(userModel), goodScope); @@ -1903,19 +1942,18 @@ public void testSharePermissionErrorOnUpdateSingularRelationship() { } @Test - public void testSharePermissionErrorOnUpdateRelationshipPackageLevel() { + public void testTransferPermissionErrorOnUpdateRelationshipPackageLevel() { ContainerWithPackageShare containerWithPackageShare = new ContainerWithPackageShare(); - UnshareableWithEntityUnshare unshareableWithEntityUnshare = new UnshareableWithEntityUnshare(); - unshareableWithEntityUnshare.setContainerWithPackageShare(containerWithPackageShare); + Untransferable untransferable = new Untransferable(); + untransferable.setContainerWithPackageShare(containerWithPackageShare); List unShareableList = new ArrayList<>(); - unShareableList.add(new ResourceIdentifier("unshareableWithEntityUnshare", "1").castToResource()); + unShareableList.add(new ResourceIdentifier("untransferable", "1").castToResource()); Relationship unShareales = new Relationship(null, new Data<>(unShareableList)); - User goodUser = new User(1); - DataStoreTransaction tx = mock(DataStoreTransaction.class); - when(tx.loadObject(eq(UnshareableWithEntityUnshare.class), eq(1L), any(), any())).thenReturn(unshareableWithEntityUnshare); + when(tx.loadObject(any(), eq(1L), any())).thenReturn(untransferable); + RequestScope goodScope = buildRequestScope(tx, goodUser); PersistentResource containerResource = new PersistentResource<>(containerWithPackageShare, null, goodScope.getUUIDFor(containerWithPackageShare), goodScope); @@ -1923,11 +1961,11 @@ public void testSharePermissionErrorOnUpdateRelationshipPackageLevel() { assertThrows( ForbiddenAccessException.class, () -> containerResource.updateRelation( - "unshareableWithEntityUnshares", unShareales.toPersistentResources(goodScope))); + "untransferables", unShareales.toPersistentResources(goodScope))); } @Test - public void testSharePermissionSuccessOnUpdateManyRelationshipPackageLevel() { + public void testTransferPermissionSuccessOnUpdateManyRelationshipPackageLevel() { ContainerWithPackageShare containerWithPackageShare = new ContainerWithPackageShare(); ShareableWithPackageShare shareableWithPackageShare = new ShareableWithPackageShare(); @@ -1937,9 +1975,7 @@ public void testSharePermissionSuccessOnUpdateManyRelationshipPackageLevel() { shareableList.add(new ResourceIdentifier("shareableWithPackageShare", "1").castToResource()); Relationship shareables = new Relationship(null, new Data<>(shareableList)); - User goodUser = new User(1); - DataStoreTransaction tx = mock(DataStoreTransaction.class); - when(tx.loadObject(eq(ShareableWithPackageShare.class), eq(1L), any(), any())).thenReturn(shareableWithPackageShare); + when(tx.loadObject(any(), eq(1L), any())).thenReturn(shareableWithPackageShare); RequestScope goodScope = buildRequestScope(tx, goodUser); PersistentResource containerResource = new PersistentResource<>(containerWithPackageShare, null, goodScope.getUUIDFor(containerWithPackageShare), goodScope); @@ -1951,7 +1987,7 @@ public void testSharePermissionSuccessOnUpdateManyRelationshipPackageLevel() { } @Test - public void testSharePermissionErrorOnUpdateManyRelationship() { + public void testTransferPermissionErrorOnUpdateManyRelationship() { example.User userModel = new example.User(); userModel.setId(1); @@ -1965,10 +2001,8 @@ public void testSharePermissionErrorOnUpdateManyRelationship() { idList.add(new ResourceIdentifier("noshare", "2").castToResource()); Relationship ids = new Relationship(null, new Data<>(idList)); - User goodUser = new User(1); - DataStoreTransaction tx = mock(DataStoreTransaction.class); - when(tx.loadObject(eq(NoShareEntity.class), eq(1L), any(), any())).thenReturn(noShare1); - when(tx.loadObject(eq(NoShareEntity.class), eq(2L), any(), any())).thenReturn(noShare2); + when(tx.loadObject(any(), eq(1L), any())).thenReturn(noShare1); + when(tx.loadObject(any(), eq(2L), any())).thenReturn(noShare2); RequestScope goodScope = buildRequestScope(tx, goodUser); PersistentResource userResource = new PersistentResource<>(userModel, null, goodScope.getUUIDFor(userModel), goodScope); @@ -1979,7 +2013,7 @@ public void testSharePermissionErrorOnUpdateManyRelationship() { } @Test - public void testSharePermissionSuccessOnUpdateManyRelationship() { + public void testTransferPermissionSuccessOnUpdateManyRelationship() { example.User userModel = new example.User(); userModel.setId(1); @@ -1996,10 +2030,8 @@ public void testSharePermissionSuccessOnUpdateManyRelationship() { idList.add(new ResourceIdentifier("noshare", "1").castToResource()); Relationship ids = new Relationship(null, new Data<>(idList)); - User goodUser = new User(1); - DataStoreTransaction tx = mock(DataStoreTransaction.class); - when(tx.loadObject(eq(NoShareEntity.class), eq(1L), any(), any())).thenReturn(noShare1); - when(tx.getRelation(any(), eq(userModel), eq("noShares"), any(), any(), any(), any())).thenReturn(noshares); + when(tx.loadObject(any(), eq(1L), any())).thenReturn(noShare1); + when(tx.getRelation(any(), eq(userModel), any(), any())).thenReturn(noshares); RequestScope goodScope = buildRequestScope(tx, goodUser); PersistentResource userResource = new PersistentResource<>(userModel, null, goodScope.getUUIDFor(userModel), goodScope); @@ -2012,7 +2044,7 @@ public void testSharePermissionSuccessOnUpdateManyRelationship() { } @Test - public void testSharePermissionSuccessOnUpdateSingularRelationship() { + public void testTransferPermissionSuccessOnUpdateSingularRelationship() { example.User userModel = new example.User(); userModel.setId(1); @@ -2025,11 +2057,8 @@ public void testSharePermissionSuccessOnUpdateSingularRelationship() { idList.add(new ResourceIdentifier("noshare", "1").castToResource()); Relationship ids = new Relationship(null, new Data<>(idList)); - User goodUser = new User(1); - DataStoreTransaction tx = mock(DataStoreTransaction.class); - - when(tx.getRelation(any(), eq(userModel), eq("noShare"), any(), any(), any(), any())).thenReturn(noShare); - when(tx.loadObject(eq(NoShareEntity.class), eq(1L), any(), any())).thenReturn(noShare); + when(tx.getRelation(any(), eq(userModel), any(), any())).thenReturn(noShare); + when(tx.loadObject(any(), eq(1L), any())).thenReturn(noShare); RequestScope goodScope = buildRequestScope(tx, goodUser); PersistentResource userResource = new PersistentResource<>(userModel, null, goodScope.getUUIDFor(userModel), goodScope); @@ -2041,7 +2070,7 @@ public void testSharePermissionSuccessOnUpdateSingularRelationship() { } @Test - public void testSharePermissionSuccessOnClearSingularRelationship() { + public void testTransferPermissionSuccessOnClearSingularRelationship() { example.User userModel = new example.User(); userModel.setId(1); @@ -2053,9 +2082,7 @@ public void testSharePermissionSuccessOnClearSingularRelationship() { List empty = new ArrayList<>(); Relationship ids = new Relationship(null, new Data<>(empty)); - User goodUser = new User(1); - DataStoreTransaction tx = mock(DataStoreTransaction.class); - when(tx.getRelation(any(), eq(userModel), eq("noShare"), any(), any(), any(), any())).thenReturn(noShare); + when(tx.getRelation(any(), eq(userModel), any(), any())).thenReturn(noShare); RequestScope goodScope = buildRequestScope(tx, goodUser); PersistentResource userResource = new PersistentResource<>(userModel, null, goodScope.getUUIDFor(userModel), goodScope); @@ -2077,7 +2104,6 @@ public void testCollectionChangeSpecType() { return condFn.apply((Collection) spec.getOriginal(), (Collection) spec.getModified()); }; - DataStoreTransaction tx = mock(DataStoreTransaction.class); // Ensure that change specs coming from collections work properly ChangeSpecModel csModel = new ChangeSpecModel((spec) -> collectionCheck @@ -2086,7 +2112,7 @@ public void testCollectionChangeSpecType() { PersistentResource model = bootstrapPersistentResource(csModel, tx); - when(tx.getRelation(any(), eq(model.obj), eq("otherKids"), any(), any(), any(), any())).thenReturn(new HashSet<>()); + when(tx.getRelation(any(), eq(model.obj), any(), any())).thenReturn(new HashSet<>()); /* Attributes */ // Set new data from null @@ -2124,7 +2150,8 @@ public void testCollectionChangeSpecType() { model.getObject().checkFunction = (spec) -> collectionCheck.apply("otherKids").apply(spec, (original, modified) -> original.size() == 3 && original.contains(new ChangeSpecChild(1)) && original.contains(new ChangeSpecChild(2)) && original.contains(new ChangeSpecChild(3)) && modified.size() == 2 && modified.contains(new ChangeSpecChild(1)) && modified.contains(new ChangeSpecChild(3))); model.removeRelation("otherKids", bootstrapPersistentResource(child2)); - when(tx.getRelation(any(), eq(model.obj), eq("otherKids"), any(), any(), any(), any())).thenReturn(Sets.newHashSet(child1, child3)); + when(tx.getRelation(any(), eq(model.obj), any(), any())).thenReturn(Sets.newHashSet(child1, child3)); + // Clear the rest model.getObject().checkFunction = (spec) -> collectionCheck.apply("otherKids").apply(spec, (original, modified) -> original.size() <= 2 && modified.size() < original.size()); @@ -2168,24 +2195,23 @@ public void testRelationChangeSpecType() { } return checkFn.apply((ChangeSpecChild) spec.getOriginal(), (ChangeSpecChild) spec.getModified()); }; - DataStoreTransaction tx = mock(DataStoreTransaction.class); PersistentResource model = bootstrapPersistentResource(new ChangeSpecModel((spec) -> relCheck.apply(spec, (original, modified) -> (original == null) && new ChangeSpecChild(1).equals(modified))), tx); - when(tx.getRelation(any(), eq(model.obj), eq("child"), any(), any(), any(), any())).thenReturn(null); + when(tx.getRelation(any(), eq(model.obj), any(), any())).thenReturn(null); ChangeSpecChild child1 = new ChangeSpecChild(1); assertTrue(model.updateRelation("child", Sets.newHashSet(bootstrapPersistentResource(child1, tx)))); - when(tx.getRelation(any(), eq(model.obj), eq("child"), any(), any(), any(), any())).thenReturn(child1); + when(tx.getRelation(any(), eq(model.obj), any(), any())).thenReturn(child1); model.getObject().checkFunction = (spec) -> relCheck.apply(spec, (original, modified) -> new ChangeSpecChild(1).equals(original) && new ChangeSpecChild(2).equals(modified)); ChangeSpecChild child2 = new ChangeSpecChild(2); assertTrue(model.updateRelation("child", Sets.newHashSet(bootstrapPersistentResource(child2, tx)))); - when(tx.getRelation(any(), eq(model.obj), eq("child"), any(), any(), any(), any())).thenReturn(child2); + when(tx.getRelation(any(), eq(model.obj), any(), any())).thenReturn(child2); model.getObject().checkFunction = (spec) -> relCheck.apply(spec, (original, modified) -> new ChangeSpecChild(2).equals(original) && modified == null); assertTrue(model.updateRelation("child", null)); @@ -2194,13 +2220,10 @@ public void testRelationChangeSpecType() { @Test public void testPatchRequestScope() { DataStoreTransaction tx = mock(DataStoreTransaction.class); - PatchRequestScope parentScope = - new PatchRequestScope(null, tx, new User(1), elideSettings); + PatchRequestScope parentScope = new PatchRequestScope("/book", NO_VERSION, tx, new TestUser("1"), elideSettings); PatchRequestScope scope = new PatchRequestScope( parentScope.getPath(), parentScope.getJsonApiDocument(), parentScope); // verify wrap works - assertEquals(parentScope.isUseFilterExpressions(), scope.isUseFilterExpressions()); - assertEquals(parentScope.getSorting(), scope.getSorting()); assertEquals(parentScope.getUpdateStatusCode(), scope.getUpdateStatusCode()); assertEquals(parentScope.getObjectEntityCache(), scope.getObjectEntityCache()); @@ -2209,7 +2232,10 @@ public void testPatchRequestScope() { PersistentResource parentResource = new PersistentResource<>(parent, null, "1", scope); parentResource.updateAttribute("firstName", "foobar"); - verify(tx, times(1)).setAttribute(parent, "firstName", "foobar", scope); + ArgumentCaptor attributeArgument = ArgumentCaptor.forClass(Attribute.class); + verify(tx, times(1)).setAttribute(eq(parent), attributeArgument.capture(), eq(scope)); + assertEquals(attributeArgument.getValue().getName(), "firstName"); + assertEquals(attributeArgument.getValue().getArguments().iterator().next().getValue(), "foobar"); } @Test @@ -2221,7 +2247,8 @@ public void testFilterExpressionByType() { "Hemingway" ); - RequestScope scope = buildRequestScope("/", mock(DataStoreTransaction.class), new User(1), queryParams); + RequestScope scope = buildRequestScope("/", mock(DataStoreTransaction.class), + new TestUser("1"), queryParams); Optional filter = scope.getLoadFilterExpression(Author.class); FilterPredicate predicate = (FilterPredicate) filter.get(); @@ -2238,11 +2265,10 @@ public void testSparseFields() { queryParams.add("fields[author]", "name"); - RequestScope scope = buildRequestScope("/", mock(DataStoreTransaction.class), new User(1), queryParams); + RequestScope scope = buildRequestScope("/", mock(DataStoreTransaction.class), + new TestUser("1"), queryParams); Map> expected = ImmutableMap.of("author", ImmutableSet.of("name")); assertEquals(expected, scope.getSparseFields()); - assertEquals(10, scope.getPagination().getLimit()); - assertEquals(0, scope.getPagination().getPageTotals()); } @Test @@ -2250,10 +2276,12 @@ public void testEqualsAndHashcode() { Child childWithId = newChild(1); Child childWithoutId = newChild(0); - PersistentResource resourceWithId = new PersistentResource<>(childWithId, null, goodUserScope.getUUIDFor(childWithId), goodUserScope); - PersistentResource resourceWithDifferentId = new PersistentResource<>(childWithoutId, null, goodUserScope.getUUIDFor(childWithoutId), goodUserScope); - PersistentResource resourceWithUUID = new PersistentResource<>(childWithoutId, null, "abc", goodUserScope); - PersistentResource resourceWithIdAndUUID = new PersistentResource<>(childWithId, null, "abc", goodUserScope); + RequestScope scope = new TestRequestScope(tx, goodUser, dictionary); + + PersistentResource resourceWithId = new PersistentResource<>(childWithId, null, scope.getUUIDFor(childWithId), scope); + PersistentResource resourceWithDifferentId = new PersistentResource<>(childWithoutId, null, scope.getUUIDFor(childWithoutId), scope); + PersistentResource resourceWithUUID = new PersistentResource<>(childWithoutId, null, "abc", scope); + PersistentResource resourceWithIdAndUUID = new PersistentResource<>(childWithId, null, "abc", scope); assertNotEquals(resourceWithUUID, resourceWithId); assertNotEquals(resourceWithId, resourceWithUUID); diff --git a/elide-core/src/test/java/com/yahoo/elide/core/RequestScopeTest.java b/elide-core/src/test/java/com/yahoo/elide/core/RequestScopeTest.java index 9fc9d6c88e..20c89ed9a9 100644 --- a/elide-core/src/test/java/com/yahoo/elide/core/RequestScopeTest.java +++ b/elide-core/src/test/java/com/yahoo/elide/core/RequestScopeTest.java @@ -5,6 +5,7 @@ */ package com.yahoo.elide.core; +import static com.yahoo.elide.core.EntityDictionary.NO_VERSION; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -65,14 +66,14 @@ class MyInheritedClass extends MyBaseClass { dictionary.bindEntity(MyBaseClass.class); dictionary.bindEntity(MyInheritedClass.class); - RequestScope requestScope = new RequestScope("/", null, null, null, null, + RequestScope requestScope = new RequestScope("/", NO_VERSION, null, null, null, null, new ElideSettingsBuilder(null) .withEntityDictionary(dictionary) .build()); String myId = "myId"; // Test that a new inherited class is counted for base type - requestScope.setUUIDForObject(dictionary.getJsonAliasFor(MyInheritedClass.class), myId, new MyInheritedClass()); - assertNotNull(requestScope.getObjectById(dictionary.getJsonAliasFor(MyBaseClass.class), myId)); + requestScope.setUUIDForObject(MyInheritedClass.class, myId, new MyInheritedClass()); + assertNotNull(requestScope.getObjectById(MyBaseClass.class, myId)); } } diff --git a/elide-core/src/test/java/com/yahoo/elide/core/TestDictionary.java b/elide-core/src/test/java/com/yahoo/elide/core/TestDictionary.java new file mode 100644 index 0000000000..245d3c7ed1 --- /dev/null +++ b/elide-core/src/test/java/com/yahoo/elide/core/TestDictionary.java @@ -0,0 +1,70 @@ +/* + * Copyright 2019, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.core; + +import com.yahoo.elide.Injector; +import com.yahoo.elide.security.checks.Check; + +import com.google.inject.Binder; +import com.google.inject.Guice; +import com.google.inject.Module; +import com.google.inject.TypeLiteral; +import com.google.inject.name.Names; +import example.TestCheckMappings; + +import java.util.Map; +import javax.inject.Inject; +import javax.inject.Named; +import javax.inject.Singleton; + +/** + * Test Entity Dictionary. + */ +@Singleton +public class TestDictionary extends EntityDictionary { + + @Inject + public TestDictionary(Injector injector, + @Named("checkMappings") Map> checks) { + super(checks, injector); + } + + @Override + public Class lookupBoundClass(Class objClass) { + // Special handling for mocked Book class which has Entity annotation + if (objClass.getName().contains("$MockitoMock$")) { + objClass = objClass.getSuperclass(); + } + return super.lookupBoundClass(objClass); + } + + /** + * Returns a test dictionary injected with Guice. + * @return a test dictionary. + */ + public static EntityDictionary getTestDictionary() { + return getTestDictionary(TestCheckMappings.MAPPINGS); + } + + /** + * Returns a test dictionary injected with Guice. + * @param checks The security checks to setup the dictionary with. + * @return a test dictionary. + */ + public static EntityDictionary getTestDictionary(Map> checks) { + return Guice.createInjector(new Module() { + @Override + public void configure(Binder binder) { + binder.bind(Injector.class).to(TestInjector.class); + binder.bind(EntityDictionary.class).to(TestDictionary.class); + binder.bind(new TypeLiteral>>() { }) + .annotatedWith(Names.named("checkMappings")) + .toInstance(checks); + } + }).getInstance(EntityDictionary.class); + } +} diff --git a/elide-core/src/test/java/com/yahoo/elide/core/TestInjector.java b/elide-core/src/test/java/com/yahoo/elide/core/TestInjector.java new file mode 100644 index 0000000000..1d88588586 --- /dev/null +++ b/elide-core/src/test/java/com/yahoo/elide/core/TestInjector.java @@ -0,0 +1,33 @@ +/* + * Copyright 2019, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.core; + +import com.yahoo.elide.Injector; + +import javax.inject.Inject; + +/** + * Test Dependency Injector. + */ +public class TestInjector implements Injector { + private final com.google.inject.Injector injector; + + @Inject + public TestInjector(com.google.inject.Injector injector) { + this.injector = injector; + } + + @Override + public void inject(Object entity) { + injector.injectMembers(entity); + } + + @Override + public T instantiate(Class cls) { + return injector.getInstance(cls); + } +} diff --git a/elide-core/src/test/java/com/yahoo/elide/core/TestRequestScope.java b/elide-core/src/test/java/com/yahoo/elide/core/TestRequestScope.java new file mode 100644 index 0000000000..1ee820048f --- /dev/null +++ b/elide-core/src/test/java/com/yahoo/elide/core/TestRequestScope.java @@ -0,0 +1,54 @@ +/* + * Copyright 2016, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.core; + +import static com.yahoo.elide.core.EntityDictionary.NO_VERSION; +import com.yahoo.elide.ElideSettingsBuilder; +import com.yahoo.elide.jsonapi.models.JsonApiDocument; +import com.yahoo.elide.security.User; + +import java.util.Optional; +import javax.ws.rs.core.MultivaluedMap; + +/** + * Utility subclass that helps construct RequestScope objects for testing. + */ +public class TestRequestScope extends RequestScope { + + private MultivaluedMap queryParamOverrides = null; + + public TestRequestScope(DataStoreTransaction transaction, + User user, + EntityDictionary dictionary) { + super(null, NO_VERSION, new JsonApiDocument(), transaction, user, null, + new ElideSettingsBuilder(null) + .withEntityDictionary(dictionary) + .build()); + } + + public TestRequestScope(EntityDictionary dictionary, + String path, + MultivaluedMap queryParams) { + super(path, NO_VERSION, new JsonApiDocument(), null, null, queryParams, + new ElideSettingsBuilder(null) + .withEntityDictionary(dictionary) + .build()); + } + + public void setQueryParams(MultivaluedMap queryParams) { + this.queryParamOverrides = queryParams; + } + + @Override + public Optional> getQueryParams() { + if (queryParamOverrides != null) { + return Optional.of(queryParamOverrides); + } else { + return super.getQueryParams(); + } + } +} diff --git a/elide-core/src/test/java/com/yahoo/elide/core/UpdateOnCreateTest.java b/elide-core/src/test/java/com/yahoo/elide/core/UpdateOnCreateTest.java index acc34dcdcf..a20631f6a1 100644 --- a/elide-core/src/test/java/com/yahoo/elide/core/UpdateOnCreateTest.java +++ b/elide-core/src/test/java/com/yahoo/elide/core/UpdateOnCreateTest.java @@ -9,83 +9,39 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.reset; import static org.mockito.Mockito.when; import com.yahoo.elide.core.exceptions.ForbiddenAccessException; +import com.yahoo.elide.request.EntityProjection; +import com.yahoo.elide.security.TestUser; import com.yahoo.elide.security.User; -import com.yahoo.elide.utils.coerce.CoerceUtil; + import example.Author; import example.Book; -import example.Editor; -import example.Publisher; import example.UpdateAndCreate; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import java.io.Serializable; import java.util.Optional; public class UpdateOnCreateTest extends PersistenceResourceTestSetup { - private RequestScope userOneScope; - private RequestScope userTwoScope; - private RequestScope userThreeScope; - private RequestScope userFourScope; - - public UpdateOnCreateTest() { - super(); - init(); - } - - public void init() { - dictionary.bindEntity(Author.class); - dictionary.bindEntity(Book.class); - dictionary.bindEntity(Publisher.class); - dictionary.bindEntity(Editor.class); - dictionary.bindEntity(UpdateAndCreate.class); - - UpdateAndCreate updateAndCreateNewObject = new UpdateAndCreate(); - updateAndCreateNewObject.setId(1L); - UpdateAndCreate updateAndCreateExistingObject = new UpdateAndCreate(); - updateAndCreateExistingObject.setId(2L); - Book book = new Book(); - Author author = new Author(); - Publisher publisher = new Publisher(); - Editor editor = new Editor(); + private User userOne = new TestUser("1"); + private User userTwo = new TestUser("2"); + private User userThree = new TestUser("3"); + private User userFour = new TestUser("4"); - publisher.setEditor(editor); + private DataStoreTransaction tx = mock(DataStoreTransaction.class); - DataStoreTransaction tx = mock(DataStoreTransaction.class); - - User userOne = new User(1); - userOneScope = new RequestScope(null, null, tx, userOne, null, elideSettings); - User userTwo = new User(2); - userTwoScope = new RequestScope(null, null, tx, userTwo, null, elideSettings); - User userThree = new User(3); - userThreeScope = new RequestScope(null, null, tx, userThree, null, elideSettings); - User userFour = new User(4); - userFourScope = new RequestScope(null, null, tx, userFour, null, elideSettings); + @BeforeEach + public void beforeMethod() { + reset(tx); + } - when(tx.createNewObject(UpdateAndCreate.class)).thenReturn(updateAndCreateNewObject); - when(tx.loadObject(eq(UpdateAndCreate.class), - eq((Serializable) CoerceUtil.coerce(1, Long.class)), - eq(Optional.empty()), - any(RequestScope.class) - )).thenReturn(updateAndCreateExistingObject); - when(tx.loadObject(eq(Book.class), - eq((Serializable) CoerceUtil.coerce(1, Long.class)), - eq(Optional.empty()), - any(RequestScope.class) - )).thenReturn(book); - when(tx.loadObject(eq(Author.class), - eq((Serializable) CoerceUtil.coerce(1, Long.class)), - eq(Optional.empty()), - any(RequestScope.class) - )).thenReturn(author); - when(tx.loadObject(eq(Publisher.class), - eq((Serializable) CoerceUtil.coerce(1, Long.class)), - eq(Optional.empty()), - any(RequestScope.class) - )).thenReturn(publisher); + public UpdateOnCreateTest() { + super(); + initDictionary(); } //----------------------------------------- ** Entity Creation ** ------------------------------------------------- @@ -93,30 +49,56 @@ public void init() { //Create allowed based on class level expression @Test public void createPermissionCheckClassAnnotationForCreatingAnEntitySuccessCase() { - PersistentResource created = PersistentResource.createObject(null, UpdateAndCreate.class, userOneScope, Optional.of("1")); + RequestScope userOneScope = new TestRequestScope(tx, userOne, dictionary); + + UpdateAndCreate updateAndCreateNewObject = new UpdateAndCreate(); + when(tx.createNewObject(UpdateAndCreate.class)).thenReturn(updateAndCreateNewObject); + + PersistentResource created = PersistentResource.createObject(UpdateAndCreate.class, userOneScope, Optional.of("1")); created.getRequestScope().getPermissionExecutor().executeCommitChecks(); } //Create allowed based on field level expression @Test public void createPermissionCheckFieldAnnotationForCreatingAnEntitySuccessCase() { - PersistentResource created = PersistentResource.createObject(null, UpdateAndCreate.class, userThreeScope, Optional.of("2")); + RequestScope userThreeScope = new TestRequestScope(tx, userThree, dictionary); + + UpdateAndCreate updateAndCreateNewObject = new UpdateAndCreate(); + when(tx.createNewObject(UpdateAndCreate.class)).thenReturn(updateAndCreateNewObject); + + PersistentResource created = PersistentResource.createObject(UpdateAndCreate.class, userThreeScope, Optional.of("2")); created.getRequestScope().getPermissionExecutor().executeCommitChecks(); } //Create denied based on field level expression @Test public void createPermissionCheckFieldAnnotationForCreatingAnEntityFailureCase() { + RequestScope userFourScope = new TestRequestScope(tx, userFour, dictionary); + + UpdateAndCreate updateAndCreateNewObject = new UpdateAndCreate(); + when(tx.createNewObject(UpdateAndCreate.class)).thenReturn(updateAndCreateNewObject); assertThrows( ForbiddenAccessException.class, - () -> PersistentResource.createObject(null, UpdateAndCreate.class, userFourScope, Optional.of("3"))); + () -> PersistentResource.createObject(UpdateAndCreate.class, userFourScope, Optional.of("3"))); } //----------------------------------------- ** Update Attribute ** ------------------------------------------------ //Expression for field inherited from class level expression @Test public void updatePermissionInheritedForAttributeSuccessCase() { - PersistentResource loaded = PersistentResource.loadRecord(UpdateAndCreate.class, + RequestScope userTwoScope = new TestRequestScope(tx, userTwo, dictionary); + + UpdateAndCreate updateAndCreateExistingObject = new UpdateAndCreate(); + + when(tx.loadObject(any(), + eq(1L), + any(RequestScope.class) + )).thenReturn(updateAndCreateExistingObject); + + PersistentResource loaded = PersistentResource.loadRecord( + EntityProjection.builder() + .type(UpdateAndCreate.class) + .build(), "1", userTwoScope); loaded.updateAttribute("name", ""); @@ -125,7 +107,19 @@ public void updatePermissionInheritedForAttributeSuccessCase() { @Test public void updatePermissionInheritedForAttributeFailureCase() { - PersistentResource loaded = PersistentResource.loadRecord(UpdateAndCreate.class, + RequestScope userOneScope = new TestRequestScope(tx, userOne, dictionary); + + UpdateAndCreate updateAndCreateExistingObject = new UpdateAndCreate(); + + when(tx.loadObject(any(), + eq(1L), + any(RequestScope.class) + )).thenReturn(updateAndCreateExistingObject); + + PersistentResource loaded = PersistentResource.loadRecord( + EntityProjection.builder() + .type(UpdateAndCreate.class) + .build(), "1", userOneScope); assertThrows(ForbiddenAccessException.class, () -> loaded.updateAttribute("name", "")); @@ -134,7 +128,19 @@ public void updatePermissionInheritedForAttributeFailureCase() { //Class level expression overwritten by field level expression @Test public void updatePermissionOverwrittenForAttributeSuccessCase() { - PersistentResource loaded = PersistentResource.loadRecord(UpdateAndCreate.class, + RequestScope userFourScope = new TestRequestScope(tx, userFour, dictionary); + + UpdateAndCreate updateAndCreateExistingObject = new UpdateAndCreate(); + + when(tx.loadObject(any(), + eq(1L), + any(RequestScope.class) + )).thenReturn(updateAndCreateExistingObject); + + PersistentResource loaded = PersistentResource.loadRecord( + EntityProjection.builder() + .type(UpdateAndCreate.class) + .build(), "1", userFourScope); loaded.updateAttribute("alias", ""); @@ -143,7 +149,19 @@ public void updatePermissionOverwrittenForAttributeSuccessCase() { @Test public void updatePermissionOverwrittenForAttributeFailureCase() { - PersistentResource loaded = PersistentResource.loadRecord(UpdateAndCreate.class, + RequestScope userThreeScope = new TestRequestScope(tx, userThree, dictionary); + + UpdateAndCreate updateAndCreateExistingObject = new UpdateAndCreate(); + + when(tx.loadObject(any(), + eq(1L), + any(RequestScope.class) + )).thenReturn(updateAndCreateExistingObject); + + PersistentResource loaded = PersistentResource.loadRecord( + EntityProjection.builder() + .type(UpdateAndCreate.class) + .build(), "1", userThreeScope); assertThrows(ForbiddenAccessException.class, () -> loaded.updateAttribute("alias", "")); @@ -154,11 +172,31 @@ public void updatePermissionOverwrittenForAttributeFailureCase() { //Expression for relation inherited from class level expression @Test public void updatePermissionInheritedForRelationSuccessCase() { - PersistentResource loaded = PersistentResource.loadRecord(UpdateAndCreate.class, + RequestScope userTwoScope = new TestRequestScope(tx, userTwo, dictionary); + + UpdateAndCreate updateAndCreateExistingObject = new UpdateAndCreate(); + + when(tx.loadObject(any(), + eq(1L), + any(RequestScope.class) + )).thenReturn(updateAndCreateExistingObject); + + when(tx.loadObject(any(), + eq(2L), + any(RequestScope.class) + )).thenReturn(new Book()); + + PersistentResource loaded = PersistentResource.loadRecord( + EntityProjection.builder() + .type(UpdateAndCreate.class) + .build(), "1", userTwoScope); - PersistentResource loadedBook = PersistentResource.loadRecord(Book.class, - "1", + PersistentResource loadedBook = PersistentResource.loadRecord( + EntityProjection.builder() + .type(Book.class) + .build(), + "2", userTwoScope); loaded.addRelation("books", loadedBook); loaded.getRequestScope().getPermissionExecutor().executeCommitChecks(); @@ -166,11 +204,31 @@ public void updatePermissionInheritedForRelationSuccessCase() { @Test public void updatePermissionInheritedForRelationFailureCase() { - PersistentResource loaded = PersistentResource.loadRecord(UpdateAndCreate.class, + RequestScope userOneScope = new TestRequestScope(tx, userOne, dictionary); + + UpdateAndCreate updateAndCreateExistingObject = new UpdateAndCreate(); + + when(tx.loadObject(any(), + eq(1L), + any(RequestScope.class) + )).thenReturn(updateAndCreateExistingObject); + + when(tx.loadObject(any(), + eq(2L), + any(RequestScope.class) + )).thenReturn(new Book()); + + PersistentResource loaded = PersistentResource.loadRecord( + EntityProjection.builder() + .type(UpdateAndCreate.class) + .build(), "1", userOneScope); - PersistentResource loadedBook = PersistentResource.loadRecord(Book.class, - "1", + PersistentResource loadedBook = PersistentResource.loadRecord( + EntityProjection.builder() + .type(Book.class) + .build(), + "2", userOneScope); assertThrows(ForbiddenAccessException.class, () -> loaded.addRelation("books", loadedBook)); } @@ -178,11 +236,33 @@ public void updatePermissionInheritedForRelationFailureCase() { //Class level expression overwritten by field level expression @Test public void updatePermissionOverwrittenForRelationSuccessCase() { - PersistentResource loaded = PersistentResource.loadRecord(UpdateAndCreate.class, + RequestScope userThreeScope = new TestRequestScope(tx, new TestUser("3"), dictionary); + + + UpdateAndCreate updateAndCreateExistingObject = new UpdateAndCreate(); + updateAndCreateExistingObject.setId(1L); + + when(tx.loadObject(any(), + eq(1L), + any(RequestScope.class) + )).thenReturn(updateAndCreateExistingObject); + + when(tx.loadObject(any(), + eq(2L), + any(RequestScope.class) + )).thenReturn(new Author()); + + PersistentResource loaded = PersistentResource.loadRecord( + EntityProjection.builder() + .type(UpdateAndCreate.class) + .build(), "1", userThreeScope); - PersistentResource loadedAuthor = PersistentResource.loadRecord(Author.class, - "1", + PersistentResource loadedAuthor = PersistentResource.loadRecord( + EntityProjection.builder() + .type(Author.class) + .build(), + "2", userThreeScope); loaded.addRelation("author", loadedAuthor); loaded.getRequestScope().getPermissionExecutor().executeCommitChecks(); @@ -190,11 +270,32 @@ public void updatePermissionOverwrittenForRelationSuccessCase() { @Test public void updatePermissionOverwrittenForRelationFailureCase() { - PersistentResource loaded = PersistentResource.loadRecord(UpdateAndCreate.class, + RequestScope userTwoScope = new TestRequestScope(tx, userTwo, dictionary); + + UpdateAndCreate updateAndCreateExistingObject = new UpdateAndCreate(); + + when(tx.loadObject(any(), + eq(1L), + any(RequestScope.class) + )).thenReturn(updateAndCreateExistingObject); + + when(tx.loadObject(any(), + eq(2L), + any(RequestScope.class) + )).thenReturn(new Author()); + + PersistentResource loaded = PersistentResource.loadRecord( + EntityProjection.builder() + .type(UpdateAndCreate.class) + + .build(), "1", userTwoScope); - PersistentResource loadedAuthor = PersistentResource.loadRecord(Author.class, - "1", + PersistentResource loadedAuthor = PersistentResource.loadRecord( + EntityProjection.builder() + .type(Author.class) + .build(), + "2", userTwoScope); assertThrows(ForbiddenAccessException.class, () -> loaded.addRelation("author", loadedAuthor)); } @@ -203,54 +304,99 @@ public void updatePermissionOverwrittenForRelationFailureCase() { //Expression for field inherited from class level expression @Test public void createPermissionInheritedForAttributeSuccessCase() { - PersistentResource created = PersistentResource.createObject(null, UpdateAndCreate.class, userOneScope, Optional.of("4")); + RequestScope userOneScope = new TestRequestScope(tx, userOne, dictionary); + + UpdateAndCreate updateAndCreateNewObject = new UpdateAndCreate(); + when(tx.createNewObject(UpdateAndCreate.class)).thenReturn(updateAndCreateNewObject); + + PersistentResource created = PersistentResource.createObject(UpdateAndCreate.class, userOneScope, Optional.of("4")); created.updateAttribute("name", ""); created.getRequestScope().getPermissionExecutor().executeCommitChecks(); } @Test public void createPermissionInheritedForAttributeFailureCase() { - PersistentResource created = PersistentResource.createObject(null, UpdateAndCreate.class, userThreeScope, Optional.of("5")); + RequestScope userThreeScope = new TestRequestScope(tx, userThree, dictionary); + + UpdateAndCreate updateAndCreateNewObject = new UpdateAndCreate(); + when(tx.createNewObject(UpdateAndCreate.class)).thenReturn(updateAndCreateNewObject); + + PersistentResource created = PersistentResource.createObject(UpdateAndCreate.class, userThreeScope, Optional.of("5")); assertThrows(ForbiddenAccessException.class, () -> created.updateAttribute("name", "")); } //Class level expression overwritten by field level expression @Test public void createPermissionOverwrittenForAttributeSuccessCase() { - PersistentResource created = PersistentResource.createObject(null, UpdateAndCreate.class, userThreeScope, Optional.of("6")); + RequestScope userThreeScope = new TestRequestScope(tx, userThree, dictionary); + + UpdateAndCreate updateAndCreateNewObject = new UpdateAndCreate(); + when(tx.createNewObject(UpdateAndCreate.class)).thenReturn(updateAndCreateNewObject); + + PersistentResource created = PersistentResource.createObject(UpdateAndCreate.class, userThreeScope, Optional.of("6")); created.updateAttribute("alias", ""); created.getRequestScope().getPermissionExecutor().executeCommitChecks(); } @Test public void createPermissionOverwrittenForAttributeFailureCase() { + RequestScope userFourScope = new TestRequestScope(tx, userFour, dictionary); + + UpdateAndCreate updateAndCreateNewObject = new UpdateAndCreate(); + when(tx.createNewObject(UpdateAndCreate.class)).thenReturn(updateAndCreateNewObject); assertThrows( ForbiddenAccessException.class, () -> { PersistentResource created = - PersistentResource.createObject(null, UpdateAndCreate.class, userFourScope, Optional.of("7")); + PersistentResource.createObject(UpdateAndCreate.class, userFourScope, Optional.of("7")); created.updateAttribute("alias", ""); } ); } - //----------------------------------------- ** Update Relation On Create ** -------------------------------------- //Expression for relation inherited from class level expression @Test public void createPermissionInheritedForRelationSuccessCase() { - PersistentResource created = PersistentResource.createObject(null, UpdateAndCreate.class, userOneScope, Optional.of("8")); - PersistentResource loadedBook = PersistentResource.loadRecord(Book.class, - "1", + RequestScope userOneScope = new TestRequestScope(tx, userOne, dictionary); + + UpdateAndCreate updateAndCreateNewObject = new UpdateAndCreate(); + when(tx.createNewObject(UpdateAndCreate.class)).thenReturn(updateAndCreateNewObject); + + when(tx.loadObject(any(), + eq(2L), + any(RequestScope.class) + )).thenReturn(new Book()); + + PersistentResource created = PersistentResource.createObject(UpdateAndCreate.class, userOneScope, Optional.of("8")); + PersistentResource loadedBook = PersistentResource.loadRecord( + EntityProjection.builder() + .type(Book.class) + .build(), + "2", userOneScope); + created.addRelation("books", loadedBook); created.getRequestScope().getPermissionExecutor().executeCommitChecks(); } @Test public void createPermissionInheritedForRelationFailureCase() { - PersistentResource created = PersistentResource.createObject(null, UpdateAndCreate.class, userThreeScope, Optional.of("9")); - PersistentResource loadedBook = PersistentResource.loadRecord(Book.class, - "1", + RequestScope userThreeScope = new TestRequestScope(tx, userThree, dictionary); + + UpdateAndCreate updateAndCreateNewObject = new UpdateAndCreate(); + when(tx.createNewObject(UpdateAndCreate.class)).thenReturn(updateAndCreateNewObject); + + when(tx.loadObject(any(), + eq(2L), + any(RequestScope.class) + )).thenReturn(new Book()); + + PersistentResource created = PersistentResource.createObject(UpdateAndCreate.class, userThreeScope, Optional.of("9")); + PersistentResource loadedBook = PersistentResource.loadRecord( + EntityProjection.builder() + .type(Book.class) + .build(), + "2", userThreeScope); assertThrows(ForbiddenAccessException.class, () -> created.addRelation("books", loadedBook)); } @@ -258,9 +404,22 @@ public void createPermissionInheritedForRelationFailureCase() { //Class level expression overwritten by field level expression @Test public void createPermissionOverwrittenForRelationSuccessCase() { - PersistentResource created = PersistentResource.createObject(null, UpdateAndCreate.class, userTwoScope, Optional.of("10")); - PersistentResource loadedAuthor = PersistentResource.loadRecord(Author.class, - "1", + RequestScope userTwoScope = new TestRequestScope(tx, userTwo, dictionary); + + UpdateAndCreate updateAndCreateNewObject = new UpdateAndCreate(); + when(tx.createNewObject(UpdateAndCreate.class)).thenReturn(updateAndCreateNewObject); + + when(tx.loadObject(any(), + eq(2L), + any(RequestScope.class) + )).thenReturn(new Author()); + + PersistentResource created = PersistentResource.createObject(UpdateAndCreate.class, userTwoScope, Optional.of("10")); + PersistentResource loadedAuthor = PersistentResource.loadRecord( + EntityProjection.builder() + .type(Author.class) + .build(), + "2", userTwoScope); created.addRelation("author", loadedAuthor); created.getRequestScope().getPermissionExecutor().executeCommitChecks(); @@ -268,9 +427,22 @@ public void createPermissionOverwrittenForRelationSuccessCase() { @Test public void createPermissionOverwrittenForRelationFailureCase() { - PersistentResource created = PersistentResource.createObject(null, UpdateAndCreate.class, userOneScope, Optional.of("11")); - PersistentResource loadedAuthor = PersistentResource.loadRecord(Author.class, - "1", + RequestScope userOneScope = new TestRequestScope(tx, userOne, dictionary); + + UpdateAndCreate updateAndCreateNewObject = new UpdateAndCreate(); + when(tx.createNewObject(UpdateAndCreate.class)).thenReturn(updateAndCreateNewObject); + + when(tx.loadObject(any(), + eq(2L), + any(RequestScope.class) + )).thenReturn(new Author()); + + PersistentResource created = PersistentResource.createObject(UpdateAndCreate.class, userOneScope, Optional.of("11")); + PersistentResource loadedAuthor = PersistentResource.loadRecord( + EntityProjection.builder() + .type(Author.class) + .build(), + "2", userOneScope); assertThrows(ForbiddenAccessException.class, () -> created.addRelation("author", loadedAuthor)); } diff --git a/elide-core/src/test/java/com/yahoo/elide/core/VerifyFieldAccessFilterExpressionVisitorTest.java b/elide-core/src/test/java/com/yahoo/elide/core/VerifyFieldAccessFilterExpressionVisitorTest.java index b9b188a4aa..4be1e2d167 100644 --- a/elide-core/src/test/java/com/yahoo/elide/core/VerifyFieldAccessFilterExpressionVisitorTest.java +++ b/elide-core/src/test/java/com/yahoo/elide/core/VerifyFieldAccessFilterExpressionVisitorTest.java @@ -9,6 +9,7 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; @@ -234,7 +235,7 @@ public void testShortCircuitRejectDeferThenFail() throws Exception { verify(permissionExecutor, never()).checkSpecificFieldPermissionsDeferred(any(), any(), any(), any()); verify(permissionExecutor, times(2)).checkUserPermissions(any(), any(), any()); verify(permissionExecutor, times(1)).handleFilterJoinReject(any(), any(), any()); - verify(tx, never()).getRelation(any(), any(), any(), any(), any(), any(), any()); + verify(tx, never()).getRelation(any(), any(), any(), any()); } @Test @@ -291,7 +292,7 @@ public void testShortCircuitPass() throws Exception { verify(permissionExecutor, never()).checkSpecificFieldPermissions(resource, null, ReadPermission.class, GENRE); verify(permissionExecutor, times(2)).checkUserPermissions(any(), any(), any()); verify(permissionExecutor, never()).handleFilterJoinReject(any(), any(), any()); - verify(tx, never()).getRelation(any(), any(), any(), any(), any(), any(), any()); + verify(tx, never()).getRelation(any(), any(), any(), any()); } @Test @@ -322,8 +323,7 @@ public void testUserChecksDeferred() throws Exception { when(permissionExecutor.checkSpecificFieldPermissions(resourceAuthor, null, ReadPermission.class, HOME)) .thenThrow(ForbiddenAccessException.class); - when(tx.getRelation(tx, book, AUTHORS, Optional.empty(), Optional.empty(), Optional.empty(), scope)) - .thenReturn(book.getAuthors()); + when(tx.getRelation(eq(tx), eq(book), any(), eq(scope))).thenReturn(book.getAuthors()); VerifyFieldAccessFilterExpressionVisitor visitor = new VerifyFieldAccessFilterExpressionVisitor(resource); // restricted HOME field @@ -336,7 +336,7 @@ public void testUserChecksDeferred() throws Exception { verify(permissionExecutor, times(1)).checkSpecificFieldPermissions(resourceAuthor, null, ReadPermission.class, HOME); verify(permissionExecutor, times(2)).checkUserPermissions(any(), any(), any()); verify(permissionExecutor, times(1)).handleFilterJoinReject(any(), any(), any()); - verify(tx, times(1)).getRelation(tx, book, AUTHORS, Optional.empty(), Optional.empty(), Optional.empty(), scope); + verify(tx, times(1)).getRelation(eq(tx), eq(book), any(), eq(scope)); } @Test @@ -361,7 +361,7 @@ public void testBypassReadonlyFilterRestriction() throws Exception { verify(permissionExecutor, never()).checkSpecificFieldPermissions(any(), any(), any(), any()); verify(permissionExecutor, never()).checkUserPermissions(any(), any(), any()); verify(permissionExecutor, never()).handleFilterJoinReject(any(), any(), any()); - verify(tx, never()).getRelation(any(), any(), any(), any(), any(), any(), any()); + verify(tx, never()).getRelation(any(), any(), any(), any()); } @Test @@ -379,7 +379,7 @@ public void testCustomFilterJoin() throws Exception { when(permissionExecutor.checkUserPermissions(Book.class, ReadPermission.class, GENRE)) .thenReturn(ExpressionResult.DEFERRED); when(permissionExecutor.checkSpecificFieldPermissions(resource, null, ReadPermission.class, GENRE)) - .thenThrow(new ForbiddenAccessException("check expression")); + .thenThrow(new ForbiddenAccessException(ReadPermission.class)); when(permissionExecutor.evaluateFilterJoinUserChecks(any(), any())).thenReturn(ExpressionResult.DEFERRED); when(permissionExecutor.handleFilterJoinReject(any(), any(), any())).thenAnswer(invocation -> { @@ -394,7 +394,7 @@ public void testCustomFilterJoin() throws Exception { // custom processing return "Book".equals(pathElement.getType().getSimpleName()) && filterPredicate.toString().matches("book.genre IN_INSENSITIVE \\[\\w+\\]") - && reason.getLoggedMessage().matches(".*Message=check expression.*") + && reason.getLoggedMessage().matches(".*Message=ReadPermission Denied.*") ? ExpressionResult.DEFERRED : ExpressionResult.FAIL; }); @@ -407,6 +407,6 @@ public void testCustomFilterJoin() throws Exception { verify(permissionExecutor, times(1)).checkSpecificFieldPermissions(resource, null, ReadPermission.class, GENRE); verify(permissionExecutor, never()).checkUserPermissions(any(), any(), any()); verify(permissionExecutor, times(1)).handleFilterJoinReject(any(), any(), any()); - verify(tx, never()).getRelation(any(), any(), any(), any(), any(), any(), any()); + verify(tx, never()).getRelation(any(), any(), any(), any()); } } diff --git a/elide-core/src/test/java/com/yahoo/elide/core/datastore/inmemory/InMemoryStoreTransactionTest.java b/elide-core/src/test/java/com/yahoo/elide/core/datastore/inmemory/InMemoryStoreTransactionTest.java index e25f294a4d..7e5a1fad0b 100644 --- a/elide-core/src/test/java/com/yahoo/elide/core/datastore/inmemory/InMemoryStoreTransactionTest.java +++ b/elide-core/src/test/java/com/yahoo/elide/core/datastore/inmemory/InMemoryStoreTransactionTest.java @@ -6,6 +6,7 @@ package com.yahoo.elide.core.datastore.inmemory; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; @@ -25,17 +26,22 @@ import com.yahoo.elide.core.filter.InPredicate; import com.yahoo.elide.core.filter.expression.AndFilterExpression; import com.yahoo.elide.core.filter.expression.FilterExpression; -import com.yahoo.elide.core.pagination.Pagination; -import com.yahoo.elide.core.sort.Sorting; +import com.yahoo.elide.core.pagination.PaginationImpl; +import com.yahoo.elide.core.sort.SortingImpl; +import com.yahoo.elide.request.EntityProjection; +import com.yahoo.elide.request.Relationship; +import com.yahoo.elide.request.Sorting; import com.google.common.collect.Lists; import com.google.common.collect.Sets; + import example.Author; import example.Book; import example.Editor; import example.Publisher; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; import java.util.ArrayList; import java.util.Arrays; @@ -44,7 +50,6 @@ import java.util.HashSet; import java.util.List; import java.util.Map; -import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; @@ -54,7 +59,7 @@ public class InMemoryStoreTransactionTest { private RequestScope scope = mock(RequestScope.class); private InMemoryStoreTransaction inMemoryStoreTransaction = new InMemoryStoreTransaction(wrappedTransaction); private EntityDictionary dictionary; - private Set books = new HashSet<>(); + private Set books = new HashSet<>(); private Book book1; private Book book2; private Book book3; @@ -132,24 +137,17 @@ public void testFullFilterPredicatePushDown() { FilterExpression expression = new InPredicate(new Path(Book.class, dictionary, "genre"), "Literary Fiction"); - when(wrappedTransaction.supportsFiltering(eq(Book.class), - any())).thenReturn(DataStoreTransaction.FeatureSupport.FULL); - when(wrappedTransaction.loadObjects(eq(Book.class), eq(Optional.of(expression)), - eq(Optional.empty()), eq(Optional.empty()), eq(scope))).thenReturn((Set) books); + EntityProjection projection = EntityProjection.builder() + .type(Book.class) + .filterExpression(expression) + .build(); - Collection loaded = (Collection) inMemoryStoreTransaction.loadObjects( - Book.class, - Optional.of(expression), - Optional.empty(), - Optional.empty(), - scope); + when(wrappedTransaction.supportsFiltering(eq(Book.class), any())).thenReturn(DataStoreTransaction.FeatureSupport.FULL); + when(wrappedTransaction.loadObjects(eq(projection), eq(scope))).thenReturn(books); - verify(wrappedTransaction, times(1)).loadObjects( - eq(Book.class), - eq(Optional.of(expression)), - eq(Optional.empty()), - eq(Optional.empty()), - eq(scope)); + Collection loaded = (Collection) inMemoryStoreTransaction.loadObjects(projection, scope); + + verify(wrappedTransaction, times(1)).loadObjects(eq(projection), eq(scope)); assertEquals(3, loaded.size()); assertTrue(loaded.contains(book1)); @@ -162,89 +160,66 @@ public void testTransactionRequiresInMemoryFilterDuringGetRelation() { FilterExpression expression = new InPredicate(new Path(Book.class, dictionary, "genre"), "Literary Fiction"); + Relationship relationship = Relationship.builder() + .projection(EntityProjection.builder() + .type(Book.class) + .filterExpression(expression) + .build()) + .name("books") + .alias("books") + .build(); + + ArgumentCaptor relationshipArgument = ArgumentCaptor.forClass(Relationship.class); + when(scope.getNewPersistentResources()).thenReturn(Sets.newHashSet(mock(PersistentResource.class))); when(wrappedTransaction.supportsFiltering(eq(Book.class), any())).thenReturn(DataStoreTransaction.FeatureSupport.FULL); - when(wrappedTransaction.getRelation(eq(inMemoryStoreTransaction), eq(author), eq("books"), - eq(Optional.empty()), eq(Optional.empty()), eq(Optional.empty()), eq(scope))).thenReturn(books); + when(wrappedTransaction.getRelation(eq(inMemoryStoreTransaction), eq(author), any(), eq(scope))).thenReturn(books); Collection loaded = (Collection) inMemoryStoreTransaction.getRelation( - inMemoryStoreTransaction, - author, - "books", - Optional.of(expression), - Optional.empty(), - Optional.empty(), - scope); + inMemoryStoreTransaction, author, relationship, scope); verify(wrappedTransaction, times(1)).getRelation( eq(inMemoryStoreTransaction), eq(author), - eq("books"), - eq(Optional.empty()), - eq(Optional.empty()), - eq(Optional.empty()), + relationshipArgument.capture(), eq(scope)); + assertNull(relationshipArgument.getValue().getProjection().getFilterExpression()); + assertNull(relationshipArgument.getValue().getProjection().getSorting()); + assertNull(relationshipArgument.getValue().getProjection().getPagination()); + assertEquals(2, loaded.size()); assertTrue(loaded.contains(book1)); assertTrue(loaded.contains(book3)); } @Test - public void testTransactionRequiresInMemoryFilterDuringLoad() { + public void testDataStoreRequiresTotalInMemoryFilter() { FilterExpression expression = new InPredicate(new Path(Book.class, dictionary, "genre"), "Literary Fiction"); - when(wrappedTransaction.supportsFiltering(eq(Book.class), - any())).thenReturn(DataStoreTransaction.FeatureSupport.FULL); - when(wrappedTransaction.loadObjects(eq(Book.class), eq(Optional.of(expression)), - eq(Optional.empty()), eq(Optional.empty()), eq(scope))).thenReturn((Set) books); + EntityProjection projection = EntityProjection.builder() + .type(Book.class) + .filterExpression(expression) + .build(); - Collection loaded = (Collection) inMemoryStoreTransaction.loadObjects( - Book.class, - Optional.of(expression), - Optional.empty(), - Optional.empty(), - scope); - - verify(wrappedTransaction, times(1)).loadObjects( - eq(Book.class), - eq(Optional.of(expression)), - eq(Optional.empty()), - eq(Optional.empty()), - eq(scope)); - - assertEquals(3, loaded.size()); - assertTrue(loaded.contains(book1)); - assertTrue(loaded.contains(book2)); - assertTrue(loaded.contains(book3)); - } - - @Test - public void testDataStoreRequiresTotalInMemoryFilter() { - FilterExpression expression = - new InPredicate(new Path(Book.class, dictionary, "genre"), "Literary Fiction"); + ArgumentCaptor projectionArgument = ArgumentCaptor.forClass(EntityProjection.class); when(wrappedTransaction.supportsFiltering(eq(Book.class), any())).thenReturn(DataStoreTransaction.FeatureSupport.NONE); - when(wrappedTransaction.loadObjects(eq(Book.class), eq(Optional.empty()), - eq(Optional.empty()), eq(Optional.empty()), eq(scope))).thenReturn((Set) books); - Collection loaded = (Collection) inMemoryStoreTransaction.loadObjects( - Book.class, - Optional.of(expression), - Optional.empty(), - Optional.empty(), - scope); + when(wrappedTransaction.loadObjects(any(), eq(scope))).thenReturn(books); + + Collection loaded = (Collection) inMemoryStoreTransaction.loadObjects(projection, scope); verify(wrappedTransaction, times(1)).loadObjects( - eq(Book.class), - eq(Optional.empty()), - eq(Optional.empty()), - eq(Optional.empty()), + projectionArgument.capture(), eq(scope)); + assertNull(projectionArgument.getValue().getFilterExpression()); + assertNull(projectionArgument.getValue().getPagination()); + assertNull(projectionArgument.getValue().getSorting()); assertEquals(2, loaded.size()); assertTrue(loaded.contains(book1)); assertTrue(loaded.contains(book3)); @@ -258,25 +233,28 @@ public void testDataStoreRequiresPartialInMemoryFilter() { new InPredicate(new Path(Book.class, dictionary, "editor.firstName"), "Jane"); FilterExpression expression = new AndFilterExpression(expression1, expression2); + EntityProjection projection = EntityProjection.builder() + .type(Book.class) + .filterExpression(expression) + .build(); + + ArgumentCaptor projectionArgument = ArgumentCaptor.forClass(EntityProjection.class); + when(wrappedTransaction.supportsFiltering(eq(Book.class), any())).thenReturn(DataStoreTransaction.FeatureSupport.PARTIAL); - when(wrappedTransaction.loadObjects(eq(Book.class), eq(Optional.of(expression1)), - eq(Optional.empty()), eq(Optional.empty()), eq(scope))).thenReturn((Set) books); + when(wrappedTransaction.loadObjects(any(), eq(scope))).thenReturn(books); Collection loaded = (Collection) inMemoryStoreTransaction.loadObjects( - Book.class, - Optional.of(expression), - Optional.empty(), - Optional.empty(), + projection, scope); verify(wrappedTransaction, times(1)).loadObjects( - eq(Book.class), - eq(Optional.of(expression1)), - eq(Optional.empty()), - eq(Optional.empty()), + projectionArgument.capture(), eq(scope)); + assertEquals(projectionArgument.getValue().getFilterExpression(), expression1); + assertNull(projectionArgument.getValue().getPagination()); + assertNull(projectionArgument.getValue().getSorting()); assertEquals(1, loaded.size()); assertTrue(loaded.contains(book3)); } @@ -286,30 +264,32 @@ public void testSortingPushDown() { Map sortOrder = new HashMap<>(); sortOrder.put("title", Sorting.SortOrder.asc); - Sorting sorting = new Sorting(sortOrder); + Sorting sorting = new SortingImpl(sortOrder, Book.class, dictionary); + + EntityProjection projection = EntityProjection.builder() + .type(Book.class) + .sorting(sorting) + .build(); + + ArgumentCaptor projectionArgument = ArgumentCaptor.forClass(EntityProjection.class); when(wrappedTransaction.supportsFiltering(eq(Book.class), any())).thenReturn(DataStoreTransaction.FeatureSupport.FULL); when(wrappedTransaction.supportsSorting(eq(Book.class), any())).thenReturn(true); - - when(wrappedTransaction.loadObjects(eq(Book.class), eq(Optional.empty()), - eq(Optional.of(sorting)), eq(Optional.empty()), eq(scope))).thenReturn((Set) books); + when(wrappedTransaction.loadObjects(any(), eq(scope))).thenReturn(books); Collection loaded = (Collection) inMemoryStoreTransaction.loadObjects( - Book.class, - Optional.empty(), - Optional.of(sorting), - Optional.empty(), + projection, scope); verify(wrappedTransaction, times(1)).loadObjects( - eq(Book.class), - eq(Optional.empty()), - eq(Optional.of(sorting)), - eq(Optional.empty()), + projectionArgument.capture(), eq(scope)); + assertNull(projectionArgument.getValue().getFilterExpression()); + assertNull(projectionArgument.getValue().getPagination()); + assertEquals(projectionArgument.getValue().getSorting(), sorting); assertEquals(3, loaded.size()); } @@ -318,30 +298,32 @@ public void testDataStoreRequiresInMemorySorting() { Map sortOrder = new HashMap<>(); sortOrder.put("title", Sorting.SortOrder.asc); - Sorting sorting = new Sorting(sortOrder); + Sorting sorting = new SortingImpl(sortOrder, Book.class, dictionary); + + EntityProjection projection = EntityProjection.builder() + .type(Book.class) + .sorting(sorting) + .build(); + + ArgumentCaptor projectionArgument = ArgumentCaptor.forClass(EntityProjection.class); when(wrappedTransaction.supportsFiltering(eq(Book.class), any())).thenReturn(DataStoreTransaction.FeatureSupport.FULL); when(wrappedTransaction.supportsSorting(eq(Book.class), any())).thenReturn(false); - - when(wrappedTransaction.loadObjects(eq(Book.class), eq(Optional.empty()), - eq(Optional.empty()), eq(Optional.empty()), eq(scope))).thenReturn((Set) books); + when(wrappedTransaction.loadObjects(any(), eq(scope))).thenReturn(books); Collection loaded = (Collection) inMemoryStoreTransaction.loadObjects( - Book.class, - Optional.empty(), - Optional.of(sorting), - Optional.empty(), + projection, scope); verify(wrappedTransaction, times(1)).loadObjects( - eq(Book.class), - eq(Optional.empty()), - eq(Optional.empty()), - eq(Optional.empty()), + projectionArgument.capture(), eq(scope)); + assertNull(projectionArgument.getValue().getFilterExpression()); + assertNull(projectionArgument.getValue().getPagination()); + assertNull(projectionArgument.getValue().getSorting()); assertEquals(3, loaded.size()); List bookTitles = loaded.stream().map((o) -> ((Book) o).getTitle()).collect(Collectors.toList()); @@ -356,30 +338,33 @@ public void testFilteringRequiresInMemorySorting() { Map sortOrder = new HashMap<>(); sortOrder.put("title", Sorting.SortOrder.asc); - Sorting sorting = new Sorting(sortOrder); + Sorting sorting = new SortingImpl(sortOrder, Book.class, dictionary); + + EntityProjection projection = EntityProjection.builder() + .type(Book.class) + .filterExpression(expression) + .sorting(sorting) + .build(); + + ArgumentCaptor projectionArgument = ArgumentCaptor.forClass(EntityProjection.class); when(wrappedTransaction.supportsFiltering(eq(Book.class), any())).thenReturn(DataStoreTransaction.FeatureSupport.NONE); when(wrappedTransaction.supportsSorting(eq(Book.class), any())).thenReturn(true); - - when(wrappedTransaction.loadObjects(eq(Book.class), eq(Optional.empty()), - eq(Optional.empty()), eq(Optional.empty()), eq(scope))).thenReturn((Set) books); + when(wrappedTransaction.loadObjects(any(), eq(scope))).thenReturn(books); Collection loaded = (Collection) inMemoryStoreTransaction.loadObjects( - Book.class, - Optional.of(expression), - Optional.of(sorting), - Optional.empty(), + projection, scope); verify(wrappedTransaction, times(1)).loadObjects( - eq(Book.class), - eq(Optional.empty()), - eq(Optional.empty()), - eq(Optional.empty()), + projectionArgument.capture(), eq(scope)); + assertNull(projectionArgument.getValue().getFilterExpression()); + assertNull(projectionArgument.getValue().getPagination()); + assertNull(projectionArgument.getValue().getSorting()); assertEquals(2, loaded.size()); List bookTitles = loaded.stream().map((o) -> ((Book) o).getTitle()).collect(Collectors.toList()); @@ -388,57 +373,63 @@ public void testFilteringRequiresInMemorySorting() { @Test public void testPaginationPushDown() { - Pagination pagination = Pagination.getDefaultPagination(elideSettings); + PaginationImpl pagination = PaginationImpl.getDefaultPagination(Book.class, elideSettings); + + EntityProjection projection = EntityProjection.builder() + .type(Book.class) + .pagination(pagination) + .build(); + + ArgumentCaptor projectionArgument = ArgumentCaptor.forClass(EntityProjection.class); when(wrappedTransaction.supportsFiltering(eq(Book.class), any())).thenReturn(DataStoreTransaction.FeatureSupport.FULL); - when(wrappedTransaction.supportsPagination(eq(Book.class))).thenReturn(true); + when(wrappedTransaction.supportsPagination(eq(Book.class), any())).thenReturn(true); - when(wrappedTransaction.loadObjects(eq(Book.class), eq(Optional.empty()), - eq(Optional.empty()), eq(Optional.of(pagination)), eq(scope))).thenReturn((Set) books); + when(wrappedTransaction.loadObjects(any(), eq(scope))).thenReturn(books); Collection loaded = (Collection) inMemoryStoreTransaction.loadObjects( - Book.class, - Optional.empty(), - Optional.empty(), - Optional.of(pagination), + projection, scope); verify(wrappedTransaction, times(1)).loadObjects( - eq(Book.class), - eq(Optional.empty()), - eq(Optional.empty()), - eq(Optional.of(pagination)), + projectionArgument.capture(), eq(scope)); + assertNull(projectionArgument.getValue().getFilterExpression()); + assertEquals(projectionArgument.getValue().getPagination(), pagination); + assertNull(projectionArgument.getValue().getSorting()); assertEquals(3, loaded.size()); } @Test public void testDataStoreRequiresInMemoryPagination() { - Pagination pagination = Pagination.getDefaultPagination(elideSettings); + PaginationImpl pagination = PaginationImpl.getDefaultPagination(Book.class, elideSettings); + + EntityProjection projection = EntityProjection.builder() + .type(Book.class) + .pagination(pagination) + .build(); + + ArgumentCaptor projectionArgument = ArgumentCaptor.forClass(EntityProjection.class); when(wrappedTransaction.supportsFiltering(eq(Book.class), any())).thenReturn(DataStoreTransaction.FeatureSupport.FULL); - when(wrappedTransaction.supportsPagination(eq(Book.class))).thenReturn(false); + when(wrappedTransaction.supportsPagination(eq(Book.class), any())).thenReturn(false); - when(wrappedTransaction.loadObjects(eq(Book.class), eq(Optional.empty()), - eq(Optional.empty()), eq(Optional.empty()), eq(scope))).thenReturn((Set) books); + when(wrappedTransaction.loadObjects(any(), eq(scope))).thenReturn(books); Collection loaded = (Collection) inMemoryStoreTransaction.loadObjects( - Book.class, - Optional.empty(), - Optional.empty(), - Optional.of(pagination), + projection, scope); verify(wrappedTransaction, times(1)).loadObjects( - eq(Book.class), - eq(Optional.empty()), - eq(Optional.empty()), - eq(Optional.empty()), + projectionArgument.capture(), eq(scope)); + assertNull(projectionArgument.getValue().getFilterExpression()); + assertNull(projectionArgument.getValue().getPagination()); + assertNull(projectionArgument.getValue().getSorting()); assertEquals(3, loaded.size()); assertTrue(loaded.contains(book1)); assertTrue(loaded.contains(book2)); @@ -450,29 +441,33 @@ public void testFilteringRequiresInMemoryPagination() { FilterExpression expression = new InPredicate(new Path(Book.class, dictionary, "genre"), "Literary Fiction"); - Pagination pagination = Pagination.getDefaultPagination(elideSettings); + PaginationImpl pagination = PaginationImpl.getDefaultPagination(Book.class, elideSettings); + + EntityProjection projection = EntityProjection.builder() + .type(Book.class) + .filterExpression(expression) + .pagination(pagination) + .build(); + + ArgumentCaptor projectionArgument = ArgumentCaptor.forClass(EntityProjection.class); when(wrappedTransaction.supportsFiltering(eq(Book.class), any())).thenReturn(DataStoreTransaction.FeatureSupport.NONE); - when(wrappedTransaction.supportsPagination(eq(Book.class))).thenReturn(true); + when(wrappedTransaction.supportsPagination(eq(Book.class), any())).thenReturn(true); - when(wrappedTransaction.loadObjects(eq(Book.class), eq(Optional.empty()), - eq(Optional.empty()), eq(Optional.empty()), eq(scope))).thenReturn((Set) books); + when(wrappedTransaction.loadObjects(any(), eq(scope))).thenReturn(books); Collection loaded = (Collection) inMemoryStoreTransaction.loadObjects( - Book.class, - Optional.of(expression), - Optional.empty(), - Optional.of(pagination), + projection, scope); verify(wrappedTransaction, times(1)).loadObjects( - eq(Book.class), - eq(Optional.empty()), - eq(Optional.empty()), - eq(Optional.empty()), + projectionArgument.capture(), eq(scope)); + assertNull(projectionArgument.getValue().getFilterExpression()); + assertNull(projectionArgument.getValue().getPagination()); + assertNull(projectionArgument.getValue().getSorting()); assertEquals(2, loaded.size()); assertTrue(loaded.contains(book1)); assertTrue(loaded.contains(book3)); @@ -480,36 +475,40 @@ public void testFilteringRequiresInMemoryPagination() { @Test public void testSortingRequiresInMemoryPagination() { - Pagination pagination = Pagination.getDefaultPagination(elideSettings); + PaginationImpl pagination = PaginationImpl.getDefaultPagination(Book.class, elideSettings); Map sortOrder = new HashMap<>(); sortOrder.put("title", Sorting.SortOrder.asc); - Sorting sorting = new Sorting(sortOrder); + Sorting sorting = new SortingImpl(sortOrder, Book.class, dictionary); + + EntityProjection projection = EntityProjection.builder() + .type(Book.class) + .sorting(sorting) + .pagination(pagination) + .build(); + + ArgumentCaptor projectionArgument = ArgumentCaptor.forClass(EntityProjection.class); when(wrappedTransaction.supportsFiltering(eq(Book.class), any())).thenReturn(DataStoreTransaction.FeatureSupport.FULL); when(wrappedTransaction.supportsSorting(eq(Book.class), any())).thenReturn(false); - when(wrappedTransaction.supportsPagination(eq(Book.class))).thenReturn(true); + when(wrappedTransaction.supportsPagination(eq(Book.class), any())).thenReturn(true); - when(wrappedTransaction.loadObjects(eq(Book.class), eq(Optional.empty()), - eq(Optional.empty()), eq(Optional.empty()), eq(scope))).thenReturn((Set) books); + when(wrappedTransaction.loadObjects(any(), eq(scope))).thenReturn(books); Collection loaded = (Collection) inMemoryStoreTransaction.loadObjects( - Book.class, - Optional.empty(), - Optional.of(sorting), - Optional.of(pagination), + projection, scope); verify(wrappedTransaction, times(1)).loadObjects( - eq(Book.class), - eq(Optional.empty()), - eq(Optional.empty()), - eq(Optional.empty()), + projectionArgument.capture(), eq(scope)); + assertNull(projectionArgument.getValue().getFilterExpression()); + assertNull(projectionArgument.getValue().getPagination()); + assertNull(projectionArgument.getValue().getSorting()); assertEquals(3, loaded.size()); assertTrue(loaded.contains(book1)); assertTrue(loaded.contains(book2)); diff --git a/elide-core/src/test/java/com/yahoo/elide/core/datastore/wrapped/TransactionWrapperTest.java b/elide-core/src/test/java/com/yahoo/elide/core/datastore/wrapped/TransactionWrapperTest.java index 4a84d6a615..2cd67f3d74 100644 --- a/elide-core/src/test/java/com/yahoo/elide/core/datastore/wrapped/TransactionWrapperTest.java +++ b/elide-core/src/test/java/com/yahoo/elide/core/datastore/wrapped/TransactionWrapperTest.java @@ -10,41 +10,24 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isA; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import com.yahoo.elide.core.DataStoreTransaction; -import com.yahoo.elide.security.User; +import com.yahoo.elide.request.Attribute; import org.junit.jupiter.api.Test; -import java.util.Optional; - public class TransactionWrapperTest { private class TestTransactionWrapper extends TransactionWrapper { - public TestTransactionWrapper(DataStoreTransaction wrapped) { super(wrapped); } } - @Test - public void testAccessUser() { - DataStoreTransaction wrapped = mock(DataStoreTransaction.class); - DataStoreTransaction wrapper = new TestTransactionWrapper(wrapped); - - Object wrappedUser = new Object(); - User expectedUser = new User(wrappedUser); - when(wrapped.accessUser(eq(wrappedUser))).thenReturn(expectedUser); - - User actualUser = wrapper.accessUser(wrappedUser); - - verify(wrapped, times(1)).accessUser(eq(wrappedUser)); - assertEquals(expectedUser, actualUser); - } - @Test public void testPreCommit() { DataStoreTransaction wrapped = mock(DataStoreTransaction.class); @@ -84,12 +67,11 @@ public void testLoadObjects() throws Exception { DataStoreTransaction wrapper = new TestTransactionWrapper(wrapped); Iterable expected = mock(Iterable.class); - when(wrapped.loadObjects(any(), any(), any(), any(), any())).thenReturn(expected); + when(wrapped.loadObjects(any(), any())).thenReturn(expected); - Iterable actual = wrapper.loadObjects(null, Optional.empty(), - Optional.empty(), Optional.empty(), null); + Iterable actual = wrapper.loadObjects(null, null); - verify(wrapped, times(1)).loadObjects(any(), any(), any(), any(), any()); + verify(wrapped, times(1)).loadObjects(any(), any()); assertEquals(expected, actual); } @@ -160,10 +142,10 @@ public void testSupportsPagination() { DataStoreTransaction wrapped = mock(DataStoreTransaction.class); DataStoreTransaction wrapper = new TestTransactionWrapper(wrapped); - when(wrapped.supportsPagination(any())).thenReturn(true); - boolean actual = wrapper.supportsPagination(null); + when(wrapped.supportsPagination(any(), any())).thenReturn(true); + boolean actual = wrapper.supportsPagination(null, null); - verify(wrapped, times(1)).supportsPagination(any()); + verify(wrapped, times(1)).supportsPagination(any(), any()); assertTrue(actual); } @@ -184,11 +166,11 @@ public void testGetAttribute() { DataStoreTransaction wrapped = mock(DataStoreTransaction.class); DataStoreTransaction wrapper = new TestTransactionWrapper(wrapped); - when(wrapped.getAttribute(any(), any(), any())).thenReturn(1L); + when(wrapped.getAttribute(any(), isA(Attribute.class), any())).thenReturn(1L); - Object actual = wrapper.getAttribute(null, null, null); + Object actual = wrapper.getAttribute(null, Attribute.builder().name("foo").type(String.class).build(), null); - verify(wrapped, times(1)).getAttribute(any(), any(), any()); + verify(wrapped, times(1)).getAttribute(any(), isA(Attribute.class), any()); assertEquals(1L, actual); } @@ -197,9 +179,9 @@ public void testSetAttribute() { DataStoreTransaction wrapped = mock(DataStoreTransaction.class); DataStoreTransaction wrapper = new TestTransactionWrapper(wrapped); - wrapper.setAttribute(null, null, null, null); + wrapper.setAttribute(null, null, null); - verify(wrapped, times(1)).setAttribute(any(), any(), any(), any()); + verify(wrapped, times(1)).setAttribute(any(), any(), any()); } @Test @@ -227,12 +209,11 @@ public void testGetRelation() { DataStoreTransaction wrapped = mock(DataStoreTransaction.class); DataStoreTransaction wrapper = new TestTransactionWrapper(wrapped); - when(wrapped.getRelation(any(), any(), any(), any(), any(), any(), any())).thenReturn(1L); + when(wrapped.getRelation(any(), any(), any(), any())).thenReturn(1L); - Object actual = wrapper.getRelation(null, null, null, null, - null, null, null); + Object actual = wrapper.getRelation(null, null, null, null); - verify(wrapped, times(1)).getRelation(any(), any(), any(), any(), any(), any(), any()); + verify(wrapped, times(1)).getRelation(any(), any(), any(), any()); assertEquals(1L, actual); } @@ -241,11 +222,11 @@ public void testLoadObject() { DataStoreTransaction wrapped = mock(DataStoreTransaction.class); DataStoreTransaction wrapper = new TestTransactionWrapper(wrapped); - when(wrapped.loadObject(any(), any(), any(), any())).thenReturn(1L); + when(wrapped.loadObject(any(), any(), any())).thenReturn(1L); - Object actual = wrapper.loadObject(null, null, null, null); + Object actual = wrapper.loadObject(null, null, null); - verify(wrapped, times(1)).loadObject(any(), any(), any(), any()); + verify(wrapped, times(1)).loadObject(any(), any(), any()); assertEquals(1L, actual); } } diff --git a/elide-core/src/test/java/com/yahoo/elide/core/exceptions/HttpStatusExceptionTest.java b/elide-core/src/test/java/com/yahoo/elide/core/exceptions/HttpStatusExceptionTest.java index a11a744c1d..29102d6dc9 100644 --- a/elide-core/src/test/java/com/yahoo/elide/core/exceptions/HttpStatusExceptionTest.java +++ b/elide-core/src/test/java/com/yahoo/elide/core/exceptions/HttpStatusExceptionTest.java @@ -15,57 +15,39 @@ public class HttpStatusExceptionTest { - @Test - public void testGetResponse() { - // result should not be encoded - String expected = "{\"errors\":[\": test